diff --git a/Cargo.frontend.lock b/Cargo.frontend.lock new file mode 100644 index 0000000..2bb4305 --- /dev/null +++ b/Cargo.frontend.lock @@ -0,0 +1,3245 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + +[[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 = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "anymap" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33954243bd79057c2de7338850b85983a44588021f8a5fee574a8888c6de4344" + +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.1", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers 0.3.0", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atom_syndication" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8100e46ff92eb85bf6dc2930c73f2a4f7176393c84a9446b3d501e1b354e7b34" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "diligent-date-parser" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" +dependencies = [ + "chrono", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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.1", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gloo" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ce6f2dfa9f57f15b848efa2aade5e1850dc72986b87a2b0752d44ca08f4967" +dependencies = [ + "gloo-console-timer", + "gloo-events 0.1.2", + "gloo-file 0.1.0", + "gloo-timers 0.2.6", +] + +[[package]] +name = "gloo" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d" +dependencies = [ + "gloo-console 0.2.3", + "gloo-dialogs 0.1.1", + "gloo-events 0.1.2", + "gloo-file 0.2.3", + "gloo-history 0.1.5", + "gloo-net 0.3.1", + "gloo-render 0.1.1", + "gloo-storage 0.2.2", + "gloo-timers 0.2.6", + "gloo-utils 0.1.7", + "gloo-worker 0.2.1", +] + +[[package]] +name = "gloo" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.4.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.4.0", +] + +[[package]] +name = "gloo" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.5.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.5.0", +] + +[[package]] +name = "gloo-console" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console-timer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b48675544b29ac03402c6dffc31a912f716e38d19f7e74b78b7e900ec3c941ea" +dependencies = [ + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f9fecfe46b5dc3cc46f58e98ba580cc714f2c93860796d002eb3527a465ef49" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "futures-channel", + "gloo-events 0.2.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f" +dependencies = [ + "gloo-events 0.1.2", + "gloo-utils 0.1.7", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom 0.2.16", + "gloo-events 0.2.0", + "gloo-utils 0.2.0", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.1.7", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 1.3.1", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a" +dependencies = [ + "anymap2", + "bincode", + "gloo-console 0.2.3", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + +[[package]] +name = "htmlentity" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd54ae4f69adcc1a43637dcff230852832c3ad50df31a90e0cb5f001dd441359" +dependencies = [ + "anyhow", + "lazy_static", + "thiserror", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "i18nrs" +version = "0.1.7" +source = "git+https://github.com/madeofpendletonwool/i18n-rs#c0e755729b3c8ff220d8417fb126972fa0665a46" +dependencies = [ + "serde_json", + "web-sys", + "yew 0.21.0", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "implicit-clone" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" +dependencies = [ + "implicit-clone-derive", + "indexmap 2.11.4", +] + +[[package]] +name = "implicit-clone-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.176" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +dependencies = [ + "value-bag", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror", +] + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.1", +] + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488" +dependencies = [ + "futures", + "gloo 0.8.1", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + +[[package]] +name = "rss" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" +dependencies = [ + "atom_syndication", + "derive_builder", + "never", + "quick-xml", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.1", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[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 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.0.4", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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 2.0.106", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "value-bag" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "serde_json", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web" +version = "0.1.0" +dependencies = [ + "ammonia", + "anyhow", + "argon2", + "async-std", + "base64", + "chrono", + "chrono-tz", + "data-encoding", + "futures", + "futures-util", + "getrandom 0.3.4", + "gloo 0.11.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-net 0.6.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "htmlentity", + "i18nrs", + "js-sys", + "log", + "md5", + "percent-encoding", + "pulldown-cmark", + "rand 0.9.2", + "regex", + "rss", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_json", + "serde_with", + "url", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew 0.21.0", + "yew-router", + "yewdux", + "yewtil", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "windows-core" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yew" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d5154faef86dddd2eb333d4755ea5643787d20aca683e58759b0e53351409f" +dependencies = [ + "anyhow", + "anymap", + "bincode", + "cfg-if", + "cfg-match", + "console_error_panic_hook", + "gloo 0.2.1", + "http 0.2.12", + "indexmap 1.9.3", + "js-sys", + "log", + "ryu", + "serde", + "serde_json", + "slab", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro 0.18.0", +] + +[[package]] +name = "yew" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo 0.10.0", + "implicit-clone", + "indexmap 2.11.4", + "js-sys", + "prokio", + "rustversion", + "serde", + "slab", + "thiserror", + "tokio", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro 0.21.0", +] + +[[package]] +name = "yew-macro" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6e23bfe3dc3933fbe9592d149c9985f3047d08c637a884b9344c21e56e092ef" +dependencies = [ + "boolinator", + "lazy_static", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "yew-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" +dependencies = [ + "boolinator", + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "yew-router" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca1d5052c96e6762b4d6209a8aded597758d442e6c479995faf0c7b5538e0c6" +dependencies = [ + "gloo 0.10.0", + "js-sys", + "route-recognizer", + "serde", + "serde_urlencoded", + "tracing", + "urlencoding", + "wasm-bindgen", + "web-sys", + "yew 0.21.0", + "yew-router-macro", +] + +[[package]] +name = "yew-router-macro" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "yewdux" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8030a7de50678c07c038dcb96a42f1e8a7c4cc5610451fbee0c676aa7df42967" +dependencies = [ + "log", + "serde", + "serde_json", + "slab", + "thiserror", + "wasm-bindgen", + "web-sys", + "yew 0.21.0", + "yewdux-macros", +] + +[[package]] +name = "yewdux-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ac6ccd84a49bbce44610d44eb6686a1266337d0cd3aeadb5564ab76a2819f0" +dependencies = [ + "darling 0.20.11", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "yewtil" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543663ac49cd613df079282a1d8bdbdebdad6e02bac229f870fd4237b5d9aaa" +dependencies = [ + "log", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew 0.18.0", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4f48d69 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4918 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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 = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "directories", + "dirs", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tokio", + "ureq", + "warp", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.3", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" +dependencies = [ + "serde", +] + +[[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.16", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.5", +] + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.1.3", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.9.3", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.3", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dlopen2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6d81016d6c977deefb2ef8d8290da019e27cc26167e102185da528e6c0ab38" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.5", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.3", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.11.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +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 = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", + "serde", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.3", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.11.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags 2.9.3", + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.16", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.3", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate 2.0.2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.9.3", + "block2 0.6.1", + "libc", + "objc2 0.6.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.3", + "dispatch2", + "objc2 0.6.2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.9.3", + "dispatch2", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +dependencies = [ + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.9.3", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.3", + "block2 0.6.1", + "libc", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a" +dependencies = [ + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.9.3", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.3", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-security" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" +dependencies = [ + "bitflags 2.9.3", + "block2 0.6.1", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.11.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.3", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.16", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[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 = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[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-untagged" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[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 2.0.106", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.0", + "schemars 0.9.0", + "schemars 1.0.4", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics", + "foreign-types", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.9.3", + "block2 0.6.1", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bceb52453e507c505b330afe3398510e87f428ea42b6e76ecb6bd63b15965b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.3", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.16", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a924b6c50fe83193f0f8b14072afa7c25b7a72752a2a73d9549b463f5fe91a38" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.5", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c1fe64c74cc40f90848281a90058a6db931eb400b60205840e09801ee30f190" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.106", + "tauri-utils", + "thiserror 2.0.16", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260c5d2eb036b76206b9fca20b7be3614cfd21046c5396f7959e0e64a4b07f2f" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.16", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.16", + "toml 0.9.5", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" +dependencies = [ + "embed-resource", + "toml 0.9.5", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", +] + +[[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 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap 2.11.0", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.11.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.11.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.3", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.16", + "windows-sys 0.59.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "warp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d06d9202adc1f15d709c4f4a2069be5428aa912cc025d6f268ac441ab066b0" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project", + "scoped-tls", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-util", + "tower-service", + "tracing", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +dependencies = [ + "thiserror 2.0.16", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" +dependencies = [ + "windows-link 0.1.3", +] + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[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_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[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_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.3", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wry" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +dependencies = [ + "base64 0.22.1", + "block2 0.6.1", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.16", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/PinePods-0.8.2/.dockerignore b/PinePods-0.8.2/.dockerignore new file mode 100644 index 0000000..2d1b92d --- /dev/null +++ b/PinePods-0.8.2/.dockerignore @@ -0,0 +1 @@ +web/target/* \ No newline at end of file diff --git a/PinePods-0.8.2/.gitattributes b/PinePods-0.8.2/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/PinePods-0.8.2/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/PinePods-0.8.2/.github/FUNDING.yml b/PinePods-0.8.2/.github/FUNDING.yml new file mode 100644 index 0000000..cff3423 --- /dev/null +++ b/PinePods-0.8.2/.github/FUNDING.yml @@ -0,0 +1,14 @@ +# These are supported funding model platforms + +github: madeofpendletonwool +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: collinscoffee +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/PinePods-0.8.2/.github/dependabot.yml b/PinePods-0.8.2/.github/dependabot.yml new file mode 100644 index 0000000..5194765 --- /dev/null +++ b/PinePods-0.8.2/.github/dependabot.yml @@ -0,0 +1,16 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "cargo" # See documentation for possible values + directory: "/web" # Location of package manifests + schedule: + interval: "weekly" + + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/PinePods-0.8.2/.github/workflows/backwards-compatibility.yml b/PinePods-0.8.2/.github/workflows/backwards-compatibility.yml new file mode 100644 index 0000000..6375522 --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/backwards-compatibility.yml @@ -0,0 +1,406 @@ +name: Database Backwards Compatibility Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + TEST_DB_PASSWORD: "test_password_123!" + TEST_DB_NAME: "pinepods_test_db" + +jobs: + test-mysql-compatibility: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:latest + env: + MYSQL_ROOT_PASSWORD: test_password_123! + MYSQL_DATABASE: pinepods_test_db + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + valkey: + image: valkey/valkey:8-alpine + ports: + - 6379:6379 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get previous release tag + id: get_previous_tag + run: | + # Get the latest stable release (exclude rc, alpha, beta) + PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + + if [ -z "$PREVIOUS_TAG" ]; then + echo "No stable release tag found, using 0.7.9 as baseline" + PREVIOUS_TAG="0.7.9" + fi + + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "Using previous tag: $PREVIOUS_TAG" + + - name: Start previous PinePods version + run: | + echo "🚀 Starting PinePods ${{ steps.get_previous_tag.outputs.previous_tag }}" + + # Create docker-compose for previous version + cat > docker-compose.previous.yml << EOF + version: '3.8' + services: + pinepods_previous: + image: madeofpendletonwool/pinepods:${{ steps.get_previous_tag.outputs.previous_tag }} + environment: + DB_TYPE: mysql + DB_HOST: mysql + DB_PORT: 3306 + DB_USER: root + DB_PASSWORD: ${{ env.TEST_DB_PASSWORD }} + DB_NAME: ${{ env.TEST_DB_NAME }} + VALKEY_HOST: valkey + VALKEY_PORT: 6379 + HOSTNAME: 'http://localhost:8040' + DEBUG_MODE: true + SEARCH_API_URL: 'https://search.pinepods.online/api/search' + PEOPLE_API_URL: 'https://people.pinepods.online' + ports: + - "8040:8040" + depends_on: + - mysql + - valkey + networks: + - test_network + + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: ${{ env.TEST_DB_PASSWORD }} + MYSQL_DATABASE: ${{ env.TEST_DB_NAME }} + networks: + - test_network + + valkey: + image: valkey/valkey:8-alpine + networks: + - test_network + + networks: + test_network: + driver: bridge + EOF + + # Start previous version and wait for it to be ready + docker compose -f docker-compose.previous.yml up -d + + # Wait for services to be ready + echo "⏳ Waiting for previous version to initialize..." + sleep 30 + + # Check if previous version is responding + timeout 60 bash -c 'while ! curl -f http://localhost:8040/api/pinepods_check; do sleep 5; done' + echo "✅ Previous version (${{ steps.get_previous_tag.outputs.previous_tag }}) is ready" + + - name: Stop previous version + run: | + echo "🛑 Stopping previous PinePods version" + docker compose -f docker-compose.previous.yml stop pinepods_previous + echo "✅ Previous version stopped (database preserved)" + + + - name: Build current version + run: | + echo "🔨 Building current PinePods version from source" + docker build -f dockerfile -t pinepods-current:test . + echo "✅ Build complete" + + - name: Start current version + run: | + + # Create docker-compose for current version + cat > docker-compose.current.yml << EOF + version: '3.8' + services: + pinepods_current: + image: pinepods-current:test + environment: + DB_TYPE: mysql + DB_HOST: mysql + DB_PORT: 3306 + DB_USER: root + DB_PASSWORD: ${{ env.TEST_DB_PASSWORD }} + DB_NAME: ${{ env.TEST_DB_NAME }} + VALKEY_HOST: valkey + VALKEY_PORT: 6379 + HOSTNAME: 'http://localhost:8040' + DEBUG_MODE: true + SEARCH_API_URL: 'https://search.pinepods.online/api/search' + PEOPLE_API_URL: 'https://people.pinepods.online' + ports: + - "8040:8040" + depends_on: + - mysql + - valkey + networks: + - test_network + + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: ${{ env.TEST_DB_PASSWORD }} + MYSQL_DATABASE: ${{ env.TEST_DB_NAME }} + networks: + - test_network + + valkey: + image: valkey/valkey:8-alpine + networks: + - test_network + + networks: + test_network: + driver: bridge + EOF + + echo "🚀 Starting current PinePods version" + # Start current version + docker compose -f docker-compose.current.yml up -d pinepods_current + + # Wait for current version to be ready + echo "⏳ Waiting for current version to initialize..." + sleep 60 + + # Check if current version is responding + timeout 120 bash -c 'while ! curl -f http://localhost:8040/api/pinepods_check; do echo "Waiting for current version..."; sleep 10; done' + echo "✅ Current version is ready" + + - name: Build validator and validate upgraded database + run: | + echo "🔨 Building database validator" + docker build -f Dockerfile.validator -t pinepods-validator . + + echo "🔍 Validating upgraded database schema" + docker run --rm --network pinepods_test_network \ + -e DB_TYPE=mysql \ + -e DB_HOST=mysql \ + -e DB_PORT=3306 \ + -e DB_USER=root \ + -e DB_PASSWORD=${{ env.TEST_DB_PASSWORD }} \ + -e DB_NAME=${{ env.TEST_DB_NAME }} \ + pinepods-validator + + - name: Test basic functionality + run: | + echo "🧪 Testing basic API functionality" + + # Test health endpoint + curl -f http://localhost:8040/api/health || exit 1 + + # Test pinepods check endpoint + curl -f http://localhost:8040/api/pinepods_check || exit 1 + + echo "✅ Basic functionality tests passed" + + - name: Cleanup + if: always() + run: | + echo "🧹 Cleaning up test environment" + docker compose -f docker-compose.previous.yml down -v || true + docker compose -f docker-compose.current.yml down -v || true + + test-postgresql-compatibility: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: test_password_123! + POSTGRES_DB: pinepods_test_db + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + valkey: + image: valkey/valkey:8-alpine + ports: + - 6379:6379 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get previous release tag + id: get_previous_tag + run: | + # Get the latest stable release (exclude rc, alpha, beta) + PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + + if [ -z "$PREVIOUS_TAG" ]; then + echo "No stable release tag found, using 0.7.9 as baseline" + PREVIOUS_TAG="0.7.9" + fi + + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "Using previous tag: $PREVIOUS_TAG" + + - name: Start previous PinePods version + run: | + echo "🚀 Starting PinePods ${{ steps.get_previous_tag.outputs.previous_tag }} (PostgreSQL)" + + cat > docker-compose.postgres-previous.yml << EOF + version: '3.8' + services: + pinepods_previous: + image: madeofpendletonwool/pinepods:${{ steps.get_previous_tag.outputs.previous_tag }} + environment: + DB_TYPE: postgresql + DB_HOST: postgres + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: ${{ env.TEST_DB_PASSWORD }} + DB_NAME: ${{ env.TEST_DB_NAME }} + VALKEY_HOST: valkey + VALKEY_PORT: 6379 + HOSTNAME: 'http://localhost:8040' + DEBUG_MODE: true + SEARCH_API_URL: 'https://search.pinepods.online/api/search' + PEOPLE_API_URL: 'https://people.pinepods.online' + ports: + - "8040:8040" + depends_on: + - postgres + - valkey + networks: + - test_network + + postgres: + image: postgres:latest + environment: + POSTGRES_PASSWORD: ${{ env.TEST_DB_PASSWORD }} + POSTGRES_DB: ${{ env.TEST_DB_NAME }} + networks: + - test_network + + valkey: + image: valkey/valkey:8-alpine + networks: + - test_network + + networks: + test_network: + driver: bridge + EOF + + docker compose -f docker-compose.postgres-previous.yml up -d + sleep 30 + timeout 60 bash -c 'while ! curl -f http://localhost:8040/api/pinepods_check; do sleep 5; done' + + - name: Stop previous version + run: | + echo "🛑 Stopping previous PinePods version" + docker compose -f docker-compose.postgres-previous.yml stop pinepods_previous + echo "✅ Previous version stopped (database preserved)" + + - name: Build current version (PostgreSQL) + run: | + echo "🔨 Building current PinePods version from source" + docker build -f dockerfile -t pinepods-current:test . + echo "✅ Build complete" + + - name: Test current version (PostgreSQL) + run: | + echo "🚀 Starting current PinePods version with PostgreSQL" + + # Create docker-compose for current version + cat > docker-compose.postgres-current.yml << EOF + version: '3.8' + services: + pinepods_current: + image: pinepods-current:test + environment: + DB_TYPE: postgresql + DB_HOST: postgres + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: ${{ env.TEST_DB_PASSWORD }} + DB_NAME: ${{ env.TEST_DB_NAME }} + VALKEY_HOST: valkey + VALKEY_PORT: 6379 + HOSTNAME: 'http://localhost:8040' + DEBUG_MODE: true + SEARCH_API_URL: 'https://search.pinepods.online/api/search' + PEOPLE_API_URL: 'https://people.pinepods.online' + ports: + - "8040:8040" + depends_on: + - postgres + - valkey + networks: + - test_network + + postgres: + image: postgres:latest + environment: + POSTGRES_PASSWORD: ${{ env.TEST_DB_PASSWORD }} + POSTGRES_DB: ${{ env.TEST_DB_NAME }} + networks: + - test_network + + valkey: + image: valkey/valkey:8-alpine + networks: + - test_network + + networks: + test_network: + driver: bridge + EOF + + # Start current version + docker compose -f docker-compose.postgres-current.yml up -d pinepods_current + + # Wait for current version to be ready + echo "⏳ Waiting for current version to initialize..." + sleep 60 + + # Check if current version is responding + timeout 120 bash -c 'while ! curl -f http://localhost:8040/api/pinepods_check; do echo "Waiting for current version..."; sleep 10; done' + echo "✅ Current version is ready" + + - name: Build validator and validate upgraded database (PostgreSQL) + run: | + echo "🔨 Building PostgreSQL database validator" + docker build -f Dockerfile.validator.postgres -t pinepods-validator-postgres . + + echo "🔍 Validating upgraded database schema" + docker run --rm --network pinepods_test_network \ + -e DB_TYPE=postgresql \ + -e DB_HOST=postgres \ + -e DB_PORT=5432 \ + -e DB_USER=postgres \ + -e DB_PASSWORD=${{ env.TEST_DB_PASSWORD }} \ + -e DB_NAME=${{ env.TEST_DB_NAME }} \ + pinepods-validator-postgres + + - name: Cleanup + if: always() + run: | + docker compose -f docker-compose.postgres-previous.yml down -v || true + docker compose -f docker-compose.postgres-current.yml down -v || true diff --git a/PinePods-0.8.2/.github/workflows/build-android-flutter.yml b/PinePods-0.8.2/.github/workflows/build-android-flutter.yml new file mode 100644 index 0000000..e7db754 --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/build-android-flutter.yml @@ -0,0 +1,115 @@ +permissions: + contents: write +name: Build Android Flutter App + +on: + push: + tags: + - "*" + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Manual override version tag (optional)" + required: false + +jobs: + build: + name: Build Android Release + runs-on: ubuntu-latest + + steps: + - name: Set Image Tag + run: echo "IMAGE_TAG=${{ github.event.release.tag_name || github.event.inputs.version || 'latest' }}" >> $GITHUB_ENV + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch full git history for accurate commit count + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.35.2" + channel: "stable" + + - name: Install dependencies + run: | + cd mobile + flutter pub get + + - name: Setup Android signing + run: | + cd mobile/android + echo "storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}" > key.properties + echo "keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}" >> key.properties + echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" >> key.properties + echo "storeFile=../upload-keystore.jks" >> key.properties + echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > upload-keystore.jks + + - name: Verify version files + run: | + cd mobile + echo "Current version in pubspec.yaml:" + grep "^version:" pubspec.yaml + echo "Current version in environment.dart:" + grep "_projectVersion\|_build" lib/core/environment.dart + echo "Build will use versions exactly as they are in the repository" + + + - name: Build APK + run: | + cd mobile + flutter build apk --release --split-per-abi + + - name: Build AAB + run: | + cd mobile + flutter build appbundle --release + + - name: Rename APK files + run: | + cd mobile/build/app/outputs/flutter-apk + # Extract version from IMAGE_TAG (remove 'v' prefix if present) + VERSION=${IMAGE_TAG#v} + if [[ "$VERSION" == "latest" ]]; then + VERSION="0.0.0" + fi + + # Rename APK files with proper naming convention + mv app-armeabi-v7a-release.apk pinepods-armeabi-${VERSION}.apk + mv app-arm64-v8a-release.apk pinepods-arm64-${VERSION}.apk + mv app-x86_64-release.apk pinepods-x86_64-${VERSION}.apk + + - name: Upload APK artifacts + uses: actions/upload-artifact@v4 + with: + name: android-apk-builds + path: mobile/build/app/outputs/flutter-apk/pinepods-*.apk + + - name: Upload AAB artifact + uses: actions/upload-artifact@v4 + with: + name: android-aab-build + path: mobile/build/app/outputs/bundle/release/app-release.aab + + # - name: Upload to Google Play Store + # if: github.event_name == 'release' + # env: + # GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + # run: | + # echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > service-account.json + # # Install fastlane if needed for Play Store upload + # # gem install fastlane + # # fastlane supply --aab mobile/build/app/outputs/bundle/release/app-release.aab --json_key service-account.json --package_name com.gooseberrydevelopment.pinepods --track production diff --git a/PinePods-0.8.2/.github/workflows/build-flatpak.yml b/PinePods-0.8.2/.github/workflows/build-flatpak.yml new file mode 100644 index 0000000..744c968 --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/build-flatpak.yml @@ -0,0 +1,124 @@ +name: Build Pinepods Flatpak + +on: + workflow_run: + workflows: ["Build Tauri Clients"] + types: + - completed + workflow_dispatch: + inputs: + version: + description: "Version to build (for testing)" + required: true + default: "test" + +env: + FLATPAK_ID: com.gooseberrydevelopment.pinepods + +jobs: + build-flatpak: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Flatpak + run: | + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder appstream + + - name: Install Flatpak SDK + run: | + flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + flatpak install --user -y flathub org.gnome.Platform//47 org.gnome.Sdk//47 + + - name: Clone Flathub repo + run: | + git clone https://github.com/flathub/com.gooseberrydevelopment.pinepods flathub-repo + cp flathub-repo/com.gooseberrydevelopment.pinepods.yml . + + - name: Set VERSION variable + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + else + LATEST_RELEASE=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name) + echo "VERSION=$LATEST_RELEASE" >> $GITHUB_ENV + fi + + - name: Download DEBs and calculate checksums + run: | + # Download both DEBs + curl -L "https://github.com/${{ github.repository }}/releases/download/$VERSION/Pinepods_${VERSION}_amd64.deb" -o amd64.deb + curl -L "https://github.com/${{ github.repository }}/releases/download/$VERSION/Pinepods_${VERSION}_arm64.deb" -o arm64.deb + + # Calculate and display checksums + AMD64_SHA256=$(sha256sum amd64.deb | cut -d' ' -f1) + ARM64_SHA256=$(sha256sum arm64.deb | cut -d' ' -f1) + echo "Calculated AMD64 SHA256: $AMD64_SHA256" + echo "Calculated ARM64 SHA256: $ARM64_SHA256" + + # Export to environment + echo "AMD64_SHA256=$AMD64_SHA256" >> $GITHUB_ENV + echo "ARM64_SHA256=$ARM64_SHA256" >> $GITHUB_ENV + + - name: Update manifest version and URL + run: | + echo "Updating manifest for version: $VERSION" + + # Show environment variables + echo "Using AMD64 SHA256: $AMD64_SHA256" + echo "Using ARM64 SHA256: $ARM64_SHA256" + + # Update AMD64 entry first + sed -i "/.*amd64.deb/,/sha256:/ s|sha256: .*|sha256: $AMD64_SHA256|" com.gooseberrydevelopment.pinepods.yml + + # Update ARM64 entry second + sed -i "/.*arm64.deb/,/sha256:/ s|sha256: .*|sha256: $ARM64_SHA256|" com.gooseberrydevelopment.pinepods.yml + + # Update URLs + sed -i "s|url: .*amd64.deb|url: https://github.com/${{ github.repository }}/releases/download/$VERSION/Pinepods_${VERSION}_amd64.deb|" com.gooseberrydevelopment.pinepods.yml + sed -i "s|url: .*arm64.deb|url: https://github.com/${{ github.repository }}/releases/download/$VERSION/Pinepods_${VERSION}_arm64.deb|" com.gooseberrydevelopment.pinepods.yml + + echo "Updated manifest content:" + cat com.gooseberrydevelopment.pinepods.yml + + - name: Get shared Modules + run: | + git clone https://github.com/flathub/shared-modules + + # Test build steps + - name: Build and test Flatpak + run: | + flatpak-builder --force-clean --sandbox --user --install-deps-from=flathub --ccache \ + --mirror-screenshots-url=https://dl.flathub.org/media/ --repo=repo builddir \ + com.gooseberrydevelopment.pinepods.yml + + flatpak remote-add --user --no-gpg-verify test-repo "$(pwd)/repo" + flatpak install --user -y test-repo ${{ env.FLATPAK_ID }} + + # Basic launch test (timeout after 30s) + timeout 30s flatpak run ${{ env.FLATPAK_ID }} || true + + # Verify metainfo + flatpak run --command=cat ${{ env.FLATPAK_ID }} \ + /app/share/metainfo/${{ env.FLATPAK_ID }}.metainfo.xml + + - name: Create Flatpak bundle + run: | + flatpak build-bundle repo ${{ env.FLATPAK_ID }}.flatpak ${{ env.FLATPAK_ID }} + + # Archive everything needed for the Flathub PR + - name: Archive Flatpak files + run: | + mkdir flatpak_output + cp ${{ env.FLATPAK_ID }}.flatpak flatpak_output/ + cp com.gooseberrydevelopment.pinepods.yml flatpak_output/ + tar -czvf flatpak_files.tar.gz flatpak_output + + - name: Upload Flatpak archive + uses: actions/upload-artifact@v4 + with: + name: flatpak-files + path: flatpak_files.tar.gz \ No newline at end of file diff --git a/PinePods-0.8.2/.github/workflows/build-helm-chart.yml b/PinePods-0.8.2/.github/workflows/build-helm-chart.yml new file mode 100644 index 0000000..93e6185 --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/build-helm-chart.yml @@ -0,0 +1,82 @@ +name: Build Helm Chart + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Manual override version tag (optional)" + required: false + +env: + REGISTRY: docker.io + IMAGE_NAME: madeofpendletonwool/pinepods + CHART_NAME: Pinepods + +jobs: + build-helm-chart: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.PUSH_PAT }} + persist-credentials: true + + - name: Setup Helm + uses: Azure/setup-helm@v4.2.0 + + - name: Install yq + run: | + sudo wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq &&\ + sudo chmod +x /usr/bin/yq + + - name: Set Chart Version + run: | + if [ -n "${{ github.event.release.tag_name }}" ]; then + version=${{ github.event.release.tag_name }} + elif [ -n "${{ github.event.inputs.version }}" ]; then + version=${{ github.event.inputs.version }} + else + echo "No version provided. Exiting." + exit 1 + fi + echo "Setting chart version to $version" + yq e ".version = \"$version\"" -i deployment/kubernetes/helm/pinepods/Chart.yaml + + - name: Package Helm chart + run: | + helm dependency update ./deployment/kubernetes/helm/pinepods + helm package ./deployment/kubernetes/helm/pinepods --destination ./docs + + - name: Remove old Helm chart + run: | + ls docs/ + find docs/ -type f -name "${CHART_NAME}-*.tgz" ! -name "${CHART_NAME}-${{ github.event.release.tag_name }}.tgz" -exec rm {} + + + - name: Update Helm repo index + run: | + helm repo index docs --url https://helm.pinepods.online + + - name: Fetch all branches + run: git fetch --all + + - name: Fetch tags + run: git fetch --tags + + - name: Checkout main branch + run: git checkout main + + - uses: EndBug/add-and-commit@v9 + with: + github_token: ${{ secrets.PUSH_PAT }} + committer_name: GitHub Actions + committer_email: actions@github.com + message: "Update Helm chart for release ${{ github.event.release.tag_name }}" + add: "docs" diff --git a/PinePods-0.8.2/.github/workflows/build-ios-flutter.yml b/PinePods-0.8.2/.github/workflows/build-ios-flutter.yml new file mode 100644 index 0000000..fb1541b --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/build-ios-flutter.yml @@ -0,0 +1,138 @@ +permissions: + contents: read +name: Build iOS Flutter App + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Manual override version tag (optional)" + required: false + +jobs: + build: + name: Build iOS Release + runs-on: macOS-latest + + steps: + - name: Set Image Tag + run: echo "IMAGE_TAG=${{ github.event.release.tag_name || github.event.inputs.version || 'latest' }}" >> $GITHUB_ENV + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.32.0' + channel: 'stable' + + - name: Install dependencies + run: | + cd mobile + flutter pub get + cd ios + pod install + + - name: Setup iOS signing + env: + IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + # Create keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -l build.keychain + + # Import certificate + echo "$IOS_CERTIFICATE_BASE64" | base64 -d > certificate.p12 + security import certificate.p12 -P "$IOS_CERTIFICATE_PASSWORD" -A + + # Install provisioning profile + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + echo "$IOS_PROVISIONING_PROFILE_BASE64" | base64 -d > ~/Library/MobileDevice/Provisioning\ Profiles/build.mobileprovision + + - name: Update app version + run: | + cd mobile + # Update pubspec.yaml version + if [[ "$IMAGE_TAG" != "latest" ]]; then + sed -i '' "s/^version: .*/version: ${IMAGE_TAG#v}/" pubspec.yaml + fi + + - name: Build iOS app + run: | + cd mobile + flutter build ios --release --no-codesign + + - name: Archive and sign iOS app + run: | + cd mobile/ios + xcodebuild -workspace Runner.xcworkspace \ + -scheme Runner \ + -configuration Release \ + -destination generic/platform=iOS \ + -archivePath build/Runner.xcarchive \ + archive + + xcodebuild -exportArchive \ + -archivePath build/Runner.xcarchive \ + -exportPath build \ + -exportOptionsPlist exportOptions.plist + + - name: Create export options plist + run: | + cd mobile/ios + cat > exportOptions.plist << EOF + + + + + method + app-store + teamID + ${{ secrets.IOS_TEAM_ID }} + uploadBitcode + + uploadSymbols + + compileBitcode + + + + EOF + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + with: + name: ios-ipa-build + path: mobile/ios/build/*.ipa + + - name: Upload to App Store Connect + if: github.event_name == 'release' + env: + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }} + run: | + echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 -d > AuthKey.p8 + xcrun altool --upload-app \ + --type ios \ + --file mobile/ios/build/*.ipa \ + --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \ + --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID" + + - name: Cleanup keychain and provisioning profile + if: always() + run: | + if security list-keychains | grep -q "build.keychain"; then + security delete-keychain build.keychain + fi + rm -f ~/Library/MobileDevice/Provisioning\ Profiles/build.mobileprovision + rm -f certificate.p12 + rm -f AuthKey.p8 \ No newline at end of file diff --git a/PinePods-0.8.2/.github/workflows/build-snap.yml b/PinePods-0.8.2/.github/workflows/build-snap.yml new file mode 100644 index 0000000..5141df1 --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/build-snap.yml @@ -0,0 +1,92 @@ +name: Build Pinepods Snap + +on: + # workflow_run: + # workflows: ["Build Tauri Clients"] + # types: + # - completed + workflow_dispatch: + inputs: + version: + description: "Version to build (for testing)" + required: true + default: "test" + +jobs: + build-snap: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: get_version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + else + LATEST_RELEASE=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name) + echo "VERSION=$LATEST_RELEASE" >> $GITHUB_ENV + fi + + - name: Install Snap + run: | + sudo apt-get update + sudo apt-get install -y snapd + + - name: Install Snapcraft + run: | + sudo apt-get install -y snapd + sudo snap install core22 + sudo snap install snapcraft --classic + + - name: Install Multipass + run: | + sudo snap install multipass --classic + + # - name: Setup LXD + # uses: canonical/setup-lxd@main + # with: + # channel: latest/edge + + - name: Prepare Snap configuration + run: | + cp clients/snap/snapcraft.yaml ./snapcraft.yaml + sudo chown root:root snapcraft.yaml + sudo chmod 644 snapcraft.yaml + sed -i "s|version: '.*'|version: '$VERSION'|" snapcraft.yaml + sed -i "s|url: .*|url: https://github.com/${{ github.repository }}/releases/download/$VERSION/pinepods_${VERSION}_amd64.deb|" snapcraft.yaml + sed -i "s|Icon=appname|Icon=/usr/share/icons/hicolor/128x128/apps/com.gooseberrydevelopment.pinepods.png|" snapcraft.yaml + + - name: Configure snapcraft to use Multipass + run: | + sudo snap set snapcraft provider=multipass + + - name: Nuclear permissions reset + run: | + sudo rm -rf /root/project || true + sudo mkdir -p /root/project + sudo cp -r . /root/project/ + sudo chown -R root:root /root/project + sudo chmod -R 777 /root/project + sudo chmod -R a+rwx /root/project + sudo ls -la /root/project + + - name: Build Snap package + env: + SNAPCRAFT_PROJECT_DIR: ${{ github.workspace }} + run: sudo -E snapcraft --verbose + + - name: Archive Snap files + run: | + mkdir snap_output + cp *.snap snap_output/ + cp snapcraft.yaml snap_output/ + tar -czvf snap_files.tar.gz snap_output + + - name: Upload Snap archive + uses: actions/upload-artifact@v4 + with: + name: snap-files + path: snap_files.tar.gz diff --git a/PinePods-0.8.2/.github/workflows/build-tauri-clients.yml b/PinePods-0.8.2/.github/workflows/build-tauri-clients.yml new file mode 100644 index 0000000..6d9a08e --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/build-tauri-clients.yml @@ -0,0 +1,377 @@ +name: Build Tauri Clients + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Manual override version tag (optional)" + required: false + +jobs: + compile: + name: Compile + strategy: + matrix: + os: + - ubuntu-latest + - ubuntu-arm64 + - macOS-latest + - macOS-13 + - windows-latest + include: + - os: ubuntu-arm64 + runs-on: ubuntu-24.04-arm + + runs-on: ${{ matrix.runs-on || matrix.os }} + + env: + DEPENDS_SETUP: ${{ startsWith(matrix.os, 'ubuntu-') && 'true' || 'false' }} + + steps: + - name: Set Image Tag (Unix) + if: matrix.os != 'windows-latest' + run: echo "IMAGE_TAG=${{ github.event.release.tag_name || github.event.inputs.version || 'latest' }}" >> $GITHUB_ENV + + - name: Set Image Tag (Windows) + if: matrix.os == 'windows-latest' + run: echo "IMAGE_TAG=${{ github.event.release.tag_name || github.event.inputs.version || 'latest' }}" >> $Env:GITHUB_ENV + shell: pwsh + + - name: Set environment variables + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + echo "ARTIFACT_NAME1=Pinepods_${{ env.IMAGE_TAG }}_amd64.deb" >> $GITHUB_ENV + echo "ARTIFACT_NAME2=Pinepods_${{ env.IMAGE_TAG }}_amd64.AppImage" >> $GITHUB_ENV + echo "ARTIFACT_NAME3=Pinepods-${{ env.IMAGE_TAG }}-1.x86_64.rpm" >> $GITHUB_ENV + elif [ "${{ matrix.os }}" = "ubuntu-arm64" ]; then + echo "ARTIFACT_NAME1=Pinepods_${{ env.IMAGE_TAG }}_arm64.deb" >> $GITHUB_ENV + echo "ARTIFACT_NAME2=Pinepods_${{ env.IMAGE_TAG }}_aarch64.AppImage" >> $GITHUB_ENV + echo "ARTIFACT_NAME3=Pinepods-${{ env.IMAGE_TAG }}-1.aarch64.rpm" >> $GITHUB_ENV + # ... rest of conditions ... + elif [ "${{ matrix.os }}" = "windows-latest" ]; then + echo "ARTIFACT_NAME1=Pinepods_${{ env.IMAGE_TAG }}_x64-setup.exe" >> $Env:GITHUB_ENV + echo "ARTIFACT_NAME2=Pinepods_${{ env.IMAGE_TAG }}_x64_en-US.msi" >> $Env:GITHUB_ENV + elif [ "${{ matrix.os }}" = "macOS-latest" ]; then + echo "ARTIFACT_NAME1=Pinepods_${{ env.IMAGE_TAG }}_aarch64.dmg" >> $GITHUB_ENV + echo "ARTIFACT_NAME2=Pinepods.app" >> $GITHUB_ENV + elif [ "${{ matrix.os }}" = "macOS-13" ]; then + echo "ARTIFACT_NAME1=Pinepods_${{ env.IMAGE_TAG }}_x64.dmg" >> $GITHUB_ENV + echo "ARTIFACT_NAME2=Pinepods.app" >> $GITHUB_ENV + fi + shell: bash + if: ${{ matrix.os != 'windows-latest' }} + + - name: Set environment variables (Windows) + run: | + echo "ARTIFACT_NAME1=Pinepods_${{ env.IMAGE_TAG }}_x64-setup.exe" >> $Env:GITHUB_ENV + echo "ARTIFACT_NAME2=Pinepods_${{ env.IMAGE_TAG }}_x64_en-US.msi" >> $Env:GITHUB_ENV + shell: pwsh + if: ${{ matrix.os == 'windows-latest' }} + + - name: Setup | Checkout + uses: actions/checkout@v4 + + - uses: hecrj/setup-rust-action@v2 + with: + rust-version: 1.89 + targets: wasm32-unknown-unknown + + # Install cargo-binstall for Linux/Windows + - name: Install cargo-binstall + if: matrix.os != 'macos-latest' && matrix.os != 'macOS-13' + uses: cargo-bins/cargo-binstall@main + + - name: Depends install + if: ${{ env.DEPENDS_SETUP == 'true' }} + run: | + sudo apt update + sudo apt install -qy libgtk-3-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: wasm-addition + run: | + rustup target add wasm32-unknown-unknown + + - name: Install Trunk (macOS) + if: matrix.os == 'macos-latest' || matrix.os == 'macOS-13' + run: | + brew install trunk + + - name: Install Trunk (Linux/Windows) + if: matrix.os != 'macos-latest' && matrix.os != 'macOS-13' + run: | + cargo binstall trunk -y + + - name: Install Tauri + run: | + cargo install tauri-cli@2.0.0-rc.15 --locked + + - name: Update Tauri version (UNIX) + run: | + cd web/src-tauri + # Use different sed syntax for macOS + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s/\"version\": \".*\"/\"version\": \"${IMAGE_TAG}\"/" tauri.conf.json + else + sed -i "s/\"version\": \".*\"/\"version\": \"${IMAGE_TAG}\"/" tauri.conf.json + fi + cat tauri.conf.json + shell: bash + if: ${{ matrix.os != 'windows-latest' }} + + - name: Setup Python + if: ${{ matrix.os == 'windows-latest' }} + uses: actions/setup-python@v2 + with: + python-version: "3.x" + + - name: Verify directory and update Tauri version (Windows) + if: ${{ matrix.os == 'windows-latest' }} + run: | + cd web/src-tauri + dir + python .\change-version.py tauri.conf.json ${{ env.IMAGE_TAG }} + Get-Content tauri.conf.json + shell: pwsh + + - name: Build | Compile (UNIX) + run: | + cd web + RUSTFLAGS="--cfg=web_sys_unstable_apis --cfg getrandom_backend=\"wasm_js\"" trunk build --features server_build + cd src-tauri + cat tauri.conf.json + cargo tauri build + pwd + ls + ls -la target/release/bundle + shell: bash + if: ${{ matrix.os != 'windows-latest' }} + + - name: Build | Compile (Windows) + run: | + cd web + powershell -ExecutionPolicy Bypass -File .\build.ps1 + cd src-tauri + Get-Content tauri.conf.json + cargo tauri build + ls target/release/bundle + shell: pwsh + if: ${{ matrix.os == 'windows-latest' }} + + # Ubuntu (x86_64) builds + - name: Archive builds (Ubuntu) + uses: actions/upload-artifact@v4 + with: + name: ubuntu-latest-builds + path: | + ./web/src-tauri/target/release/bundle/deb/${{ env.ARTIFACT_NAME1 }} + ./web/src-tauri/target/release/bundle/appimage/${{ env.ARTIFACT_NAME2 }} + ./web/src-tauri/target/release/bundle/rpm/${{ env.ARTIFACT_NAME3 }} + if: ${{ matrix.os == 'ubuntu-latest' }} + + # Ubuntu ARM64 builds + - name: Archive builds (Ubuntu ARM) + uses: actions/upload-artifact@v4 + with: + name: ubuntu-arm64-builds + path: | + ./web/src-tauri/target/release/bundle/deb/${{ env.ARTIFACT_NAME1 }} + ./web/src-tauri/target/release/bundle/appimage/${{ env.ARTIFACT_NAME2 }} + ./web/src-tauri/target/release/bundle/rpm/${{ env.ARTIFACT_NAME3 }} + if: ${{ matrix.os == 'ubuntu-arm64' }} + + # macOS builds - with distinct names + - name: Archive build (macOS ARM) + uses: actions/upload-artifact@v4 + with: + name: macos-arm64-builds + path: | + ./web/src-tauri/target/release/bundle/dmg/${{ env.ARTIFACT_NAME1 }} + ./web/src-tauri/target/release/bundle/macos/${{ env.ARTIFACT_NAME2 }} + if: ${{ matrix.os == 'macOS-latest' }} + + - name: Archive build (macOS x64) + uses: actions/upload-artifact@v4 + with: + name: macos-x64-builds + path: | + ./web/src-tauri/target/release/bundle/dmg/${{ env.ARTIFACT_NAME1 }} + ./web/src-tauri/target/release/bundle/macos/${{ env.ARTIFACT_NAME2 }} + if: ${{ matrix.os == 'macOS-13' }} + + - name: Archive build (Windows) + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.os }}-build + path: | + ./web/src-tauri/target/release/bundle/nsis/${{ env.ARTIFACT_NAME1 }} + ./web/src-tauri/target/release/bundle/msi/${{ env.ARTIFACT_NAME2 }} + if: ${{ matrix.os == 'windows-latest' }} + + - name: Upload release asset (Ubuntu - DEB) + if: github.event_name == 'release' && matrix.os == 'ubuntu-latest' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/deb/${{ env.ARTIFACT_NAME1 }} + asset_name: ${{ env.ARTIFACT_NAME1 }} + asset_content_type: application/vnd.debian.binary-package + + - name: Upload release asset (Ubuntu ARM - DEB) + if: github.event_name == 'release' && matrix.os == 'ubuntu-arm64' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/deb/${{ env.ARTIFACT_NAME1 }} + asset_name: ${{ env.ARTIFACT_NAME1 }} + asset_content_type: application/vnd.debian.binary-package + + - name: Upload release asset (Ubuntu - AppImage) + if: github.event_name == 'release' && matrix.os == 'ubuntu-latest' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/appimage/${{ env.ARTIFACT_NAME2 }} + asset_name: ${{ env.ARTIFACT_NAME2 }} + asset_content_type: application/x-executable + + - name: Upload release asset (Ubuntu ARM - AppImage) + if: github.event_name == 'release' && matrix.os == 'ubuntu-arm64' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/appimage/${{ env.ARTIFACT_NAME2 }} + asset_name: ${{ env.ARTIFACT_NAME2 }} + asset_content_type: application/x-executable + + - name: Upload release asset (Ubuntu - RPM) + if: github.event_name == 'release' && matrix.os == 'ubuntu-latest' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/rpm/${{ env.ARTIFACT_NAME3 }} + asset_name: ${{ env.ARTIFACT_NAME3 }} + asset_content_type: application/x-rpm + + - name: Upload release asset (Ubuntu ARM - RPM) + if: github.event_name == 'release' && matrix.os == 'ubuntu-arm64' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/rpm/${{ env.ARTIFACT_NAME3 }} + asset_name: ${{ env.ARTIFACT_NAME3 }} + asset_content_type: application/x-rpm + + - name: Upload release asset (macOS - DMG) + if: github.event_name == 'release' && (matrix.os == 'macOS-latest' || matrix.os == 'macOS-13') + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/dmg/${{ env.ARTIFACT_NAME1 }} + asset_name: ${{ env.ARTIFACT_NAME1 }} + asset_content_type: application/x-apple-diskimage + + - name: Upload release asset (Windows - EXE) + if: github.event_name == 'release' && matrix.os == 'windows-latest' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/nsis/${{ env.ARTIFACT_NAME1 }} + asset_name: ${{ env.ARTIFACT_NAME1 }} + asset_content_type: application/vnd.microsoft.portable-executable + + - name: Upload release asset (Windows - MSI) + if: github.event_name == 'release' && matrix.os == 'windows-latest' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./web/src-tauri/target/release/bundle/msi/${{ env.ARTIFACT_NAME2 }} + asset_name: ${{ env.ARTIFACT_NAME2 }} + asset_content_type: application/x-msi + + # release: + # needs: compile + # runs-on: ubuntu-latest + # steps: + # - name: Checkout code + # uses: actions/checkout@v2 + + # - name: Download artifacts + # uses: actions/download-artifact@v2 + # with: + # name: ubuntu-latest-build + # path: artifacts/ubuntu-latest + # - name: Download artifacts + # uses: actions/download-artifact@v2 + # with: + # name: macOS-latest-build + # path: artifacts/macOS-latest + # - name: Download artifacts + # uses: actions/download-artifact@v2 + # with: + # name: windows-latest-build + # path: artifacts/windows-latest + + # - name: Create Release + # id: create_release + # uses: actions/create-release@v1 + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # with: + # tag_name: release-${{ github.run_id }}-beta + # release_name: Release-${{ github.run_id }}-beta + # draft: false + # prerelease: true + + # - name: Upload Release Asset + # id: upload-release-asset-ubuntu + # uses: actions/upload-release-asset@v1 + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # with: + # upload_url: ${{ steps.create_release.outputs.upload_url }} + # asset_path: ./artifacts/ubuntu-latest/PinePods + # asset_name: PinePods-ubuntu-latest + # asset_content_type: application/octet-stream + + # - name: Upload Release Asset + # id: upload-release-asset-macos + # uses: actions/upload-release-asset@v1 + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # with: + # upload_url: ${{ steps.create_release.outputs.upload_url }} + # asset_path: ./artifacts/macOS-latest/PinePods + # asset_name: PinePods-macOS-latest + # asset_content_type: application/octet-stream + + # - name: Upload Release Asset + # id: upload-release-asset-windows + # uses: actions/upload-release-asset@v1 + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # with: + # upload_url: ${{ steps.create_release.outputs.upload_url }} + # asset_path: ./artifacts/windows-latest/PinePods.exe + # asset_name: PinePods-windows-latest.exe + # asset_content_type: application/octet-stream diff --git a/PinePods-0.8.2/.github/workflows/ci.yaml b/PinePods-0.8.2/.github/workflows/ci.yaml new file mode 100644 index 0000000..58bbc8b --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/ci.yaml @@ -0,0 +1,103 @@ +name: Pinepods CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +jobs: + backend-tests: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_DB: test_db + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: Setup test environment + run: | + chmod +x ./setup-tests.sh + ./setup-tests.sh + + - name: Run backend tests + env: + TEST_MODE: true + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: test_user + DB_PASSWORD: test_password + DB_NAME: test_db + DB_TYPE: postgresql + TEST_DB_TYPE: postgresql + PYTHONPATH: ${{ github.workspace }} + run: | + chmod +x ./run-tests.sh + ./run-tests.sh postgresql + + frontend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: hecrj/setup-rust-action@v2 + with: + rust-version: 1.89 + targets: wasm32-unknown-unknown + + # Install cargo-binstall for other OSes using the standard method + - name: Install cargo-binstall + if: matrix.os != 'macos-latest' + uses: cargo-bins/cargo-binstall@main + + - name: Depends install + if: ${{ env.DEPENDS_SETUP == 'true' }} + run: | + sudo apt update + sudo apt install -qy libgtk-3-dev + sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: wasm-addition + run: | + rustup target add wasm32-unknown-unknown + + - name: Install Trunk + run: | + cargo binstall trunk -y + + - name: Run frontend tests + working-directory: ./web + run: | + RUSTFLAGS="--cfg=web_sys_unstable_apis" cargo test --features server_build -- --nocapture + + # docker-build: + # runs-on: ubuntu-latest + # needs: [backend-tests, frontend-tests] + # steps: + # - uses: actions/checkout@v3 + + # - name: Set up Docker Buildx + # uses: docker/setup-buildx-action@v2 + + # - name: Build and test Docker image + # run: | + # docker build -t pinepods:test . + # docker run --rm pinepods:test /bin/sh -c "python3 -m pytest /pinepods/tests/" diff --git a/PinePods-0.8.2/.github/workflows/docker-publish.yml b/PinePods-0.8.2/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..6ce793e --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/docker-publish.yml @@ -0,0 +1,120 @@ +name: Publish Pinepods Multi-Architecture Image to DockerHub +on: + release: + types: [released] + workflow_dispatch: + inputs: + version: + description: "Manual override version tag (optional)" + required: false +env: + REGISTRY: docker.io + IMAGE_NAME: madeofpendletonwool/pinepods +jobs: + set-env: + runs-on: ubuntu-latest + outputs: + IMAGE_TAG: ${{ steps.set_tags.outputs.IMAGE_TAG }} + CREATE_LATEST: ${{ steps.set_tags.outputs.CREATE_LATEST }} + steps: + - name: Set Image Tag and Latest Tag + id: set_tags + run: | + echo "IMAGE_TAG=${{ github.event.release.tag_name || github.event.inputs.version || 'latest' }}" >> $GITHUB_OUTPUT + if [ "${{ github.event_name }}" == "release" ]; then + echo "CREATE_LATEST=true" >> $GITHUB_OUTPUT + else + echo "CREATE_LATEST=false" >> $GITHUB_OUTPUT + fi + + build-and-push-x86: + needs: set-env + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_KEY }} + - name: Build and push x86 image + run: | + docker build --platform linux/amd64 --build-arg PINEPODS_VERSION=${{ needs.set-env.outputs.IMAGE_TAG }} -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-amd64 -f dockerfile . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-amd64 + if [ "${{ needs.set-env.outputs.CREATE_LATEST }}" == "true" ]; then + docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-amd64 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 + fi + + build-and-push-arm64: + needs: set-env + runs-on: ubuntu-24.04-arm + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_KEY }} + - name: Build and push ARM64 image + run: | + docker build --platform linux/arm64 --build-arg PINEPODS_VERSION=${{ needs.set-env.outputs.IMAGE_TAG }} -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-arm64 -f dockerfile-arm . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-arm64 + if [ "${{ needs.set-env.outputs.CREATE_LATEST }}" == "true" ]; then + docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-arm64 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64 + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64 + fi + + create-manifests: + needs: [set-env, build-and-push-x86, build-and-push-arm64] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_KEY }} + + - name: Create and push Docker manifest for the version tag + run: | + sleep 10 + # Pull the images first to ensure they're available + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-amd64 + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-arm64 + + # Create and push manifest + docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }} \ + --amend ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-amd64 \ + --amend ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }}-arm64 + + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.set-env.outputs.IMAGE_TAG }} + + - name: Create and push Docker manifest for the latest tag + if: needs.set-env.outputs.CREATE_LATEST == 'true' + run: | + docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ + --amend ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \ + --amend ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64 + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/PinePods-0.8.2/.github/workflows/nightly-docker-publish.yml b/PinePods-0.8.2/.github/workflows/nightly-docker-publish.yml new file mode 100644 index 0000000..57a298b --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/nightly-docker-publish.yml @@ -0,0 +1,93 @@ +name: Publish Pinepods Nightly Multi-Architecture Image to DockerHub + +on: + schedule: + - cron: "23 1 * * *" + workflow_dispatch: + +env: + REGISTRY: docker.io + IMAGE_NAME: madeofpendletonwool/pinepods + NIGHTLY_TAG: nightly + +jobs: + build-and-push-nightly-x86: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_KEY }} + + - name: Build and push x86 image + run: | + docker build --platform linux/amd64 -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.NIGHTLY_TAG }}-amd64 -f dockerfile . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.NIGHTLY_TAG }}-amd64 + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} + + build-and-push-nightly-arm64: + runs-on: ubuntu-24.04-arm + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_KEY }} + + - name: Build and push ARMv8 image + run: | + docker build --platform linux/arm64 -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.NIGHTLY_TAG }}-arm64 -f dockerfile-arm . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.NIGHTLY_TAG }}-arm64 + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} + + manifest-nightly: + needs: [build-and-push-nightly-x86, build-and-push-nightly-arm64] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_KEY }} + + - name: Create and push Docker manifest for the nightly tag + run: | + docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.NIGHTLY_TAG }} \ + --amend ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.NIGHTLY_TAG }}-amd64 \ + --amend ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.NIGHTLY_TAG }}-arm64 + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.NIGHTLY_TAG }} \ No newline at end of file diff --git a/PinePods-0.8.2/.github/workflows/notification.yml b/PinePods-0.8.2/.github/workflows/notification.yml new file mode 100644 index 0000000..dbd9ea9 --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/notification.yml @@ -0,0 +1,67 @@ +name: Notifications on release + +on: + workflow_run: + workflows: ["Publish Pinepods Multi-Architecture Image to DockerHub"] + types: + - completed + workflow_dispatch: + inputs: + message_text: + description: "Manual override text (optional)" + required: false + +jobs: + discord_announcement: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Fetch the latest release + id: fetch_release + run: | + latest_release=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name') + release_url=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.html_url') + echo "Latest release version: $latest_release" + echo "Release URL: $release_url" + echo "::set-output name=version::$latest_release" + echo "::set-output name=release_url::$release_url" + + # Check if this is an RC release + if [[ "$latest_release" == *"-rc"* ]]; then + echo "RC release detected, skipping Discord notification" + echo "::set-output name=is_rc::true" + else + echo "::set-output name=is_rc::false" + fi + + - name: Set release message + id: set_message + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "::set-output name=message::${{ github.event.inputs.message_text }}" + else + version="${{ steps.fetch_release.outputs.version }}" + release_url="${{ steps.fetch_release.outputs.release_url }}" + message="Pinepods Version $version Released! Check out the release [here]($release_url)" + echo "::set-output name=message::$message" + fi + + - name: Skip Discord notification for RC release + if: steps.fetch_release.outputs.is_rc == 'true' + run: | + echo "Skipping Discord notification for RC release: ${{ steps.fetch_release.outputs.version }}" + + - name: Discord notification to announce deployment + if: steps.fetch_release.outputs.is_rc == 'false' + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@master + with: + args: ${{ steps.set_message.outputs.message }} diff --git a/PinePods-0.8.2/.github/workflows/pre-release-version-update.yml b/PinePods-0.8.2/.github/workflows/pre-release-version-update.yml new file mode 100644 index 0000000..c360c0c --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/pre-release-version-update.yml @@ -0,0 +1,53 @@ +name: Pre-Release Version Update + +on: + workflow_dispatch: + inputs: + version: + description: "Version to set (e.g., 0.8.0)" + required: true + type: string + +jobs: + update-version: + name: Update Version Files + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update app version + run: | + cd mobile + VERSION_NAME=${{ github.event.inputs.version }} + # Calculate what the git count WILL BE after we commit (current + 1) + BUILD_NUMBER=$(($(git rev-list --count HEAD) + 1 + 20250000)) + + # Update pubspec.yaml version + sed -i "s/^version: .*/version: ${VERSION_NAME}+${BUILD_NUMBER}/" pubspec.yaml + + # Update environment.dart constants + sed -i "s/static const _projectVersion = '[^']*';/static const _projectVersion = '${VERSION_NAME}';/" lib/core/environment.dart + sed -i "s/static const _build = '[^']*';/static const _build = '${BUILD_NUMBER}';/" lib/core/environment.dart + + echo "Updated version to ${VERSION_NAME}+${BUILD_NUMBER}" + + - name: Commit and push version update + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add mobile/pubspec.yaml mobile/lib/core/environment.dart + git commit -m "chore: update version to ${{ github.event.inputs.version }} [skip ci]" + git push + + - name: Summary + run: | + echo "✅ Version updated to ${{ github.event.inputs.version }}" + echo "📋 Next steps:" + echo "1. Create a GitHub release pointing to the latest commit" + echo "2. The release workflow will build from that exact commit" + echo "3. Version files will match the commit for reproducible builds" \ No newline at end of file diff --git a/PinePods-0.8.2/.github/workflows/static.yml b/PinePods-0.8.2/.github/workflows/static.yml new file mode 100644 index 0000000..77d7e80 --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/static.yml @@ -0,0 +1,43 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy static content to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: './docs' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/PinePods-0.8.2/.github/workflows/test-pinepods.yml b/PinePods-0.8.2/.github/workflows/test-pinepods.yml new file mode 100644 index 0000000..cf01dfa --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/test-pinepods.yml @@ -0,0 +1,41 @@ +name: Test Pinepods +on: + # pull_request: + # types: + # - opened + # - synchronize + # branches: [ master ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build the Docker test container + run: docker build -t madeofpendletonwool/pinepods-test . -f dockerfile-test + - uses: rustsec/audit-check@v1.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: Run tests in the Docker container + run: docker run madeofpendletonwool/pinepods-test + + cache-checkmate: + runs-on: ubuntu-latest + steps: + - uses: taiki-e/cache-cargo-install-action@v1 + with: + tool: cargo-checkmate + + run-phase: + strategy: + matrix: + phase: [audit, build, check, clippy, doc, test] + needs: cache-checkmate + runs-on: ubuntu-latest + steps: + - uses: taiki-e/cache-cargo-install-action@v1 + with: + tool: cargo-checkmate + - uses: actions/checkout@v4 + - run: cargo-checkmate run ${{ matrix.phase }} \ No newline at end of file diff --git a/PinePods-0.8.2/.github/workflows/update-aur-package.yml b/PinePods-0.8.2/.github/workflows/update-aur-package.yml new file mode 100644 index 0000000..38f2d7f --- /dev/null +++ b/PinePods-0.8.2/.github/workflows/update-aur-package.yml @@ -0,0 +1,98 @@ +name: Update AUR Package + +on: + workflow_run: + workflows: ["Build Tauri Clients"] + types: + - completed + workflow_dispatch: + inputs: + version: + description: "Version tag (e.g. 0.6.6)" + required: true + +jobs: + update-aur-package: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + else + # Extract version from the triggering release + RELEASE_TAG=$(curl -s "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r .tag_name) + echo "VERSION=$RELEASE_TAG" >> $GITHUB_ENV + fi + + - name: Generate PKGBUILD + run: | + # Calculate checksums for both architectures + x86_64_url="https://github.com/madeofpendletonwool/PinePods/releases/download/$VERSION/Pinepods_${VERSION}_amd64.deb" + aarch64_url="https://github.com/madeofpendletonwool/PinePods/releases/download/$VERSION/Pinepods_${VERSION}_arm64.deb" + + echo "Downloading and calculating checksums..." + curl -L "$x86_64_url" -o x86_64.deb + curl -L "$aarch64_url" -o aarch64.deb + + x86_64_sum=$(sha256sum x86_64.deb | cut -d' ' -f1) + aarch64_sum=$(sha256sum aarch64.deb | cut -d' ' -f1) + + cat > PKGBUILD << EOF + pkgname=pinepods + pkgver=$VERSION + pkgrel=1 + pkgdesc="Pinepods is a complete podcast management system and allows you to play, download, and keep track of podcasts you enjoy. All self hosted and enjoyed on your own server!" + arch=('x86_64' 'aarch64') + url="https://github.com/madeofpendletonwool/PinePods" + license=('gpl3') + depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3' 'hicolor-icon-theme' 'libsoup' 'pango' 'webkit2gtk') + options=('!strip' '!emptydirs') + source_x86_64=("https://github.com/madeofpendletonwool/PinePods/releases/download/\${pkgver}/Pinepods_\${pkgver}_amd64.deb") + source_aarch64=("https://github.com/madeofpendletonwool/PinePods/releases/download/\${pkgver}/Pinepods_\${pkgver}_arm64.deb") + sha256sums_x86_64=('$x86_64_sum') + sha256sums_aarch64=('$aarch64_sum') + + package() { + # Extract the .deb package + cd "\$srcdir" + tar xf data.tar.gz -C "\$pkgdir/" + + # Create symlink from /usr/bin/app to /usr/bin/pinepods + ln -s /usr/bin/app "\$pkgdir/usr/bin/pinepods" + + # Ensure correct permissions + chmod 755 "\$pkgdir/usr/bin/app" + chmod 644 "\$pkgdir/usr/share/applications/Pinepods.desktop" + find "\$pkgdir/usr/share/icons" -type f -exec chmod 644 {} + + find "\$pkgdir" -type d -exec chmod 755 {} + + } + EOF + + - name: Test PKGBUILD + uses: KSXGitHub/github-actions-deploy-aur@v3.0.1 + with: + pkgname: pinepods + pkgbuild: ./PKGBUILD + test: true + commit_username: ${{ secrets.GIT_USER }} + commit_email: ${{ secrets.GIT_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "Update to version ${{ env.VERSION }}" + ssh_keyscan_types: rsa,ecdsa,ed25519 + + - name: Publish AUR package + if: success() + uses: KSXGitHub/github-actions-deploy-aur@v3.0.1 + with: + pkgname: pinepods + pkgbuild: ./PKGBUILD + commit_username: ${{ secrets.GIT_USER }} + commit_email: ${{ secrets.GIT_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "Update to version ${{ env.VERSION }}" + ssh_keyscan_types: rsa,ecdsa,ed25519 diff --git a/PinePods-0.8.2/.gitignore b/PinePods-0.8.2/.gitignore new file mode 100644 index 0000000..5051838 --- /dev/null +++ b/PinePods-0.8.2/.gitignore @@ -0,0 +1,206 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +/lib/ +/lib64/ +# Exception for Flutter lib directory +!/mobile/lib/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +# Sphinx documentation +docs/_build/ + +# InstallerFiles +clients/windows-app/pinepods.exe +clients/linux-app/pinepods +clients/windows-app/dist +clients/linux-app/dist +clients/mac-app/dist +clients/mac-app/pinepods +clients/windows-app/generated* +clients/windows-app/pinepods.spec +clients/linux-app/pinepods.spec + +# Mac Files +.DS_Store +clients/.DS_Store +*/.DS_Store +*/pinepods.spec +*/generated +clients/mac-app/pinepods.spec + + +# env files +*/env_file +*/.env + +# pycharm +.idea/* +.idea/misc.xml +.idea/misc.xml +.idea/PyPods.iml +.idea/misc.xml +.idea/misc.xml +.idea/PyPods.iml + +# Web Removals +web/target/* +web/.idea/* +keystore.properties +key.properties +**/key.properties + + +# Virtual Environment +venv/ +.venv/ +ENV/ + +# Python cache files +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.coverage +coverage.xml +.hypothesis/ + +# Environment variables +.env +.env.test + +# IDE specific files +.vscode/ +.idea/ +*.swp +*.swo + +# Test database +*.sqlite3 +*.db + +# Log files +*.log + +# Local test directory +tests_local/ + + +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VS Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.* + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 + +# C/C++ build files +**/android/app/.cxx/ +**/android/**/.cxx/ diff --git a/PinePods-0.8.2/Backend/docker-compose.yml b/PinePods-0.8.2/Backend/docker-compose.yml new file mode 100644 index 0000000..1f4f825 --- /dev/null +++ b/PinePods-0.8.2/Backend/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3' +services: + pinepods-backend: + image: madeofpendletonwool/pinepods_backend:latest + container_name: pinepods-backend + env_file: env_file + environment: + # Add your YouTube Data API v3 key here for YouTube channel search + - YOUTUBE_API_KEY=your_youtube_api_key_here + ports: + - 5000:5000 + restart: unless-stopped \ No newline at end of file diff --git a/PinePods-0.8.2/Backend/dockerfile b/PinePods-0.8.2/Backend/dockerfile new file mode 100644 index 0000000..c49186c --- /dev/null +++ b/PinePods-0.8.2/Backend/dockerfile @@ -0,0 +1,46 @@ +# Builder stage for compiling the Actix web application +FROM rust:bookworm AS builder + +# Install build dependencies +RUN apt-get update && apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + libssl-dev pkg-config build-essential + +# Set the working directory +WORKDIR /app + +# Copy your application files to the builder stage +COPY ./pinepods_backend/Cargo.toml ./Cargo.toml +COPY ./pinepods_backend/src ./src + +# Build the Actix web application in release mode +RUN cargo build --release + +# Final stage for setting up the runtime environment +FROM debian:bookworm-slim + +# Metadata +LABEL maintainer="Collin Pendleton " + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash curl openssl ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Copy the compiled binary from the builder stage +COPY --from=builder /app/target/release/pinepods_backend /usr/local/bin/pinepods_backend + +COPY ./startup.sh /startup.sh +RUN chmod +x /startup.sh + +# Set the working directory +WORKDIR / + +# Set environment variables if needed +ENV RUST_LOG=info + +# Expose the port that Actix will run on +EXPOSE 8080 + +# Start the Actix web server +CMD ["/startup.sh"] diff --git a/PinePods-0.8.2/Backend/pinepods_backend/Cargo.lock b/PinePods-0.8.2/Backend/pinepods_backend/Cargo.lock new file mode 100644 index 0000000..3411cf4 --- /dev/null +++ b/PinePods-0.8.2/Backend/pinepods_backend/Cargo.lock @@ -0,0 +1,2712 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-http" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dfe5c9e0004c623edc65391dfd51daa201e7e30ebd9c9bedf873048ec32bc2" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2 0.3.27", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.5.10", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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 = "anstream" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bytestring" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.12", + "http 1.3.1", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.3.1", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.0", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinepods_backend" +version = "0.1.0" +dependencies = [ + "actix-cors", + "actix-web", + "chrono", + "dotenvy", + "env_logger", + "log", + "reqwest", + "serde", + "serde_json", + "sha1", + "urlencoding", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.12", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.142" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2 0.6.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/PinePods-0.8.2/Backend/pinepods_backend/Cargo.toml b/PinePods-0.8.2/Backend/pinepods_backend/Cargo.toml new file mode 100644 index 0000000..773afca --- /dev/null +++ b/PinePods-0.8.2/Backend/pinepods_backend/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pinepods_backend" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "4.11.0" +serde = { version = "1.0.225", features = ["derive"] } +serde_json = "1.0.145" +reqwest = { version = "0.12.23", features = ["json", "rustls-tls"] } +env_logger = "0.11.8" +log = "0.4.28" +dotenvy = "0.15.7" +sha1 = "0.10.6" +urlencoding = "2.1.3" +actix-cors = "0.7.1" +chrono = { version = "0.4.42", features = ["serde"] } diff --git a/PinePods-0.8.2/Backend/pinepods_backend/src/main.rs b/PinePods-0.8.2/Backend/pinepods_backend/src/main.rs new file mode 100644 index 0000000..05cc24d --- /dev/null +++ b/PinePods-0.8.2/Backend/pinepods_backend/src/main.rs @@ -0,0 +1,642 @@ +use actix_web::{web, App, HttpResponse, HttpServer, Responder}; +use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use std::env; +use dotenvy::dotenv; +use std::time::{SystemTime, UNIX_EPOCH}; +use sha1::{Digest, Sha1}; +use log::{info, error}; +use actix_cors::Cors; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use chrono; + +#[derive(Deserialize)] +struct SearchQuery { + query: Option, + index: Option, + search_type: Option, +} + +#[derive(Deserialize)] +struct PodcastQuery { + id: String, +} + +#[derive(Deserialize)] +struct YouTubeChannelQuery { + id: String, +} + +// Hit counter for API usage tracking +#[derive(Clone)] +struct HitCounters { + itunes_hits: Arc, + podcast_index_hits: Arc, + youtube_hits: Arc, +} + +impl HitCounters { + fn new() -> Self { + Self { + itunes_hits: Arc::new(AtomicU64::new(0)), + podcast_index_hits: Arc::new(AtomicU64::new(0)), + youtube_hits: Arc::new(AtomicU64::new(0)), + } + } + + fn increment_itunes(&self) { + self.itunes_hits.fetch_add(1, Ordering::Relaxed); + } + + fn increment_podcast_index(&self) { + self.podcast_index_hits.fetch_add(1, Ordering::Relaxed); + } + + fn increment_youtube(&self) { + self.youtube_hits.fetch_add(1, Ordering::Relaxed); + } + + fn get_stats(&self) -> (u64, u64, u64) { + ( + self.itunes_hits.load(Ordering::Relaxed), + self.podcast_index_hits.load(Ordering::Relaxed), + self.youtube_hits.load(Ordering::Relaxed), + ) + } +} + +// YouTube API response structures for search +#[derive(Deserialize, Serialize)] +struct YouTubeSearchResponse { + items: Vec, +} + +#[derive(Deserialize, Serialize)] +struct YouTubeChannelResult { + id: YouTubeChannelId, + snippet: YouTubeChannelSnippet, +} + +#[derive(Deserialize, Serialize)] +struct YouTubeChannelId { + #[serde(rename = "channelId")] + channel_id: String, +} + +#[derive(Deserialize, Serialize)] +struct YouTubeChannelSnippet { + title: String, + description: String, + thumbnails: YouTubeThumbnails, + #[serde(rename = "channelTitle")] + channel_title: Option, +} + +#[derive(Deserialize, Serialize)] +struct YouTubeThumbnails { + default: Option, + medium: Option, + high: Option, +} + +#[derive(Deserialize, Serialize)] +struct YouTubeThumbnail { + url: String, +} + +// YouTube API response structures for channel details +#[derive(Deserialize)] +struct YouTubeChannelDetailsResponse { + items: Vec, +} + +#[derive(Deserialize)] +struct YouTubeChannelDetailsItem { + snippet: YouTubeChannelDetailsSnippet, + statistics: Option, +} + +#[derive(Deserialize)] +struct YouTubeChannelDetailsSnippet { + title: String, + description: String, + thumbnails: YouTubeThumbnails, +} + +#[derive(Deserialize)] +struct YouTubeChannelStatistics { + #[serde(rename = "subscriberCount")] + subscriber_count: Option, + #[serde(rename = "videoCount")] + video_count: Option, +} + +// YouTube API response structures for channel videos +#[derive(Deserialize)] +struct YouTubeVideosResponse { + items: Vec, +} + +#[derive(Deserialize)] +struct YouTubeVideoItem { + id: YouTubeVideoId, + snippet: YouTubeVideoSnippet, + #[serde(rename = "contentDetails")] + content_details: Option, +} + +#[derive(Deserialize)] +struct YouTubeVideoId { + #[serde(rename = "videoId")] + video_id: String, +} + +#[derive(Deserialize)] +struct YouTubeVideoSnippet { + title: String, + description: String, + thumbnails: YouTubeThumbnails, + #[serde(rename = "publishedAt")] + published_at: String, +} + +#[derive(Deserialize)] +struct YouTubeVideoContentDetails { + duration: Option, +} + +// Simplified response format to match other APIs +#[derive(Serialize)] +struct YouTubeSearchResult { + results: Vec, +} + +#[derive(Serialize)] +struct YouTubeChannel { + #[serde(rename = "channelId")] + channel_id: String, + name: String, + description: String, + #[serde(rename = "thumbnailUrl")] + thumbnail_url: String, + url: String, +} + +// YouTube channel details response (when user clicks a channel) +#[derive(Serialize)] +struct YouTubeChannelDetails { + #[serde(rename = "channelId")] + channel_id: String, + name: String, + description: String, + #[serde(rename = "thumbnailUrl")] + thumbnail_url: String, + url: String, + #[serde(rename = "subscriberCount")] + subscriber_count: Option, + #[serde(rename = "videoCount")] + video_count: Option, + #[serde(rename = "recentVideos")] + recent_videos: Vec, +} + +#[derive(Serialize)] +struct YouTubeVideo { + id: String, + title: String, + description: String, + url: String, + thumbnail: String, + #[serde(rename = "publishedAt")] + published_at: String, + duration: Option, +} + +async fn search_handler( + query: web::Query, + hit_counters: web::Data, +) -> impl Responder { + println!("search_handler called"); + + if query.query.is_none() && query.index.is_none() { + println!("Empty query and index - returning 200 OK"); + return HttpResponse::Ok().body("Test connection successful"); + } + + let search_term = query.query.clone().unwrap_or_default(); + let index = query.index.clone().unwrap_or_default().to_lowercase(); + let search_type = query.search_type.clone().unwrap_or_else(|| "term".to_string()); + + println!("Received search request - Query: {}, Index: {}, Type: {}", search_term, index, search_type); + println!("Searching for: {}", search_term); + let client = reqwest::Client::new(); + println!("Client created"); + + let response = if index == "itunes" { + // iTunes Search + hit_counters.increment_itunes(); + let itunes_search_url = format!("https://itunes.apple.com/search?term={}&media=podcast", search_term); + println!("Using iTunes search URL: {}", itunes_search_url); + + client.get(&itunes_search_url).send().await + } else if index == "youtube" { + // YouTube Data API v3 Search + hit_counters.increment_youtube(); + return search_youtube_channels(&search_term).await; + } else { + // Podcast Index API search + hit_counters.increment_podcast_index(); + let (api_key, api_secret) = match get_api_credentials() { + Ok(creds) => creds, + Err(response) => return response, + }; + + let encoded_search_term = urlencoding::encode(&search_term); + println!("Encoded search term: {}", encoded_search_term); + println!("Search type: {}", search_type); + let podcast_search_url = match search_type.as_str() { + "person" => { + println!("Using /search/byperson endpoint"); + format!("https://api.podcastindex.org/api/1.0/search/byperson?q={}", encoded_search_term) + }, + _ => { + println!("Using /search/byterm endpoint"); + format!("https://api.podcastindex.org/api/1.0/search/byterm?q={}", encoded_search_term) + }, + }; + + println!("Using Podcast Index search URL: {}", podcast_search_url); + + let headers = match create_auth_headers(&api_key, &api_secret) { + Ok(h) => h, + Err(response) => return response, + }; + + println!("Final Podcast Index URL: {}", podcast_search_url); + + client.get(&podcast_search_url).headers(headers).send().await + }; + + handle_response(response).await +} + +async fn search_youtube_channels(search_term: &str) -> HttpResponse { + println!("Searching YouTube for: {}", search_term); + + let youtube_api_key = match env::var("YOUTUBE_API_KEY") { + Ok(key) => key, + Err(_) => { + error!("YOUTUBE_API_KEY not set in the environment"); + return HttpResponse::InternalServerError().body("YouTube API key not configured"); + } + }; + + let client = reqwest::Client::new(); + let encoded_search_term = urlencoding::encode(search_term); + + // YouTube Data API v3 search for channels + let youtube_search_url = format!( + "https://www.googleapis.com/youtube/v3/search?part=snippet&type=channel&q={}&maxResults=25&key={}", + encoded_search_term, youtube_api_key + ); + + println!("Using YouTube search URL: {}", youtube_search_url); + + match client.get(&youtube_search_url).send().await { + Ok(resp) => { + if resp.status().is_success() { + match resp.json::().await { + Ok(youtube_response) => { + // Convert YouTube response to our format + let channels: Vec = youtube_response.items.into_iter().map(|item| { + let thumbnail_url = item.snippet.thumbnails.high + .or(item.snippet.thumbnails.medium) + .or(item.snippet.thumbnails.default) + .map(|thumb| thumb.url) + .unwrap_or_default(); + + YouTubeChannel { + channel_id: item.id.channel_id.clone(), + name: item.snippet.title, + description: item.snippet.description, + thumbnail_url, + url: format!("https://www.youtube.com/channel/{}", item.id.channel_id), + } + }).collect(); + + let result = YouTubeSearchResult { results: channels }; + + match serde_json::to_string(&result) { + Ok(json_response) => { + println!("YouTube search successful, found {} channels", result.results.len()); + HttpResponse::Ok().content_type("application/json").body(json_response) + } + Err(e) => { + error!("Failed to serialize YouTube response: {}", e); + HttpResponse::InternalServerError().body("Failed to process YouTube response") + } + } + } + Err(e) => { + error!("Failed to parse YouTube API response: {}", e); + HttpResponse::InternalServerError().body("Failed to parse YouTube response") + } + } + } else { + error!("YouTube API request failed with status: {}", resp.status()); + HttpResponse::InternalServerError().body(format!("YouTube API error: {}", resp.status())) + } + } + Err(e) => { + error!("YouTube API request error: {}", e); + HttpResponse::InternalServerError().body("YouTube API request failed") + } + } +} + +async fn podcast_handler( + query: web::Query, + hit_counters: web::Data, +) -> impl Responder { + println!("podcast_handler called"); + hit_counters.increment_podcast_index(); + + let podcast_id = &query.id; + let client = reqwest::Client::new(); + + let (api_key, api_secret) = match get_api_credentials() { + Ok(creds) => creds, + Err(response) => return response, + }; + + let podcast_url = format!("https://api.podcastindex.org/api/1.0/podcasts/byfeedid?id={}", podcast_id); + println!("Using Podcast Index URL: {}", podcast_url); + + let headers = match create_auth_headers(&api_key, &api_secret) { + Ok(h) => h, + Err(response) => return response, + }; + + let response = client.get(&podcast_url).headers(headers).send().await; + handle_response(response).await +} + +async fn youtube_channel_handler( + query: web::Query, + hit_counters: web::Data, +) -> impl Responder { + println!("youtube_channel_handler called for channel: {}", query.id); + hit_counters.increment_youtube(); + + let youtube_api_key = match env::var("YOUTUBE_API_KEY") { + Ok(key) => key, + Err(_) => { + error!("YOUTUBE_API_KEY not set in the environment"); + return HttpResponse::InternalServerError().body("YouTube API key not configured"); + } + }; + + let client = reqwest::Client::new(); + let channel_id = &query.id; + + // Step 1: Get channel details and statistics + let channel_details_url = format!( + "https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id={}&key={}", + channel_id, youtube_api_key + ); + + println!("Fetching channel details: {}", channel_details_url); + + let channel_details = match client.get(&channel_details_url).send().await { + Ok(resp) => { + if resp.status().is_success() { + match resp.json::().await { + Ok(details) => { + if details.items.is_empty() { + return HttpResponse::NotFound().body("Channel not found"); + } + details.items.into_iter().next().unwrap() + } + Err(e) => { + error!("Failed to parse channel details: {}", e); + return HttpResponse::InternalServerError().body("Failed to parse channel details"); + } + } + } else { + error!("Channel details request failed with status: {}", resp.status()); + return HttpResponse::InternalServerError().body(format!("YouTube API error: {}", resp.status())); + } + } + Err(e) => { + error!("Channel details request error: {}", e); + return HttpResponse::InternalServerError().body("YouTube API request failed"); + } + }; + + // Step 2: Get recent videos from the channel + let videos_url = format!( + "https://www.googleapis.com/youtube/v3/search?part=snippet&channelId={}&type=video&order=date&maxResults=10&key={}", + channel_id, youtube_api_key + ); + + println!("Fetching recent videos: {}", videos_url); + + let videos = match client.get(&videos_url).send().await { + Ok(resp) => { + if resp.status().is_success() { + match resp.json::().await { + Ok(videos_response) => { + videos_response.items.into_iter().map(|item| { + let thumbnail_url = item.snippet.thumbnails.medium + .or(item.snippet.thumbnails.high) + .or(item.snippet.thumbnails.default) + .map(|thumb| thumb.url) + .unwrap_or_default(); + + YouTubeVideo { + id: item.id.video_id.clone(), + title: item.snippet.title, + description: item.snippet.description, + url: format!("https://www.youtube.com/watch?v={}", item.id.video_id), + thumbnail: thumbnail_url, + published_at: item.snippet.published_at, + duration: item.content_details.and_then(|cd| cd.duration), + } + }).collect() + } + Err(e) => { + error!("Failed to parse videos response: {}", e); + return HttpResponse::InternalServerError().body("Failed to parse videos"); + } + } + } else { + error!("Videos request failed with status: {}", resp.status()); + // Continue without videos rather than failing completely + Vec::new() + } + } + Err(e) => { + error!("Videos request error: {}", e); + // Continue without videos rather than failing completely + Vec::new() + } + }; + + // Extract thumbnail URL from channel details + let thumbnail_url = channel_details.snippet.thumbnails.high + .or(channel_details.snippet.thumbnails.medium) + .or(channel_details.snippet.thumbnails.default) + .map(|thumb| thumb.url) + .unwrap_or_default(); + + // Parse subscriber and video counts + let subscriber_count = channel_details.statistics.as_ref() + .and_then(|stats| stats.subscriber_count.as_ref()) + .and_then(|count| count.parse::().ok()); + + let video_count = channel_details.statistics.as_ref() + .and_then(|stats| stats.video_count.as_ref()) + .and_then(|count| count.parse::().ok()); + + let result = YouTubeChannelDetails { + channel_id: channel_id.to_string(), + name: channel_details.snippet.title, + description: channel_details.snippet.description, + thumbnail_url, + url: format!("https://www.youtube.com/channel/{}", channel_id), + subscriber_count, + video_count, + recent_videos: videos, + }; + + match serde_json::to_string(&result) { + Ok(json_response) => { + println!("YouTube channel details successful for {}, found {} videos", result.name, result.recent_videos.len()); + HttpResponse::Ok().content_type("application/json").body(json_response) + } + Err(e) => { + error!("Failed to serialize channel details response: {}", e); + HttpResponse::InternalServerError().body("Failed to process channel details") + } + } +} + +async fn stats_handler(hit_counters: web::Data) -> impl Responder { + let (itunes, podcast_index, youtube) = hit_counters.get_stats(); + + let stats = serde_json::json!({ + "api_usage": { + "itunes_hits": itunes, + "podcast_index_hits": podcast_index, + "youtube_hits": youtube, + "total_hits": itunes + podcast_index + youtube + }, + "timestamp": chrono::Utc::now().to_rfc3339() + }); + + HttpResponse::Ok().content_type("application/json").json(stats) +} + +fn get_api_credentials() -> Result<(String, String), HttpResponse> { + let api_key = match env::var("API_KEY") { + Ok(key) => key, + Err(_) => { + println!("API_KEY not set in the environment"); + return Err(HttpResponse::InternalServerError().body("API_KEY not set")); + } + }; + let api_secret = match env::var("API_SECRET") { + Ok(secret) => secret, + Err(_) => { + println!("API_SECRET not set in the environment"); + return Err(HttpResponse::InternalServerError().body("API_SECRET not set")); + } + }; + Ok((api_key, api_secret)) +} + +fn create_auth_headers(api_key: &str, api_secret: &str) -> Result { + let epoch_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().to_string(); + let data_to_hash = format!("{}{}{}", api_key, api_secret, epoch_time); + + let mut hasher = Sha1::new(); + hasher.update(data_to_hash.as_bytes()); + let sha_1 = format!("{:x}", hasher.finalize()); + + let mut headers = HeaderMap::new(); + headers.insert("X-Auth-Date", HeaderValue::from_str(&epoch_time).unwrap_or_else(|e| { + error!("Failed to insert X-Auth-Date header: {:?}", e); + std::process::exit(1); + })); + headers.insert("X-Auth-Key", HeaderValue::from_str(api_key).unwrap_or_else(|e| { + error!("Failed to insert X-Auth-Key header: {:?}", e); + std::process::exit(1); + })); + headers.insert("Authorization", HeaderValue::from_str(&sha_1).unwrap_or_else(|e| { + error!("Failed to insert Authorization header: {:?}", e); + std::process::exit(1); + })); + headers.insert(USER_AGENT, HeaderValue::from_static("PodPeopleDB/1.0")); + + Ok(headers) +} + +async fn handle_response(response: Result) -> HttpResponse { + match response { + Ok(resp) => { + if resp.status().is_success() { + println!("Request succeeded"); + match resp.text().await { + Ok(body) => { + println!("Response body: {:?}", body); + HttpResponse::Ok().content_type("application/json").body(body) + }, + Err(_) => { + error!("Failed to parse response body"); + HttpResponse::InternalServerError().body("Failed to parse response body") + } + } + } else { + error!("Request failed with status code: {}", resp.status()); + println!("Request Headers: {:?}", resp.headers()); + HttpResponse::InternalServerError().body(format!("Request failed with status code: {}", resp.status())) + } + } + Err(err) => { + error!("Request error: {:?}", err); + HttpResponse::InternalServerError().body("Request error occurred") + } + } +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + dotenv().ok(); + env_logger::init(); + + println!("Starting the Actix Web server with yt search"); + + // Initialize hit counters + let hit_counters = web::Data::new(HitCounters::new()); + + HttpServer::new(move || { + let cors = Cors::default() + .allow_any_origin() // Allow all origins since this is self-hostable + .allow_any_method() // Allow all HTTP methods + .allow_any_header() // Allow all headers + .supports_credentials() + .max_age(3600); // Cache preflight requests for 1 hour + + App::new() + .app_data(hit_counters.clone()) + .wrap(cors) + .route("/api/search", web::get().to(search_handler)) + .route("/api/podcast", web::get().to(podcast_handler)) + .route("/api/youtube/channel", web::get().to(youtube_channel_handler)) + .route("/api/stats", web::get().to(stats_handler)) + }) + .bind("0.0.0.0:5000")? + .run() + .await +} diff --git a/PinePods-0.8.2/Backend/startup.sh b/PinePods-0.8.2/Backend/startup.sh new file mode 100644 index 0000000..e95d813 --- /dev/null +++ b/PinePods-0.8.2/Backend/startup.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Source the environment variables directly from the env_file +if [ -f /path/to/env_file ]; then + source /path/to/env_file +fi + +# Log the environment variables to ensure they're set +echo "API_KEY: ${API_KEY}, API_SECRET: ${API_SECRET}" + +# Start the Actix web application +/usr/local/bin/pinepods_backend +if [ $? -ne 0 ]; then + echo "Failed to start pinepods_backend" + exit 1 +fi + +# Print debugging information +echo "Actix web application started." + +# Keep the container running +tail -f /dev/null diff --git a/PinePods-0.8.2/CONTRIBUTING.md b/PinePods-0.8.2/CONTRIBUTING.md new file mode 100644 index 0000000..829e2e5 --- /dev/null +++ b/PinePods-0.8.2/CONTRIBUTING.md @@ -0,0 +1,12 @@ +Welcome to the Pinepods Repository! Thanks for considering contributing to this passion project! Check out the Readme if you haven't for a project overview. + +Not a whole lot of rules here. To contribute to this project please simply fork the project and create a pull request when done with detail on what you added. Take a look at the issues for some inspiration. There's quite a few issues in there listed as first time issues that, once you get a hang of the project would be super quick to fix. There's also an issue in there to fill out some documentation in the external documentation repo for some no-code contributions. + +There's a dev guide on the doc site to help get you set up: +https://www.pinepods.online/docs/Developing/Developing + +Priorities right now is getting the app to a full v1 state. If you're looking for something big and exciting take a look at the Youtube Subscriptions issue, otherwise there's plenty of quick and easy visual fixes or quick and easy functionality additions. Category button improvments, remove shared episode reference job, known timezone issue, downloads page visual improvments... etc. + +Here's the docs repo: https://github.com/madeofpendletonwool/Pinepods-Docs +There's also Pinepods Firewood, a project I've been working on for a CLI interface to either share pocasts to or browse podcasts on your Pinepods server. Entirely built in Rust! +Here's Pinepods firewood: https://github.com/madeofpendletonwool/pinepods-firewood diff --git a/PinePods-0.8.2/Dockerfile.validator b/PinePods-0.8.2/Dockerfile.validator new file mode 100644 index 0000000..c99992a --- /dev/null +++ b/PinePods-0.8.2/Dockerfile.validator @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +# Install PostgreSQL dev libraries and required packages +RUN apt-get update && apt-get install -y \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install required packages +RUN pip install psycopg[binary] mysql-connector-python cryptography passlib argon2-cffi + +# Copy validation scripts +COPY database_functions/ /app/database_functions/ +COPY validate_db.py /app/ + +# Set working directory +WORKDIR /app + +# Set default environment variables for MySQL (TEST ONLY - NOT SECURE) +ENV DB_TYPE=mysql +ENV DB_HOST=mysql_db +ENV DB_PORT=3306 +ENV DB_USER=root +ENV DB_PASSWORD=test_password_123 +ENV DB_NAME=pinepods_database + +# Run validator +CMD ["python", "validate_db.py", "--verbose"] \ No newline at end of file diff --git a/PinePods-0.8.2/Dockerfile.validator.postgres b/PinePods-0.8.2/Dockerfile.validator.postgres new file mode 100644 index 0000000..d733ca8 --- /dev/null +++ b/PinePods-0.8.2/Dockerfile.validator.postgres @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +# Install PostgreSQL dev libraries and required packages +RUN apt-get update && apt-get install -y \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install required packages +RUN pip install psycopg[binary] mysql-connector-python cryptography passlib argon2-cffi + +# Copy validation scripts +COPY database_functions/ /app/database_functions/ +COPY validate_db.py /app/ + +# Set working directory +WORKDIR /app + +# Set default environment variables for PostgreSQL (TEST ONLY - NOT SECURE) +ENV DB_TYPE=postgresql +ENV DB_HOST=postgres_db +ENV DB_PORT=5432 +ENV DB_USER=postgres +ENV DB_PASSWORD=test_password_123 +ENV DB_NAME=pinepods_database + +# Run validator +CMD ["python", "validate_db.py", "--verbose"] \ No newline at end of file diff --git a/PinePods-0.8.2/LICENSE b/PinePods-0.8.2/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/PinePods-0.8.2/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/PinePods-0.8.2/README.md b/PinePods-0.8.2/README.md new file mode 100644 index 0000000..bef12c7 --- /dev/null +++ b/PinePods-0.8.2/README.md @@ -0,0 +1,683 @@ +

+ +

+ +# PinePods :evergreen_tree: +[![Discord](https://img.shields.io/badge/discord-join%20chat-5B5EA6)](https://discord.gg/bKzHRa4GNc) +[![Chat on Matrix](https://matrix.to/img/matrix-badge.svg)](https://matrix.to/#/#pinepods:matrix.org) +[![Docker Container Build](https://github.com/madeofpendletonwool/PinePods/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/madeofpendletonwool/PinePods/actions) +[![GitHub Release](https://img.shields.io/github/v/release/madeofpendletonwool/pinepods)](https://github.com/madeofpendletonwool/PinePods/releases) + +--- + +- [PinePods :evergreen_tree:](#pinepods-evergreen_tree) +- [Getting Started](#getting-started) + - [Features](#features) + - [Try it out! :zap:](#try-it-out-zap) + - [Installing :runner:](#installing-runner) + - [Server Installation :floppy_disk:](#server-installation-floppy_disk) + - [Docker Compose](#docker-compose) + - [Helm Deployment](#helm-deployment) + - [Admin User Info](#admin-user-info) + - [Note on the Search API](#note-on-the-search-api) + - [Timezone Configuration](#timezone-configuration) + - [Start it up!](#start-it-up) + - [Client Installs](#client-installs) + - [Linux Client Install :computer:](#linux-client-install-computer) + - [Windows Client Install :computer:](#windows-client-install-computer) + - [Mac Client Install :computer:](#mac-client-install-computer) + - [Android Install :iphone:](#android-install-iphone) + - [iOS Install :iphone:](#ios-install-iphone) + - [PodPeople DB](#podpeople-db) + - [Pinepods Firewood](#pinepods-firewood) + - [Platform Availability](#platform-availability) + - [ToDo](#todo) + - [Screenshots :camera:](#screenshots-camera) + +# Getting Started + +PinePods is a Rust based podcast management system that manages podcasts with multi-user support and relies on a central database with clients to connect to it. It's browser based and your podcasts and settings follow you from device to device due to everything being stored on the server. You can subscribe to podcasts and even hosts for podcasts with the help of the PodPeopleDB. It has a native mobile app for Ios and Android and comes prebaked with it own internal gpodder server so you can use external apps like Antennapod as well! + +For more information than what's provided in this repo visit the [documentation site](https://www.pinepods.online/). + +

+ +

+ +## Features + +Pinepods is a complete podcast management system and allows you to play, download, and keep track of podcasts you (or any of your users) enjoy. It allows for searching and subscribing to hosts and podcasts using The Podcast Index or Itunes and provides a modern looking UI to browse through shows and episodes. In addition, Pinepods provides simple user management and can be used by multiple users at once using a browser or app version. Everything is saved into a MySQL, MariaDB, or Postgres database including user settings, podcasts and episodes. It's fully self-hosted, open-sourced, and I provide an option to use a hosted search API or you can also get one from the Podcast Index and use your own. There's even many different themes to choose from! Everything is fully dockerized and I provide a simple guide found below explaining how to install and run Pinepods on your own system. + +There's plenty more features as well, check out the [Pinepods Site](https://www.pinepods.online/docs/Features/smart-playlists) for more! + +## Try it out! :zap: + +I maintain an instance of Pinepods that's publicly accessible for testing over at [try.pinepods.online](https://try.pinepods.online). Feel free to make an account there and try it out before making your own server instance. This is not intended as a permanent method of using Pinepods and it's expected you run your own server; accounts will often be deleted from there. + +## Installing :runner: + +There's potentially a few steps to getting Pinepods fully installed. After you get your server up and running fully you can also install the client editions of your choice. The server install of Pinepods runs a server and a browser client over a port of your choice in order to be accessible on the web. With the client installs you simply give the client your server url to connect to the database and then sign in. + +### Server Installation :floppy_disk: + +First, the server. You have multiple options for deploying Pinepods: + +- [Using Docker Compose :whale:](#docker-compose) +- [Using Helm for Kubernetes :anchor:](#helm-deployment) + +You can also choose to use MySQL/MariaDB or Postgres as your database. Examples for both are provided below. + +### Docker Compose + +> **⚠️ WARNING:** An issue was recently pointed out to me related to postgres version 18. If you run into an error that looks like this on startup when using postgres: + +``` +Failed to deploy a stack: compose up operation failed: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error mounting "" to rootfs at "/var/lib/postgresql/data": change mount propagation through procfd: open o_path procfd: open //overlay2/17561d31d0730b3fd3071752d82cf8fe60b2ea0ed84521c6ee8b06427ca8f064/merged/var/lib/postgresql/data: no such file or directory: unknown` +``` +> Please change your postgres tag in your compose to '17'. See [this issue](https://github.com/docker-library/postgres/issues/1363) for more details. + +#### User Permissions +Pinepods can run with specific user permissions to ensure downloaded files are accessible on the host system. This is controlled through two environment variables: +- `PUID`: Process User ID (defaults to 1000 if not set) +- `PGID`: Process Group ID (defaults to 1000 if not set) + +To find your user's UID and GID, run: +```bash +id -u # Your UID +id -g # Your GID +``` + +#### Compose File - PostgreSQL (Recommended) +```yaml +services: + db: + container_name: db + image: postgres:17 + environment: + POSTGRES_DB: pinepods_database + POSTGRES_USER: postgres + POSTGRES_PASSWORD: myS3curepass + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - /home/user/pinepods/pgdata:/var/lib/postgresql/data + restart: always + + valkey: + image: valkey/valkey:8-alpine + restart: always + + pinepods: + image: madeofpendletonwool/pinepods:latest + ports: + - "8040:8040" + environment: + # Basic Server Info + SEARCH_API_URL: 'https://search.pinepods.online/api/search' + PEOPLE_API_URL: 'https://people.pinepods.online' + HOSTNAME: 'http://localhost:8040' + # Database Vars + DB_TYPE: postgresql + DB_HOST: db + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: myS3curepass + DB_NAME: pinepods_database + # Valkey Settings + VALKEY_HOST: valkey + VALKEY_PORT: 6379 + # Enable or Disable Debug Mode for additional Printing + DEBUG_MODE: false + PUID: ${UID:-911} + PGID: ${GID:-911} + # Add timezone configuration + TZ: "America/New_York" + volumes: + # Mount the download and backup locations on the server + - /home/user/pinepods/downloads:/opt/pinepods/downloads + - /home/user/pinepods/backups:/opt/pinepods/backups + # Timezone volumes, HIGHLY optional. Read the timezone notes below + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + restart: always + depends_on: + - db + - valkey +``` + +#### Compose File - MariaDB (Alternative) +```yaml +services: + db: + container_name: db + image: mariadb:12 + command: --wait_timeout=1800 + environment: + MYSQL_TCP_PORT: 3306 + MYSQL_ROOT_PASSWORD: myS3curepass + MYSQL_DATABASE: pinepods_database + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_INIT_CONNECT: 'SET @@GLOBAL.max_allowed_packet=64*1024*1024;' + volumes: + - /home/user/pinepods/sql:/var/lib/mysql + restart: always + + valkey: + image: valkey/valkey:8-alpine + + pinepods: + image: madeofpendletonwool/pinepods:latest + ports: + - "8040:8040" + environment: + # Basic Server Info + SEARCH_API_URL: 'https://search.pinepods.online/api/search' + PEOPLE_API_URL: 'https://people.pinepods.online' + HOSTNAME: 'http://localhost:8040' + # Database Vars + DB_TYPE: mariadb + DB_HOST: db + DB_PORT: 3306 + DB_USER: root + DB_PASSWORD: myS3curepass + DB_NAME: pinepods_database + # Valkey Settings + VALKEY_HOST: valkey + VALKEY_PORT: 6379 + # Enable or Disable Debug Mode for additional Printing + DEBUG_MODE: false + PUID: ${UID:-911} + PGID: ${GID:-911} + # Add timezone configuration + TZ: "America/New_York" + + volumes: + # Mount the download and backup locations on the server + - /home/user/pinepods/downloads:/opt/pinepods/downloads + - /home/user/pinepods/backups:/opt/pinepods/backups + # Timezone volumes, HIGHLY optional. Read the timezone notes below + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + depends_on: + - db + - valkey +``` + +Make sure you change these variables to variables specific to yourself at a minimum. + +``` + # The url you hit the site at. Only used for sharing rss feeds + HOSTNAME: 'http://localhost:8040' + # These next 4 are optional. They allow you to set an admin without setting on the first boot + USERNAME: pinepods + PASSWORD: password + FULLNAME: John Pinepods + EMAIL: john@pinepods.com + # DB vars should match your values for the db you set up above + DB_TYPE: postgresql + DB_HOST: db + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: myS3curepass + DB_NAME: pinepods_database +``` + +Most of those are pretty obvious, but let's break a couple of them down. + +#### Admin User Info + +First of all, the USERNAME, PASSWORD, FULLNAME, and EMAIL vars are your details for your default admin account. This account will have admin credentials and will be able to log in right when you start up the app. Once started you'll be able to create more users and even more admins but you need an account to kick things off on. If you don't specify credentials in the compose file it will prompt you to create an account before first login. + + +#### Note on the Search API + +Let's talk quickly about the searching API. This allows you to search for new podcasts and it queries either itunes or the podcast index for new podcasts. It also allows for searching youtube channels via the Google Search API. The podcast index and Google Search require an api key while itunes does not. If you'd rather not mess with the api at all simply set the API_URL to the one below, however, know that Google implements a limit per day on youtube searches and the search api that I maintain below hits it's limit pretty quick. So if you're a big youtube user you might want to host your own. + +``` +SEARCH_API_URL: 'https://search.pinepods.online/api/search' +``` + +Above is an api that I maintain. I do not guarantee 100% uptime on this api though, it should be up most of the time besides a random internet or power outage here or there. A better idea though, and what I would honestly recommend is to maintain your own api. It's super easy. Check out the API docs for more information on doing this. Link Below - + +https://www.pinepods.online/docs/API/search_api + +#### Timezone Configuration + +PinePods supports displaying timestamps in your local timezone instead of UTC. This helps improve readability and prevents confusion when viewing timestamps such as "last sync" times in the gpodder API. Note that this configuration is specifically for logs. Each user sets their own timezone settings on first login. That is seperate from this server timezone config. + +##### Setting the Timezone + +You have two main options for configuring the timezone in PinePods: + +##### Option 1: Using the TZ Environment Variable (Recommended) + +Add the `TZ` environment variable to your docker-compose.yml file: + +```yaml +services: + pinepods: + image: madeofpendletonwool/pinepods:latest + environment: + # Other environment variables... + TZ: "America/Chicago" # Set your preferred timezone +``` + +This method works consistently across all operating systems (Linux, macOS, Windows) and is the recommended approach. + +##### Option 2: Mounting Host Timezone Files (Linux Only) + +On Linux systems, you can mount the host's timezone files: + +```yaml +services: + pinepods: + image: madeofpendletonwool/pinepods:latest + volumes: + # Other volumes... + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro +``` + +**Note**: This method only works reliably on Linux hosts. For macOS and Windows users, please use the TZ environment variable (Option 1). + +##### Priority + +If both methods are used: +1. The TZ environment variable takes precedence +2. Mounted timezone files are used as a fallback + +##### Common Timezone Values + +Here are some common timezone identifiers: +- `America/New_York` - Eastern Time +- `America/Chicago` - Central Time +- `America/Denver` - Mountain Time +- `America/Los_Angeles` - Pacific Time +- `Europe/London` - United Kingdom +- `Europe/Berlin` - Central Europe +- `Asia/Tokyo` - Japan +- `Australia/Sydney` - Australia Eastern + +For a complete list of valid timezone identifiers, see the [IANA Time Zone Database](https://www.iana.org/time-zones). + +##### Troubleshooting Timezones + +**I'm on macOS and timezone settings aren't working** + +macOS uses a different timezone file format than Linux. You must use the TZ environment variable method on macOS. + + +#### Start it up! + +Either way, once you have everything all setup and your compose file created go ahead and run + +``` +sudo docker-compose up +``` + +To pull the container images and get started. Once fully started up you'll be able to access pinepods at the port you configured and you'll be able to start connecting clients as well. + + +### Helm Deployment + +Alternatively, you can deploy Pinepods using Helm on a Kubernetes cluster. Helm is a package manager for Kubernetes that simplifies deployment. + +#### Adding the Helm Repository + +First, add the Pinepods Helm repository: + +```bash +helm repo add pinepods http://helm.pinepods.online +helm repo update +``` + +#### Installing the Chart + +To install the Pinepods Helm chart with default values: + +```bash +helm install pinepods pinepods/pinepods --namespace pinepods-namespace --create-namespace +``` + +Or with custom values: + +```bash +helm install pinepods pinepods/pinepods -f my-values.yaml --namespace pinepods-namespace --create-namespace +``` + +#### Configuration Options + +The Helm chart supports extensive configuration. Key areas include: + +**Main Application:** +- Image repository and tag configuration +- Service type and port settings +- Ingress configuration with TLS support +- Persistent storage for downloads and backups +- Resource limits and requests +- Security contexts and pod placement + +**Dependencies:** +- PostgreSQL database (can be disabled for external database) +- Valkey/Redis for caching (can be disabled) +- Optional backend API deployment for self-hosted search +- Optional PodPeople database for podcast host information + +**Example values.yaml:** + +```yaml +# Main application configuration +image: + repository: madeofpendletonwool/pinepods + tag: latest + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 8040 + +ingress: + enabled: true + className: "" + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + hosts: + - host: pinepods.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +# Persistent storage +persistence: + enabled: true + downloads: + storageClass: "" # Use default storage class + size: 5Gi + backups: + storageClass: "" + size: 2Gi + +# Database configuration +postgresql: + enabled: true + auth: + username: postgres + password: "changeme" + database: pinepods_database + persistence: + enabled: true + size: 3Gi + +# Valkey/Redis configuration +valkey: + enabled: true + architecture: standalone + auth: + enabled: false + +# Optional backend API (self-hosted search) +backend: + enabled: false + secrets: + apiKey: "YOUR_PODCAST_INDEX_KEY" + apiSecret: "YOUR_PODCAST_INDEX_SECRET" + +# Optional PodPeople database +podpeople: + enabled: false + +# Application environment +env: + USERNAME: "admin" + PASSWORD: "password" + FULLNAME: "Admin User" + EMAIL: "admin@example.com" + DEBUG_MODE: "false" + HOSTNAME: 'http://localhost:8040' +``` + +#### External Database Configuration + +To use an external database instead of the included PostgreSQL: + +```yaml +postgresql: + enabled: false + +externalDatabase: + host: "your-postgres-host" + port: 5432 + user: postgres + password: "your-password" + database: pinepods_database +``` + +#### Create a Namespace for Pinepods + +Create a namespace to hold the deployment: + +```bash +kubectl create namespace pinepods-namespace +``` + +#### Starting Helm + +Once you have everything set up, install the Helm chart: + +```bash +helm install pinepods pinepods/pinepods -f my-values.yaml --namespace pinepods-namespace --create-namespace +``` + +This will deploy Pinepods on your Kubernetes cluster with a postgres database. MySQL/MariaDB is not supported with the kubernetes setup. The service will be accessible at the specified NodePort. + +Check out the Tutorials on the documentation site for more information on how to do basic things: + +https://pinepods.online/tutorial-basic/sign-in-homescreen.md + +## Client Installs + +Any of the client additions are super easy to get going. + +### Linux Client Install :computer: + +#### AppImage, Fedora/Red Hat Derivative/Debian based (Ubuntu) + +First head over to the releases page on Github + +https://github.com/madeofpendletonwool/PinePods/releases + +Grab the latest linux release. There's both an appimage a deb, and an rpm. Use the appimage of course if you aren't using a debian or red hat based distro. Change the permissions if using the appimage version to allow it to run. + +``` +sudo chmod +x pinepods.appimage +``` + +^ The name of the app file will vary slightly based on the version so be sure you change it or it won't work. + +For the rpm or deb version just run and install + +Once started you'll be able to sign in with your username and password. The server name is simply the url you browse to to access the server. + +#### Arch Linux (AUR) + +Install the Pinepods Client right from the AUR! Replace the command below with your favorite aur helper + +``` +paru -S pinepods +``` + +#### Flatpak + + + Get it on Flathub + + +You can search for Pinepods in your favorite flatpak installer gui app such as Gnome Software. + +Flathub page can be found [here](https://flathub.org/apps/com.gooseberrydevelopment.pinepods) + +``` +flatpak install flathub com.gooseberrydevelopment.pinepods +``` + +#### Snap + +I have had such a nightmare trying to make the snap client work. Pass, use the flatpak. They're better anyway. I'll test it again in the future and see if Canonical has gotten it together. If you really want a snap version of the client please reach out and tell me you're interested in the first place. + +### Windows Client Install :computer: + +First head over to the releases page on Github + +https://github.com/madeofpendletonwool/PinePods/releases + +There's a exe and msi windows install file. + +The exe will actually start an install window and allow you to properly install the program to your computer. + +The msi will simply run a portable version of the app. + +Either one does the same thing ultimately and will work just fine. + +Once started you'll be able to sign in with your username and password. The server name is simply the url you browse to to access the server. + +### Mac Client Install :computer: + +First head over to the releases page on Github + +https://github.com/madeofpendletonwool/PinePods/releases + +There's a dmg and pinepods_mac file. + +Simply extract, and then go into Contents/MacOS. From there you can run the app. + +The dmg file will prompt you to install the Pinepods client into your applications folder while the _mac file will just run a portable version of the app. + +Once started you'll be able to sign in with your username and password. The server name is simply the url you browse to to access the server. + +### Android Install :iphone: + + + Get it on IzzyOnDroid + + + + Get it on Obtainium + + +Currently there's options for direct downloads and Pinepods is on the IzzyOnDroid storefront! More locations coming soon! + +### iOS Install :iphone: + + + Download on the App Store + + +The iOS app has arrived! Enjoy! + +## PodPeople DB + +Podpeople DB is a project that I maintain and also develop. Podpeople DB is a way to supplement Person tags for podcasts that don't support them by default. This allows the community to maintain hosts and follow them to all podcasts! I maintain an instance of Podpeople DB at podpeopledb.com. Otherwise, it's an open source project and you can maintain an instance of your own if you prefer. For information on that go [here](https://podpeopledb.com/docs/self-host). You can download the database yourself and maintain your own instance. If you do decide to go this route please still add any hosts for your favorite podcasts at the instance hosted at podpeopledb.com. The community will thank you! + +For additional info on Podpeople DB check out [the docs](https://podpeopledb.com/docs/what-is-this-for). + +Additionally, I've written [a blog](https://www.pinepods.online/blog) post discussing the rationale around its creation. + +Finally, you can check out the Repo for it [here!](https://github.com/madeofpendletonwool/podpeople-db) + +## Pinepods Firewood + +A CLI only client that can be used to remotely share your podcasts to has had it's first release! Now you can enjoy podcasts from the comfort of your terminal! Check out [Pinepods Firewood!](https://github.com/madeofpendletonwool/pinepods-firewood) + +## Platform Availability + +The Intention is for this app to become available on Windows, Linux, Mac, Android, and iOS. Windows, Linux, Mac, web, and android are all currently available and working. + +ARM devices are also supported including raspberry pis. The app is shockingly performant on a raspberry pi as well. The only limitation is that a 64bit OS is required on an ARM device. Setup is exactly the same, just use the latest tag and docker will auto pull the ARM version. + + +### Clients to support + +- [x] Flatpak Package +- [ ] Nix Package +- [x] Aur Package +- [x] Helm Chart and repo for kubernetes deployment +- [x] Mobile Apps + - [x] Android App - Beta + - [ ] Android Auto support + - [x] iOS App + - [ ] Packaging and automation + +## Screenshots :camera: + +Main Homepage with podcasts displayed + +

+ +

+ +Loads of themes! + +

+ +

+

+ +

+

+ +

+ +Full Podcast Management + +

+ +

+ +Browse through episodes + +

+ +

+ +Markdown and HTML display compatible + +

+ +

+ +Mobile support baked right in! + +

+ +

+

+ +

+ +#### Runners + +ARM Images made possible by Runs-On: +https://runs-on.com + +#### 📜 Credits & Licensing + +PinePods is an open-source podcast player developed by Gooseberry Development, licensed under the GNU General Public License v3.0 (GPL-3.0). + +The Pinepods Mobile app in the mobile directory includes code adapted from the excellent [Anytime Podcast Player](https://github.com/amugofjava/anytime_podcast_player), originally created by Ben Hills. + +#### 🧩 Included Third-Party Code + +**Anytime Podcast Player** +© 2020 Ben Hills and project contributors +Licensed under the BSD 3-Clause License + +Portions of the mobile app retain the original BSD license and attribution as required. Files with this license are labeled at the top to clearly indicate. See the LICENSE.ben_hills in the mobile directory for details. + +#### 💬 Acknowledgment + +Huge thanks to Ben Hills for open-sourcing the Anytime Podcast Player. It served as a solid foundation and greatly accelerated development of PinePods. + +#### 🌐 Translation + +Translations are managed through [Weblate](https://hosted.weblate.org), a web-based translation tool that makes it easy for the community to contribute translations. If you'd like to help translate PinePods into your language, please visit our Weblate project and join the translation effort! diff --git a/PinePods-0.8.2/clients/aur/.SRCINFO b/PinePods-0.8.2/clients/aur/.SRCINFO new file mode 100644 index 0000000..8cf0c23 --- /dev/null +++ b/PinePods-0.8.2/clients/aur/.SRCINFO @@ -0,0 +1,24 @@ +pkgbase = pinepods + pkgdesc = Pinepods is a complete podcast management system and allows you to play, download, and keep track of podcasts you enjoy. All self hosted and enjoyed on your own server! + pkgver = 0.7.0 + pkgrel = 1 + url = https://github.com/madeofpendletonwool/PinePods + install = pinepods.install + arch = x86_64 + arch = aarch64 + license = gpl3 + depends = cairo + depends = desktop-file-utils + depends = gdk-pixbuf2 + depends = glib2 + depends = gtk3 + depends = hicolor-icon-theme + depends = libsoup + depends = pango + depends = webkit2gtk + options = !strip + options = !emptydirs + source_x86_64 = https://github.com/madeofpendletonwool/PinePods/releases/download/0.7.0/Pinepods_0.7.0_amd64.deb + source_aarch64 = https://github.com/madeofpendletonwool/PinePods/releases/download/0.7.0/Pinepods_0.7.0_arm64.deb + +pkgname = pinepods diff --git a/PinePods-0.8.2/clients/aur/PKGBUILD b/PinePods-0.8.2/clients/aur/PKGBUILD new file mode 100644 index 0000000..4285171 --- /dev/null +++ b/PinePods-0.8.2/clients/aur/PKGBUILD @@ -0,0 +1,14 @@ +pkgname=pinepods +pkgver=0.6.6 +pkgrel=1 +pkgdesc="Pinepods is a complete podcast management system and allows you to play, download, and keep track of podcasts you enjoy. All self hosted and enjoyed on your own server!" +arch=('x86_64' 'aarch64') +url="https://github.com/madeofpendletonwool/PinePods" +license=('gpl3') +depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3' 'hicolor-icon-theme' 'libsoup' 'pango' 'webkit2gtk') +options=('!strip' '!emptydirs') +install=${pkgname}.install +source_x86_64=("https://github.com/madeofpendletonwool/PinePods/releases/download/$pkgver/Pinepods_"$pkgver"_amd64.deb") +source_aarch64=("https://github.com/madeofpendletonwool/PinePods/releases/download/$pkgver/Pinepods_"$pkgver"_arm64.deb") +sha256sums_x86_64=('SKIP') +sha256sums_aarch64=('SKIP') diff --git a/PinePods-0.8.2/completed_todos.md b/PinePods-0.8.2/completed_todos.md new file mode 100644 index 0000000..9346c60 --- /dev/null +++ b/PinePods-0.8.2/completed_todos.md @@ -0,0 +1,680 @@ +# Completed todos + +This is the list of previous todos that are now completed + +Major Version: + +- [] iOS App + +- [ ] Make sure youtube entirely works on playlists +- [ ] Make sure youtube entirely works on homepage +- [ ] Fix Virtual Line Spacing on Playlist Page +- [ ] Update /home/collinp/Documents/github/PinePods/web/src-tauri/com.gooseberrydevelopment.pinepods.metainfo.xml file along with flatpak automation. This must be done on each release +- [ ] Fix episode spacing on queue page. The context button still shows even on smallest screens +- [ ] Check youtube download Issues when changing the download time + +0.8.2 + +- [x] Translations on the web app +- [x] Account Settings now updates dropdowns with pre-populated values +- [x] episode-layout (podcast page) will now set sort settings based on pod id +- [x] Added endpoint to delete OIDC settings +- [x] Added endpoint to Edit OIDC settings +- [x] Manually search or enter podcast index id for matching to podcast index +- [x] OIDC Setup on start +- [x] Better errors if needed vars are missing +- [x] Redis/Valkey Authentication +- [x] Move Episode Addition process to the background when adding a podcast +- [x] Support HTTP request notifications. Will work with Telegram and quite a few other basic http notification platforms +- [x] Podcast Merge Options +- [x] Individual Episode download on /episode page +- [x] Option to use Podcast covers if desired +- [x] Fix issue where release date on podcasts not added shows as current date/time +- [x] Fix yt-dlp issues + +- [x] Gpodder Completion Set Bug where if episode played length was exactly the length of the podcast episode it wouldn't mark complete +- [x] Fixed issue with auto complete threshold. Will now mark historical episodes complete when enabled +- [x] Some sort of loading indicator for the single ep download +- [x] Fix issue where duplicate episodes were created if details of the episode were updated +- [x] Fully dynamic Playlist implementation +- [x] Checking on rss feeds returning downloaded urls correctly + +0.7.9 + +- [x] Finish implementing long finger press - fix on iOS (close, it doesn't auto close when clicking away currently) +- [x] Finish making UI css adjustments +- [x] Fix error where refreshing on episode layout page causes panic +- [x] Issue with rss caused by new migration system +- [x] user stats gpodder sync css fix +- [x] Fix playback speed setting css +- [x] test ntfy sending on nightly +- [x] Test everything in mysql +- [x] Test everything in postgres +- [x] Test upgrades from previous in postgres +- [x] Test upgrades from previous in mysql +- [x] Test fresh postgres +- [x] Test fresh mysql +- [x] retest rss in nightly +- [x] Package upgrades +- [] Local downloads tauri are broken again +- [x] Fix downloads Layout +- [x] Finish super small screen visual Improvements +- [x] Return Gpodder info as part of get_stats + +- [x] Allow for custom server Timezone +- [x] display gpodder info on the user stats page +- [x] 100 RSS feed limit +- [x] Add unique RSS feed keys to generated feeds +- [x] Updated youtube search results page to be similar to new pod results page +- [x] Improved search dropdown to be more compatible with more devices, also improved style +- [x] Added container time zone options +- [x] Finish playback speed Settings + - [x] Fix issue with the numbers auto updating + - [x] Playing works but results in really strange decimals +- [x] Fix known bugs with gpodder sync +- [x] Changed youtube search view to match podcast search view +- [x] Check opml import issues +- [x] Fixed issues with helm chart +- [x] Rebuilt db migration system to be far more reliable + + +0.7.8 + +- [x] External gpodder api message shows internal gpodder message +- [x] User refresh now shows refresh status in notification center +- [x] Potential home page issue on tauri app +- [x] Local download issue tauri app +- [x] Freaking caching +- [x] Fix spacing of play button on shared episodes page +- [x] Issue with client builds +- [x] Finish validating every call +- [x] When YT video is added we need to increment episode count +- [x] validate external pod sync platforms again +- [x] Validate YT feed deletion +- [x] The below error happens on Honoring Juneteenth from Short Wave. Seems to happen when there's an episode that goes longer than the expected possible length +- [x] Add youtube feed retention time setting onto settings for each pod +- [x] Finish custom pod notifications +- [x] Validate that mysql and postgres upgrade correctly +- [x] Weirdly different color trash can on podcast page +- [x] gpodder pod deletions on local +- [x] Fixed issue with time created by timestamps +- [x] Fix up warnings +- [x] Fixed up issue with saved search, and queue pages not showing saved and queued status correct in context button sometimes +- [x] Pinepods news feed not adding at all +- [x] episode count is being doubled +- [x] Show youtube feed cutoff only on youtube channels - It should also show a notification when updated +pinepods-1 | Error creating GPodder tables: 1061 (42000): Duplicate key name 'idx_gpodder_devices_userid' +pinepods-1 | Error setting up platlists: 1061 (42000): Duplicate key name 'idx_playlists_userid' +- [x] ^ On mariadb startup +- [x] postgres pod removals while pod sync enabled + +0.7.6 +- [x] Add ability to delete playlsits +- [x] Notification system +- [x] Ability to delete nextcloud +- [x] Finalize OIDC errors +- [x] Fix context menu on downloads page +- [x] adjust login screen component to be set amount down +- [x] Implement download_youtube_video_task +- [x] Fix specific issue with playlist creation +- [x] mysql tests +- [x] Go from 0.7.3 to 0.7.5 check startpage +- [x] Clean warnings +- [x] Check tauri +- [x] Update packages +- [] Automation implements correct SHA in the deb files +- [] release +- [] flatpak + +Pre 0.7.4 + +- [x] Implement specific podcasts to pass to playlist creation. So you can choose specific ones +- [x] Make the create playlist function work +- [x] On deletion of a podcast delete any references to it in the playlist content func +- [x] Run a playlist refresh after adding a podcast, deleting a podcast, and any other time that makes sense. Maybe even on a standard refresh of podcasts? +- [x] Make states work on homepage. Saved or not, current progress, completed etc. +- [x] Make Podcast tiles Adapt better to large screens +- [x] Make podcast downloading not stop the server from functioning +- [x] Fixed an issue where sometimes chapters didn't load due to incorrect headers +- [x] Recent Episodes on homepage is not correct + +- [x] All of mysql +- [x] Check almost done and currently listening playlists +- [x] Add user doesn't work on MYSQL +- [x] Upgrade from 0.7.3 to 0.7.4 works both postgres and mysql +- [x] Notifications in mysql +- [x] Validate Builds with tauri +- [x] Upgrade packages +- [ ] Build flatpak and ensure version bump + +- [x] Adjusted Downloads page so that podcast headers take up less space +- [x] Ensure configured start page is navigated to +- [x] Ensure OIDC Logins work +- [x] Ensure Github logins work +- [x] Ensure Google Logins work + + +- [x] OIDC Logins +- [x] Smart Playlists +- [x] New Homepage Component +- [x] Experimental finger hold context button homepage +- [x] Configurable start page +- [x] Fixed issue where sometimes it was possible for images to not load for episodes and podcasts +- [x] Image Caching +- [x] Added fallback options for when podcast images fail to load. They will now route through the server if needed +- [x] Fixed filter button size consistency on Podcast Page +- [x] Additional filtering on Podcast page for incomplete and/or complete episodes + + + + +Next Minor Version: + +- [ ] Ensure even when a podcast is clicked via the search page it still loads all the podcast db context +- [ ] Allow user to adjust amount of time to save/download youtube videos +- [ ] After adding podcast we no longer show dumpster +- [ ] Bad url while no channels added youtube + +Version 0.7.3 + +- [x] Youtube Subscriptions + - [x] Fix refreshing so it handlees youtube subscriptions + - [x] Thumbnails for youtube episodes currently are just sections of the video + - [x] Validate some more channel adds + - [x] Speed up channel add process by only checking recent videos up to 30 days. + - [x] When searching channels show more recent vids than just one + - [x] Dynamic updating youtube channels + - [x] Delete youtube subs + - [x] Ensure youtube videos update completion/listen time status correctly + - [x] check refreshing on episode/other youtube related pages + - [x] Make /episode page work with youtube +- [x] Allowed and documented option to download episodes as specific user on host machine +- [x] Nextcloud Sync Fixed +- [x] Episode Completion Status is now pushed to Nextcloud/Gpodder +- [x] Adjusted Downloaded Episode titles to be more descriptive - Also added metadata +- [x] Fixed issue with news feed adding +- [x] Additional Podcast parsing when things are missing +- [x] Add pinepods news feed to any admin rather than hard id of 2 +- [x] Fix recent episodes so it handles incompletes better +- [x] Check mark episode complete on episode page +- [x] Uncomplete/complete - and in prog episode sorting on episode_layout page +- [x] Add completed icon and in prog info to episodes on episode_layout page +- [x] Check for and fix issues with refreshing again on every page +- [x] Fix issue with episodes page opening when clicking show notes while on episodes page already +- [x] Fix issues with ability to open episode_layout page from episode page. That includes whether the podcast is added or not +- [x] Add podcastindexid to episode page url vars - Then pass to dynamic func call +- [x] Validate Mysql functions +- [x] Build clients and verify +- [x] Sometimes episodes are not even close to newest or right order in episode_layout +- [x] Think the weird yt double refreshing after search is messing up which one is subbed to +- [x] Queuing yt ep also queues standard pod counterpart id + +Version 0.7.2 + +- [x] Mobile Progress line (little line that appears above the line player to indicate your progress in the episode) +- [x] Dynamically Adjusting chapters. Chapters now adapt and update as you play each one +- [x] Dynamic Play button. This means when you play an episode it will update to a pause button as you see it in a list of other episodes +- [x] Fixed issue where Gpodder wasn't adding in podcasts right away after being connected. +- [x] Fixed issues with admin user add component where you could adjust user settings +- [x] Also adjusted error messages on user component so that it's more clear what went wrong +- [x] Added in RSS feed capability. There's a new setting to turn on RSS feeds in the user settings. This will allow you to get a feed of all your Pinepods podcasts that you can add into another podcast app. +- [x] Individual Podcasts can also be subscribed to with feeds as well. Opening the individual Podcast page there's a new RSS icon you can click to get the feed +- [x] Fixed issues where theme wasn't staying applied sometimes +- [x] Added filtering throughout the app. You can now selectively filter whether podcasts is completed or in progress +- [x] Added quick search in numerous places. This allows you to quickly search for a podcast based on the name. Pages like History, Saved, Podcast have all gotten this +- [x] Added Sorting throughout the app. You can now sort podcasts in numerous ways, such as a-z, longest to shortest, newest to oldest, etc... +- [x] Fixed issue where images in descriptions could break the layout of episodes +- [x] Adjusted categories to look nicer in the podcast page +- [x] Fixed issues with DB backup options +- [x] Implemented DB restore options +- [x] Fixed issue where the Queue on mobile wasn't adjusting episode placement + + +Version 0.7.0 + +- [x] Android App +- [x] Flatpak Client +- [x] Snap Client +- [x] aur client + +- [x] Added Valkey to make many processes faster +- [x] People Table with background jobs to update people found in podcasts +- [x] Subscribe to people +- [x] Add loading spinner when adding podcast via people page +- [x] Four new themes added +- [x] People page dropdowns on podcasts and episodes +- [x] Stop issues with timeouts on occation with mobile apps - Potentially fixed due to audio file caching. Testing needed +- [x] Virtual Lines implemented for Home and Episode Layout. This will improve performance on those pages greatly +- [x] Dynamically adjusting buttons on episode page +- [x] PodcastPeople DB up and running and can be contributed to +- [x] Show currently updating podcast in refresh feed button at top of screen +- [x] Fixed up remaining issues with user podcast refresh +- [x] Podcast 3x layout +- [x] Finalize loading states so you don't see login page when you are already authenticated +- [x] Using valkey to ensure stateless opml imports +- [x] Android play/pause episode metadata +- [x] Draggable Queues on Mobile Devices +- [x] Make Chapters much nicer. Nice modern look to them +- [x] Add background task to remove shared episode references in db after 60 days +- [x] Dynamically adjusting Download, Queue, and Saved Episodes so that every page can add or remove from these lists +- [x] Fixed issue where some episodes weren't adding when refreshing due to redirects +- [x] Some pods not loading in from opml import - better opml validation. Say number importing. - OPML imports moved to backend to get pod values, also reporting function created to update status +- [x] Update queue slider to be centered +- [x] People don't clear out of hosts and people dropdowns if a podcast doesn't have people. So it shows the old podcast currently +- [x] div .title on audio player is now a link, not selectable text. +- [x] Improved the playback and volume dropdowns so they don't interact with the rest of the page now +- [x] Added some box shadow to the episode image in the full screen player +- [x] When playing an episode <- and -> arrow keys skips forward and back for the playback now +- [x] Layout improved all over the place +- [x] Phosphor icons implemented as opposed to material +- [x] Settings page layout rebuilt +- [x] Better handle description html formatting + +Version 0.6.6 + +- [x] Manually adjust tags for podcast in podcast settings +- [x] Dynamically refresh tags on ep-layout when adding and removing them +- [x] Removed see more button from the episodes_layout, queue, and downloads page +- [x] Added a People page so that you can see other episodes and podcasts a particular person has been on +- [x] Speed up people page loading (happens in async now) +- [x] Add loading component to people page loading process +- [x] Added category filtering to podcasts page +- [x] Link Sharing to a podcast to share and allow people to listen to that episode on the server without logging in +- [x] Update api key creation and deletion after change dynamically with use_effect +- [x] Update mfa setup slider after setup dynamically with use_effect +- [x] Fixed refreshing on episode screen so it no longer breaks the session +- [x] Fixed refreshing on episode-layout screen so it no longer breaks the session +- [x] Fixed issue with episode page where refreshing caused it to break +- [x] Fixed issue with queue where episode couldn't be manually removed +- [x] Added loading spinner when opening an episode to ensure you don't momentarily see the wrong episode +- [x] Improve Filtering css so that things align correctly +- [x] Made the button to add and remove podcasts more consistent (Sometimes it was just not registering) +- [x] Upgraded pulldown-cmark library +- [x] Upgraded python mysql-connection library to 9 +- [x] Upgraded chrono-tz rust library +- [x] mac version attached like this: +- [x] Update Rust dependancies + +CI/CD: + +- [x] mac version attached like this: +dmg.Pinepods_0.6.5_aarch64.dmg - Also second mac archive build failed +- [x] Fix the archived builds for linux. Which are huge because we include a ton of appimage info +- [x] Add in x64 mac releases +- [x] Build in arm cross compile into ubuntu build + +Version 0.6.5 + +- [x] Fixed issue with Podcasts page not refreshing correctly +- [x] Added Add Custom Feed to Podcasts page +- [x] Allow for podcast feeds with user and pass +- [x] Add option to add podcast from feed on podcasts page +- [x] Ensure podcast loads onto podcast page when adding a new custom one in +- [x] Adjusted buttons on episode layout page so they dynamically adjust position to fit better +- [x] Option for user to manually update feeds +- [x] Update Feed directly after adding a Nextcloud/gpodder sync server instead of waiting for the next refresh +- [x] Fixed issue with episode refreshing where a panic could occur (This was due to the categories list) +- [x] Ensured See More Button only shows when needed (Just made the descriptions clickable) +- [x] Fixed issue with context for podcasts not dynamically updating on the episode layout page once the podcast was added to the db +- [x] Fixed issue with nextcloud sync on mysql dbs +- [x] Fixed issue with db setup with mysql +- [x] Ensured deleting podcast when on the episode layout page it closes the deleted modal + +Version 0.6.4 + +- [x] Added a fallback to the opml import for when the opml file uses text instead of title for the podcast name key +- [x] Added a new route for the version tag that dynamically updates when the application is compiled. This allows for automation around the version numbers all based around the the Github release tag as the original source of truth. +- [x] Fixed layout for podcasts when searching +- [x] Support floating point chapters +- [x] Fixed issue with white space at the bottom of every page #229 +- [x] Cleaned up incorrect or not needed logging at startup #219 +- [x] Fixed issue with user stats page where it would lose user context on reload #135 +- [x] Fixed issue with settings page where it would lose user context on reload #134 +- [x] Fixed issue with episode_layout page where it would lose user context on reload and also made podcasts sharable via link #213 +- [x] Fixed issue where podcast episode counts wouldn't increment after initial add to the db +- [x] Ugraded gloo::net to 0.6.0 +- [x] Upgraded openssl in src-tauri to 0.10.66 +- [x] Upgraded a few other rust depends to next minor version +- [x] Added loading spinner to custom feed and implemented more clear success message +- [x] Fixed postgres return issue on user_stats route +- [x] Fixed postgres return issue on mfa return route +- [x] Fixed delete api key route for postgres +- [x] Implemented adjustment on all modals throughout the app so clicking outside them closes them (episode layout confiramtions missing yet - also test all login modals) +- [x] Implemented adjustment on all modals so that they overlap everything in the app (This was causing issues on small screens) +- [x] Added Confirmation dialog modal to podcast deletion on /podcasts layout page +- [x] Changed name of bt user to background_tasks to make the user more clear on api key settings display + +Version 0.6.3 + +- [x] Jump to clicked timestamp +- [x] Full Chapter Support (Support for floating points needed yet) +- [x] Chapter Image Support +- [x] Basic Support for People Tags (Host and Guest) +- [x] Support for Funding Tags +- [x] Draggable Queue placement +- [x] Fixed issue with self service user creation when using a postgres db +- [x] Rebuilt the Podcast Episode Layout display page so that on small screens everything fits on screen and looks much nicer +- [x] Rebuilt the Single Episode display page so that on small screens everything fits on screen and looks much nicer +- [x] Fixed Issue with Episodes on small screens where if a word in the title was long enough it would overflow the container +- [x] Adjusted the Podcast Episode Layout display page so that you can click and episode title and view the description +- [x] Removed Unneeded space between First episode/podcast container and the title bar at the top on multiple pages - Just cleans things up a bit +- [x] Fixed image layout issue where if episode had wide image it would overflow the container and title text +- [x] Fixed issue with categories where it showed them as part of a dictionary and sometimes didn't show them at all +- [x] Added verification before downloading all episodes since this is quite a weighty process +- [x] Added Complete Episode Option to Episode Page +- [x] Adjusted downloads page to display the number of downloaded episodes instead of the number of episodes in the podcast +- [x] Added Episode Completion Status to Episode Page +- [x] Fixed Issue with Postgres DBs where sometimes it would return dictionaries and try to refresh episodes using :podcastid as the podcast id. Now it always refreshes correctly +- [x] Fixed issue where when using postgres the User Created date on the user stats page would display the unix Epoch date +- [x] Added Validations on Episode layout page to verify the user wants to delete the podcast or download all episodes + +Pre launch tests: + Check routes for mysql and postgres + Create self service user on mysql and postgres + +Version 0.6.2 + +- [x] Kubernetes deployment option with helm +- [x] Easy to use helm repo setup and active https://helm.pinepods.online +- [x] Added Local Download support to the client versions + - [x] Local Downloads and Server Downloads tabs in client versions + - [x] Created logic to keep track of locally downloaded episodes + - [x] Episodes download using tauri function + - [x] Episodes play using tauri functions + - [x] Episodes delete using tauri functions + - [x] Create a system to queue the local download jobs so that you don't need to wait for the downloads to complete +- [x] Added offline support to the client versions. +- [x] Installable PWA +- [x] Fixed bug where some requests would queue instead of clearing on continued episode plays. For example, if you played an episode and then played another episode, the first episode would still make reqeuests for updating certain values. +- [x] Fixed issue with postgres dbs not adding episodes after addding a Nextcloud sync server (It was calling the refresh nextcloud function in the wrong file) +- [x] Fixed issue with manual completion where it only could complete, but not uncomplete +- [x] Fixed issue in downloads page where see more button didn't work on episodes + +Version 0.6.1 + +- [x] Add support for gpodder sync standalone container. You can now sync to either Nextcloud or a gpodder standalone server that supports user and passwords. +- [x] Volume control in the player +- [x] Fixed a couple parsing issues with mysql dbs found after implementing the new postgres support +- [x] Fixed issue where MFA couldn't be disabled. It just tried to enable it again. +- [x] Fixed issue with time zone parsing in postgres and mysql dbs +- [x] Implemented a mac dmg client +- [x] Added Current Version to User Stats Page + +Version 0.6.0 + +- [x] Added Postgresql support +- [x] Added option to podcast pages to allow for downloading every episode +- [x] Enhanced downloads page to better display podcasts. This improves archival experience +- [x] Added ability to download all episodes of a podcast at once with a button +- [x] Added Individual Podcast Settings Button +- [x] Completed status added so podcasts can be marked as completed manually and will auto complete once finished +- [x] Auto Download Episodes when released for given podcasts +- [x] Added Auto Skip options for intro and outros of podcasts +- [x] Fixed issue where episodes could be downloaded multiple times + +Version 0.5.4 + +- [x] Fixed enter key to login when highlighted on username or password field of login page + +- [x] Created a confirmation message when a user gets created using self service user creation +- [x] Fixed issue with viewing episodes with certain podcasts when any episodes were missing a duration +- [x] Fixed issue where release date would show current timestamp when the podcast wasn't added to the db +- [x] Added user deletion option when editing a user +- [x] Fixed issue with password changing in the ui. It now works great. + + +Version 0.5.3 + +- [x] Fix appearance and layout of podcasts on podcast screen or on searching pages. (Also added additional see more type dropdowns for descriptions to make them fit better.) +- [x] Fix mobile experience to make images consistently sized +- [x] Fixed layout of pinepods logo on user stats screen +- [x] Expanded the search bar on search podcasts page for small screens. It was being cut off a bit +- [x] Fixed order of history page +- [x] Downloads page typo +- [x] Improve look of search podcast dropdown on small screens +- [x] Made the setting accordion hover effect only over the arrows. +- [x] Added area in the settings to add custom podcast feeds +- [x] Added a Pinepods news feed that gets automatically subscribed to on fresh installs. You can easily unsubscribe from this if you don't care about it +- [x] Added ability to access episodes for an entire podcast from the episode display screen (click the podcast name) +- [x] Created functionality so the app can handle when a feed doesn't contain an audio file +- [x] Added playback speed button in the episode playing page. Now you can make playback faster! +- [x] Added episode skip button in the episode playing page. Skips to the next in the queue. +- [x] Fixed issue with the reverse button in the episode page so that it now reverses the playback by 15 seconds. +- [x] Fixed issue where spacebar didn't work in app when episode was playing +- [x] Added and verified support for mysql databases. Thanks @rgarcia6520 + +Version 0.5.2 + +- [x] Fixed issue with removal of podcasts when no longer in nextcloud subscription +- [x] Fixed scrolling problems where the app would sometimes start you at the bottom of the page when scrolling to different locations. +- [x] Fixed issue where a very occaitional podcast is unable to open it's feed. This was due to podcast redirects. Which caused the function to not work. It will now follow a redirect. +- [x] Fixed an issue where podcasts would be removed after adding when nextcloud sync is active +- [x] Added Nextcloud timestamp functionality. Podcasts will now sync listen timestamps from nextcloud. Start an episode on pinepods and finish it on Antennapods! +- [x] Added css files for material icons rather than pulling them down from Google's servers (Thanks @civilblur) +- [x] Fixed display issue on the search bar so it correctly formats itunes and podcast index +- [x] Added in check on the podcast page to check if the podcast has been added. This allows the podcast to have the context button if it's added to the db +- [x] Readjusted the format of episodes on screen. This tightens them up and ensures they are all always consistently sized. It also allows more episodes to show at once. +- [x] Added loading icon when a podcast is being added. This gives some feedback to the user during a couple seconds it takes to add the feed. (Also improved the look of that button) +- [x] Fixed date formatting issue on all pages so they format using the user's timezone preferences. +- [x] Added notifications when saving, downloading, or queueing episode from search page. +- [x] Improved look at the episode page. Fixed up the spacing and the buttons. + + +Version 0.5.1 + +- [x] Fixed Nextcloud cors issues that were appearing due to requests being made from the client side +- [x] Fixed Docker auto uploads in actions CI/CD + +Version 0.5.0 + +- [x] Complete Rust WASM Rebuild +- [x] Make Timestamps with with Auto Resume +- [x] Nextcloud Subscriptions +- [x] Finalize User Stats recording and display +- [x] MFA Logins +- [x] User Settings +- [x] Ensure Queue Functions after episode End +- [x] Auto Update Button interactions based on current page. (EX. When on saved page - Save button should be Remove from Saved rather than Save) +- [x] Refresh of podcasts needs to be async (Currently that process stops the server dead) +- [x] Make the Queue functional and verify auto removals and adds +- [x] Downloads Page +- [x] Backup Server +- [x] Allow for episodes to be played without being added +- [x] Fix images on some podcasts that don't appear. Likely a fallback issue +- [x] Issues occur server side when adding podcast without itunes_duration +(pinepods-1 | Error adding episodes: object has no attribute 'itunes_duration') +- [x] Click Episode Title to Open into Episode Screen +- [x] Duration Not showing when podcast played from episode layout screen +- [x] Episodes not appearing in history (Issue due to recent episode in db check) +- [x] Panic being caused when searching podcasts sometimes (due to an empty value) <- Silly Categories being empty +- [x] Auto close queue, download, save context menu when clicking an option or clicking away from it +- [x] Added login screen random image selection. For some nice styling +- [x] Check for Added Podcasts to ensure you can't add a second time. Searching a podcast already added should present with remove button instead of add < - On search results page (done), on podcasts page (done), and on podcast episode list page +- [x] Show Currently Connected Nextcloud Server in settings +- [x] Allow Setting and removing user admin status in settings +- [x] Show released time of episodes - use function call_get_time_info in pod_reqs (Additional date format display implemented along with AM/PM time based on user pref) +- [x] Require Re-Login if API Key that's saved doesn't work +- [x] Episodes directly get the wrong images sometimes. This likely has to do with the way the database is parsing the podcasts as they refresh and pull in. (Should be fixed. Need to allow feeds to load in some episodes to know for sure) +- [x] Episode Releases are showing now time. Rather than actual release in app (Bug with Parsing) +- [x] Consistent Styling Throughout +- [x] Setup All Themes +- [x] Downloads page playing streamed episodes. Should stream from server files +- [x] Loading icon in the center of screen while episodes load in (Done on home - Further test) +- [x] Podcasts show episode images sometimes on podcasts page for some reason (This was because it used the first episode in the feed for the import. Not anymore) +- [x] Initial Screen loading as we pull in context - It swaps a lot quicker now. Theme stores itself in local storage +- [x] Run Podcast Descriptions on Podcasts page through html parsing +- [x] Fix all auth Problems with redirecting and episodes loading (Solution Found, implementing on all routes) <- Fixed, F5 now returns you to the page you were previously on +- [x] Nextcloud Subscription Timestamps +- [x] Verify Users only see what they have access to +- [x] Do not delete theme context on logout +- [x] Make validations work correctly on login user create +- [x] Make no or wrong pass display error in server Restore and Backup +- [x] Improve Import Experience +- [x] Update All Depends +- [x] Loading animations where if makes sense +- [x] Verify Funtional Mobile Version (Functional - Will be made better with time) +- [x] Cleanup prints on server and client end. Make debugging functionality work again +- [x] Fix all CORs Issues - Verify behind Reverse Proxy (Seems to all work great with no issues) +- [x] Client release with Tauri (Compiles and runs. Feature testing needed - Mainly Audio) <- Audo tested and working. Everything seems to be totally fine. +- [x] Automation - client auto release and compile - auto compile and push to docker hub +- [x] Revamp Readme +- [x] Cors errors when browsing certain podcast results +- [x] Perfect the scrubbing (Mostly good to go at this point. The only potential issue is the coloring. Another pass on colors will be done after the first beta release.) +- [x] Itunes +- [x] Revamp Documentation + +Version 0.5.0 + +- [x] v0.1 of Pinepods Firewood released! +- [x] Nextcloud Gpodder Support added to Pinepods! + +Version 0.4.1 + +- [x] Fixed issue where get_user_episode_count wasn't displaying episode numbers. There was a syntax error in the api call +- [x] Added /api/data/podcast_episodes and /api/data/get_podcast_id api calls. These are needed for Pinepods Firewood + + +Version 0.4 + +- [x] Unlock api creation for standard users - The API has been completely re-written to follow along the permissions that users actually have. Meaning users can easily request their own api keys and sign into the client with admin consent +- [x] Signing into the client edition is now possible with either an API key or username and password sign in. It gives the option to choose which you would prefer. +- [x] Email resets currently broken for non-admins due to lockdown on encryption key. Need to handle encryption server-side +- [x] Client version images load a lot faster now +- [x] Fixed issue with audio container not reappearing after entering playing fullscreen +- [x] Fixed Issue with Queue Bump Not working right +- [x] Added verification when deleting user + +Version 0.3.1 + +- [x] Finalize reverse proxy processes and web playing + +Version 0.3 + +- [x] Export and import of following podcasts (basically user backups) +- [x] Entire Server Backup and Import. This allows you to export and import your entire database for complete backups +- [x] New refresh system added to automatically update podcasts in database with no user input. +- [x] Reworked the controls displayed on the page to be components of a class. This should improve performance. +- [x] fixed issues with logging in on small screens. (a big step for mobile version) +- [x] Bug fixing such as fixing queue bump, and fixing an audio changing issue - Along with quite a few random UI bug fixing throughout + +Version 0.2 + +- [x] Implement custom urls for feeds +- [x] Organize folder layout in the same way as the client when server downloading + +Version 0.1 + +- [X] Create Code that can pull Podcasts +- [X] Integrate Podcast Index +- [X] Play Audio Files using Python - Flet's Audio library is used +- [X] Record listen history and display user history on specific page +- [X] Record accurate listen time. So if you stop listening part-way through you can resume from the same spot +- [X] Scrubbing playback from a progress bar - ft.slider() +- [X] Visual progress bar based on time listened to podcasts partly listened to +- [X] Download option for podcasts. In addition, display downloaded podcasts in downloads area. Allow for deletion of these after downloaded +- [X] Queue, and allow podcasts to be removed from queue once added (Queue is added but you can't remove them from it yet) +- [X] Login screen +- [X] Episode view (Also display html in descriptions via markdown) +- [X] Multiple Themes (like 10 I think?) +- [X] Add picture of current episode to soundbar +- [X] Complete user management with admin options +- [X] Ability to Delete Users +- [X] Allow guest user to be disabled (Is disabled by default) +- [X] Ensure changes cannot be made to guest user +- [X] Ensure Users cannot delete themselves +- [X] Guest sign in via button on login screen when enabled +- [X] Saved episodes view +- [X] Caching image server (proxy) +- [X] Optional user self service creation +- [X] User stats page +- [X] Implement sign in retention. (App retention now works. It creates session keys and stores them locally. Browser retention is next, this will need some kind of oauth) +- [X] Audio Volume adjustment options +- [X] Create Web App + - [X] Responsive layout + - [X] Security and Logins + - [X] Database interaction for users and podcast data +- [x] Fully update Readme with updated info and docs including deployment guide +- [X] Bugs + - [X] Links when searching an episode are blue (wrong color) + - [X] When changing theme, then selecting 'podcasts' page, the navbar does not retain theme + - [X] There's an issue with Queue not working properly. Sometimes it just plays instead of queues (Fixed when switching to flet audio control) + - [X] Clicking podcast that's already been added displays add podcast view with no current way to play + - [X] Clicking play buttons on a podcast while another is loading currently breaks things + - [X] Pausing audio changes font color + - [X] Login screen colors are wrong on first boot + - [X] Themeing currently wrong on audio interaction control + - [X] Starting a podcast results in audio bar being in phone mode on application version (This should be fixed. I load the check screensize method now further down the page. Which results in consistent width collection.) + - [X] Starting a podcast results in audio bar being in phone mode on application version + - [X] Adding a podcast with an emoji in the description currently appears to break it + - [X] Layout breaks when pausing for podcast names + - [X] The queue works but currently does not remove podcasts after switching to a new one + - [X] Resume is currently broken (it now works but it double plays an episode before resuming for some reason. It still double plays and there's not a great way to fix it. Return later. Updates to flet are likely to help eventually) + - [X] Double check 2 users adding the same podcast (There was an issue with checking playback status that is now fixed) + - [X] After refresh auto update current route + - [X] Double and triple check all interactions to verify functionality + - [X] Fix any additional browser playback bugs (Audio now routes properly through the proxy) +- [x] Dockerize + - [X] Package into Container/Dockerfile + - [X] Pypods image in docker hub + - [X] Create Docker-Compose Code + - [X] Mixed content - Currently running http or https content can cause an error + - [x] Option to run your own local podcast index api connection +- [x] Implement Gravitar API for profile picture +- [x] Make web version utilize API Routes instead of database connections directly +- [x] Update flet dependancy to v6 (This fixes audio routing) +- [x] Ability to disable downloads (for public servers) +- [x] One set of functions. Currently client and web app uses different function set. This is be changed for consistency. +- [x] GUI Wrapper for App + - [x] Server Hosting and client Interaction - Client interaction works via API with mariadb which is hosted on server side + - [x] Options to create API keys on the web client as well as ability to remove them + - [x] Linux App + - [x] Install Script + - [x] Packaging and automation + - [X] Proper web layout + - [x] Windows App + - [x] Packaging and automation + - [x] Mac App + - [x] Packaging and automation +- [x] Self Service PW Resets +- [x] Add creator info to bottom of stats page +- [x] Default User Creation (Default User is now created if user vars aren't specified in compoose file) +- [x] Issue with web search bar may be due to appbar (This was a rabbit hole. Turns out this was due to the way the top bar was created prior to the routes. I needed to rebuild how searching is done, but this is now fixed) +- [x] Occasionally podcasts will put seconds value in mins (This was a bug due to duration parsing. Code fixed, everything now displays properly) +- [x] Fix client pooling issue (This is a tough issue. Pooling is occasionally a problem. I set the idle timeout to kill old connections and I also fixed a couple database connections that didn't run cnx.close) Edit: I actually think this is truly fixed now. I rebuilt the way this works using async, no problems so far +- [x] Rebuild image Pulling process. The current one is just unworkable (It runs a lot better now. It spawns 4 workers to handle image gathering. Though it still isn't perfect, it hangs a bit occationally but for the time being it's totally usable) +- [x] Layout Settings page better +- [x] MFA Logins +- [x] Allow local downloads to just download the mp3 files direct (Likely only possible on app version) +- [x] Add Itunes podcast API +- [x] MFA Logins on web version +- [x] Do something when search results aren't found - Currently Blank screen +- [x] Implement smoother scrolling with big list loading (I've started a fix for this. ListViews are now active and working right on home and podview) +- [x] Option to remove from history +- [x] Reload not needed to add and remove episodes from pages +- [x] Add mfa to dynamic settings class +- [x] Add new users to dynamic settings class +- [x] Add Email settings to dynamic users class +- [x] logout on client remove saved app cache (Implemented button in settings to clear cache) +- [x] On top bar cutoff add a search button that opens a search prompt (There's a small version of the search button now) +- [x] custom timezone entry +- [x] MFA Display totp secret +- [x] Fix guest with timezone stuff +- [x] 2.0 description features +- [x] Mass downloading episodes. Entire podcast at once (Implemented but I'm working on getting it to display on download page to see status) +- [x] Remove local podcasts if podcast is no longer in database - Handle this somehow - Mass delete feature added +- [x] Speed up database queries (Indexing added to episodes and podcasts) +- [x] Check local downloads if already downloaded +- [x] Allow description view on podcasts not added +- [x] Configure some kind of auto-refresh feature - Refreshes now on first boot and once every hour +- [x] Mass download options not working on web +- [x] Issue with loading poddisplay on web +- [x] Search options missing from web (Restored - Entirely due to flet jank from app to web) +- [x] Small layout Improvements (Try, complete layout overhaul actually) +- [x] Apparently I broke itunes searching (description addition was causing a problem) +- [x] Internal Episode Search +- [x] Refresh causes episode to restart +- [x] Fix logout - It's shows navbar still +- [x] Refresh with nothing in database breaks things +- [x] Revamp queue - It should just save to the database +- [x] Refresh changes on readme +- [x] API documentation (Site Built with Docusaurus) diff --git a/PinePods-0.8.2/database_functions/__init__.py b/PinePods-0.8.2/database_functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/PinePods-0.8.2/database_functions/migrate.py b/PinePods-0.8.2/database_functions/migrate.py new file mode 100755 index 0000000..7e5ad7d --- /dev/null +++ b/PinePods-0.8.2/database_functions/migrate.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Database Migration Runner for PinePods + +This script can be run standalone to apply database migrations. +Useful for updating existing installations. +""" + +import os +import sys +import logging +import argparse +from pathlib import Path + +# Set up logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Add pinepods to path +pinepods_path = Path(__file__).parent.parent +sys.path.insert(0, str(pinepods_path)) + + +def run_migrations(target_version=None, validate_only=False): + """Run database migrations""" + try: + # Import migration system + import database_functions.migration_definitions + from database_functions.migrations import get_migration_manager, run_all_migrations + + # Register all migrations + database_functions.migration_definitions.register_all_migrations() + + # Get migration manager + manager = get_migration_manager() + + if validate_only: + logger.info("Validating existing migrations...") + success = manager.validate_migrations() + if success: + logger.info("All migrations validated successfully") + else: + logger.error("Migration validation failed") + return success + + # Show current state + applied = manager.get_applied_migrations() + logger.info(f"Currently applied migrations: {len(applied)}") + for version in applied: + logger.info(f" - {version}") + + # Run migrations + logger.info("Starting migration process...") + success = run_all_migrations() + + if success: + logger.info("All migrations completed successfully") + else: + logger.error("Migration process failed") + + return success + + except Exception as e: + logger.error(f"Migration failed: {e}") + return False + + +def list_migrations(): + """List all available migrations""" + try: + import database_functions.migration_definitions + from database_functions.migrations import get_migration_manager + + # Register migrations + database_functions.migration_definitions.register_all_migrations() + + # Get manager and list migrations + manager = get_migration_manager() + applied = set(manager.get_applied_migrations()) + + logger.info("Available migrations:") + for version, migration in sorted(manager.migrations.items()): + status = "APPLIED" if version in applied else "PENDING" + logger.info(f" {version} - {migration.name} [{status}]") + logger.info(f" {migration.description}") + if migration.requires: + logger.info(f" Requires: {', '.join(migration.requires)}") + + return True + + except Exception as e: + logger.error(f"Failed to list migrations: {e}") + return False + + +def main(): + """Main CLI interface""" + parser = argparse.ArgumentParser(description="PinePods Database Migration Tool") + parser.add_argument( + "command", + choices=["migrate", "list", "validate"], + help="Command to execute" + ) + parser.add_argument( + "--target", + help="Target migration version (migrate only)" + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose logging" + ) + + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + # Execute command + if args.command == "migrate": + success = run_migrations(args.target) + elif args.command == "list": + success = list_migrations() + elif args.command == "validate": + success = run_migrations(validate_only=True) + else: + logger.error(f"Unknown command: {args.command}") + success = False + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/PinePods-0.8.2/database_functions/migration_definitions.py b/PinePods-0.8.2/database_functions/migration_definitions.py new file mode 100644 index 0000000..db97898 --- /dev/null +++ b/PinePods-0.8.2/database_functions/migration_definitions.py @@ -0,0 +1,3789 @@ +""" +Migration Definitions for PinePods Database Schema + +This file contains all database migrations in chronological order. +Each migration is versioned and idempotent. +""" + +import logging +import os +import sys +from cryptography.fernet import Fernet +import string +import secrets +import random +from typing import Any + +# Add pinepods to path for imports +sys.path.append('/pinepods') + +from database_functions.migrations import Migration, get_migration_manager, register_migration + +# Import password hashing utilities +try: + from passlib.hash import argon2 + from argon2 import PasswordHasher + from argon2.exceptions import HashingError +except ImportError: + pass + +logger = logging.getLogger(__name__) + + +def generate_random_password(length=12): + """Generate a random password""" + characters = string.ascii_letters + string.digits + string.punctuation + return ''.join(random.choice(characters) for i in range(length)) + + +def hash_password(password: str): + """Hash password using Argon2""" + try: + ph = PasswordHasher() + return ph.hash(password) + except (HashingError, NameError) as e: + logger.error(f"Error hashing password: {e}") + return None + + +def safe_execute_sql(cursor, sql: str, params=None, conn=None): + """Safely execute SQL with error handling""" + try: + if params: + cursor.execute(sql, params) + else: + cursor.execute(sql) + return True + except Exception as e: + error_msg = str(e).lower() + # These are expected errors when objects already exist + expected_errors = [ + 'already exists', + 'duplicate column', + 'duplicate key name', + 'constraint already exists', + 'relation already exists' + ] + + if any(expected in error_msg for expected in expected_errors): + logger.info(f"Skipping SQL (object already exists): {error_msg}") + # For PostgreSQL, we need to rollback the transaction and start fresh + if conn and 'current transaction is aborted' in str(e).lower(): + try: + conn.rollback() + except: + pass + return True + else: + logger.warning(f"SQL execution warning: {e}") + # For PostgreSQL, rollback if transaction is aborted + if conn and 'current transaction is aborted' in str(e).lower(): + try: + conn.rollback() + except: + pass + return False + + +def check_constraint_exists(cursor, db_type: str, table_name: str, constraint_name: str) -> bool: + """Check if a constraint exists""" + try: + if db_type == 'postgresql': + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_name = %s AND table_name = %s + """, (constraint_name, table_name.strip('"'))) + else: # mysql + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_name = %s AND table_name = %s + AND table_schema = DATABASE() + """, (constraint_name, table_name)) + + return cursor.fetchone()[0] > 0 + except: + return False + + +def check_index_exists(cursor, db_type: str, index_name: str) -> bool: + """Check if an index exists""" + try: + if db_type == 'postgresql': + cursor.execute(""" + SELECT COUNT(*) + FROM pg_indexes + WHERE indexname = %s + """, (index_name,)) + else: # mysql + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.statistics + WHERE index_name = %s AND table_schema = DATABASE() + """, (index_name,)) + + return cursor.fetchone()[0] > 0 + except: + return False + + +def safe_add_constraint(cursor, db_type: str, sql: str, table_name: str, constraint_name: str, conn=None): + """Safely add a constraint if it doesn't exist""" + if not check_constraint_exists(cursor, db_type, table_name, constraint_name): + try: + cursor.execute(sql) + logger.info(f"Added constraint {constraint_name}") + return True + except Exception as e: + logger.warning(f"Failed to add constraint {constraint_name}: {e}") + return False + else: + logger.info(f"Constraint {constraint_name} already exists, skipping") + return True + + +def safe_add_index(cursor, db_type: str, sql: str, index_name: str, conn=None): + """Safely add an index if it doesn't exist""" + if not check_index_exists(cursor, db_type, index_name): + try: + cursor.execute(sql) + logger.info(f"Added index {index_name}") + return True + except Exception as e: + logger.warning(f"Failed to add index {index_name}: {e}") + return False + else: + logger.info(f"Index {index_name} already exists, skipping") + return True + + +# Migration 001: Core Tables Creation +@register_migration("001", "create_core_tables", "Create core database tables (Users, OIDCProviders, APIKeys, etc.)") +def migration_001_core_tables(conn, db_type: str): + """Create core database tables""" + cursor = conn.cursor() + + try: + if db_type == 'postgresql': + # Create Users table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Users" ( + UserID SERIAL PRIMARY KEY, + Fullname VARCHAR(255), + Username VARCHAR(255) UNIQUE, + Email VARCHAR(255), + Hashed_PW VARCHAR(500), + IsAdmin BOOLEAN, + Reset_Code TEXT, + Reset_Expiry TIMESTAMP, + MFA_Secret VARCHAR(70), + TimeZone VARCHAR(50) DEFAULT 'UTC', + TimeFormat INT DEFAULT 24, + DateFormat VARCHAR(3) DEFAULT 'ISO', + FirstLogin BOOLEAN DEFAULT false, + GpodderUrl VARCHAR(255) DEFAULT '', + Pod_Sync_Type VARCHAR(50) DEFAULT 'None', + GpodderLoginName VARCHAR(255) DEFAULT '', + GpodderToken VARCHAR(255) DEFAULT '', + EnableRSSFeeds BOOLEAN DEFAULT FALSE, + auth_type VARCHAR(50) DEFAULT 'standard', + oidc_provider_id INT, + oidc_subject VARCHAR(255), + PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0 + ) + """) + + # Create OIDCProviders table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "OIDCProviders" ( + ProviderID SERIAL PRIMARY KEY, + ProviderName VARCHAR(255) NOT NULL, + ClientID VARCHAR(255) NOT NULL, + ClientSecret VARCHAR(500) NOT NULL, + AuthorizationURL VARCHAR(255) NOT NULL, + TokenURL VARCHAR(255) NOT NULL, + UserInfoURL VARCHAR(255) NOT NULL, + Scope VARCHAR(255) DEFAULT 'openid email profile', + ButtonColor VARCHAR(50) DEFAULT '#000000', + ButtonText VARCHAR(255) NOT NULL, + ButtonTextColor VARCHAR(50) DEFAULT '#000000', + IconSVG TEXT, + Enabled BOOLEAN DEFAULT true, + Created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + Modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Add foreign key constraint + safe_add_constraint(cursor, db_type, """ + ALTER TABLE "Users" + ADD CONSTRAINT fk_oidc_provider + FOREIGN KEY (oidc_provider_id) + REFERENCES "OIDCProviders"(ProviderID) + """, "Users", "fk_oidc_provider") + + else: # mysql + # Create Users table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS Users ( + UserID INT AUTO_INCREMENT PRIMARY KEY, + Fullname VARCHAR(255), + Username VARCHAR(255) UNIQUE, + Email VARCHAR(255), + Hashed_PW CHAR(255), + IsAdmin TINYINT(1), + Reset_Code TEXT, + Reset_Expiry DATETIME, + MFA_Secret VARCHAR(70), + TimeZone VARCHAR(50) DEFAULT 'UTC', + TimeFormat INT DEFAULT 24, + DateFormat VARCHAR(3) DEFAULT 'ISO', + FirstLogin TINYINT(1) DEFAULT 0, + GpodderUrl VARCHAR(255) DEFAULT '', + Pod_Sync_Type VARCHAR(50) DEFAULT 'None', + GpodderLoginName VARCHAR(255) DEFAULT '', + GpodderToken VARCHAR(255) DEFAULT '', + EnableRSSFeeds TINYINT(1) DEFAULT 0, + auth_type VARCHAR(50) DEFAULT 'standard', + oidc_provider_id INT, + oidc_subject VARCHAR(255), + PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0 + ) + """) + + # Create OIDCProviders table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS OIDCProviders ( + ProviderID INT AUTO_INCREMENT PRIMARY KEY, + ProviderName VARCHAR(255) NOT NULL, + ClientID VARCHAR(255) NOT NULL, + ClientSecret VARCHAR(500) NOT NULL, + AuthorizationURL VARCHAR(255) NOT NULL, + TokenURL VARCHAR(255) NOT NULL, + UserInfoURL VARCHAR(255) NOT NULL, + Scope VARCHAR(255) DEFAULT 'openid email profile', + ButtonColor VARCHAR(50) DEFAULT '#000000', + ButtonText VARCHAR(255) NOT NULL, + ButtonTextColor VARCHAR(50) DEFAULT '#000000', + IconSVG TEXT, + Enabled TINYINT(1) DEFAULT 1, + Created DATETIME DEFAULT CURRENT_TIMESTAMP, + Modified DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """) + + # Add foreign key constraint + safe_add_constraint(cursor, db_type, """ + ALTER TABLE Users + ADD CONSTRAINT fk_oidc_provider + FOREIGN KEY (oidc_provider_id) + REFERENCES OIDCProviders(ProviderID) + """, "Users", "fk_oidc_provider") + + # Create API and RSS key tables (same for both databases) + table_prefix = '"' if db_type == 'postgresql' else '' + table_suffix = '"' if db_type == 'postgresql' else '' + + cursor.execute(f""" + CREATE TABLE IF NOT EXISTS {table_prefix}APIKeys{table_suffix} ( + APIKeyID {'SERIAL' if db_type == 'postgresql' else 'INT AUTO_INCREMENT'} PRIMARY KEY, + UserID INT, + APIKey TEXT, + Created {'TIMESTAMP' if db_type == 'postgresql' else 'TIMESTAMP'} DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES {table_prefix}Users{table_suffix}(UserID) ON DELETE CASCADE + ) + """) + + cursor.execute(f""" + CREATE TABLE IF NOT EXISTS {table_prefix}RssKeys{table_suffix} ( + RssKeyID {'SERIAL' if db_type == 'postgresql' else 'INT AUTO_INCREMENT'} PRIMARY KEY, + UserID INT, + RssKey TEXT, + Created {'TIMESTAMP' if db_type == 'postgresql' else 'TIMESTAMP'} DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES {table_prefix}Users{table_suffix}(UserID) ON DELETE CASCADE + ) + """) + + cursor.execute(f""" + CREATE TABLE IF NOT EXISTS {table_prefix}RssKeyMap{table_suffix} ( + RssKeyID INT, + PodcastID INT, + FOREIGN KEY (RssKeyID) REFERENCES {table_prefix}RssKeys{table_suffix}(RssKeyID) ON DELETE CASCADE + ) + """) + + logger.info("Created core tables successfully") + + finally: + cursor.close() + + +# Migration 002: App Settings and Configuration Tables +@register_migration("002", "app_settings", "Create app settings and configuration tables", requires=["001"]) +def migration_002_app_settings(conn, db_type: str): + """Create app settings and configuration tables""" + cursor = conn.cursor() + + try: + table_prefix = '"' if db_type == 'postgresql' else '' + table_suffix = '"' if db_type == 'postgresql' else '' + + # Generate encryption key + key = Fernet.generate_key() + + if db_type == 'postgresql': + # Create AppSettings table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "AppSettings" ( + AppSettingsID SERIAL PRIMARY KEY, + SelfServiceUser BOOLEAN DEFAULT false, + DownloadEnabled BOOLEAN DEFAULT true, + EncryptionKey BYTEA, + NewsFeedSubscribed BOOLEAN DEFAULT false + ) + """) + + # Insert default settings if not exists + cursor.execute('SELECT COUNT(*) FROM "AppSettings" WHERE AppSettingsID = 1') + count = cursor.fetchone()[0] + if count == 0: + cursor.execute(""" + INSERT INTO "AppSettings" (SelfServiceUser, DownloadEnabled, EncryptionKey) + VALUES (false, true, %s) + """, (key,)) + + # Create EmailSettings table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "EmailSettings" ( + EmailSettingsID SERIAL PRIMARY KEY, + Server_Name VARCHAR(255), + Server_Port INT, + From_Email VARCHAR(255), + Send_Mode VARCHAR(255), + Encryption VARCHAR(255), + Auth_Required BOOLEAN, + Username VARCHAR(255), + Password VARCHAR(255) + ) + """) + + else: # mysql + # Create AppSettings table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS AppSettings ( + AppSettingsID INT AUTO_INCREMENT PRIMARY KEY, + SelfServiceUser TINYINT(1) DEFAULT 0, + DownloadEnabled TINYINT(1) DEFAULT 1, + EncryptionKey BINARY(44), + NewsFeedSubscribed TINYINT(1) DEFAULT 0 + ) + """) + + # Insert default settings if not exists + cursor.execute("SELECT COUNT(*) FROM AppSettings WHERE AppSettingsID = 1") + count = cursor.fetchone()[0] + if count == 0: + cursor.execute(""" + INSERT INTO AppSettings (SelfServiceUser, DownloadEnabled, EncryptionKey) + VALUES (0, 1, %s) + """, (key,)) + + # Create EmailSettings table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS EmailSettings ( + EmailSettingsID INT AUTO_INCREMENT PRIMARY KEY, + Server_Name VARCHAR(255), + Server_Port INT, + From_Email VARCHAR(255), + Send_Mode VARCHAR(255), + Encryption VARCHAR(255), + Auth_Required TINYINT(1), + Username VARCHAR(255), + Password VARCHAR(255) + ) + """) + + # Insert default email settings if not exists + cursor.execute(f"SELECT COUNT(*) FROM {table_prefix}EmailSettings{table_suffix}") + rows = cursor.fetchone() + if rows[0] == 0: + cursor.execute(f""" + INSERT INTO {table_prefix}EmailSettings{table_suffix} + (Server_Name, Server_Port, From_Email, Send_Mode, Encryption, Auth_Required, Username, Password) + VALUES ('default_server', 587, 'default_email@domain.com', 'default_mode', 'default_encryption', + {'true' if db_type == 'postgresql' else '1'}, 'default_username', 'default_password') + """) + + logger.info("Created app settings tables successfully") + + finally: + cursor.close() + + +# Migration 003: User Management Tables +@register_migration("003", "user_tables", "Create user stats and settings tables", requires=["001"]) +def migration_003_user_tables(conn, db_type: str): + """Create user management tables""" + cursor = conn.cursor() + + try: + table_prefix = '"' if db_type == 'postgresql' else '' + table_suffix = '"' if db_type == 'postgresql' else '' + + # Create UserStats table + if db_type == 'postgresql': + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "UserStats" ( + UserStatsID SERIAL PRIMARY KEY, + UserID INT UNIQUE, + UserCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PodcastsPlayed INT DEFAULT 0, + TimeListened INT DEFAULT 0, + PodcastsAdded INT DEFAULT 0, + EpisodesSaved INT DEFAULT 0, + EpisodesDownloaded INT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "UserSettings" ( + usersettingid SERIAL PRIMARY KEY, + userid INT UNIQUE, + theme VARCHAR(255) DEFAULT 'Nordic', + startpage VARCHAR(255) DEFAULT 'home', + FOREIGN KEY (userid) REFERENCES "Users"(userid) + ) + """) + else: # mysql + cursor.execute(""" + CREATE TABLE IF NOT EXISTS UserStats ( + UserStatsID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT UNIQUE, + UserCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PodcastsPlayed INT DEFAULT 0, + TimeListened INT DEFAULT 0, + PodcastsAdded INT DEFAULT 0, + EpisodesSaved INT DEFAULT 0, + EpisodesDownloaded INT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS UserSettings ( + UserSettingID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT UNIQUE, + Theme VARCHAR(255) DEFAULT 'Nordic', + StartPage VARCHAR(255) DEFAULT 'home', + FOREIGN KEY (UserID) REFERENCES Users(UserID) + ) + """) + + logger.info("Created user management tables successfully") + + finally: + cursor.close() + + +# Migration 004: Default Users Creation +@register_migration("004", "default_users", "Create default background tasks and admin users", requires=["001", "003"]) +def migration_004_default_users(conn, db_type: str): + """Create default users""" + cursor = conn.cursor() + + try: + table_prefix = '"' if db_type == 'postgresql' else '' + table_suffix = '"' if db_type == 'postgresql' else '' + + # Create background tasks user + random_password = generate_random_password() + hashed_password = hash_password(random_password) + + if hashed_password: + if db_type == 'postgresql': + cursor.execute(""" + INSERT INTO "Users" (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (Username) DO NOTHING + """, ('Background Tasks', 'background_tasks', 'inactive', hashed_password, False)) + else: # mysql + cursor.execute(""" + INSERT IGNORE INTO Users (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (%s, %s, %s, %s, %s) + """, ('Background Tasks', 'background_tasks', 'inactive', hashed_password, False)) + + # Create admin user from environment variables if provided + admin_fullname = os.environ.get("FULLNAME") + admin_username = os.environ.get("USERNAME") + admin_email = os.environ.get("EMAIL") + admin_pw = os.environ.get("PASSWORD") + + admin_created = False + if all([admin_fullname, admin_username, admin_email, admin_pw]): + hashed_pw = hash_password(admin_pw) + if hashed_pw: + if db_type == 'postgresql': + cursor.execute(""" + INSERT INTO "Users" (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (Username) DO NOTHING + RETURNING UserID + """, (admin_fullname, admin_username, admin_email, hashed_pw, True)) + admin_created = cursor.fetchone() is not None + else: # mysql + cursor.execute(""" + INSERT IGNORE INTO Users (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (%s, %s, %s, %s, %s) + """, (admin_fullname, admin_username, admin_email, hashed_pw, True)) + admin_created = cursor.rowcount > 0 + + # Create user stats and settings for default users + if db_type == 'postgresql': + cursor.execute(""" + INSERT INTO "UserStats" (UserID) VALUES (1) + ON CONFLICT (UserID) DO NOTHING + """) + cursor.execute(""" + INSERT INTO "UserSettings" (UserID, Theme) VALUES (1, 'Nordic') + ON CONFLICT (UserID) DO NOTHING + """) + if admin_created: + cursor.execute(""" + INSERT INTO "UserStats" (UserID) VALUES (2) + ON CONFLICT (UserID) DO NOTHING + """) + cursor.execute(""" + INSERT INTO "UserSettings" (UserID, Theme) VALUES (2, 'Nordic') + ON CONFLICT (UserID) DO NOTHING + """) + else: # mysql + cursor.execute("INSERT IGNORE INTO UserStats (UserID) VALUES (1)") + cursor.execute("INSERT IGNORE INTO UserSettings (UserID, Theme) VALUES (1, 'Nordic')") + if admin_created: + cursor.execute("INSERT IGNORE INTO UserStats (UserID) VALUES (2)") + cursor.execute("INSERT IGNORE INTO UserSettings (UserID, Theme) VALUES (2, 'Nordic')") + + # Create API key for background tasks user + cursor.execute(f'SELECT APIKey FROM {table_prefix}APIKeys{table_suffix} WHERE UserID = 1') + result = cursor.fetchone() + + if not result: + alphabet = string.ascii_letters + string.digits + api_key = ''.join(secrets.choice(alphabet) for _ in range(64)) + cursor.execute(f'INSERT INTO {table_prefix}APIKeys{table_suffix} (UserID, APIKey) VALUES (1, %s)', (api_key,)) + else: + # Extract API key from existing record + api_key = result[0] if isinstance(result, tuple) else result['apikey'] + + # Note: Web API key file removed for security - background tasks now authenticate via database + + logger.info("Created default users successfully") + + finally: + cursor.close() + + +# Migration 005: Podcast and Episode Tables +@register_migration("005", "podcast_episode_tables", "Create podcast and episode management tables", requires=["001"]) +def migration_005_podcast_episode_tables(conn, db_type: str): + """Create podcast and episode tables""" + cursor = conn.cursor() + + try: + table_prefix = '"' if db_type == 'postgresql' else '' + table_suffix = '"' if db_type == 'postgresql' else '' + + if db_type == 'postgresql': + # Create Podcasts table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Podcasts" ( + PodcastID SERIAL PRIMARY KEY, + PodcastIndexID INT, + PodcastName TEXT, + ArtworkURL TEXT, + Author TEXT, + Categories TEXT, + Description TEXT, + EpisodeCount INT, + FeedURL TEXT, + WebsiteURL TEXT, + Explicit BOOLEAN, + UserID INT, + AutoDownload BOOLEAN DEFAULT FALSE, + StartSkip INT DEFAULT 0, + EndSkip INT DEFAULT 0, + Username TEXT, + Password TEXT, + IsYouTubeChannel BOOLEAN DEFAULT FALSE, + NotificationsEnabled BOOLEAN DEFAULT FALSE, + FeedCutoffDays INT DEFAULT 0, + PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0, + PlaybackSpeedCustomized BOOLEAN DEFAULT FALSE, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + ) + """) + + # Add unique constraint + safe_add_constraint(cursor, db_type, """ + ALTER TABLE "Podcasts" + ADD CONSTRAINT podcasts_userid_feedurl_key + UNIQUE (UserID, FeedURL) + """, "Podcasts", "podcasts_userid_feedurl_key") + + # Create Episodes table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Episodes" ( + EpisodeID SERIAL PRIMARY KEY, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate TIMESTAMP, + EpisodeDuration INT, + Completed BOOLEAN DEFAULT FALSE, + FOREIGN KEY (PodcastID) REFERENCES "Podcasts"(PodcastID) + ) + """) + + # Create YouTube Videos table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "YouTubeVideos" ( + VideoID SERIAL PRIMARY KEY, + PodcastID INT, + VideoTitle TEXT, + VideoDescription TEXT, + VideoURL TEXT, + ThumbnailURL TEXT, + PublishedAt TIMESTAMP, + Duration INT, + YouTubeVideoID TEXT, + Completed BOOLEAN DEFAULT FALSE, + ListenPosition INT DEFAULT 0, + FOREIGN KEY (PodcastID) REFERENCES "Podcasts"(PodcastID) + ) + """) + + else: # mysql + # Create Podcasts table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS Podcasts ( + PodcastID INT AUTO_INCREMENT PRIMARY KEY, + PodcastIndexID INT, + PodcastName TEXT, + ArtworkURL TEXT, + Author TEXT, + Categories TEXT, + Description TEXT, + EpisodeCount INT, + FeedURL TEXT, + WebsiteURL TEXT, + Explicit TINYINT(1), + UserID INT, + AutoDownload TINYINT(1) DEFAULT 0, + StartSkip INT DEFAULT 0, + EndSkip INT DEFAULT 0, + Username TEXT, + Password TEXT, + IsYouTubeChannel TINYINT(1) DEFAULT 0, + NotificationsEnabled TINYINT(1) DEFAULT 0, + FeedCutoffDays INT DEFAULT 0, + PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0, + PlaybackSpeedCustomized TINYINT(1) DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID) + ) + """) + + # Create Episodes table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS Episodes ( + EpisodeID INT AUTO_INCREMENT PRIMARY KEY, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate DATETIME, + EpisodeDuration INT, + Completed TINYINT(1) DEFAULT 0, + FOREIGN KEY (PodcastID) REFERENCES Podcasts(PodcastID) + ) + """) + + # Create YouTube Videos table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS YouTubeVideos ( + VideoID INT AUTO_INCREMENT PRIMARY KEY, + PodcastID INT, + VideoTitle TEXT, + VideoDescription TEXT, + VideoURL TEXT, + ThumbnailURL TEXT, + PublishedAt TIMESTAMP, + Duration INT, + YouTubeVideoID TEXT, + Completed TINYINT(1) DEFAULT 0, + ListenPosition INT DEFAULT 0, + FOREIGN KEY (PodcastID) REFERENCES Podcasts(PodcastID) + ) + """) + + # Create indexes for performance + safe_add_index(cursor, db_type, f'CREATE INDEX idx_podcasts_userid ON {table_prefix}Podcasts{table_suffix}(UserID)', 'idx_podcasts_userid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_episodes_podcastid ON {table_prefix}Episodes{table_suffix}(PodcastID)', 'idx_episodes_podcastid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_episodes_episodepubdate ON {table_prefix}Episodes{table_suffix}(EpisodePubDate)', 'idx_episodes_episodepubdate') + + logger.info("Created podcast and episode tables successfully") + + finally: + cursor.close() + + +# Migration 006: User Activity Tables +@register_migration("006", "user_activity_tables", "Create user activity tracking tables", requires=["005"]) +def migration_006_user_activity_tables(conn, db_type: str): + """Create user activity tracking tables""" + cursor = conn.cursor() + + try: + table_prefix = '"' if db_type == 'postgresql' else '' + table_suffix = '"' if db_type == 'postgresql' else '' + + if db_type == 'postgresql': + # User Episode History + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "UserEpisodeHistory" ( + UserEpisodeHistoryID SERIAL PRIMARY KEY, + UserID INT, + EpisodeID INT, + ListenDate TIMESTAMP, + ListenDuration INT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) + ) + """) + + # User Video History + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "UserVideoHistory" ( + UserVideoHistoryID SERIAL PRIMARY KEY, + UserID INT, + VideoID INT, + ListenDate TIMESTAMP, + ListenDuration INT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) + ) + """) + + # Add unique constraints + safe_add_constraint(cursor, db_type, """ + ALTER TABLE "UserEpisodeHistory" + ADD CONSTRAINT user_episode_unique + UNIQUE (UserID, EpisodeID) + """, "UserEpisodeHistory", "user_episode_unique") + + safe_add_constraint(cursor, db_type, """ + ALTER TABLE "UserVideoHistory" + ADD CONSTRAINT user_video_unique + UNIQUE (UserID, VideoID) + """, "UserVideoHistory", "user_video_unique") + + else: # mysql + # User Episode History + cursor.execute(""" + CREATE TABLE IF NOT EXISTS UserEpisodeHistory ( + UserEpisodeHistoryID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + EpisodeID INT, + ListenDate DATETIME, + ListenDuration INT, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) + ) + """) + + # User Video History + cursor.execute(""" + CREATE TABLE IF NOT EXISTS UserVideoHistory ( + UserVideoHistoryID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + VideoID INT, + ListenDate TIMESTAMP, + ListenDuration INT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) + ) + """) + + logger.info("Created user activity tables successfully") + + finally: + cursor.close() + + +# Migration 007: Queue and Download Tables +@register_migration("007", "queue_download_tables", "Create queue and download management tables", requires=["005"]) +def migration_007_queue_download_tables(conn, db_type: str): + """Create queue and download tables""" + cursor = conn.cursor() + + try: + table_prefix = '"' if db_type == 'postgresql' else '' + table_suffix = '"' if db_type == 'postgresql' else '' + + if db_type == 'postgresql': + # Episode Queue + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "EpisodeQueue" ( + QueueID SERIAL PRIMARY KEY, + QueueDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UserID INT, + EpisodeID INT, + QueuePosition INT NOT NULL DEFAULT 0, + is_youtube BOOLEAN DEFAULT FALSE, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + ) + """) + + # Saved Episodes and Videos + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "SavedEpisodes" ( + SaveID SERIAL PRIMARY KEY, + UserID INT, + EpisodeID INT, + SaveDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "SavedVideos" ( + SaveID SERIAL PRIMARY KEY, + UserID INT, + VideoID INT, + SaveDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) + ) + """) + + # Downloaded Episodes and Videos + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "DownloadedEpisodes" ( + DownloadID SERIAL PRIMARY KEY, + UserID INT, + EpisodeID INT, + DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DownloadedSize INT, + DownloadedLocation VARCHAR(255), + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "DownloadedVideos" ( + DownloadID SERIAL PRIMARY KEY, + UserID INT, + VideoID INT, + DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DownloadedSize INT, + DownloadedLocation VARCHAR(255), + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) + ) + """) + + else: # mysql + # Episode Queue + cursor.execute(""" + CREATE TABLE IF NOT EXISTS EpisodeQueue ( + QueueID INT AUTO_INCREMENT PRIMARY KEY, + QueueDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UserID INT, + EpisodeID INT, + QueuePosition INT NOT NULL DEFAULT 0, + is_youtube TINYINT(1) DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID) + ) + """) + + # Saved Episodes and Videos + cursor.execute(""" + CREATE TABLE IF NOT EXISTS SavedEpisodes ( + SaveID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + EpisodeID INT, + SaveDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS SavedVideos ( + SaveID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + VideoID INT, + SaveDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) + ) + """) + + # Downloaded Episodes and Videos + cursor.execute(""" + CREATE TABLE IF NOT EXISTS DownloadedEpisodes ( + DownloadID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + EpisodeID INT, + DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DownloadedSize INT, + DownloadedLocation VARCHAR(255), + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS DownloadedVideos ( + DownloadID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + VideoID INT, + DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DownloadedSize INT, + DownloadedLocation VARCHAR(255), + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) + ) + """) + + logger.info("Created queue and download tables successfully") + + finally: + cursor.close() + + +@register_migration("008", "gpodder_tables", "Create GPodder sync tables", requires=["001"]) +def migration_008_gpodder_tables(conn, db_type: str): + """Create GPodder sync tables""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "GpodderDevices" ( + DeviceID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceName VARCHAR(255) NOT NULL, + DeviceType VARCHAR(50) DEFAULT 'desktop', + DeviceCaption VARCHAR(255), + IsDefault BOOLEAN DEFAULT FALSE, + LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + IsActive BOOLEAN DEFAULT TRUE, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceName) + ) + """) + + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_gpodder_devices_userid + ON "GpodderDevices"(UserID) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "GpodderSyncState" ( + SyncStateID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + LastTimestamp BIGINT DEFAULT 0, + EpisodesTimestamp BIGINT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ) + """) + else: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS GpodderDevices ( + DeviceID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceName VARCHAR(255) NOT NULL, + DeviceType VARCHAR(50) DEFAULT 'desktop', + DeviceCaption VARCHAR(255), + IsDefault BOOLEAN DEFAULT FALSE, + LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + IsActive BOOLEAN DEFAULT TRUE, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceName) + ) + """) + + # Check if index exists before creating it + try: + cursor.execute(""" + CREATE INDEX idx_gpodder_devices_userid + ON GpodderDevices(UserID) + """) + logger.info("Created index idx_gpodder_devices_userid") + except Exception as e: + if "Duplicate key name" in str(e) or "1061" in str(e): + logger.info("Index idx_gpodder_devices_userid already exists, skipping") + else: + raise + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS GpodderSyncState ( + SyncStateID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + LastTimestamp BIGINT DEFAULT 0, + EpisodesTimestamp BIGINT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ) + """) + + logger.info("Created GPodder tables") + + finally: + cursor.close() + + +@register_migration("009", "people_sharing_tables", "Create people and episode sharing tables", requires=["005"]) +def migration_009_people_sharing_tables(conn, db_type: str): + """Create people and episode sharing tables""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "People" ( + PersonID SERIAL PRIMARY KEY, + Name TEXT, + PersonImg TEXT, + PeopleDBID INT, + AssociatedPodcasts TEXT, + UserID INT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "PeopleEpisodes" ( + EpisodeID SERIAL PRIMARY KEY, + PersonID INT, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate TIMESTAMP, + EpisodeDuration INT, + AddedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PersonID) REFERENCES "People"(PersonID), + FOREIGN KEY (PodcastID) REFERENCES "Podcasts"(PodcastID) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "SharedEpisodes" ( + SharedEpisodeID SERIAL PRIMARY KEY, + EpisodeID INT NOT NULL, + SharedBy INT NOT NULL, + SharedWith INT, + ShareCode TEXT UNIQUE, + ExpirationDate TIMESTAMP, + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) ON DELETE CASCADE, + FOREIGN KEY (SharedBy) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (SharedWith) REFERENCES "Users"(UserID) ON DELETE CASCADE + ) + """) + else: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS People ( + PersonID INT AUTO_INCREMENT PRIMARY KEY, + Name TEXT, + PersonImg TEXT, + PeopleDBID INT, + AssociatedPodcasts TEXT, + UserID INT, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS PeopleEpisodes ( + EpisodeID INT AUTO_INCREMENT PRIMARY KEY, + PersonID INT, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate TIMESTAMP, + EpisodeDuration INT, + AddedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PersonID) REFERENCES People(PersonID), + FOREIGN KEY (PodcastID) REFERENCES Podcasts(PodcastID) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS SharedEpisodes ( + SharedEpisodeID INT AUTO_INCREMENT PRIMARY KEY, + EpisodeID INT NOT NULL, + SharedBy INT NOT NULL, + SharedWith INT, + ShareCode TEXT, + ExpirationDate TIMESTAMP, + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) ON DELETE CASCADE, + FOREIGN KEY (SharedBy) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (SharedWith) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(ShareCode(255)) + ) + """) + + logger.info("Created people and sharing tables") + + finally: + cursor.close() + + +@register_migration("010", "playlist_tables", "Create playlist management tables", requires=["005"]) +def migration_010_playlist_tables(conn, db_type: str): + """Create playlist management tables""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Playlists" ( + PlaylistID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + Name VARCHAR(255) NOT NULL, + Description TEXT, + IsSystemPlaylist BOOLEAN NOT NULL DEFAULT FALSE, + PodcastIDs INTEGER[], + IncludeUnplayed BOOLEAN NOT NULL DEFAULT TRUE, + IncludePartiallyPlayed BOOLEAN NOT NULL DEFAULT TRUE, + IncludePlayed BOOLEAN NOT NULL DEFAULT FALSE, + MinDuration INTEGER, + MaxDuration INTEGER, + SortOrder VARCHAR(50) NOT NULL DEFAULT 'date_desc' + CHECK (SortOrder IN ('date_asc', 'date_desc', + 'duration_asc', 'duration_desc', + 'listen_progress', 'completion')), + GroupByPodcast BOOLEAN NOT NULL DEFAULT FALSE, + MaxEpisodes INTEGER, + PlayProgressMin FLOAT, + PlayProgressMax FLOAT, + TimeFilterHours INTEGER, + LastUpdated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + Created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + IconName VARCHAR(50) NOT NULL DEFAULT 'ph-playlist', + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Name), + CHECK (PlayProgressMin IS NULL OR (PlayProgressMin >= 0 AND PlayProgressMin <= 100)), + CHECK (PlayProgressMax IS NULL OR (PlayProgressMax >= 0 AND PlayProgressMax <= 100)), + CHECK (PlayProgressMin IS NULL OR PlayProgressMax IS NULL OR PlayProgressMin <= PlayProgressMax), + CHECK (MinDuration IS NULL OR MinDuration >= 0), + CHECK (MaxDuration IS NULL OR MaxDuration >= 0) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "PlaylistContents" ( + PlaylistContentID SERIAL PRIMARY KEY, + PlaylistID INT, + EpisodeID INT, + VideoID INT, + Position INT, + DateAdded TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PlaylistID) REFERENCES "Playlists"(PlaylistID) ON DELETE CASCADE, + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) ON DELETE CASCADE, + FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) ON DELETE CASCADE, + CHECK ((EpisodeID IS NOT NULL AND VideoID IS NULL) OR (EpisodeID IS NULL AND VideoID IS NOT NULL)) + ) + """) + + # Create indexes for better performance + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_playlists_userid ON "Playlists"(UserID); + CREATE INDEX IF NOT EXISTS idx_playlist_contents_playlistid ON "PlaylistContents"(PlaylistID); + CREATE INDEX IF NOT EXISTS idx_playlist_contents_episodeid ON "PlaylistContents"(EpisodeID); + CREATE INDEX IF NOT EXISTS idx_playlist_contents_videoid ON "PlaylistContents"(VideoID); + """) + else: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS Playlists ( + PlaylistID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + Name VARCHAR(255) NOT NULL, + Description TEXT, + IsSystemPlaylist BOOLEAN NOT NULL DEFAULT FALSE, + PodcastIDs JSON, + IncludeUnplayed BOOLEAN NOT NULL DEFAULT TRUE, + IncludePartiallyPlayed BOOLEAN NOT NULL DEFAULT TRUE, + IncludePlayed BOOLEAN NOT NULL DEFAULT FALSE, + MinDuration INT, + MaxDuration INT, + SortOrder VARCHAR(50) NOT NULL DEFAULT 'date_desc' + CHECK (SortOrder IN ('date_asc', 'date_desc', + 'duration_asc', 'duration_desc', + 'listen_progress', 'completion')), + GroupByPodcast BOOLEAN NOT NULL DEFAULT FALSE, + MaxEpisodes INT, + PlayProgressMin FLOAT, + PlayProgressMax FLOAT, + TimeFilterHours INT, + LastUpdated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + Created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + IconName VARCHAR(50) NOT NULL DEFAULT 'ph-playlist', + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Name), + CHECK (PlayProgressMin IS NULL OR (PlayProgressMin >= 0 AND PlayProgressMin <= 100)), + CHECK (PlayProgressMax IS NULL OR (PlayProgressMax >= 0 AND PlayProgressMax <= 100)), + CHECK (PlayProgressMin IS NULL OR PlayProgressMax IS NULL OR PlayProgressMin <= PlayProgressMax), + CHECK (MinDuration IS NULL OR MinDuration >= 0), + CHECK (MaxDuration IS NULL OR MaxDuration >= 0) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS PlaylistContents ( + PlaylistContentID INT AUTO_INCREMENT PRIMARY KEY, + PlaylistID INT, + EpisodeID INT, + VideoID INT, + Position INT, + DateAdded TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PlaylistID) REFERENCES Playlists(PlaylistID) ON DELETE CASCADE, + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) ON DELETE CASCADE, + FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) ON DELETE CASCADE, + CHECK ((EpisodeID IS NOT NULL AND VideoID IS NULL) OR (EpisodeID IS NULL AND VideoID IS NOT NULL)) + ) + """) + + # Create indexes for better performance (MySQL doesn't support IF NOT EXISTS for indexes) + try: + cursor.execute("CREATE INDEX idx_playlists_userid ON Playlists(UserID)") + except: + pass # Index may already exist + try: + cursor.execute("CREATE INDEX idx_playlist_contents_playlistid ON PlaylistContents(PlaylistID)") + except: + pass # Index may already exist + try: + cursor.execute("CREATE INDEX idx_playlist_contents_episodeid ON PlaylistContents(EpisodeID)") + except: + pass # Index may already exist + try: + cursor.execute("CREATE INDEX idx_playlist_contents_videoid ON PlaylistContents(VideoID)") + except: + pass # Index may already exist + + logger.info("Created playlist tables") + + finally: + cursor.close() + + +@register_migration("011", "session_notification_tables", "Create session and notification tables", requires=["001"]) +def migration_011_session_notification_tables(conn, db_type: str): + """Create session and notification tables""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Sessions" ( + SessionID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + SessionToken TEXT NOT NULL, + ExpirationTime TIMESTAMP NOT NULL, + IsActive BOOLEAN DEFAULT TRUE, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "UserNotificationSettings" ( + SettingID SERIAL PRIMARY KEY, + UserID INT, + Platform VARCHAR(50) NOT NULL, + Enabled BOOLEAN DEFAULT TRUE, + NtfyTopic VARCHAR(255), + NtfyServerUrl VARCHAR(255) DEFAULT 'https://ntfy.sh', + GotifyUrl VARCHAR(255), + GotifyToken VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Platform) + ) + """) + else: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS Sessions ( + SessionID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + SessionToken TEXT NOT NULL, + ExpirationTime TIMESTAMP NOT NULL, + IsActive BOOLEAN DEFAULT TRUE, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS UserNotificationSettings ( + SettingID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + Platform VARCHAR(50) NOT NULL, + Enabled BOOLEAN DEFAULT TRUE, + NtfyTopic VARCHAR(255), + NtfyServerUrl VARCHAR(255) DEFAULT 'https://ntfy.sh', + GotifyUrl VARCHAR(255), + GotifyToken VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Platform) + ) + """) + + logger.info("Created session and notification tables") + + finally: + cursor.close() + + +@register_migration("012", "create_system_playlists", "Create default system playlists", requires=["010"]) +def migration_012_create_system_playlists(conn, db_type: str): + """Create default system playlists""" + cursor = conn.cursor() + + try: + # Define system playlists + system_playlists = [ + { + 'name': 'Quick Listens', + 'description': 'Short episodes under 15 minutes, perfect for quick breaks', + 'min_duration': None, + 'max_duration': 900, # 15 minutes + 'sort_order': 'duration_asc', + 'icon_name': 'ph-fast-forward' + }, + { + 'name': 'Longform', + 'description': 'Extended episodes over 1 hour, ideal for long drives or deep dives', + 'min_duration': 3600, # 1 hour + 'max_duration': None, + 'sort_order': 'duration_desc', + 'icon_name': 'ph-car' + }, + { + 'name': 'Currently Listening', + 'description': 'Episodes you\'ve started but haven\'t finished', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_desc', + 'include_unplayed': False, + 'include_partially_played': True, + 'include_played': False, + 'icon_name': 'ph-play' + }, + { + 'name': 'Fresh Releases', + 'description': 'Latest episodes from the last 24 hours', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_desc', + 'include_unplayed': True, + 'include_partially_played': False, + 'include_played': False, + 'time_filter_hours': 24, + 'icon_name': 'ph-sparkle' + }, + { + 'name': 'Weekend Marathon', + 'description': 'Longer episodes (30+ minutes) perfect for weekend listening', + 'min_duration': 1800, # 30 minutes + 'max_duration': None, + 'sort_order': 'duration_desc', + 'group_by_podcast': True, + 'icon_name': 'ph-couch' + }, + { + 'name': 'Commuter Mix', + 'description': 'Episodes between 20-40 minutes, ideal for average commute times', + 'min_duration': 1200, # 20 minutes + 'max_duration': 2400, # 40 minutes + 'sort_order': 'date_desc', + 'icon_name': 'ph-train' + }, + { + 'name': 'Almost Done', + 'description': 'Episodes you\'re close to finishing (75%+ complete)', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_asc', + 'include_unplayed': False, + 'include_partially_played': True, + 'include_played': False, + 'play_progress_min': 75.0, + 'play_progress_max': None, + 'icon_name': 'ph-hourglass' + } + ] + + # Insert system playlists for background tasks user (UserID = 1) + for playlist in system_playlists: + try: + # First check if this playlist already exists + if db_type == "postgresql": + cursor.execute(""" + SELECT COUNT(*) + FROM "Playlists" + WHERE UserID = 1 AND Name = %s AND IsSystemPlaylist = TRUE + """, (playlist['name'],)) + else: + cursor.execute(""" + SELECT COUNT(*) + FROM Playlists + WHERE UserID = 1 AND Name = %s AND IsSystemPlaylist = TRUE + """, (playlist['name'],)) + + if cursor.fetchone()[0] == 0: + if db_type == "postgresql": + cursor.execute(""" + INSERT INTO "Playlists" ( + UserID, + Name, + Description, + IsSystemPlaylist, + MinDuration, + MaxDuration, + SortOrder, + GroupByPodcast, + IncludeUnplayed, + IncludePartiallyPlayed, + IncludePlayed, + IconName, + TimeFilterHours, + PlayProgressMin, + PlayProgressMax + ) VALUES ( + 1, %s, %s, TRUE, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + """, ( + playlist['name'], + playlist['description'], + playlist.get('min_duration'), + playlist.get('max_duration'), + playlist.get('sort_order', 'date_asc'), + playlist.get('group_by_podcast', False), + playlist.get('include_unplayed', True), + playlist.get('include_partially_played', True), + playlist.get('include_played', False), + playlist.get('icon_name', 'ph-playlist'), + playlist.get('time_filter_hours'), + playlist.get('play_progress_min'), + playlist.get('play_progress_max') + )) + else: + cursor.execute(""" + INSERT INTO Playlists ( + UserID, + Name, + Description, + IsSystemPlaylist, + MinDuration, + MaxDuration, + SortOrder, + GroupByPodcast, + IncludeUnplayed, + IncludePartiallyPlayed, + IncludePlayed, + IconName, + TimeFilterHours, + PlayProgressMin, + PlayProgressMax + ) VALUES ( + 1, %s, %s, TRUE, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + """, ( + playlist['name'], + playlist['description'], + playlist.get('min_duration'), + playlist.get('max_duration'), + playlist.get('sort_order', 'date_asc'), + playlist.get('group_by_podcast', False), + playlist.get('include_unplayed', True), + playlist.get('include_partially_played', True), + playlist.get('include_played', False), + playlist.get('icon_name', 'ph-playlist'), + playlist.get('time_filter_hours'), + playlist.get('play_progress_min'), + playlist.get('play_progress_max') + )) + + logger.info(f"Created system playlist: {playlist['name']}") + else: + logger.info(f"System playlist already exists: {playlist['name']}") + + except Exception as e: + logger.error(f"Error creating system playlist {playlist['name']}: {e}") + continue + + logger.info("System playlists creation completed") + + finally: + cursor.close() + + +@register_migration("013", "add_playback_speed_columns", "Add PlaybackSpeed columns to Users and Podcasts tables for existing installations") +def add_playback_speed_columns(conn, db_type: str) -> None: + """Add PlaybackSpeed columns to Users and Podcasts tables for existing installations""" + + cursor = conn.cursor() + + try: + # Add PlaybackSpeed to Users table if it doesn't exist + try: + if db_type == "postgresql": + cursor.execute(""" + ALTER TABLE "Users" + ADD COLUMN IF NOT EXISTS PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0 + """) + else: # MySQL/MariaDB + # Check if column exists first + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Users' + AND COLUMN_NAME = 'PlaybackSpeed' + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE Users + ADD COLUMN PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0 + """) + logger.info("Added PlaybackSpeed column to Users table") + else: + logger.info("PlaybackSpeed column already exists in Users table") + + except Exception as e: + logger.error(f"Error adding PlaybackSpeed to Users table: {e}") + # Don't fail the migration for this + + # Add PlaybackSpeed columns to Podcasts table if they don't exist + try: + if db_type == "postgresql": + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN IF NOT EXISTS PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0, + ADD COLUMN IF NOT EXISTS PlaybackSpeedCustomized BOOLEAN DEFAULT FALSE + """) + else: # MySQL/MariaDB + # Check if PlaybackSpeed column exists + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'PlaybackSpeed' + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0 + """) + logger.info("Added PlaybackSpeed column to Podcasts table") + else: + logger.info("PlaybackSpeed column already exists in Podcasts table") + + # Check if PlaybackSpeedCustomized column exists + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'PlaybackSpeedCustomized' + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN PlaybackSpeedCustomized TINYINT(1) DEFAULT 0 + """) + logger.info("Added PlaybackSpeedCustomized column to Podcasts table") + else: + logger.info("PlaybackSpeedCustomized column already exists in Podcasts table") + + except Exception as e: + logger.error(f"Error adding PlaybackSpeed columns to Podcasts table: {e}") + # Don't fail the migration for this + + logger.info("Playback speed columns migration completed") + + finally: + cursor.close() + + +@register_migration("014", "fix_missing_rss_tables", "Create missing RSS tables from migration 001 for 0.7.8 upgrades") +def fix_missing_rss_tables(conn, db_type: str) -> None: + """Create missing RSS tables for users upgrading from 0.7.8""" + + cursor = conn.cursor() + + try: + # Check and create RssKeys table if it doesn't exist + if db_type == 'postgresql': + table_name = '"RssKeys"' + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = %s + ) + """, ('RssKeys',)) + else: # mysql + table_name = 'RssKeys' + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = %s + """, ('RssKeys',)) + + table_exists = cursor.fetchone()[0] + + if not table_exists: + logger.info("Creating missing RssKeys table") + if db_type == 'postgresql': + cursor.execute(""" + CREATE TABLE "RssKeys" ( + RssKeyID SERIAL PRIMARY KEY, + UserID INT, + RssKey TEXT, + Created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE + ) + """) + else: # mysql + cursor.execute(""" + CREATE TABLE RssKeys ( + RssKeyID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + RssKey TEXT, + Created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE + ) + """) + logger.info("Created RssKeys table") + else: + logger.info("RssKeys table already exists") + + # Check and create RssKeyMap table if it doesn't exist + if db_type == 'postgresql': + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = %s + ) + """, ('RssKeyMap',)) + else: # mysql + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = %s + """, ('RssKeyMap',)) + + table_exists = cursor.fetchone()[0] + + if not table_exists: + logger.info("Creating missing RssKeyMap table") + if db_type == 'postgresql': + cursor.execute(""" + CREATE TABLE "RssKeyMap" ( + RssKeyID INT, + PodcastID INT, + FOREIGN KEY (RssKeyID) REFERENCES "RssKeys"(RssKeyID) ON DELETE CASCADE + ) + """) + else: # mysql + cursor.execute(""" + CREATE TABLE RssKeyMap ( + RssKeyID INT, + PodcastID INT, + FOREIGN KEY (RssKeyID) REFERENCES RssKeys(RssKeyID) ON DELETE CASCADE + ) + """) + logger.info("Created RssKeyMap table") + else: + logger.info("RssKeyMap table already exists") + + logger.info("Missing RSS tables migration completed") + + finally: + cursor.close() + + +# Migration 015: OIDC settings for claims & roles. +@register_migration("015", "oidc_claims_and_roles", "Add columns for OIDC claims & roles settings", requires=["002"]) +def migration_015_oidc_claims_and_roles(conn, db_type: str): + """Add columns for OIDC claims & roles settings""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + cursor.execute(""" + ALTER TABLE "OIDCProviders" + ADD COLUMN IF NOT EXISTS NameClaim VARCHAR(255), + ADD COLUMN IF NOT EXISTS EmailClaim VARCHAR(255), + ADD COLUMN IF NOT EXISTS UsernameClaim VARCHAR(255), + ADD COLUMN IF NOT EXISTS RolesClaim VARCHAR(255), + ADD COLUMN IF NOT EXISTS UserRole VARCHAR(255), + ADD COLUMN IF NOT EXISTS AdminRole VARCHAR(255); + """) + else: + cursor.execute(""" + ALTER TABLE OIDCProviders + ADD COLUMN NameClaim VARCHAR(255) AFTER IconSVG, + ADD COLUMN EmailClaim VARCHAR(255) AFTER NameClaim, + ADD COLUMN UsernameClaim VARCHAR(255) AFTER EmailClaim, + ADD COLUMN RolesClaim VARCHAR(255) AFTER UsernameClaim, + ADD COLUMN UserRole VARCHAR(255) AFTER RolesClaim, + ADD COLUMN AdminRole VARCHAR(255) AFTER UserRole; + """) + + logger.info("Added claim & roles settings to OIDC table") + + finally: + cursor.close() + + +# Migration 016: Add autocompleteseconds to UserSettings +@register_migration("016", "add_auto_complete_seconds", "Add autocompleteseconds column to UserSettings table", requires=["003"]) +def migration_016_add_auto_complete_seconds(conn, db_type: str): + """Add autocompleteseconds column to UserSettings table""" + cursor = conn.cursor() + + try: + if db_type == 'postgresql': + # Check if column exists first + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'UserSettings' + AND column_name = 'autocompleteseconds' + AND table_schema = 'public' + """) + column_exists = cursor.fetchone()[0] > 0 + + if not column_exists: + cursor.execute(""" + ALTER TABLE "UserSettings" + ADD COLUMN autocompleteseconds INTEGER DEFAULT 0 + """) + logger.info("Added autocompleteseconds column to UserSettings table (PostgreSQL)") + else: + logger.info("autocompleteseconds column already exists in UserSettings table (PostgreSQL)") + + else: # mysql + # Check if column exists first + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'UserSettings' + AND column_name = 'AutoCompleteSeconds' + AND table_schema = DATABASE() + """) + column_exists = cursor.fetchone()[0] > 0 + + if not column_exists: + cursor.execute(""" + ALTER TABLE UserSettings + ADD COLUMN AutoCompleteSeconds INT DEFAULT 0 + """) + logger.info("Added AutoCompleteSeconds column to UserSettings table (MySQL)") + else: + logger.info("AutoCompleteSeconds column already exists in UserSettings table (MySQL)") + + logger.info("Auto complete seconds migration completed successfully") + + finally: + cursor.close() + + +@register_migration("017", "add_ntfy_auth_columns", "Add ntfy authentication columns to UserNotificationSettings table", requires=["011"]) +def migration_017_add_ntfy_auth_columns(conn, db_type: str): + """Add ntfy authentication columns (username, password, access_token) to UserNotificationSettings table""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + # Check if columns already exist (PostgreSQL - lowercase column names) + cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'UserNotificationSettings' + AND column_name IN ('ntfyusername', 'ntfypassword', 'ntfyaccesstoken') + """) + existing_columns = [row[0] for row in cursor.fetchall()] + + if 'ntfyusername' not in existing_columns: + cursor.execute(""" + ALTER TABLE "UserNotificationSettings" + ADD COLUMN ntfyusername VARCHAR(255) + """) + logger.info("Added ntfyusername column to UserNotificationSettings table (PostgreSQL)") + + if 'ntfypassword' not in existing_columns: + cursor.execute(""" + ALTER TABLE "UserNotificationSettings" + ADD COLUMN ntfypassword VARCHAR(255) + """) + logger.info("Added ntfypassword column to UserNotificationSettings table (PostgreSQL)") + + if 'ntfyaccesstoken' not in existing_columns: + cursor.execute(""" + ALTER TABLE "UserNotificationSettings" + ADD COLUMN ntfyaccesstoken VARCHAR(255) + """) + logger.info("Added ntfyaccesstoken column to UserNotificationSettings table (PostgreSQL)") + + else: + # Check if columns already exist (MySQL) + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'UserNotificationSettings' + AND column_name = 'NtfyUsername' + AND table_schema = DATABASE() + """) + username_exists = cursor.fetchone()[0] > 0 + + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'UserNotificationSettings' + AND column_name = 'NtfyPassword' + AND table_schema = DATABASE() + """) + password_exists = cursor.fetchone()[0] > 0 + + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'UserNotificationSettings' + AND column_name = 'NtfyAccessToken' + AND table_schema = DATABASE() + """) + token_exists = cursor.fetchone()[0] > 0 + + if not username_exists: + cursor.execute(""" + ALTER TABLE UserNotificationSettings + ADD COLUMN NtfyUsername VARCHAR(255) + """) + logger.info("Added NtfyUsername column to UserNotificationSettings table (MySQL)") + + if not password_exists: + cursor.execute(""" + ALTER TABLE UserNotificationSettings + ADD COLUMN NtfyPassword VARCHAR(255) + """) + logger.info("Added NtfyPassword column to UserNotificationSettings table (MySQL)") + + if not token_exists: + cursor.execute(""" + ALTER TABLE UserNotificationSettings + ADD COLUMN NtfyAccessToken VARCHAR(255) + """) + logger.info("Added NtfyAccessToken column to UserNotificationSettings table (MySQL)") + + logger.info("Ntfy authentication columns migration completed successfully") + + finally: + cursor.close() + + +@register_migration("018", "add_gpodder_sync_timestamp", "Add GPodder last sync timestamp for incremental sync", requires=["001"]) +def migration_018_gpodder_sync_timestamp(conn, db_type: str): + """Add GPodder last sync timestamp column for proper incremental sync per GPodder spec""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + # Check if column already exists (PostgreSQL) + cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'Users' + AND column_name = 'lastsynctime' + """) + existing_columns = [row[0] for row in cursor.fetchall()] + + if 'lastsynctime' not in existing_columns: + cursor.execute(""" + ALTER TABLE "Users" + ADD COLUMN LastSyncTime TIMESTAMP WITH TIME ZONE + """) + logger.info("Added LastSyncTime column to Users table (PostgreSQL)") + + else: + # Check if column already exists (MySQL) + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'Users' + AND column_name = 'LastSyncTime' + AND table_schema = DATABASE() + """) + column_exists = cursor.fetchone()[0] > 0 + + if not column_exists: + cursor.execute(""" + ALTER TABLE Users + ADD COLUMN LastSyncTime DATETIME + """) + logger.info("Added LastSyncTime column to Users table (MySQL)") + + logger.info("GPodder sync timestamp migration completed successfully") + + finally: + cursor.close() + + +@register_migration("019", "fix_encryption_key_storage", "Convert EncryptionKey from binary to text format for consistency", requires=["001"]) +def migration_019_fix_encryption_key_storage(conn, db_type: str): + """Convert EncryptionKey storage from binary to text format""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + # First, get the current encryption key value as bytes + cursor.execute('SELECT encryptionkey FROM "AppSettings" WHERE appsettingsid = 1') + result = cursor.fetchone() + + if result and result[0]: + # Convert bytes to string + key_bytes = result[0] + if isinstance(key_bytes, bytes): + key_string = key_bytes.decode('utf-8') + else: + key_string = str(key_bytes) + + # Drop and recreate column as TEXT + cursor.execute('ALTER TABLE "AppSettings" DROP COLUMN encryptionkey') + cursor.execute('ALTER TABLE "AppSettings" ADD COLUMN encryptionkey TEXT') + + # Insert the key back as text + cursor.execute('UPDATE "AppSettings" SET encryptionkey = %s WHERE appsettingsid = 1', (key_string,)) + logger.info("Converted PostgreSQL encryptionkey from BYTEA to TEXT") + else: + # No existing key, just change the column type + cursor.execute('ALTER TABLE "AppSettings" DROP COLUMN encryptionkey') + cursor.execute('ALTER TABLE "AppSettings" ADD COLUMN encryptionkey TEXT') + logger.info("Changed PostgreSQL encryptionkey column to TEXT (no existing data)") + + else: # MySQL + # First, get the current encryption key value + cursor.execute('SELECT EncryptionKey FROM AppSettings WHERE AppSettingsID = 1') + result = cursor.fetchone() + + if result and result[0]: + # Convert binary to string + key_data = result[0] + if isinstance(key_data, bytes): + # Remove null padding and decode + key_string = key_data.rstrip(b'\x00').decode('utf-8') + else: + key_string = str(key_data) + + # Change column type and update value + cursor.execute('ALTER TABLE AppSettings MODIFY EncryptionKey VARCHAR(255)') + cursor.execute('UPDATE AppSettings SET EncryptionKey = %s WHERE AppSettingsID = 1', (key_string,)) + logger.info("Converted MySQL EncryptionKey from BINARY to VARCHAR") + else: + # No existing key, just change the column type + cursor.execute('ALTER TABLE AppSettings MODIFY EncryptionKey VARCHAR(255)') + logger.info("Changed MySQL EncryptionKey column to VARCHAR (no existing data)") + + logger.info("Encryption key storage migration completed successfully") + + except Exception as e: + logger.error(f"Error in encryption key migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("020", "add_default_gpodder_device", "Add DefaultGpodderDevice column to Users table for tracking user's selected GPodder device", requires=["001"]) +def migration_020_add_default_gpodder_device(conn, db_type: str): + """Add DefaultGpodderDevice column to Users table""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + # Add defaultgpodderdevice column to Users table + safe_execute_sql(cursor, 'ALTER TABLE "Users" ADD COLUMN defaultgpodderdevice VARCHAR(255)') + logger.info("Added defaultgpodderdevice column to Users table (PostgreSQL)") + + else: # MySQL + # Add DefaultGpodderDevice column to Users table + safe_execute_sql(cursor, 'ALTER TABLE Users ADD COLUMN DefaultGpodderDevice VARCHAR(255)') + logger.info("Added DefaultGpodderDevice column to Users table (MySQL)") + + logger.info("Default GPodder device column migration completed successfully") + + except Exception as e: + logger.error(f"Error in default GPodder device migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("021", "limit_system_playlists_episodes", "Add MaxEpisodes limit to high-volume system playlists", requires=["010"]) +def migration_021_limit_system_playlists_episodes(conn, db_type: str): + """Add MaxEpisodes limit to Commuter Mix, Longform, and Weekend Marathon system playlists""" + cursor = conn.cursor() + + try: + logger.info("Starting system playlist episodes limit migration") + + # Define the playlists to update with 1000 episode limit + playlists_to_update = ['Commuter Mix', 'Longform', 'Weekend Marathon'] + + if db_type == "postgresql": + for playlist_name in playlists_to_update: + safe_execute_sql(cursor, ''' + UPDATE "Playlists" + SET maxepisodes = 1000 + WHERE name = %s AND issystemplaylist = TRUE + ''', (playlist_name,)) + logger.info(f"Updated {playlist_name} system playlist with maxepisodes=1000 (PostgreSQL)") + + else: # MySQL + for playlist_name in playlists_to_update: + safe_execute_sql(cursor, ''' + UPDATE Playlists + SET MaxEpisodes = 1000 + WHERE Name = %s AND IsSystemPlaylist = TRUE + ''', (playlist_name,)) + logger.info(f"Updated {playlist_name} system playlist with MaxEpisodes=1000 (MySQL)") + + logger.info("System playlist episodes limit migration completed successfully") + + except Exception as e: + logger.error(f"Error in system playlist episodes limit migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("022", "expand_downloaded_location_column", "Expand DownloadedLocation column size to handle long file paths", requires=["007"]) +def migration_022_expand_downloaded_location_column(conn, db_type: str): + """Expand DownloadedLocation column size to handle long file paths""" + cursor = conn.cursor() + + try: + logger.info("Starting downloaded location column expansion migration") + + if db_type == "postgresql": + # Expand DownloadedLocation in DownloadedEpisodes table + safe_execute_sql(cursor, ''' + ALTER TABLE "DownloadedEpisodes" + ALTER COLUMN downloadedlocation TYPE TEXT + ''', conn=conn) + logger.info("Expanded downloadedlocation column in DownloadedEpisodes table (PostgreSQL)") + + # Expand DownloadedLocation in DownloadedVideos table + safe_execute_sql(cursor, ''' + ALTER TABLE "DownloadedVideos" + ALTER COLUMN downloadedlocation TYPE TEXT + ''', conn=conn) + logger.info("Expanded downloadedlocation column in DownloadedVideos table (PostgreSQL)") + + else: # MySQL + # Expand DownloadedLocation in DownloadedEpisodes table + safe_execute_sql(cursor, ''' + ALTER TABLE DownloadedEpisodes + MODIFY DownloadedLocation TEXT + ''', conn=conn) + logger.info("Expanded DownloadedLocation column in DownloadedEpisodes table (MySQL)") + + # Expand DownloadedLocation in DownloadedVideos table + safe_execute_sql(cursor, ''' + ALTER TABLE DownloadedVideos + MODIFY DownloadedLocation TEXT + ''', conn=conn) + logger.info("Expanded DownloadedLocation column in DownloadedVideos table (MySQL)") + + logger.info("Downloaded location column expansion migration completed successfully") + + except Exception as e: + logger.error(f"Error in downloaded location column expansion migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("023", "add_missing_performance_indexes", "Add missing performance indexes for queue, saved, downloaded, and history tables", requires=["006", "007"]) +def migration_023_add_missing_performance_indexes(conn, db_type: str): + """Add missing performance indexes for queue, saved, downloaded, and history tables""" + cursor = conn.cursor() + + try: + logger.info("Starting missing performance indexes migration") + + table_prefix = '"' if db_type == 'postgresql' else '' + table_suffix = '"' if db_type == 'postgresql' else '' + + # EpisodeQueue indexes (critical for get_queued_episodes performance) + safe_add_index(cursor, db_type, f'CREATE INDEX idx_episodequeue_userid ON {table_prefix}EpisodeQueue{table_suffix}(UserID)', 'idx_episodequeue_userid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_episodequeue_episodeid ON {table_prefix}EpisodeQueue{table_suffix}(EpisodeID)', 'idx_episodequeue_episodeid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_episodequeue_queueposition ON {table_prefix}EpisodeQueue{table_suffix}(QueuePosition)', 'idx_episodequeue_queueposition') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_episodequeue_userid_queueposition ON {table_prefix}EpisodeQueue{table_suffix}(UserID, QueuePosition)', 'idx_episodequeue_userid_queueposition') + + # SavedEpisodes indexes (for return_episodes LEFT JOIN performance) + safe_add_index(cursor, db_type, f'CREATE INDEX idx_savedepisodes_userid ON {table_prefix}SavedEpisodes{table_suffix}(UserID)', 'idx_savedepisodes_userid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_savedepisodes_episodeid ON {table_prefix}SavedEpisodes{table_suffix}(EpisodeID)', 'idx_savedepisodes_episodeid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_savedepisodes_userid_episodeid ON {table_prefix}SavedEpisodes{table_suffix}(UserID, EpisodeID)', 'idx_savedepisodes_userid_episodeid') + + # SavedVideos indexes (for YouTube video queries) + safe_add_index(cursor, db_type, f'CREATE INDEX idx_savedvideos_userid ON {table_prefix}SavedVideos{table_suffix}(UserID)', 'idx_savedvideos_userid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_savedvideos_videoid ON {table_prefix}SavedVideos{table_suffix}(VideoID)', 'idx_savedvideos_videoid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_savedvideos_userid_videoid ON {table_prefix}SavedVideos{table_suffix}(UserID, VideoID)', 'idx_savedvideos_userid_videoid') + + # DownloadedEpisodes indexes (for return_episodes LEFT JOIN performance) + safe_add_index(cursor, db_type, f'CREATE INDEX idx_downloadedepisodes_userid ON {table_prefix}DownloadedEpisodes{table_suffix}(UserID)', 'idx_downloadedepisodes_userid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_downloadedepisodes_episodeid ON {table_prefix}DownloadedEpisodes{table_suffix}(EpisodeID)', 'idx_downloadedepisodes_episodeid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_downloadedepisodes_userid_episodeid ON {table_prefix}DownloadedEpisodes{table_suffix}(UserID, EpisodeID)', 'idx_downloadedepisodes_userid_episodeid') + + # DownloadedVideos indexes (for YouTube video queries) + safe_add_index(cursor, db_type, f'CREATE INDEX idx_downloadedvideos_userid ON {table_prefix}DownloadedVideos{table_suffix}(UserID)', 'idx_downloadedvideos_userid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_downloadedvideos_videoid ON {table_prefix}DownloadedVideos{table_suffix}(VideoID)', 'idx_downloadedvideos_videoid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_downloadedvideos_userid_videoid ON {table_prefix}DownloadedVideos{table_suffix}(UserID, VideoID)', 'idx_downloadedvideos_userid_videoid') + + # UserEpisodeHistory indexes (for return_episodes LEFT JOIN performance) + safe_add_index(cursor, db_type, f'CREATE INDEX idx_userepisodehistory_userid ON {table_prefix}UserEpisodeHistory{table_suffix}(UserID)', 'idx_userepisodehistory_userid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_userepisodehistory_episodeid ON {table_prefix}UserEpisodeHistory{table_suffix}(EpisodeID)', 'idx_userepisodehistory_episodeid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_userepisodehistory_userid_episodeid ON {table_prefix}UserEpisodeHistory{table_suffix}(UserID, EpisodeID)', 'idx_userepisodehistory_userid_episodeid') + + # UserVideoHistory indexes (for YouTube video queries) + safe_add_index(cursor, db_type, f'CREATE INDEX idx_uservideohistory_userid ON {table_prefix}UserVideoHistory{table_suffix}(UserID)', 'idx_uservideohistory_userid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_uservideohistory_videoid ON {table_prefix}UserVideoHistory{table_suffix}(VideoID)', 'idx_uservideohistory_videoid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_uservideohistory_userid_videoid ON {table_prefix}UserVideoHistory{table_suffix}(UserID, VideoID)', 'idx_uservideohistory_userid_videoid') + + # Additional useful indexes for query performance + safe_add_index(cursor, db_type, f'CREATE INDEX idx_episodes_completed ON {table_prefix}Episodes{table_suffix}(Completed)', 'idx_episodes_completed') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_youtubevideos_completed ON {table_prefix}YouTubeVideos{table_suffix}(Completed)', 'idx_youtubevideos_completed') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_youtubevideos_podcastid ON {table_prefix}YouTubeVideos{table_suffix}(PodcastID)', 'idx_youtubevideos_podcastid') + safe_add_index(cursor, db_type, f'CREATE INDEX idx_youtubevideos_publishedat ON {table_prefix}YouTubeVideos{table_suffix}(PublishedAt)', 'idx_youtubevideos_publishedat') + + logger.info("Missing performance indexes migration completed successfully") + + except Exception as e: + logger.error(f"Error in missing performance indexes migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("025", "fix_people_table_columns", "Add missing PersonImg, PeopleDBID, and AssociatedPodcasts columns to existing People tables", requires=["009"]) +def migration_025_fix_people_table_columns(conn, db_type: str): + """Add missing columns to existing People tables for users who upgraded from older versions""" + cursor = conn.cursor() + + try: + logger.info("Starting People table columns fix migration") + + if db_type == "postgresql": + # Check if PersonImg column exists, if not add it + safe_execute_sql(cursor, ''' + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'People' AND column_name = 'personimg' + ) THEN + ALTER TABLE "People" ADD COLUMN PersonImg TEXT; + END IF; + END $$; + ''', conn=conn) + + # Check if PeopleDBID column exists, if not add it + safe_execute_sql(cursor, ''' + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'People' AND column_name = 'peopledbid' + ) THEN + ALTER TABLE "People" ADD COLUMN PeopleDBID INT; + END IF; + END $$; + ''', conn=conn) + + # Check if AssociatedPodcasts column exists, if not add it + safe_execute_sql(cursor, ''' + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'People' AND column_name = 'associatedpodcasts' + ) THEN + ALTER TABLE "People" ADD COLUMN AssociatedPodcasts TEXT; + END IF; + END $$; + ''', conn=conn) + + logger.info("Added missing columns to People table (PostgreSQL)") + + else: # MySQL + # For MySQL, use IF NOT EXISTS syntax or try-catch approach + try: + safe_execute_sql(cursor, 'ALTER TABLE People ADD COLUMN PersonImg TEXT', conn=conn) + logger.info("Added PersonImg column to People table (MySQL)") + except Exception: + logger.debug("PersonImg column already exists in People table (MySQL)") + + try: + safe_execute_sql(cursor, 'ALTER TABLE People ADD COLUMN PeopleDBID INT', conn=conn) + logger.info("Added PeopleDBID column to People table (MySQL)") + except Exception: + logger.debug("PeopleDBID column already exists in People table (MySQL)") + + try: + safe_execute_sql(cursor, 'ALTER TABLE People ADD COLUMN AssociatedPodcasts TEXT', conn=conn) + logger.info("Added AssociatedPodcasts column to People table (MySQL)") + except Exception: + logger.debug("AssociatedPodcasts column already exists in People table (MySQL)") + + logger.info("People table columns fix migration completed successfully") + + except Exception as e: + logger.error(f"Error in People table columns fix migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("026", "limit_quick_listens_episodes", "Add MaxEpisodes limit to Quick Listens system playlist", requires=["012"]) +def migration_026_limit_quick_listens_episodes(conn, db_type: str): + """Add MaxEpisodes limit to Quick Listens system playlist""" + cursor = conn.cursor() + + try: + logger.info("Starting Quick Listens MaxEpisodes limit migration") + + if db_type == "postgresql": + # Update Quick Listens playlist to have maxepisodes = 1000 + safe_execute_sql(cursor, ''' + UPDATE "Playlists" + SET maxepisodes = 1000 + WHERE name = 'Quick Listens' AND issystemplaylist = TRUE + ''', conn=conn) + logger.info("Updated Quick Listens system playlist maxepisodes=1000 (PostgreSQL)") + + else: # MySQL + # Update Quick Listens playlist to have MaxEpisodes = 1000 + safe_execute_sql(cursor, ''' + UPDATE Playlists + SET MaxEpisodes = 1000 + WHERE Name = 'Quick Listens' AND IsSystemPlaylist = TRUE + ''', conn=conn) + logger.info("Updated Quick Listens system playlist MaxEpisodes=1000 (MySQL)") + + logger.info("Quick Listens MaxEpisodes limit migration completed successfully") + + except Exception as e: + logger.error(f"Error in Quick Listens MaxEpisodes limit migration: {e}") + raise + finally: + cursor.close() + + +def register_all_migrations(): + """Register all migrations with the migration manager""" + # Migrations are auto-registered via decorators + logger.info("All migrations registered") + + +@register_migration("024", "fix_quick_listens_min_duration", "Update Quick Listens playlist to exclude 0-duration episodes", requires=["012"]) +def migration_024_fix_quick_listens_min_duration(conn, db_type: str): + """Update Quick Listens system playlist to exclude episodes with 0 duration""" + cursor = conn.cursor() + + try: + logger.info("Starting Quick Listens min duration fix migration") + + if db_type == "postgresql": + # Update Quick Listens playlist to have min_duration = 1 second + safe_execute_sql(cursor, ''' + UPDATE "Playlists" + SET minduration = 1 + WHERE name = 'Quick Listens' AND issystemplaylist = TRUE + ''', conn=conn) + logger.info("Updated Quick Listens system playlist minduration=1 (PostgreSQL)") + + else: # MySQL + # Update Quick Listens playlist to have MinDuration = 1 second + safe_execute_sql(cursor, ''' + UPDATE Playlists + SET MinDuration = 1 + WHERE Name = 'Quick Listens' AND IsSystemPlaylist = TRUE + ''', conn=conn) + logger.info("Updated Quick Listens system playlist MinDuration=1 (MySQL)") + + logger.info("Quick Listens min duration fix migration completed successfully") + + except Exception as e: + logger.error(f"Error in Quick Listens min duration fix migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("027", "add_scheduled_backups_table", "Create ScheduledBackups table for automated backup management", requires=["026"]) +def migration_027_add_scheduled_backups_table(conn, db_type: str): + """Create ScheduledBackups table for automated backup management""" + cursor = conn.cursor() + + try: + logger.info("Starting ScheduledBackups table creation migration") + + if db_type == "postgresql": + # Create ScheduledBackups table for PostgreSQL + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS "ScheduledBackups" ( + id SERIAL PRIMARY KEY, + userid INTEGER NOT NULL, + cron_schedule VARCHAR(50) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(userid), + FOREIGN KEY (userid) REFERENCES "Users"(userid) ON DELETE CASCADE + ) + ''', conn=conn) + logger.info("Created ScheduledBackups table (PostgreSQL)") + + # Create index for performance + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_scheduled_backups_enabled + ON "ScheduledBackups"(enabled) + ''', conn=conn) + logger.info("Created index on enabled column (PostgreSQL)") + + else: # MySQL + # Create ScheduledBackups table for MySQL + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS ScheduledBackups ( + ID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + CronSchedule VARCHAR(50) NOT NULL, + Enabled BOOLEAN NOT NULL DEFAULT FALSE, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_user (UserID), + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE + ) + ''', conn=conn) + logger.info("Created ScheduledBackups table (MySQL)") + + # Create index for performance + safe_execute_sql(cursor, ''' + CREATE INDEX idx_scheduled_backups_enabled + ON ScheduledBackups(Enabled) + ''', conn=conn) + logger.info("Created index on Enabled column (MySQL)") + + logger.info("ScheduledBackups table creation migration completed successfully") + + except Exception as e: + logger.error(f"Error in ScheduledBackups table creation migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("028", "add_ignore_podcast_index_column", "Add IgnorePodcastIndex column to Podcasts table", requires=["027"]) +def migration_028_add_ignore_podcast_index_column(conn, db_type: str): + """ + Migration 028: Add IgnorePodcastIndex column to Podcasts table + """ + logger.info("Starting migration 028: Add IgnorePodcastIndex column to Podcasts table") + cursor = conn.cursor() + + try: + if db_type == 'postgresql': + safe_execute_sql(cursor, ''' + ALTER TABLE "Podcasts" + ADD COLUMN IF NOT EXISTS IgnorePodcastIndex BOOLEAN DEFAULT FALSE + ''', conn=conn) + logger.info("Added IgnorePodcastIndex column to Podcasts table (PostgreSQL)") + + else: # MySQL + # Check if column already exists to avoid duplicate column error + safe_execute_sql(cursor, ''' + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'Podcasts' + AND column_name = 'IgnorePodcastIndex' + AND table_schema = DATABASE() + ''', conn=conn) + + result = cursor.fetchone() + if result[0] == 0: # Column doesn't exist + safe_execute_sql(cursor, ''' + ALTER TABLE Podcasts + ADD COLUMN IgnorePodcastIndex TINYINT(1) DEFAULT 0 + ''', conn=conn) + logger.info("Added IgnorePodcastIndex column to Podcasts table (MySQL)") + else: + logger.info("IgnorePodcastIndex column already exists in Podcasts table (MySQL)") + + logger.info("IgnorePodcastIndex column migration completed successfully") + + except Exception as e: + logger.error(f"Error in IgnorePodcastIndex column migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("029", "fix_people_episodes_table_schema", "Fix PeopleEpisodes table schema to match expected format", requires=["009"]) +def migration_029_fix_people_episodes_table_schema(conn, db_type: str): + """ + Migration 029: Fix PeopleEpisodes table schema + + This migration ensures the PeopleEpisodes table has the correct schema with all required columns. + Some databases may have an incomplete PeopleEpisodes table from migration 009. + """ + logger.info("Starting migration 029: Fix PeopleEpisodes table schema") + cursor = conn.cursor() + + try: + if db_type == 'postgresql': + # For PostgreSQL, we'll recreate the table with the correct schema + # First check if table exists and get its current structure + safe_execute_sql(cursor, ''' + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'PeopleEpisodes' + AND table_schema = current_schema() + ''', conn=conn) + + existing_columns = [row[0] for row in cursor.fetchall()] + + if 'podcastid' not in [col.lower() for col in existing_columns]: + logger.info("PeopleEpisodes table missing required columns, recreating...") + + # Drop existing table if it exists with wrong schema + safe_execute_sql(cursor, 'DROP TABLE IF EXISTS "PeopleEpisodes"', conn=conn) + + # Create with correct schema + safe_execute_sql(cursor, ''' + CREATE TABLE "PeopleEpisodes" ( + EpisodeID SERIAL PRIMARY KEY, + PersonID INT, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate TIMESTAMP, + EpisodeDuration INT, + AddedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PersonID) REFERENCES "People"(PersonID), + FOREIGN KEY (PodcastID) REFERENCES "Podcasts"(PodcastID) + ) + ''', conn=conn) + logger.info("Recreated PeopleEpisodes table with correct schema (PostgreSQL)") + else: + logger.info("PeopleEpisodes table already has correct schema (PostgreSQL)") + + else: # MySQL + # For MySQL, check current table structure + safe_execute_sql(cursor, ''' + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'PeopleEpisodes' + AND table_schema = DATABASE() + ''', conn=conn) + + existing_columns = [row[0] for row in cursor.fetchall()] + logger.info(f"Current PeopleEpisodes columns: {existing_columns}") + + if 'PodcastID' not in existing_columns: + logger.info("PeopleEpisodes table missing required columns, recreating...") + + # Backup any existing data first (if the table has useful data) + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS PeopleEpisodes_backup AS + SELECT * FROM PeopleEpisodes + ''', conn=conn) + logger.info("Created backup of existing PeopleEpisodes table") + + # Drop existing table + safe_execute_sql(cursor, 'DROP TABLE IF EXISTS PeopleEpisodes', conn=conn) + + # Create with correct schema + safe_execute_sql(cursor, ''' + CREATE TABLE PeopleEpisodes ( + EpisodeID INT AUTO_INCREMENT PRIMARY KEY, + PersonID INT, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate TIMESTAMP, + EpisodeDuration INT, + AddedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PersonID) REFERENCES People(PersonID), + FOREIGN KEY (PodcastID) REFERENCES Podcasts(PodcastID) + ) + ''', conn=conn) + logger.info("Recreated PeopleEpisodes table with correct schema (MySQL)") + else: + logger.info("PeopleEpisodes table already has correct schema (MySQL)") + + logger.info("PeopleEpisodes table schema fix completed successfully") + + except Exception as e: + logger.error(f"Error in PeopleEpisodes table schema fix migration: {e}") + raise + finally: + cursor.close() + + +@register_migration("030", "add_user_language_preference", "Add Language column to Users table for user-specific language preferences", requires=["001"]) +def migration_030_add_user_language_preference(conn, db_type: str): + """Add Language column to Users table for user-specific language preferences""" + cursor = conn.cursor() + + try: + # Get the default language from environment variable, fallback to 'en' + default_language = os.environ.get("DEFAULT_LANGUAGE", "en") + + # Validate language code (basic validation) + if not default_language or len(default_language) > 10: + default_language = "en" + + logger.info(f"Adding Language column to Users table with default '{default_language}'") + + if db_type == 'postgresql': + # Add Language column with default from environment variable + safe_execute_sql(cursor, f''' + ALTER TABLE "Users" + ADD COLUMN IF NOT EXISTS Language VARCHAR(10) DEFAULT '{default_language}' + ''', conn=conn) + + # Add comment to document the column + safe_execute_sql(cursor, ''' + COMMENT ON COLUMN "Users".Language IS 'ISO 639-1 language code for user interface language preference' + ''', conn=conn) + + else: # mysql/mariadb + # Check if column exists first + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Users' + AND COLUMN_NAME = 'Language' + """) + + if cursor.fetchone()[0] == 0: + safe_execute_sql(cursor, f''' + ALTER TABLE Users + ADD COLUMN Language VARCHAR(10) DEFAULT '{default_language}' + COMMENT 'ISO 639-1 language code for user interface language preference' + ''', conn=conn) + + logger.info(f"Successfully added Language column to Users table with default '{default_language}'") + + except Exception as e: + logger.error(f"Error in migration 030: {e}") + raise + finally: + cursor.close() + + +@register_migration("031", "add_oidc_env_initialized_column", "Add InitializedFromEnv column to OIDCProviders table to track env-initialized providers", requires=["001"]) +def migration_031_add_oidc_env_initialized_column(conn, db_type: str): + """Add InitializedFromEnv column to OIDCProviders table to track providers created from environment variables""" + cursor = conn.cursor() + + try: + logger.info("Adding InitializedFromEnv column to OIDCProviders table") + + if db_type == 'postgresql': + # Add InitializedFromEnv column (defaults to false for existing providers) + safe_execute_sql(cursor, ''' + ALTER TABLE "OIDCProviders" + ADD COLUMN IF NOT EXISTS InitializedFromEnv BOOLEAN DEFAULT false + ''', conn=conn) + + # Add comment to document the column + safe_execute_sql(cursor, ''' + COMMENT ON COLUMN "OIDCProviders".InitializedFromEnv IS 'Indicates if this provider was created from environment variables and should not be removable via UI' + ''', conn=conn) + + else: # mysql/mariadb + # Check if column exists first + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'OIDCProviders' + AND COLUMN_NAME = 'InitializedFromEnv' + """) + + if cursor.fetchone()[0] == 0: + safe_execute_sql(cursor, ''' + ALTER TABLE OIDCProviders + ADD COLUMN InitializedFromEnv TINYINT(1) DEFAULT 0 + COMMENT 'Indicates if this provider was created from environment variables and should not be removable via UI' + ''', conn=conn) + + logger.info("Successfully added InitializedFromEnv column to OIDCProviders table") + except Exception as e: + logger.error(f"Error in migration 031: {e}") + raise + finally: + cursor.close() + + +@register_migration("032", "create_user_default_playlists", "Create default playlists for all existing users", requires=["012"]) +def migration_032_create_user_default_playlists(conn, db_type: str): + """Create default playlists for all existing users, eliminating system playlists""" + cursor = conn.cursor() + + try: + logger.info("Starting user default playlists migration") + + # First, add the episode_count column to Playlists table if it doesn't exist + if db_type == "postgresql": + # Check if episode_count column exists + cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'Playlists' + AND column_name = 'episodecount' + """) + column_exists = len(cursor.fetchall()) > 0 + + if not column_exists: + cursor.execute(""" + ALTER TABLE "Playlists" + ADD COLUMN episodecount INTEGER DEFAULT 0 + """) + logger.info("Added episode_count column to Playlists table (PostgreSQL)") + else: + logger.info("episode_count column already exists in Playlists table (PostgreSQL)") + else: + # Check if episode_count column exists (MySQL) + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Playlists' + AND COLUMN_NAME = 'EpisodeCount' + AND TABLE_SCHEMA = DATABASE() + """) + column_exists = cursor.fetchone()[0] > 0 + + if not column_exists: + cursor.execute(""" + ALTER TABLE Playlists + ADD COLUMN EpisodeCount INT DEFAULT 0 + """) + logger.info("Added EpisodeCount column to Playlists table (MySQL)") + else: + logger.info("EpisodeCount column already exists in Playlists table (MySQL)") + + # Define default playlists (same as migration 012 but will be assigned to each user) + default_playlists = [ + { + 'name': 'Quick Listens', + 'description': 'Short episodes under 15 minutes, perfect for quick breaks', + 'min_duration': 1, # Exclude 0-duration episodes + 'max_duration': 900, # 15 minutes + 'sort_order': 'duration_asc', + 'icon_name': 'ph-fast-forward', + 'max_episodes': 1000 + }, + { + 'name': 'Longform', + 'description': 'Extended episodes over 1 hour, ideal for long drives or deep dives', + 'min_duration': 3600, # 1 hour + 'max_duration': None, + 'sort_order': 'duration_desc', + 'icon_name': 'ph-car', + 'max_episodes': 1000 + }, + { + 'name': 'Currently Listening', + 'description': 'Episodes you\'ve started but haven\'t finished', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_desc', + 'include_unplayed': False, + 'include_partially_played': True, + 'include_played': False, + 'icon_name': 'ph-play' + }, + { + 'name': 'Fresh Releases', + 'description': 'Latest episodes from the last 24 hours', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_desc', + 'include_unplayed': True, + 'include_partially_played': False, + 'include_played': False, + 'time_filter_hours': 24, + 'icon_name': 'ph-sparkle' + }, + { + 'name': 'Weekend Marathon', + 'description': 'Longer episodes (30+ minutes) perfect for weekend listening', + 'min_duration': 1800, # 30 minutes + 'max_duration': None, + 'sort_order': 'duration_desc', + 'group_by_podcast': True, + 'icon_name': 'ph-couch', + 'max_episodes': 1000 + }, + { + 'name': 'Commuter Mix', + 'description': 'Perfect-length episodes (15-45 minutes) for your daily commute', + 'min_duration': 900, # 15 minutes + 'max_duration': 2700, # 45 minutes + 'sort_order': 'date_desc', + 'icon_name': 'ph-car-simple', + 'max_episodes': 1000 + } + ] + + # Get all existing users (excluding background user if present) + if db_type == "postgresql": + cursor.execute('SELECT userid FROM "Users" WHERE userid > 1') + else: + cursor.execute('SELECT UserID FROM Users WHERE UserID > 1') + + users = cursor.fetchall() + logger.info(f"Found {len(users)} users to create default playlists for") + + # Create default playlists for each user + for user_row in users: + user_id = user_row[0] if isinstance(user_row, tuple) else user_row['userid' if db_type == "postgresql" else 'UserID'] + logger.info(f"Creating default playlists for user {user_id}") + + for playlist in default_playlists: + try: + # Check if this playlist already exists for this user + if db_type == "postgresql": + cursor.execute(""" + SELECT COUNT(*) + FROM "Playlists" + WHERE userid = %s AND name = %s + """, (user_id, playlist['name'])) + else: + cursor.execute(""" + SELECT COUNT(*) + FROM Playlists + WHERE UserID = %s AND Name = %s + """, (user_id, playlist['name'])) + + if cursor.fetchone()[0] == 0: + # Create the playlist for this user + if db_type == "postgresql": + cursor.execute(""" + INSERT INTO "Playlists" ( + userid, + name, + description, + issystemplaylist, + minduration, + maxduration, + sortorder, + includeunplayed, + includepartiallyplayed, + includeplayed, + timefilterhours, + groupbypodcast, + maxepisodes, + iconname, + episodecount + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + user_id, + playlist['name'], + playlist['description'], + False, # No longer system playlists + playlist.get('min_duration'), + playlist.get('max_duration'), + playlist['sort_order'], + playlist.get('include_unplayed', True), + playlist.get('include_partially_played', True), + playlist.get('include_played', True), + playlist.get('time_filter_hours'), + playlist.get('group_by_podcast', False), + playlist.get('max_episodes'), + playlist['icon_name'], + 0 # Will be updated by scheduled count update + )) + else: + cursor.execute(""" + INSERT INTO Playlists ( + UserID, + Name, + Description, + IsSystemPlaylist, + MinDuration, + MaxDuration, + SortOrder, + IncludeUnplayed, + IncludePartiallyPlayed, + IncludePlayed, + TimeFilterHours, + GroupByPodcast, + MaxEpisodes, + IconName, + EpisodeCount + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + user_id, + playlist['name'], + playlist['description'], + False, # No longer system playlists + playlist.get('min_duration'), + playlist.get('max_duration'), + playlist['sort_order'], + playlist.get('include_unplayed', True), + playlist.get('include_partially_played', True), + playlist.get('include_played', True), + playlist.get('time_filter_hours'), + playlist.get('group_by_podcast', False), + playlist.get('max_episodes'), + playlist['icon_name'], + 0 # Will be updated by scheduled count update + )) + + logger.info(f"Created playlist '{playlist['name']}' for user {user_id}") + else: + logger.info(f"Playlist '{playlist['name']}' already exists for user {user_id}") + + except Exception as e: + logger.error(f"Failed to create playlist '{playlist['name']}' for user {user_id}: {e}") + # Continue with other playlists even if one fails + + # Commit all changes + conn.commit() + logger.info("Successfully created default playlists for all existing users") + + except Exception as e: + logger.error(f"Error in user default playlists migration: {e}") + raise + finally: + cursor.close() + + +# ============================================================================ +# GPODDER SYNC MIGRATIONS +# These migrations match the gpodder-api service migrations from Go code +# ============================================================================ + +@register_migration("100", "gpodder_initial_schema", "Create initial gpodder sync tables") +def migration_100_gpodder_initial_schema(conn, db_type: str): + """Create initial gpodder sync schema - matches Go migration version 1""" + cursor = conn.cursor() + + try: + logger.info("Starting gpodder migration 100: Initial schema creation") + + if db_type == 'postgresql': + # Create all gpodder sync tables for PostgreSQL + tables_sql = [ + ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncMigrations" ( + Version INT PRIMARY KEY, + Description TEXT NOT NULL, + AppliedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncDeviceState" ( + DeviceStateID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + SubscriptionCount INT DEFAULT 0, + LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncSubscriptions" ( + SubscriptionID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + PodcastURL TEXT NOT NULL, + Action VARCHAR(10) NOT NULL, + Timestamp BIGINT NOT NULL, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncEpisodeActions" ( + ActionID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT, + PodcastURL TEXT NOT NULL, + EpisodeURL TEXT NOT NULL, + Action VARCHAR(20) NOT NULL, + Timestamp BIGINT NOT NULL, + Started INT, + Position INT, + Total INT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncPodcastLists" ( + ListID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + Name VARCHAR(255) NOT NULL, + Title VARCHAR(255) NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Name) + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncPodcastListEntries" ( + EntryID SERIAL PRIMARY KEY, + ListID INT NOT NULL, + PodcastURL TEXT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ListID) REFERENCES "GpodderSyncPodcastLists"(ListID) ON DELETE CASCADE + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncDevicePairs" ( + PairID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID1 INT NOT NULL, + DeviceID2 INT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID1) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID2) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID1, DeviceID2) + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncSettings" ( + SettingID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + Scope VARCHAR(20) NOT NULL, + DeviceID INT, + PodcastURL TEXT, + EpisodeURL TEXT, + SettingKey VARCHAR(255) NOT NULL, + SettingValue TEXT, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE + ) + ''' + ] + + # Create indexes + indexes_sql = [ + 'CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subscriptions_userid ON "GpodderSyncSubscriptions"(UserID)', + 'CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subscriptions_deviceid ON "GpodderSyncSubscriptions"(DeviceID)', + 'CREATE INDEX IF NOT EXISTS idx_gpodder_sync_episode_actions_userid ON "GpodderSyncEpisodeActions"(UserID)', + 'CREATE INDEX IF NOT EXISTS idx_gpodder_sync_podcast_lists_userid ON "GpodderSyncPodcastLists"(UserID)' + ] + + else: # mysql + # Create all gpodder sync tables for MySQL + tables_sql = [ + ''' + CREATE TABLE IF NOT EXISTS GpodderSyncMigrations ( + Version INT PRIMARY KEY, + Description TEXT NOT NULL, + AppliedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS GpodderSyncDeviceState ( + DeviceStateID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + SubscriptionCount INT DEFAULT 0, + LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS GpodderSyncSubscriptions ( + SubscriptionID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + PodcastURL TEXT NOT NULL, + Action VARCHAR(10) NOT NULL, + Timestamp BIGINT NOT NULL, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS GpodderSyncEpisodeActions ( + ActionID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT, + PodcastURL TEXT NOT NULL, + EpisodeURL TEXT NOT NULL, + Action VARCHAR(20) NOT NULL, + Timestamp BIGINT NOT NULL, + Started INT, + Position INT, + Total INT, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS GpodderSyncPodcastLists ( + ListID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + Name VARCHAR(255) NOT NULL, + Title VARCHAR(255) NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Name) + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS GpodderSyncPodcastListEntries ( + EntryID INT AUTO_INCREMENT PRIMARY KEY, + ListID INT NOT NULL, + PodcastURL TEXT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ListID) REFERENCES GpodderSyncPodcastLists(ListID) ON DELETE CASCADE + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS GpodderSyncDevicePairs ( + PairID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID1 INT NOT NULL, + DeviceID2 INT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID1) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID2) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID1, DeviceID2) + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS GpodderSyncSettings ( + SettingID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + Scope VARCHAR(20) NOT NULL, + DeviceID INT, + PodcastURL TEXT, + EpisodeURL TEXT, + SettingKey VARCHAR(255) NOT NULL, + SettingValue TEXT, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE + ) + ''' + ] + + # Create indexes + indexes_sql = [ + 'CREATE INDEX idx_gpodder_sync_subscriptions_userid ON GpodderSyncSubscriptions(UserID)', + 'CREATE INDEX idx_gpodder_sync_subscriptions_deviceid ON GpodderSyncSubscriptions(DeviceID)', + 'CREATE INDEX idx_gpodder_sync_episode_actions_userid ON GpodderSyncEpisodeActions(UserID)', + 'CREATE INDEX idx_gpodder_sync_podcast_lists_userid ON GpodderSyncPodcastLists(UserID)' + ] + + # Execute table creation + for sql in tables_sql: + safe_execute_sql(cursor, sql, conn=conn) + + # Execute index creation + for sql in indexes_sql: + safe_execute_sql(cursor, sql, conn=conn) + + logger.info("Created gpodder sync initial schema successfully") + + except Exception as e: + logger.error(f"Error in gpodder migration 100: {e}") + raise + finally: + cursor.close() + + +@register_migration("101", "gpodder_add_api_version", "Add API version column to GpodderSyncSettings") +def migration_101_gpodder_add_api_version(conn, db_type: str): + """Add API version column - matches Go migration version 2""" + cursor = conn.cursor() + + try: + logger.info("Starting gpodder migration 101: Add API version column") + + if db_type == 'postgresql': + safe_execute_sql(cursor, ''' + ALTER TABLE "GpodderSyncSettings" + ADD COLUMN IF NOT EXISTS APIVersion VARCHAR(10) DEFAULT '2.0' + ''', conn=conn) + else: # mysql + # Check if column exists first, then add if it doesn't + cursor.execute(""" + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'GpodderSyncSettings' + AND COLUMN_NAME = 'APIVersion' + AND TABLE_SCHEMA = DATABASE() + """) + + if cursor.fetchone()[0] == 0: + safe_execute_sql(cursor, ''' + ALTER TABLE GpodderSyncSettings + ADD COLUMN APIVersion VARCHAR(10) DEFAULT '2.0' + ''', conn=conn) + logger.info("Added APIVersion column to GpodderSyncSettings") + else: + logger.info("APIVersion column already exists in GpodderSyncSettings") + + logger.info("Gpodder API version migration completed successfully") + + except Exception as e: + logger.error(f"Error in gpodder migration 101: {e}") + raise + finally: + cursor.close() + + +@register_migration("102", "gpodder_create_sessions", "Create GpodderSessions table for API sessions") +def migration_102_gpodder_create_sessions(conn, db_type: str): + """Create GpodderSessions table - matches Go migration version 3""" + cursor = conn.cursor() + + try: + logger.info("Starting gpodder migration 102: Create GpodderSessions table") + + if db_type == 'postgresql': + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS "GpodderSessions" ( + SessionID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + SessionToken TEXT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ExpiresAt TIMESTAMP NOT NULL, + LastActive TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UserAgent TEXT, + ClientIP TEXT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(SessionToken) + ) + ''', conn=conn) + + # Create indexes + indexes_sql = [ + 'CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_token ON "GpodderSessions"(SessionToken)', + 'CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_userid ON "GpodderSessions"(UserID)', + 'CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_expires ON "GpodderSessions"(ExpiresAt)' + ] + else: # mysql + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS GpodderSessions ( + SessionID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + SessionToken TEXT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ExpiresAt TIMESTAMP NOT NULL, + LastActive TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UserAgent TEXT, + ClientIP TEXT, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE + ) + ''', conn=conn) + + # Create indexes + indexes_sql = [ + 'CREATE INDEX idx_gpodder_sessions_userid ON GpodderSessions(UserID)', + 'CREATE INDEX idx_gpodder_sessions_expires ON GpodderSessions(ExpiresAt)' + ] + + # Execute index creation + for sql in indexes_sql: + safe_execute_sql(cursor, sql, conn=conn) + + logger.info("Created GpodderSessions table successfully") + + except Exception as e: + logger.error(f"Error in gpodder migration 102: {e}") + raise + finally: + cursor.close() + + +@register_migration("103", "gpodder_sync_state_table", "Add sync state table for tracking device sync status") +def migration_103_gpodder_sync_state_table(conn, db_type: str): + """Create GpodderSyncState table - matches Go migration version 4""" + cursor = conn.cursor() + + try: + logger.info("Starting gpodder migration 103: Add sync state table") + + if db_type == 'postgresql': + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS "GpodderSyncState" ( + SyncStateID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + LastTimestamp BIGINT DEFAULT 0, + LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_gpodder_syncstate_userid_deviceid ON "GpodderSyncState"(UserID, DeviceID) + ''', conn=conn) + else: # mysql + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS GpodderSyncState ( + SyncStateID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + LastTimestamp BIGINT DEFAULT 0, + LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX idx_gpodder_syncstate_userid_deviceid ON GpodderSyncState(UserID, DeviceID) + ''', conn=conn) + + logger.info("Created GpodderSyncState table successfully") + + except Exception as e: + logger.error(f"Error in gpodder migration 103: {e}") + raise + finally: + cursor.close() + + +@register_migration("104", "create_people_episodes_backup", "Skip PeopleEpisodes_backup - varies by installation") +def migration_104_create_people_episodes_backup(conn, db_type: str): + """Skip PeopleEpisodes_backup table - this varies by installation and shouldn't be validated""" + logger.info("Skipping migration 104: PeopleEpisodes_backup table varies by installation") + # This migration is a no-op since backup tables vary by installation + # and shouldn't be part of the expected schema + + +@register_migration("105", "optimize_episode_actions_performance", "Add indexes and optimize episode actions queries") +def migration_105_optimize_episode_actions_performance(conn, db_type: str): + """Add critical indexes for episode actions performance and create optimized views""" + cursor = conn.cursor() + + try: + logger.info("Adding performance indexes for episode actions...") + + if db_type == 'postgresql': + # Critical indexes for episode actions performance + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_episode_actions_user_timestamp + ON "GpodderSyncEpisodeActions"(UserID, Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_episode_actions_device_timestamp + ON "GpodderSyncEpisodeActions"(DeviceID, Timestamp DESC) + WHERE DeviceID IS NOT NULL + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_episode_actions_podcast_episode + ON "GpodderSyncEpisodeActions"(UserID, PodcastURL, EpisodeURL, Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_episode_actions_since_filter + ON "GpodderSyncEpisodeActions"(UserID, Timestamp DESC, DeviceID) + WHERE Timestamp > 0 + ''', conn=conn) + + # Optimize devices table lookups + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_gpodder_devices_user_name + ON "GpodderDevices"(UserID, DeviceName) + WHERE IsActive = true + ''', conn=conn) + + else: # mysql/mariadb + # Critical indexes for episode actions performance + safe_execute_sql(cursor, ''' + CREATE INDEX idx_episode_actions_user_timestamp + ON GpodderSyncEpisodeActions(UserID, Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX idx_episode_actions_device_timestamp + ON GpodderSyncEpisodeActions(DeviceID, Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX idx_episode_actions_podcast_episode + ON GpodderSyncEpisodeActions(UserID, PodcastURL(255), EpisodeURL(255), Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX idx_episode_actions_since_filter + ON GpodderSyncEpisodeActions(UserID, Timestamp DESC, DeviceID) + ''', conn=conn) + + # Optimize devices table lookups + safe_execute_sql(cursor, ''' + CREATE INDEX idx_gpodder_devices_user_name + ON GpodderDevices(UserID, DeviceName) + ''', conn=conn) + + logger.info("Successfully added episode actions performance indexes") + + except Exception as e: + logger.error(f"Error in gpodder migration 105: {e}") + raise + finally: + cursor.close() + + +@register_migration("106", "optimize_subscription_sync_performance", "Add missing indexes for subscription sync queries", requires=["103"]) +def migration_106_optimize_subscription_sync_performance(conn, db_type: str): + """Add critical indexes for subscription sync performance to prevent AntennaPod timeouts""" + cursor = conn.cursor() + + try: + logger.info("Adding performance indexes for subscription sync...") + + if db_type == 'postgresql': + # Critical indexes for subscription sync performance + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subs_user_device_timestamp + ON "GpodderSyncSubscriptions"(UserID, DeviceID, Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subs_user_action_timestamp + ON "GpodderSyncSubscriptions"(UserID, Action, Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subs_podcast_url_user + ON "GpodderSyncSubscriptions"(UserID, PodcastURL, Timestamp DESC) + ''', conn=conn) + + # Optimize subscription change queries with compound index + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subs_complex_query + ON "GpodderSyncSubscriptions"(UserID, DeviceID, Action, Timestamp DESC, PodcastURL) + ''', conn=conn) + + else: # mysql/mariadb + # Critical indexes for subscription sync performance + safe_execute_sql(cursor, ''' + CREATE INDEX idx_gpodder_sync_subs_user_device_timestamp + ON GpodderSyncSubscriptions(UserID, DeviceID, Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX idx_gpodder_sync_subs_user_action_timestamp + ON GpodderSyncSubscriptions(UserID, Action, Timestamp DESC) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX idx_gpodder_sync_subs_podcast_url_user + ON GpodderSyncSubscriptions(UserID, PodcastURL(255), Timestamp DESC) + ''', conn=conn) + + # Optimize subscription change queries with compound index + safe_execute_sql(cursor, ''' + CREATE INDEX idx_gpodder_sync_subs_complex_query + ON GpodderSyncSubscriptions(UserID, DeviceID, Action, Timestamp DESC, PodcastURL(255)) + ''', conn=conn) + + logger.info("Successfully added subscription sync performance indexes") + + except Exception as e: + logger.error(f"Error in gpodder migration 106: {e}") + raise + finally: + cursor.close() + + +@register_migration("033", "add_http_notification_columns", "Add generic HTTP notification columns to UserNotificationSettings table", requires=["011"]) +def migration_033_add_http_notification_columns(conn, db_type: str): + """Add generic HTTP notification columns for platforms like Telegram""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + # Check if columns already exist (PostgreSQL - lowercase column names) + cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'UserNotificationSettings' + AND column_name IN ('httpurl', 'httptoken', 'httpmethod') + """) + existing_columns = [row[0] for row in cursor.fetchall()] + + if 'httpurl' not in existing_columns: + cursor.execute(""" + ALTER TABLE "UserNotificationSettings" + ADD COLUMN HttpUrl VARCHAR(500) + """) + logger.info("Added HttpUrl column to UserNotificationSettings table (PostgreSQL)") + + if 'httptoken' not in existing_columns: + cursor.execute(""" + ALTER TABLE "UserNotificationSettings" + ADD COLUMN HttpToken VARCHAR(255) + """) + logger.info("Added HttpToken column to UserNotificationSettings table (PostgreSQL)") + + if 'httpmethod' not in existing_columns: + cursor.execute(""" + ALTER TABLE "UserNotificationSettings" + ADD COLUMN HttpMethod VARCHAR(10) DEFAULT 'POST' + """) + logger.info("Added HttpMethod column to UserNotificationSettings table (PostgreSQL)") + + else: + # Check if columns already exist (MySQL) + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'UserNotificationSettings' + AND column_name = 'HttpUrl' + AND table_schema = DATABASE() + """) + url_exists = cursor.fetchone()[0] > 0 + + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'UserNotificationSettings' + AND column_name = 'HttpToken' + AND table_schema = DATABASE() + """) + token_exists = cursor.fetchone()[0] > 0 + + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'UserNotificationSettings' + AND column_name = 'HttpMethod' + AND table_schema = DATABASE() + """) + method_exists = cursor.fetchone()[0] > 0 + + if not url_exists: + cursor.execute(""" + ALTER TABLE UserNotificationSettings + ADD COLUMN HttpUrl VARCHAR(500) + """) + logger.info("Added HttpUrl column to UserNotificationSettings table (MySQL)") + + if not token_exists: + cursor.execute(""" + ALTER TABLE UserNotificationSettings + ADD COLUMN HttpToken VARCHAR(255) + """) + logger.info("Added HttpToken column to UserNotificationSettings table (MySQL)") + + if not method_exists: + cursor.execute(""" + ALTER TABLE UserNotificationSettings + ADD COLUMN HttpMethod VARCHAR(10) DEFAULT 'POST' + """) + logger.info("Added HttpMethod column to UserNotificationSettings table (MySQL)") + + logger.info("HTTP notification columns migration completed successfully") + + finally: + cursor.close() + + +@register_migration("034", "add_podcast_merge_columns", "Add podcast merge columns to support merging podcasts", requires=["033"]) +def migration_034_add_podcast_merge_columns(conn, db_type: str): + """Add DisplayPodcast, RefreshPodcast, and MergedPodcastIDs columns to Podcasts table""" + cursor = conn.cursor() + + try: + if db_type == "postgresql": + # Check if columns already exist (PostgreSQL) + cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'Podcasts' + AND column_name IN ('displaypodcast', 'refreshpodcast', 'mergedpodcastids') + """) + existing_columns = [row[0] for row in cursor.fetchall()] + + if 'displaypodcast' not in existing_columns: + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN DisplayPodcast BOOLEAN DEFAULT TRUE + """) + logger.info("Added DisplayPodcast column to Podcasts table (PostgreSQL)") + + if 'refreshpodcast' not in existing_columns: + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN RefreshPodcast BOOLEAN DEFAULT TRUE + """) + logger.info("Added RefreshPodcast column to Podcasts table (PostgreSQL)") + + if 'mergedpodcastids' not in existing_columns: + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN MergedPodcastIDs TEXT + """) + logger.info("Added MergedPodcastIDs column to Podcasts table (PostgreSQL)") + + else: # MySQL + # Check if columns already exist (MySQL) + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'Podcasts' + AND column_name = 'DisplayPodcast' + AND table_schema = DATABASE() + """) + display_exists = cursor.fetchone()[0] > 0 + + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'Podcasts' + AND column_name = 'RefreshPodcast' + AND table_schema = DATABASE() + """) + refresh_exists = cursor.fetchone()[0] > 0 + + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'Podcasts' + AND column_name = 'MergedPodcastIDs' + AND table_schema = DATABASE() + """) + merged_exists = cursor.fetchone()[0] > 0 + + if not display_exists: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN DisplayPodcast TINYINT(1) DEFAULT 1 + """) + logger.info("Added DisplayPodcast column to Podcasts table (MySQL)") + + if not refresh_exists: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN RefreshPodcast TINYINT(1) DEFAULT 1 + """) + logger.info("Added RefreshPodcast column to Podcasts table (MySQL)") + + if not merged_exists: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN MergedPodcastIDs TEXT + """) + logger.info("Added MergedPodcastIDs column to Podcasts table (MySQL)") + + # Add index on DisplayPodcast for performance + table_quote = "`" if db_type != "postgresql" else '"' + safe_add_index(cursor, db_type, + f'CREATE INDEX idx_podcasts_displaypodcast ON {table_quote}Podcasts{table_quote} (DisplayPodcast)', + 'idx_podcasts_displaypodcast') + + logger.info("Podcast merge columns migration completed successfully") + + finally: + cursor.close() + + +@register_migration("035", "add_podcast_cover_preference_columns", "Add podcast cover preference columns to Users and Podcasts tables", requires=["034"]) +def migration_035_add_podcast_cover_preference_columns(conn, db_type: str): + """Add podcast cover preference columns to Users and Podcasts tables for existing installations""" + cursor = conn.cursor() + + try: + # Add UsePodcastCovers to Users table if it doesn't exist + try: + if db_type == "postgresql": + cursor.execute(""" + ALTER TABLE "Users" + ADD COLUMN IF NOT EXISTS UsePodcastCovers BOOLEAN DEFAULT FALSE + """) + else: # MySQL/MariaDB + # Check if column exists first + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Users' + AND COLUMN_NAME = 'UsePodcastCovers' + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE Users + ADD COLUMN UsePodcastCovers TINYINT(1) DEFAULT 0 + """) + logger.info("Added UsePodcastCovers column to Users table") + else: + logger.info("UsePodcastCovers column already exists in Users table") + + except Exception as e: + logger.error(f"Error adding UsePodcastCovers to Users table: {e}") + + # Add UsePodcastCovers columns to Podcasts table if they don't exist + try: + if db_type == "postgresql": + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN IF NOT EXISTS UsePodcastCovers BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS UsePodcastCoversCustomized BOOLEAN DEFAULT FALSE + """) + else: # MySQL/MariaDB + # Check if UsePodcastCovers column exists + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'UsePodcastCovers' + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN UsePodcastCovers TINYINT(1) DEFAULT 0 + """) + logger.info("Added UsePodcastCovers column to Podcasts table") + else: + logger.info("UsePodcastCovers column already exists in Podcasts table") + + # Check if UsePodcastCoversCustomized column exists + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'UsePodcastCoversCustomized' + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN UsePodcastCoversCustomized TINYINT(1) DEFAULT 0 + """) + logger.info("Added UsePodcastCoversCustomized column to Podcasts table") + else: + logger.info("UsePodcastCoversCustomized column already exists in Podcasts table") + + except Exception as e: + logger.error(f"Error adding UsePodcastCovers columns to Podcasts table: {e}") + + logger.info("Podcast cover preference columns migration completed successfully") + + finally: + cursor.close() + + +@register_migration("036", "add_episodecount_column_to_playlists", "Add episodecount column to Playlists table for tracking episode counts", requires=["010"]) +def migration_036_add_episodecount_column(conn, db_type: str): + """Add episodecount column to Playlists table if it doesn't exist + + This migration was needed because migration 032 was applied to existing databases + before the episodecount column addition was added to it. Since migration 032 is + already marked as applied in those databases, the column was never created. + """ + cursor = conn.cursor() + + try: + logger.info("Checking for episodecount column in Playlists table") + + if db_type == "postgresql": + # Check if episodecount column exists + cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'Playlists' + AND column_name = 'episodecount' + """) + column_exists = len(cursor.fetchall()) > 0 + + if not column_exists: + cursor.execute(""" + ALTER TABLE "Playlists" + ADD COLUMN episodecount INTEGER DEFAULT 0 + """) + logger.info("Added episodecount column to Playlists table (PostgreSQL)") + else: + logger.info("episodecount column already exists in Playlists table (PostgreSQL)") + else: + # Check if episodecount column exists (MySQL/MariaDB) + cursor.execute(""" + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Playlists' + AND COLUMN_NAME = 'EpisodeCount' + AND TABLE_SCHEMA = DATABASE() + """) + column_exists = cursor.fetchone()[0] > 0 + + if not column_exists: + cursor.execute(""" + ALTER TABLE Playlists + ADD COLUMN EpisodeCount INT DEFAULT 0 + """) + logger.info("Added EpisodeCount column to Playlists table (MySQL/MariaDB)") + else: + logger.info("EpisodeCount column already exists in Playlists table (MySQL/MariaDB)") + + logger.info("episodecount column migration completed successfully") + + except Exception as e: + logger.error(f"Error in migration 036: {e}") + raise + finally: + cursor.close() + + +if __name__ == "__main__": + # Register all migrations and run them + register_all_migrations() + from database_functions.migrations import run_all_migrations + success = run_all_migrations() + sys.exit(0 if success else 1) diff --git a/PinePods-0.8.2/database_functions/migrations.py b/PinePods-0.8.2/database_functions/migrations.py new file mode 100644 index 0000000..6a08787 --- /dev/null +++ b/PinePods-0.8.2/database_functions/migrations.py @@ -0,0 +1,530 @@ +""" +Database Migration System for PinePods + +This module provides a robust, idempotent migration framework that tracks +applied migrations and ensures database schema changes are applied safely. +""" + +import logging +import os +import sys +from typing import Dict, List, Optional, Callable, Any +from dataclasses import dataclass +from datetime import datetime +import hashlib + +# Add pinepods to path for imports +sys.path.append('/pinepods') + +# Database imports +try: + import psycopg + POSTGRES_AVAILABLE = True +except ImportError: + POSTGRES_AVAILABLE = False + +try: + import mariadb as mysql_connector + MYSQL_AVAILABLE = True +except ImportError: + try: + import mysql.connector + MYSQL_AVAILABLE = True + except ImportError: + MYSQL_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +@dataclass +class Migration: + """Represents a single database migration""" + version: str + name: str + description: str + postgres_sql: Optional[str] = None + mysql_sql: Optional[str] = None + python_func: Optional[Callable] = None + requires: List[str] = None # List of migration versions this depends on + + def __post_init__(self): + if self.requires is None: + self.requires = [] + + +class DatabaseMigrationManager: + """Manages database migrations with support for PostgreSQL and MySQL/MariaDB""" + + def __init__(self, db_type: str, connection_params: Dict[str, Any]): + self.db_type = db_type.lower() + self.connection_params = connection_params + self.migrations: Dict[str, Migration] = {} + self._connection = None + + # Validate database type + if self.db_type not in ['postgresql', 'postgres', 'mariadb', 'mysql']: + raise ValueError(f"Unsupported database type: {db_type}") + + # Normalize database type + if self.db_type in ['postgres', 'postgresql']: + self.db_type = 'postgresql' + elif self.db_type in ['mysql', 'mariadb']: + self.db_type = 'mysql' + + def get_connection(self): + """Get database connection based on type""" + if self._connection: + return self._connection + + if self.db_type == 'postgresql': + if not POSTGRES_AVAILABLE: + raise ImportError("psycopg not available for PostgreSQL connections") + self._connection = psycopg.connect(**self.connection_params) + elif self.db_type == 'mysql': + if not MYSQL_AVAILABLE: + raise ImportError("MariaDB/MySQL connector not available for MySQL connections") + # Use MariaDB connector parameters + mysql_params = self.connection_params.copy() + # Convert mysql.connector parameter names to mariadb parameter names + if 'connection_timeout' in mysql_params: + mysql_params['connect_timeout'] = mysql_params.pop('connection_timeout') + if 'charset' in mysql_params: + mysql_params.pop('charset') # MariaDB connector doesn't use charset parameter + if 'collation' in mysql_params: + mysql_params.pop('collation') # MariaDB connector doesn't use collation parameter + self._connection = mysql_connector.connect(**mysql_params) + + return self._connection + + def close_connection(self): + """Close database connection""" + if self._connection: + self._connection.close() + self._connection = None + + def register_migration(self, migration: Migration): + """Register a migration to be tracked""" + self.migrations[migration.version] = migration + logger.info(f"Registered migration {migration.version}: {migration.name}") + + def create_migration_table(self): + """Create the migrations tracking table if it doesn't exist""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + if self.db_type == 'postgresql': + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "schema_migrations" ( + version VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + checksum VARCHAR(64) NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + execution_time_ms INTEGER + ) + """) + else: # mysql + cursor.execute(""" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + checksum VARCHAR(64) NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + execution_time_ms INTEGER + ) + """) + conn.commit() + logger.info("Migration tracking table created/verified") + except Exception as e: + logger.error(f"Failed to create migration table: {e}") + conn.rollback() + raise + finally: + cursor.close() + + def get_applied_migrations(self) -> List[str]: + """Get list of applied migration versions""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + table_name = '"schema_migrations"' if self.db_type == 'postgresql' else 'schema_migrations' + cursor.execute(f"SELECT version FROM {table_name} ORDER BY applied_at") + return [row[0] for row in cursor.fetchall()] + except Exception as e: + # If table doesn't exist, return empty list + logger.warning(f"Could not get applied migrations: {e}") + return [] + finally: + cursor.close() + + def calculate_migration_checksum(self, migration: Migration) -> str: + """Calculate checksum for migration content""" + content = "" + if migration.postgres_sql and self.db_type == 'postgresql': + content += migration.postgres_sql + elif migration.mysql_sql and self.db_type == 'mysql': + content += migration.mysql_sql + + if migration.python_func: + content += migration.python_func.__code__.co_code.hex() + + return hashlib.sha256(content.encode()).hexdigest() + + def record_migration(self, migration: Migration, execution_time_ms: int): + """Record a migration as applied""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + checksum = self.calculate_migration_checksum(migration) + table_name = '"schema_migrations"' if self.db_type == 'postgresql' else 'schema_migrations' + + if self.db_type == 'postgresql': + cursor.execute(f""" + INSERT INTO {table_name} (version, name, description, checksum, execution_time_ms) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (version) DO NOTHING + """, (migration.version, migration.name, migration.description, checksum, execution_time_ms)) + else: # mysql + cursor.execute(f""" + INSERT IGNORE INTO {table_name} (version, name, description, checksum, execution_time_ms) + VALUES (%s, %s, %s, %s, %s) + """, (migration.version, migration.name, migration.description, checksum, execution_time_ms)) + + conn.commit() + logger.info(f"Recorded migration {migration.version} as applied") + except Exception as e: + logger.error(f"Failed to record migration {migration.version}: {e}") + conn.rollback() + raise + finally: + cursor.close() + + def check_dependencies(self, migration: Migration, applied_migrations: List[str]) -> bool: + """Check if migration dependencies are satisfied""" + for required_version in migration.requires: + if required_version not in applied_migrations: + logger.error(f"Migration {migration.version} requires {required_version} but it's not applied") + return False + return True + + def execute_migration(self, migration: Migration) -> bool: + """Execute a single migration""" + start_time = datetime.now() + conn = self.get_connection() + + try: + # Choose appropriate SQL based on database type + sql = None + if self.db_type == 'postgresql' and migration.postgres_sql: + sql = migration.postgres_sql + elif self.db_type == 'mysql' and migration.mysql_sql: + sql = migration.mysql_sql + + # Execute SQL if available + if sql: + cursor = conn.cursor() + try: + # Split and execute multiple statements + statements = [stmt.strip() for stmt in sql.split(';') if stmt.strip()] + for statement in statements: + cursor.execute(statement) + conn.commit() + logger.info(f"Executed SQL for migration {migration.version}") + except Exception as e: + conn.rollback() + raise + finally: + cursor.close() + + # Execute Python function if available (this is the main path for our migrations) + if migration.python_func: + try: + migration.python_func(conn, self.db_type) + conn.commit() + logger.info(f"Executed Python function for migration {migration.version}") + except Exception as e: + conn.rollback() + raise + + # Record successful migration + execution_time = int((datetime.now() - start_time).total_seconds() * 1000) + self.record_migration(migration, execution_time) + + logger.info(f"Successfully applied migration {migration.version}: {migration.name}") + return True + + except Exception as e: + logger.error(f"Failed to execute migration {migration.version}: {e}") + try: + conn.rollback() + except: + pass # Connection might already be closed + return False + + def detect_existing_schema(self) -> List[str]: + """Detect which migrations have already been applied based on existing schema""" + conn = self.get_connection() + cursor = conn.cursor() + applied = [] + + try: + # Check for tables that indicate migrations have been applied + checks = { + "001": ['"Users"', '"OIDCProviders"', '"APIKeys"', '"RssKeys"'], + "002": ['"AppSettings"', '"EmailSettings"'], + "003": ['"UserStats"', '"UserSettings"'], + "005": ['"Podcasts"', '"Episodes"', '"YouTubeVideos"'], + "006": ['"UserEpisodeHistory"', '"UserVideoHistory"'], + "007": ['"EpisodeQueue"', '"SavedEpisodes"', '"DownloadedEpisodes"'] + } + + for version, tables in checks.items(): + all_exist = True + for table in tables: + if self.db_type == 'postgresql': + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = %s + ) + """, (table.strip('"'),)) + else: # mysql + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = %s + """, (table,)) + + exists = cursor.fetchone()[0] + if not exists: + all_exist = False + break + + if all_exist: + applied.append(version) + logger.info(f"Detected existing schema for migration {version}") + + # Migration 004 is harder to detect, assume it's applied if 001-003 are + if "001" in applied and "003" in applied and "004" not in applied: + # Check if background_tasks user exists + table_name = '"Users"' if self.db_type == 'postgresql' else 'Users' + cursor.execute(f"SELECT COUNT(*) FROM {table_name} WHERE Username = %s", ('background_tasks',)) + if cursor.fetchone()[0] > 0: + applied.append("004") + logger.info("Detected existing schema for migration 004") + + # Check for gpodder tables - if ANY exist, ALL gpodder migrations are applied + # (since they were created by the Go gpodder-api service and haven't changed) + gpodder_indicator_tables = ['"GpodderSyncMigrations"', '"GpodderSyncDeviceState"', + '"GpodderSyncSubscriptions"', '"GpodderSyncSettings"', + '"GpodderSessions"', '"GpodderSyncState"'] + gpodder_migration_versions = ["100", "101", "102", "103", "104"] + + gpodder_tables_exist = False + for table in gpodder_indicator_tables: + table_name = table.strip('"') + if self.db_type == 'postgresql': + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = %s + ) + """, (table_name,)) + else: # mysql + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = %s + """, (table_name,)) + + if cursor.fetchone()[0]: + gpodder_tables_exist = True + break + + if gpodder_tables_exist: + for version in gpodder_migration_versions: + if version not in applied: + applied.append(version) + logger.info(f"Detected existing gpodder tables, marking migration {version} as applied") + + # Check for PeopleEpisodes_backup table separately (migration 104) + backup_table = "PeopleEpisodes_backup" + if self.db_type == 'postgresql': + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = %s + ) + """, (backup_table,)) + else: # mysql + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = %s + """, (backup_table,)) + + if cursor.fetchone()[0] and "104" not in applied: + applied.append("104") + logger.info("Detected existing PeopleEpisodes_backup table, marking migration 104 as applied") + + return applied + + except Exception as e: + logger.warning(f"Error detecting existing schema: {e}") + return [] + finally: + cursor.close() + + def run_migrations(self, target_version: Optional[str] = None) -> bool: + """Run all pending migrations up to target version""" + try: + # Create migration table + self.create_migration_table() + + # Get applied migrations + applied_migrations = self.get_applied_migrations() + logger.info(f"Found {len(applied_migrations)} applied migrations") + + # If no migrations are recorded but we have existing schema, detect what's there + if not applied_migrations: + detected_migrations = self.detect_existing_schema() + if detected_migrations: + logger.info(f"Detected existing schema, marking {len(detected_migrations)} migrations as applied") + # Record detected migrations without executing them + for version in detected_migrations: + if version in self.migrations: + migration = self.migrations[version] + self.record_migration(migration, 0) # 0ms execution time for pre-existing + + # Refresh applied migrations list + applied_migrations = self.get_applied_migrations() + + # Sort migrations by version + pending_migrations = [] + for version, migration in sorted(self.migrations.items()): + if version not in applied_migrations: + if target_version and version > target_version: + continue + pending_migrations.append(migration) + + if not pending_migrations: + logger.info("No pending migrations to apply") + return True + + logger.info(f"Found {len(pending_migrations)} pending migrations") + + # Execute pending migrations + for migration in pending_migrations: + # Check dependencies + if not self.check_dependencies(migration, applied_migrations): + logger.error(f"Dependency check failed for migration {migration.version}") + return False + + # Execute migration + if not self.execute_migration(migration): + logger.error(f"Failed to execute migration {migration.version}") + return False + + # Add to applied list for dependency checking + applied_migrations.append(migration.version) + + logger.info("All migrations applied successfully") + return True + + except Exception as e: + logger.error(f"Migration run failed: {e}") + return False + finally: + self.close_connection() + + def validate_migrations(self) -> bool: + """Validate that applied migrations haven't changed""" + try: + conn = self.get_connection() + cursor = conn.cursor() + + table_name = '"schema_migrations"' if self.db_type == 'postgresql' else 'schema_migrations' + cursor.execute(f"SELECT version, checksum FROM {table_name}") + applied_checksums = dict(cursor.fetchall()) + + validation_errors = [] + for version, stored_checksum in applied_checksums.items(): + if version in self.migrations: + current_checksum = self.calculate_migration_checksum(self.migrations[version]) + if current_checksum != stored_checksum: + validation_errors.append(f"Migration {version} checksum mismatch") + + if validation_errors: + for error in validation_errors: + logger.error(error) + return False + + logger.info("All migration checksums validated successfully") + return True + + except Exception as e: + logger.error(f"Migration validation failed: {e}") + return False + finally: + cursor.close() + + +# Migration manager instance (singleton pattern) +_migration_manager: Optional[DatabaseMigrationManager] = None + + +def get_migration_manager() -> DatabaseMigrationManager: + """Get the global migration manager instance""" + global _migration_manager + + if _migration_manager is None: + # Get database configuration from environment + db_type = os.environ.get("DB_TYPE", "postgresql") + + if db_type.lower() in ['postgresql', 'postgres']: + connection_params = { + 'host': os.environ.get("DB_HOST", "127.0.0.1"), + 'port': int(os.environ.get("DB_PORT", "5432")), + 'user': os.environ.get("DB_USER", "postgres"), + 'password': os.environ.get("DB_PASSWORD", "password"), + 'dbname': os.environ.get("DB_NAME", "pinepods_database") + } + else: # mysql/mariadb + connection_params = { + 'host': os.environ.get("DB_HOST", "127.0.0.1"), + 'port': int(os.environ.get("DB_PORT", "3306")), + 'user': os.environ.get("DB_USER", "root"), + 'password': os.environ.get("DB_PASSWORD", "password"), + 'database': os.environ.get("DB_NAME", "pinepods_database"), + 'charset': 'utf8mb4', + 'collation': 'utf8mb4_general_ci' + } + + _migration_manager = DatabaseMigrationManager(db_type, connection_params) + + return _migration_manager + + +def register_migration(version: str, name: str, description: str, **kwargs): + """Decorator to register a migration""" + def decorator(func): + migration = Migration( + version=version, + name=name, + description=description, + python_func=func, + **kwargs + ) + get_migration_manager().register_migration(migration) + return func + return decorator + + +def run_all_migrations() -> bool: + """Run all registered migrations""" + manager = get_migration_manager() + return manager.run_migrations() \ No newline at end of file diff --git a/PinePods-0.8.2/database_functions/tasks.py b/PinePods-0.8.2/database_functions/tasks.py new file mode 100644 index 0000000..1d7a388 --- /dev/null +++ b/PinePods-0.8.2/database_functions/tasks.py @@ -0,0 +1,795 @@ +# tasks.py - Define Celery tasks with Valkey as broker +from celery import Celery +import time +import os +import asyncio +import datetime +import requests +from threading import Thread +import json +import sys +import logging +from typing import Dict, Any, Optional, List + +# Make sure pinepods is in the Python path +sys.path.append('/pinepods') + +database_type = str(os.getenv('DB_TYPE', 'mariadb')) + +class Web_Key: + def __init__(self): + self.web_key = None + + def get_web_key(self, cnx): + # Import only when needed to avoid circular imports + from database_functions.functions import get_web_key as get_key + self.web_key = get_key(cnx, database_type) + return self.web_key + +base_webkey = Web_Key() + +# Set up logging +logger = logging.getLogger("celery_tasks") + +# Import the WebSocket manager directly from clientapi +try: + from clients.clientapi import manager as websocket_manager + print("Successfully imported WebSocket manager from clientapi") +except ImportError as e: + logger.error(f"Failed to import WebSocket manager: {e}") + websocket_manager = None + +# Create a dedicated event loop thread for async operations +_event_loop = None +_event_loop_thread = None + +def start_background_loop(): + global _event_loop, _event_loop_thread + + # Only start if not already running + if _event_loop is None: + # Function to run event loop in background thread + def run_event_loop(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + global _event_loop + _event_loop = loop + loop.run_forever() + + # Start the background thread + _event_loop_thread = Thread(target=run_event_loop, daemon=True) + _event_loop_thread.start() + + # Wait a moment for the loop to start + time.sleep(0.1) + print("Started background event loop for WebSocket broadcasts") + +# Start the event loop when this module is imported +start_background_loop() + +# Use the existing Valkey connection for Celery +valkey_host = os.environ.get("VALKEY_HOST", "localhost") +valkey_port = os.environ.get("VALKEY_PORT", "6379") +broker_url = f"redis://{valkey_host}:{valkey_port}/0" +backend_url = f"redis://{valkey_host}:{valkey_port}/0" + +# Initialize Celery with Valkey as broker and result backend +celery_app = Celery('pinepods', + broker=broker_url, + backend=backend_url) + +# Configure Celery for best performance with downloads +celery_app.conf.update( + worker_concurrency=3, # Limit to 3 concurrent downloads per worker + task_acks_late=True, # Only acknowledge tasks after they're done + task_time_limit=1800, # 30 minutes time limit + task_soft_time_limit=1500, # 25 minutes soft time limit + worker_prefetch_multiplier=1, # Don't prefetch more tasks than workers +) + +# Task status tracking in Valkey for all types of tasks +class TaskManager: + def __init__(self): + from database_functions.valkey_client import valkey_client + self.valkey_client = valkey_client + + def register_task(self, task_id: str, task_type: str, user_id: int, item_id: Optional[int] = None, + details: Optional[Dict[str, Any]] = None): + """Register any Celery task for tracking""" + task_data = { + "task_id": task_id, + "user_id": user_id, + "type": task_type, + "item_id": item_id, + "progress": 0.0, + "status": "PENDING", + "details": details or {}, + "started_at": datetime.datetime.now().isoformat() + } + + self.valkey_client.set(f"task:{task_id}", json.dumps(task_data)) + # Set TTL for 24 hours + self.valkey_client.expire(f"task:{task_id}", 86400) + + # Add to user's active tasks list + self._add_to_user_tasks(user_id, task_id) + + # Try to broadcast the update if the WebSocket module is available + try: + self._broadcast_update(task_id) + except Exception as e: + logger.error(f"Error broadcasting task update: {e}") + + def update_task(self, task_id: str, progress: float = None, status: str = None, + details: Dict[str, Any] = None): + """Update any task's status and progress""" + task_json = self.valkey_client.get(f"task:{task_id}") + if task_json: + task = json.loads(task_json) + if progress is not None: + task["progress"] = progress + if status: + task["status"] = status + if details: + if "details" not in task: + task["details"] = {} + task["details"].update(details) + + self.valkey_client.set(f"task:{task_id}", json.dumps(task)) + + # Try to broadcast the update + try: + self._broadcast_update(task_id) + except Exception as e: + logger.error(f"Error broadcasting task update: {e}") + + def complete_task(self, task_id: str, success: bool = True, result: Any = None): + """Mark any task as complete or failed""" + task_json = self.valkey_client.get(f"task:{task_id}") + if task_json: + task = json.loads(task_json) + task["progress"] = 100.0 if success else 0.0 + task["status"] = "SUCCESS" if success else "FAILED" + task["completed_at"] = datetime.datetime.now().isoformat() + if result is not None: + task["result"] = result + + self.valkey_client.set(f"task:{task_id}", json.dumps(task)) + + # Try to broadcast the final update + try: + self._broadcast_update(task_id) + except Exception as e: + logger.error(f"Error broadcasting task update: {e}") + + # Keep completed tasks for 1 hour before expiring + self.valkey_client.expire(f"task:{task_id}", 3600) + + # Remove from user's active tasks list after completion + if success: + self._remove_from_user_tasks(task.get("user_id"), task_id) + + def get_task(self, task_id: str) -> Dict[str, Any]: + """Get any task's details""" + task_json = self.valkey_client.get(f"task:{task_id}") + if task_json: + return json.loads(task_json) + return {} + + def get_user_tasks(self, user_id: int) -> List[Dict[str, Any]]: + """Get all active tasks for a user (all types)""" + tasks_list_json = self.valkey_client.get(f"user_tasks:{user_id}") + result = [] + + if tasks_list_json: + task_ids = json.loads(tasks_list_json) + for task_id in task_ids: + task_info = self.get_task(task_id) + if task_info: + result.append(task_info) + + return result + + def _add_to_user_tasks(self, user_id: int, task_id: str): + """Add a task to the user's active tasks list""" + tasks_list_json = self.valkey_client.get(f"user_tasks:{user_id}") + if tasks_list_json: + tasks_list = json.loads(tasks_list_json) + if task_id not in tasks_list: + tasks_list.append(task_id) + else: + tasks_list = [task_id] + + self.valkey_client.set(f"user_tasks:{user_id}", json.dumps(tasks_list)) + # Set TTL for 7 days + self.valkey_client.expire(f"user_tasks:{user_id}", 604800) + + def _remove_from_user_tasks(self, user_id: int, task_id: str): + """Remove a task from the user's active tasks list""" + tasks_list_json = self.valkey_client.get(f"user_tasks:{user_id}") + if tasks_list_json: + tasks_list = json.loads(tasks_list_json) + if task_id in tasks_list: + tasks_list.remove(task_id) + self.valkey_client.set(f"user_tasks:{user_id}", json.dumps(tasks_list)) + +# Modified _broadcast_update method to avoid circular imports + def _broadcast_update(self, task_id: str): + """Broadcast task update via HTTP endpoint""" + # Get task info + task_info = self.get_task(task_id) + if not task_info or "user_id" not in task_info: + return + + user_id = task_info["user_id"] + cnx = None + + try: + cnx = get_direct_db_connection() + + # Import broadcaster - delay import to avoid circular dependency + sys.path.insert(0, '/pinepods/database_functions') + try: + from websocket_broadcaster import broadcaster + except ImportError: + try: + from database_functions.websocket_broadcaster import broadcaster + except ImportError as e: + print(f"Cannot import broadcaster from any location: {e}") + return + + # Get web key + web_key = None + try: + # Get web key using class method to avoid direct import + if not base_webkey.web_key: + base_webkey.get_web_key(cnx) + web_key = base_webkey.web_key + except Exception as e: + print(f"Error getting web key: {str(e)}") + # Fallback to a direct approach if needed + try: + from database_functions.functions import get_web_key + web_key = get_web_key(cnx, database_type) + except Exception as e2: + print(f"Fallback web key retrieval failed: {str(e2)}") + return + + # Progress and status details for better debugging + progress = task_info.get("progress", 0) + status = task_info.get("status", "unknown") + print(f"Broadcasting task update for user {user_id}, task {task_id}, progress: {progress}, status: {status}") + + # Broadcast the update + result = broadcaster.broadcast_task_update(user_id, task_info, web_key) + if result: + print(f"Successfully broadcast task update for task {task_id}, progress: {progress}%") + else: + print(f"Failed to broadcast task update for task {task_id}, progress: {progress}%") + + except Exception as e: + print(f"Error in task broadcast setup: {str(e)}") + finally: + if cnx: + # Close direct connection + close_direct_db_connection(cnx) + +# Initialize a general task manager +task_manager = TaskManager() + +# For backwards compatibility, keep the download_manager name too +download_manager = task_manager + +# Function to get all active tasks including both downloads and other task types +def get_all_active_tasks(user_id: int) -> List[Dict[str, Any]]: + """Get all active tasks for a user (all types)""" + return task_manager.get_user_tasks(user_id) + +# ---------------------- +# IMPROVED CONNECTION HANDLING +# ---------------------- + +def get_direct_db_connection(): + """ + Create a direct database connection instead of using the pool + This is more reliable for Celery workers to avoid pool exhaustion + """ + db_host = os.environ.get("DB_HOST", "127.0.0.1") + db_port = os.environ.get("DB_PORT", "3306") + db_user = os.environ.get("DB_USER", "root") + db_password = os.environ.get("DB_PASSWORD", "password") + db_name = os.environ.get("DB_NAME", "pypods_database") + + print(f"Creating direct database connection for task") + + if database_type == "postgresql": + import psycopg + conninfo = f"host={db_host} port={db_port} user={db_user} password={db_password} dbname={db_name}" + return psycopg.connect(conninfo) + else: # Default to MariaDB/MySQL + try: + import mariadb as mysql_connector + except ImportError: + import mysql.connector + return mysql_connector.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password, + database=db_name + ) + +def close_direct_db_connection(cnx): + """Close a direct database connection""" + if cnx: + try: + cnx.close() + print("Direct database connection closed") + except Exception as e: + print(f"Error closing direct connection: {str(e)}") + +# Minimal changes to download_podcast_task that should work right away +@celery_app.task(bind=True, max_retries=3) +def download_podcast_task(self, episode_id: int, user_id: int, database_type: str): + """ + Celery task to download a podcast episode. + Uses retries with exponential backoff for handling transient failures. + """ + task_id = self.request.id + print(f"DOWNLOAD TASK STARTED: ID={task_id}, Episode={episode_id}, User={user_id}") + cnx = None + + try: + # Get a direct connection to fetch the title first + cnx = get_direct_db_connection() + cursor = cnx.cursor() + + # Get the episode title and podcast name + if database_type == "postgresql": + # First try to get both the episode title and podcast name + query = ''' + SELECT e."episodetitle", p."podcastname" + FROM "Episodes" e + JOIN "Podcasts" p ON e."podcastid" = p."podcastid" + WHERE e."episodeid" = %s + ''' + else: + query = ''' + SELECT e.EpisodeTitle, p.PodcastName + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE e.EpisodeID = %s + ''' + + cursor.execute(query, (episode_id,)) + result = cursor.fetchone() + cursor.close() + + # Extract episode title and podcast name + episode_title = None + podcast_name = None + if result: + if isinstance(result, dict): + # Dictionary result + if "episodetitle" in result: # PostgreSQL lowercase + episode_title = result["episodetitle"] + podcast_name = result.get("podcastname") + else: # MariaDB uppercase + episode_title = result["EpisodeTitle"] + podcast_name = result.get("PodcastName") + else: + # Tuple result + episode_title = result[0] if len(result) > 0 else None + podcast_name = result[1] if len(result) > 1 else None + + # Format a nice display title + display_title = "Unknown Episode" + if episode_title and episode_title != "None" and episode_title.strip(): + display_title = episode_title + elif podcast_name: + display_title = f"{podcast_name} - Episode" + else: + display_title = f"Episode #{episode_id}" + + print(f"Using display title for episode {episode_id}: {display_title}") + + # Register task with more details + task_manager.register_task( + task_id=task_id, + task_type="podcast_download", + user_id=user_id, + item_id=episode_id, + details={ + "episode_id": episode_id, + "episode_title": display_title, + "status_text": f"Preparing to download {display_title}" + } + ) + + # Define a progress callback with the display title + def progress_callback(progress, status=None): + status_message = "" + if status == "DOWNLOADING": + status_message = f"Downloading {display_title}" + elif status == "PROCESSING": + status_message = f"Processing {display_title}" + elif status == "FINALIZING": + status_message = f"Finalizing {display_title}" + + task_manager.update_task(task_id, progress, status, { + "episode_id": episode_id, + "episode_title": display_title, + "status_text": status_message + }) + + # Close the connection used for title lookup + close_direct_db_connection(cnx) + + # Get a fresh connection for the download + cnx = get_direct_db_connection() + + # Import the download function + from database_functions.functions import download_podcast + + print(f"Starting download for episode: {episode_id} ({display_title}), user: {user_id}, task: {task_id}") + + # Execute the download with progress reporting + success = download_podcast( + cnx, + database_type, + episode_id, + user_id, + task_id, + progress_callback=progress_callback + ) + + # Mark task as complete with a nice message + completion_message = f"{'Successfully downloaded' if success else 'Failed to download'} {display_title}" + task_manager.complete_task( + task_id, + success, + { + "episode_id": episode_id, + "episode_title": display_title, + "status_text": completion_message + } + ) + + return success + except Exception as exc: + print(f"Error downloading podcast {episode_id}: {str(exc)}") + # Mark task as failed + task_manager.complete_task( + task_id, + False, + { + "episode_id": episode_id, + "episode_title": f"Episode #{episode_id}", + "status_text": f"Download failed: {str(exc)}" + } + ) + # Retry with exponential backoff (5s, 25s, 125s) + countdown = 5 * (2 ** self.request.retries) + self.retry(exc=exc, countdown=countdown) + finally: + # Always close the connection + if cnx: + close_direct_db_connection(cnx) + +@celery_app.task(bind=True, max_retries=3) +def download_youtube_video_task(self, video_id: int, user_id: int, database_type: str): + """ + Celery task to download a YouTube video. + Uses retries with exponential backoff for handling transient failures. + """ + task_id = self.request.id + print(f"YOUTUBE DOWNLOAD TASK STARTED: ID={task_id}, Video={video_id}, User={user_id}") + cnx = None + + try: + # Get a direct connection to fetch the title first + cnx = get_direct_db_connection() + cursor = cnx.cursor() + + # Get the video title and channel name + if database_type == "postgresql": + # First try to get both the video title and channel name + query = ''' + SELECT v."videotitle", p."podcastname" + FROM "YouTubeVideos" v + JOIN "Podcasts" p ON v."podcastid" = p."podcastid" + WHERE v."videoid" = %s + ''' + else: + query = ''' + SELECT v.VideoTitle, p.PodcastName + FROM YouTubeVideos v + JOIN Podcasts p ON v.PodcastID = p.PodcastID + WHERE v.VideoID = %s + ''' + + cursor.execute(query, (video_id,)) + result = cursor.fetchone() + cursor.close() + + # Extract video title and channel name + video_title = None + channel_name = None + if result: + if isinstance(result, dict): + # Dictionary result + if "videotitle" in result: # PostgreSQL lowercase + video_title = result["videotitle"] + channel_name = result.get("podcastname") + else: # MariaDB uppercase + video_title = result["VideoTitle"] + channel_name = result.get("PodcastName") + else: + # Tuple result + video_title = result[0] if len(result) > 0 else None + channel_name = result[1] if len(result) > 1 else None + + # Format a nice display title + display_title = "Unknown Video" + if video_title and video_title != "None" and video_title.strip(): + display_title = video_title + elif channel_name: + display_title = f"{channel_name} - Video" + else: + display_title = f"YouTube Video #{video_id}" + + print(f"Using display title for video {video_id}: {display_title}") + + # Register task with more details + task_manager.register_task( + task_id=task_id, + task_type="youtube_download", + user_id=user_id, + item_id=video_id, + details={ + "item_id": video_id, + "item_title": display_title, + "status_text": f"Preparing to download {display_title}" + } + ) + + # Close the connection used for title lookup + close_direct_db_connection(cnx) + + # Get a fresh connection for the download + cnx = get_direct_db_connection() + + # Import the download function + from database_functions.functions import download_youtube_video + + print(f"Starting download for YouTube video: {video_id} ({display_title}), user: {user_id}, task: {task_id}") + + # Define a progress callback with the display title + def progress_callback(progress, status=None): + status_message = "" + if status == "DOWNLOADING": + status_message = f"Downloading {display_title}" + elif status == "PROCESSING": + status_message = f"Processing {display_title}" + elif status == "FINALIZING": + status_message = f"Finalizing {display_title}" + + task_manager.update_task(task_id, progress, status, { + "item_id": video_id, + "item_title": display_title, + "status_text": status_message + }) + + # Check if the download_youtube_video function accepts progress_callback parameter + import inspect + try: + signature = inspect.signature(download_youtube_video) + has_progress_callback = 'progress_callback' in signature.parameters + except (TypeError, ValueError): + has_progress_callback = False + + # Execute the download with progress callback if supported, otherwise without it + if has_progress_callback: + success = download_youtube_video( + cnx, + database_type, + video_id, + user_id, + task_id, + progress_callback=progress_callback + ) + else: + # Call without the progress_callback parameter + success = download_youtube_video( + cnx, + database_type, + video_id, + user_id, + task_id + ) + + # Since we can't use progress callbacks directly, manually update progress after completion + task_manager.update_task(task_id, 100 if success else 0, + "SUCCESS" if success else "FAILED", + { + "item_id": video_id, + "item_title": display_title, + "status_text": f"{'Download complete' if success else 'Download failed'}" + }) + + # Mark task as complete with a nice message + completion_message = f"{'Successfully downloaded' if success else 'Failed to download'} {display_title}" + task_manager.complete_task( + task_id, + success, + { + "item_id": video_id, + "item_title": display_title, + "status_text": completion_message + } + ) + + return success + except Exception as exc: + print(f"Error downloading YouTube video {video_id}: {str(exc)}") + # Mark task as failed but include video title in the details + task_manager.complete_task( + task_id, + False, + { + "item_id": video_id, + "item_title": f"YouTube Video #{video_id}", + "status_text": f"Download failed: {str(exc)}" + } + ) + # Retry with exponential backoff (5s, 25s, 125s) + countdown = 5 * (2 ** self.request.retries) + self.retry(exc=exc, countdown=countdown) + finally: + # Always close the connection + if cnx: + close_direct_db_connection(cnx) + +@celery_app.task +def queue_podcast_downloads(podcast_id: int, user_id: int, database_type: str, is_youtube: bool = False): + """ + Task to queue individual download tasks for all episodes/videos in a podcast. + This adds downloads to the queue in small batches to prevent overwhelming the system. + """ + cnx = None + + try: + # Get a direct connection + cnx = get_direct_db_connection() + + from database_functions.functions import ( + get_episode_ids_for_podcast, + get_video_ids_for_podcast, + check_downloaded + ) + + if is_youtube: + item_ids = get_video_ids_for_podcast(cnx, database_type, podcast_id) + print(f"Queueing {len(item_ids)} YouTube videos for download") + + # Process YouTube items in batches + batch_size = 5 + for i in range(0, len(item_ids), batch_size): + batch = item_ids[i:i+batch_size] + for item_id in batch: + if not check_downloaded(cnx, database_type, user_id, item_id, is_youtube): + download_youtube_video_task.delay(item_id, user_id, database_type) + + # Add a small delay between batches + if i + batch_size < len(item_ids): + time.sleep(2) + else: + # Get episode IDs (should return dicts with id and title) + episodes = get_episode_ids_for_podcast(cnx, database_type, podcast_id) + print(f"Queueing {len(episodes)} podcast episodes for download") + + # Process episodes in batches + batch_size = 5 + for i in range(0, len(episodes), batch_size): + batch = episodes[i:i+batch_size] + + for episode in batch: + # Handle both possible formats (dict or simple ID) + if isinstance(episode, dict) and "id" in episode: + episode_id = episode["id"] + else: + # Fall back to treating it as just an ID + episode_id = episode + + if not check_downloaded(cnx, database_type, user_id, episode_id, is_youtube): + # Pass just the ID, the task will look up the title + download_podcast_task.delay(episode_id, user_id, database_type) + + # Add a small delay between batches + if i + batch_size < len(episodes): + time.sleep(2) + + return f"Queued {len(episodes if not is_youtube else item_ids)} items for download" + finally: + if cnx: + close_direct_db_connection(cnx) + +# Helper task to clean up old download records +@celery_app.task +def cleanup_old_downloads(): + """ + Periodic task to clean up old download records from Valkey + """ + from database_functions.valkey_client import valkey_client + + # This would need to be implemented with a scan operation + # For simplicity, we rely on Redis/Valkey TTL mechanisms + print("Running download cleanup task") + +# Example task for refreshing podcast feeds +@celery_app.task(bind=True, max_retries=2) +def refresh_feed_task(self, user_id: int, database_type: str): + """ + Celery task to refresh podcast feeds for a user. + """ + task_id = self.request.id + cnx = None + + try: + # Register task + task_manager.register_task( + task_id=task_id, + task_type="feed_refresh", + user_id=user_id, + details={"description": "Refreshing podcast feeds"} + ) + + # Get a direct database connection + cnx = get_direct_db_connection() + + # Get list of podcasts to refresh + # Then update progress as each one completes + try: + # Here you would have your actual feed refresh implementation + # with periodic progress updates + task_manager.update_task(task_id, 10, "PROGRESS", {"status_text": "Fetching podcast list"}) + + # Simulate feed refresh process with progress updates + # Replace with your actual implementation + total_podcasts = 10 # Example count + for i in range(total_podcasts): + # Update progress for each podcast + progress = (i + 1) / total_podcasts * 100 + task_manager.update_task( + task_id, + progress, + "PROGRESS", + {"status_text": f"Refreshing podcast {i+1}/{total_podcasts}"} + ) + + # Simulated work - replace with actual refresh logic + time.sleep(0.5) + + # Complete the task + task_manager.complete_task(task_id, True, {"refreshed_count": total_podcasts}) + return True + + except Exception as e: + raise e + + except Exception as exc: + print(f"Error refreshing feeds for user {user_id}: {str(exc)}") + task_manager.complete_task(task_id, False, {"error": str(exc)}) + self.retry(exc=exc, countdown=30) + finally: + # Always close the connection + if cnx: + close_direct_db_connection(cnx) + +# Simple debug task +@celery_app.task +def debug_task(x, y): + """Simple debug task that prints output""" + result = x + y + print(f"CELERY DEBUG TASK EXECUTED: {x} + {y} = {result}") + return result diff --git a/PinePods-0.8.2/database_functions/validate_database.py b/PinePods-0.8.2/database_functions/validate_database.py new file mode 100644 index 0000000..c0ab82f --- /dev/null +++ b/PinePods-0.8.2/database_functions/validate_database.py @@ -0,0 +1,778 @@ +#!/usr/bin/env python3 +""" +Database Validator for PinePods + +This script validates that an existing database matches the expected schema +by using the migration system as the source of truth. + +Usage: + python validate_database.py --db-type mysql --db-host localhost --db-port 3306 --db-user root --db-password pass --db-name pinepods_database + python validate_database.py --db-type postgresql --db-host localhost --db-port 5432 --db-user postgres --db-password pass --db-name pinepods_database +""" + +import argparse +import sys +import os +import tempfile +import logging +from typing import Dict, List, Set, Tuple, Any, Optional +from dataclasses import dataclass +import importlib.util + +# Add the parent directory to path so we can import database_functions +parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, parent_dir) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + import mysql.connector + MYSQL_AVAILABLE = True +except ImportError: + MYSQL_AVAILABLE = False + +try: + import psycopg + POSTGRESQL_AVAILABLE = True +except ImportError: + POSTGRESQL_AVAILABLE = False + +from database_functions.migrations import get_migration_manager + + +@dataclass +class TableInfo: + """Information about a database table""" + name: str + columns: Dict[str, Dict[str, Any]] + indexes: Dict[str, Dict[str, Any]] + constraints: Dict[str, Dict[str, Any]] + + +@dataclass +class ValidationResult: + """Result of database validation""" + is_valid: bool + missing_tables: List[str] + extra_tables: List[str] + table_differences: Dict[str, Dict[str, Any]] + missing_indexes: List[Tuple[str, str]] # (table, index) + extra_indexes: List[Tuple[str, str]] + missing_constraints: List[Tuple[str, str]] # (table, constraint) + extra_constraints: List[Tuple[str, str]] + column_differences: Dict[str, Dict[str, Dict[str, Any]]] # table -> column -> differences + + +class DatabaseInspector: + """Base class for database inspection""" + + def __init__(self, connection): + self.connection = connection + + def get_tables(self) -> Set[str]: + """Get all table names""" + raise NotImplementedError + + def get_table_info(self, table_name: str) -> TableInfo: + """Get detailed information about a table""" + raise NotImplementedError + + def get_all_table_info(self) -> Dict[str, TableInfo]: + """Get information about all tables""" + tables = {} + for table_name in self.get_tables(): + tables[table_name] = self.get_table_info(table_name) + return tables + + +class MySQLInspector(DatabaseInspector): + """MySQL database inspector""" + + def get_tables(self) -> Set[str]: + cursor = self.connection.cursor() + cursor.execute("SHOW TABLES") + tables = {row[0] for row in cursor.fetchall()} + cursor.close() + return tables + + def get_table_info(self, table_name: str) -> TableInfo: + cursor = self.connection.cursor(dictionary=True) + + # Get column information + cursor.execute(f"DESCRIBE `{table_name}`") + columns = {} + for row in cursor.fetchall(): + columns[row['Field']] = { + 'type': row['Type'], + 'null': row['Null'], + 'key': row['Key'], + 'default': row['Default'], + 'extra': row['Extra'] + } + + # Get index information + cursor.execute(f"SHOW INDEX FROM `{table_name}`") + indexes = {} + for row in cursor.fetchall(): + index_name = row['Key_name'] + if index_name not in indexes: + indexes[index_name] = { + 'columns': [], + 'unique': not row['Non_unique'], + 'type': row['Index_type'] + } + indexes[index_name]['columns'].append(row['Column_name']) + + # Get constraint information (foreign keys, etc.) + cursor.execute(f""" + SELECT kcu.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE, kcu.COLUMN_NAME, + kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + ON kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA + WHERE kcu.TABLE_SCHEMA = DATABASE() AND kcu.TABLE_NAME = %s + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + """, (table_name,)) + + constraints = {} + for row in cursor.fetchall(): + constraint_name = row['CONSTRAINT_NAME'] + constraints[constraint_name] = { + 'type': 'FOREIGN KEY', + 'column': row['COLUMN_NAME'], + 'referenced_table': row['REFERENCED_TABLE_NAME'], + 'referenced_column': row['REFERENCED_COLUMN_NAME'] + } + + cursor.close() + return TableInfo(table_name, columns, indexes, constraints) + + +class PostgreSQLInspector(DatabaseInspector): + """PostgreSQL database inspector""" + + def get_tables(self) -> Set[str]: + cursor = self.connection.cursor() + cursor.execute(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + """) + tables = {row[0] for row in cursor.fetchall()} + cursor.close() + return tables + + def get_table_info(self, table_name: str) -> TableInfo: + cursor = self.connection.cursor() + + # Get column information + cursor.execute(""" + SELECT column_name, data_type, is_nullable, column_default, + character_maximum_length, numeric_precision, numeric_scale + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s + ORDER BY ordinal_position + """, (table_name,)) + + columns = {} + for row in cursor.fetchall(): + col_name, data_type, is_nullable, default, max_length, precision, scale = row + type_str = data_type + if max_length: + type_str += f"({max_length})" + elif precision: + if scale: + type_str += f"({precision},{scale})" + else: + type_str += f"({precision})" + + columns[col_name] = { + 'type': type_str, + 'null': is_nullable, + 'default': default, + 'max_length': max_length, + 'precision': precision, + 'scale': scale + } + + # Get index information + cursor.execute(""" + SELECT i.relname as index_name, + array_agg(a.attname ORDER BY c.ordinality) as columns, + ix.indisunique as is_unique, + ix.indisprimary as is_primary + FROM pg_class t + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN unnest(ix.indkey) WITH ORDINALITY c(colnum, ordinality) ON true + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = c.colnum + WHERE t.relname = %s AND t.relkind = 'r' + GROUP BY i.relname, ix.indisunique, ix.indisprimary + """, (table_name,)) + + indexes = {} + for row in cursor.fetchall(): + index_name, columns_list, is_unique, is_primary = row + indexes[index_name] = { + 'columns': columns_list, + 'unique': is_unique, + 'primary': is_primary + } + + # Get constraint information + cursor.execute(""" + SELECT con.conname as constraint_name, + con.contype as constraint_type, + array_agg(att.attname) as columns, + cl.relname as referenced_table, + array_agg(att2.attname) as referenced_columns + FROM pg_constraint con + JOIN pg_class t ON con.conrelid = t.oid + JOIN pg_attribute att ON att.attrelid = t.oid AND att.attnum = ANY(con.conkey) + LEFT JOIN pg_class cl ON con.confrelid = cl.oid + LEFT JOIN pg_attribute att2 ON att2.attrelid = cl.oid AND att2.attnum = ANY(con.confkey) + WHERE t.relname = %s + GROUP BY con.conname, con.contype, cl.relname + """, (table_name,)) + + constraints = {} + for row in cursor.fetchall(): + constraint_name, constraint_type, columns_list, ref_table, ref_columns = row + constraints[constraint_name] = { + 'type': constraint_type, + 'columns': columns_list, + 'referenced_table': ref_table, + 'referenced_columns': ref_columns + } + + cursor.close() + return TableInfo(table_name, columns, indexes, constraints) + + +class DatabaseValidator: + """Main database validator class""" + + def __init__(self, db_type: str, db_config: Dict[str, Any]): + self.db_type = db_type.lower() + # Normalize mariadb to mysql since they use the same connector + if self.db_type == 'mariadb': + self.db_type = 'mysql' + self.db_config = db_config + self.logger = logging.getLogger(__name__) + + def create_test_database(self) -> Tuple[Any, str]: + """Create a temporary database and run all migrations""" + if self.db_type == 'mysql': + return self._create_mysql_test_db() + elif self.db_type == 'postgresql': + return self._create_postgresql_test_db() + else: + raise ValueError(f"Unsupported database type: {self.db_type}") + + def _create_mysql_test_db(self) -> Tuple[Any, str]: + """Create MySQL test database""" + if not MYSQL_AVAILABLE: + raise ImportError("mysql-connector-python is required for MySQL validation") + + # Create temporary database name + import uuid + test_db_name = f"pinepods_test_{uuid.uuid4().hex[:8]}" + + # Connect to MySQL server + config = self.db_config.copy() + config.pop('database', None) # Remove database from config + config['use_pure'] = True # Use pure Python implementation to avoid auth plugin issues + + conn = mysql.connector.connect(**config) + cursor = conn.cursor() + + try: + # Create test database + cursor.execute(f"CREATE DATABASE `{test_db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + cursor.execute(f"USE `{test_db_name}`") + cursor.close() + + # Run all migrations + self._run_migrations(conn, 'mysql') + + # Create a fresh connection to the test database for schema inspection + config['database'] = test_db_name + test_conn = mysql.connector.connect(**config) + + # Close the migration connection + conn.close() + + return test_conn, test_db_name + + except Exception as e: + if cursor: + cursor.close() + if conn: + conn.close() + raise e + + def _create_postgresql_test_db(self) -> Tuple[Any, str]: + """Create PostgreSQL test database""" + if not POSTGRESQL_AVAILABLE: + raise ImportError("psycopg is required for PostgreSQL validation") + + # Create temporary database name + import uuid + test_db_name = f"pinepods_test_{uuid.uuid4().hex[:8]}" + + # Connect to PostgreSQL server + config = self.db_config.copy() + config.pop('dbname', None) # Remove database from config + config['dbname'] = 'postgres' # Connect to default database + + conn = psycopg.connect(**config) + conn.autocommit = True + cursor = conn.cursor() + + try: + # Create test database + cursor.execute(f'CREATE DATABASE "{test_db_name}"') + cursor.close() + conn.close() + + # Connect to the new test database + config['dbname'] = test_db_name + test_conn = psycopg.connect(**config) + test_conn.autocommit = True + + # Run all migrations + self._run_migrations(test_conn, 'postgresql') + + return test_conn, test_db_name + + except Exception as e: + cursor.close() + conn.close() + raise e + + def _run_migrations(self, conn: Any, db_type: str): + """Run all migrations on the test database using existing migration system""" + # Set environment variables for the migration manager + import os + original_env = {} + + try: + # Backup original environment + for key in ['DB_TYPE', 'DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']: + original_env[key] = os.environ.get(key) + + # Set environment for test database + if db_type == 'mysql': + os.environ['DB_TYPE'] = 'mysql' + os.environ['DB_HOST'] = 'localhost' # We'll override the connection + os.environ['DB_PORT'] = '3306' + os.environ['DB_USER'] = 'test' + os.environ['DB_PASSWORD'] = 'test' + os.environ['DB_NAME'] = 'test' + else: + os.environ['DB_TYPE'] = 'postgresql' + os.environ['DB_HOST'] = 'localhost' + os.environ['DB_PORT'] = '5432' + os.environ['DB_USER'] = 'test' + os.environ['DB_PASSWORD'] = 'test' + os.environ['DB_NAME'] = 'test' + + # Import and register migrations + import database_functions.migration_definitions + + # Get migration manager and override its connection + manager = get_migration_manager() + manager._connection = conn + + # Run all migrations + success = manager.run_migrations() + if not success: + raise RuntimeError("Failed to apply migrations") + + finally: + # Restore original environment + for key, value in original_env.items(): + if value is not None: + os.environ[key] = value + elif key in os.environ: + del os.environ[key] + + def validate_database(self) -> ValidationResult: + """Validate the actual database against the expected schema""" + # Create test database with perfect schema + test_conn, test_db_name = self.create_test_database() + + try: + # Connect to actual database + actual_conn = self._connect_to_actual_database() + + try: + # Get schema information from both databases + if self.db_type == 'mysql': + expected_inspector = MySQLInspector(test_conn) + actual_inspector = MySQLInspector(actual_conn) + # Extract schemas + expected_schema = expected_inspector.get_all_table_info() + actual_schema = actual_inspector.get_all_table_info() + else: + # For PostgreSQL, create fresh connection for expected schema since migration manager closes it + fresh_test_conn = psycopg.connect( + host=self.db_config['host'], + port=self.db_config['port'], + user=self.db_config['user'], + password=self.db_config['password'], + dbname=test_db_name + ) + fresh_test_conn.autocommit = True + + try: + expected_inspector = PostgreSQLInspector(fresh_test_conn) + actual_inspector = PostgreSQLInspector(actual_conn) + + # Extract schemas + expected_schema = expected_inspector.get_all_table_info() + actual_schema = actual_inspector.get_all_table_info() + finally: + fresh_test_conn.close() + + # DEBUG: Print what we're actually comparing + print(f"\n🔍 DEBUG: Expected schema has {len(expected_schema)} tables:") + for table in sorted(expected_schema.keys()): + cols = list(expected_schema[table].columns.keys()) + print(f" {table}: {len(cols)} columns - {', '.join(cols[:5])}{'...' if len(cols) > 5 else ''}") + + print(f"\n🔍 DEBUG: Actual schema has {len(actual_schema)} tables:") + for table in sorted(actual_schema.keys()): + cols = list(actual_schema[table].columns.keys()) + print(f" {table}: {len(cols)} columns - {', '.join(cols[:5])}{'...' if len(cols) > 5 else ''}") + + # Check specifically for Playlists table + if 'Playlists' in expected_schema and 'Playlists' in actual_schema: + exp_cols = set(expected_schema['Playlists'].columns.keys()) + act_cols = set(actual_schema['Playlists'].columns.keys()) + print(f"\n🔍 DEBUG: Playlists comparison:") + print(f" Expected columns: {sorted(exp_cols)}") + print(f" Actual columns: {sorted(act_cols)}") + print(f" Missing from actual: {sorted(exp_cols - act_cols)}") + print(f" Extra in actual: {sorted(act_cols - exp_cols)}") + + # Compare schemas + result = self._compare_schemas(expected_schema, actual_schema) + + return result + + finally: + actual_conn.close() + + finally: + # Clean up test database - this will close test_conn + self._cleanup_test_database(test_conn, test_db_name) + + def _connect_to_actual_database(self) -> Any: + """Connect to the actual database""" + if self.db_type == 'mysql': + config = self.db_config.copy() + # Ensure autocommit is enabled for MySQL + config['autocommit'] = True + config['use_pure'] = True # Use pure Python implementation to avoid auth plugin issues + return mysql.connector.connect(**config) + else: + return psycopg.connect(**self.db_config) + + def _cleanup_test_database(self, test_conn: Any, test_db_name: str): + """Clean up the test database""" + try: + # Close the test connection first + if test_conn: + test_conn.close() + + if self.db_type == 'mysql': + config = self.db_config.copy() + config.pop('database', None) + config['use_pure'] = True # Use pure Python implementation to avoid auth plugin issues + cleanup_conn = mysql.connector.connect(**config) + cursor = cleanup_conn.cursor() + cursor.execute(f"DROP DATABASE IF EXISTS `{test_db_name}`") + cursor.close() + cleanup_conn.close() + else: + config = self.db_config.copy() + config.pop('dbname', None) + config['dbname'] = 'postgres' + cleanup_conn = psycopg.connect(**config) + cleanup_conn.autocommit = True + cursor = cleanup_conn.cursor() + cursor.execute(f'DROP DATABASE IF EXISTS "{test_db_name}"') + cursor.close() + cleanup_conn.close() + except Exception as e: + self.logger.warning(f"Failed to clean up test database {test_db_name}: {e}") + + def _compare_schemas(self, expected: Dict[str, TableInfo], actual: Dict[str, TableInfo]) -> ValidationResult: + """Compare expected and actual database schemas""" + expected_tables = set(expected.keys()) + actual_tables = set(actual.keys()) + + missing_tables = list(expected_tables - actual_tables) + extra_tables = list(actual_tables - expected_tables) + + table_differences = {} + missing_indexes = [] + extra_indexes = [] + missing_constraints = [] + extra_constraints = [] + column_differences = {} + + # Compare common tables + common_tables = expected_tables & actual_tables + for table_name in common_tables: + expected_table = expected[table_name] + actual_table = actual[table_name] + + # Compare columns + table_col_diffs = self._compare_columns(expected_table.columns, actual_table.columns) + if table_col_diffs: + column_differences[table_name] = table_col_diffs + + # Compare indexes + expected_indexes = set(expected_table.indexes.keys()) + actual_indexes = set(actual_table.indexes.keys()) + + for missing_idx in expected_indexes - actual_indexes: + missing_indexes.append((table_name, missing_idx)) + for extra_idx in actual_indexes - expected_indexes: + extra_indexes.append((table_name, extra_idx)) + + # Compare constraints + expected_constraints = set(expected_table.constraints.keys()) + actual_constraints = set(actual_table.constraints.keys()) + + for missing_const in expected_constraints - actual_constraints: + missing_constraints.append((table_name, missing_const)) + for extra_const in actual_constraints - expected_constraints: + extra_constraints.append((table_name, extra_const)) + + # Only fail on critical issues: + # - Missing tables (CRITICAL) + # - Missing columns (CRITICAL) + # Extra tables, extra columns, and type differences are warnings only + critical_issues = [] + critical_issues.extend(missing_tables) + + # Check for missing columns (critical) - but only in expected tables + for table, col_diffs in column_differences.items(): + # Skip extra tables entirely - they shouldn't be validated + if table in extra_tables: + continue + + for col, diff in col_diffs.items(): + if diff['status'] == 'missing': + critical_issues.append(f"missing column {col} in table {table}") + + is_valid = len(critical_issues) == 0 + + return ValidationResult( + is_valid=is_valid, + missing_tables=missing_tables, + extra_tables=extra_tables, + table_differences=table_differences, + missing_indexes=missing_indexes, + extra_indexes=extra_indexes, + missing_constraints=missing_constraints, + extra_constraints=extra_constraints, + column_differences=column_differences + ) + + def _compare_columns(self, expected: Dict[str, Dict[str, Any]], actual: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + """Compare column definitions between expected and actual""" + differences = {} + + expected_cols = set(expected.keys()) + actual_cols = set(actual.keys()) + + # Missing columns + for missing_col in expected_cols - actual_cols: + differences[missing_col] = {'status': 'missing', 'expected': expected[missing_col]} + + # Extra columns + for extra_col in actual_cols - expected_cols: + differences[extra_col] = {'status': 'extra', 'actual': actual[extra_col]} + + # Different columns + for col_name in expected_cols & actual_cols: + expected_col = expected[col_name] + actual_col = actual[col_name] + + col_diffs = {} + for key in expected_col: + if key in actual_col and expected_col[key] != actual_col[key]: + col_diffs[key] = {'expected': expected_col[key], 'actual': actual_col[key]} + + if col_diffs: + differences[col_name] = {'status': 'different', 'differences': col_diffs} + + return differences + + +def print_validation_report(result: ValidationResult): + """Print a detailed validation report""" + print("=" * 80) + print("DATABASE VALIDATION REPORT") + print("=" * 80) + + # Count critical vs warning issues + critical_issues = [] + warning_issues = [] + + # Missing tables are critical + critical_issues.extend(result.missing_tables) + + # Missing columns are critical, others are warnings + for table, col_diffs in result.column_differences.items(): + for col, diff in col_diffs.items(): + if diff['status'] == 'missing': + critical_issues.append(f"Missing column {col} in table {table}") + else: + warning_issues.append((table, col, diff)) + + # Extra tables are warnings + warning_issues.extend([('EXTRA_TABLE', table, None) for table in result.extra_tables]) + + if result.is_valid: + if warning_issues: + print("✅ DATABASE IS VALID - No critical issues found!") + print("⚠️ Some warnings exist but don't affect functionality") + else: + print("✅ DATABASE IS PERFECT - All checks passed!") + else: + print("❌ DATABASE VALIDATION FAILED - Critical issues found") + + print() + + # Show critical issues + if critical_issues: + print("🔴 CRITICAL ISSUES (MUST BE FIXED):") + if result.missing_tables: + print(" Missing Tables:") + for table in result.missing_tables: + print(f" - {table}") + + # Show missing columns + for table, col_diffs in result.column_differences.items(): + missing_cols = [col for col, diff in col_diffs.items() if diff['status'] == 'missing'] + if missing_cols: + print(f" Missing Columns in {table}:") + for col in missing_cols: + print(f" - {col}") + print() + + # Show warnings + if warning_issues: + print("⚠️ WARNINGS (ACCEPTABLE DIFFERENCES):") + + if result.extra_tables: + print(" Extra Tables (ignored):") + for table in result.extra_tables: + print(f" - {table}") + + # Show column warnings + for table, col_diffs in result.column_differences.items(): + table_warnings = [] + for col, diff in col_diffs.items(): + if diff['status'] == 'extra': + table_warnings.append(f"Extra column: {col}") + elif diff['status'] == 'different': + details = [] + for key, values in diff['differences'].items(): + details.append(f"{key}: {values}") + table_warnings.append(f"Different column {col}: {', '.join(details)}") + + if table_warnings: + print(f" Table {table}:") + for warning in table_warnings: + print(f" - {warning}") + print() + + if result.missing_indexes: + print("🟡 MISSING INDEXES:") + for table, index in result.missing_indexes: + print(f" - {table}.{index}") + print() + + if result.extra_indexes: + print("🟡 EXTRA INDEXES:") + for table, index in result.extra_indexes: + print(f" - {table}.{index}") + print() + + if result.missing_constraints: + print("🟡 MISSING CONSTRAINTS:") + for table, constraint in result.missing_constraints: + print(f" - {table}.{constraint}") + print() + + if result.extra_constraints: + print("🟡 EXTRA CONSTRAINTS:") + for table, constraint in result.extra_constraints: + print(f" - {table}.{constraint}") + print() + + +def main(): + """Main function""" + parser = argparse.ArgumentParser(description='Validate PinePods database schema') + parser.add_argument('--db-type', required=True, choices=['mysql', 'mariadb', 'postgresql'], help='Database type') + parser.add_argument('--db-host', required=True, help='Database host') + parser.add_argument('--db-port', required=True, type=int, help='Database port') + parser.add_argument('--db-user', required=True, help='Database user') + parser.add_argument('--db-password', required=True, help='Database password') + parser.add_argument('--db-name', required=True, help='Database name') + parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose logging') + + args = parser.parse_args() + + # Set up logging + level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig(level=level, format='%(asctime)s - %(levelname)s - %(message)s') + + # Build database config + if args.db_type in ['mysql', 'mariadb']: + db_config = { + 'host': args.db_host, + 'port': args.db_port, + 'user': args.db_user, + 'password': args.db_password, + 'database': args.db_name, + 'charset': 'utf8mb4', + 'collation': 'utf8mb4_unicode_ci' + } + else: # postgresql + db_config = { + 'host': args.db_host, + 'port': args.db_port, + 'user': args.db_user, + 'password': args.db_password, + 'dbname': args.db_name + } + + try: + # Create validator and run validation + validator = DatabaseValidator(args.db_type, db_config) + result = validator.validate_database() + + # Print report + print_validation_report(result) + + # Exit with appropriate code + sys.exit(0 if result.is_valid else 1) + + except Exception as e: + logging.error(f"Validation failed with error: {e}") + if args.verbose: + import traceback + traceback.print_exc() + sys.exit(2) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/PinePods-0.8.2/deployment/aws/main.tf b/PinePods-0.8.2/deployment/aws/main.tf new file mode 100644 index 0000000..033c480 --- /dev/null +++ b/PinePods-0.8.2/deployment/aws/main.tf @@ -0,0 +1,149 @@ +provider "aws" { + region = "us-east-1" # Choose your preferred region +} + +variable "db_username" { + description = "Database administrator username" + type = string + sensitive = true +} + +variable "db_password" { + description = "Database administrator password" + type = string + sensitive = true +} + +resource "aws_vpc" "main" { + cidr_block = "10.0.0.0/16" +} + +resource "aws_subnet" "subnet" { + vpc_id = aws_vpc.main.id + cidr_block = "10.15.0.0/24" +} + +resource "aws_security_group" "allow_all" { + vpc_id = aws_vpc.main.id + + ingress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_rds_instance" "default" { + allocated_storage = 20 + engine = "postgres" + engine_version = "12.5" + instance_class = "db.t2.micro" + name = "pinepods-db" + username = var.db_username + password = var.db_password + parameter_group_name = "default.postgres12" + skip_final_snapshot = true + publicly_accessible = true + vpc_security_group_ids = [aws_security_group.allow_all.id] + db_subnet_group_name = aws_db_subnet_group.main.name +} + +resource "aws_db_subnet_group" "main" { + name = "main" + subnet_ids = [aws_subnet.subnet.id] + + tags = { + Name = "Main subnet group" + } +} + +resource "aws_ecs_cluster" "main" { + name = "pinepods-cluster" +} + +resource "aws_ecs_task_definition" "pinepods" { + family = "pinepods-task" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = "1" + memory = "4" + execution_role_arn = aws_iam_role.ecs_task_execution_role.arn + container_definitions = <> /etc/apk/repositories +# Update the package index +RUN apk update +# Install the desired package from the edge community repository +RUN apk add trunk@edge +# Install wasm target and build tools +RUN rustup target add wasm32-unknown-unknown && \ + cargo install wasm-bindgen-cli +# Add application files to the builder stage +COPY ./web/Cargo.lock ./web/Cargo.toml ./web/dev-info.md ./web/index.html ./web/tailwind.config.js ./web/Trunk.toml /app/ +COPY ./web/src /app/src +COPY ./web/static /app/static +WORKDIR /app +# Build the Yew application in release mode +RUN RUSTFLAGS="--cfg=web_sys_unstable_apis --cfg getrandom_backend=\"wasm_js\"" trunk build --features server_build --release + +# Go builder stage for the gpodder API +FROM golang:alpine AS go-builder +WORKDIR /gpodder-api + +# Install build dependencies +RUN apk add --no-cache git + +# Copy go module files first for better layer caching +COPY ./gpodder-api/go.mod ./gpodder-api/go.sum ./ +RUN go mod download + +# Copy the rest of the source code +COPY ./gpodder-api/cmd ./cmd +COPY ./gpodder-api/config ./config +COPY ./gpodder-api/internal ./internal + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o gpodder-api ./cmd/server/ + +# Python builder stage for database setup +FROM python:3.11-alpine AS python-builder +WORKDIR /build + +# Install build dependencies for PyInstaller and MariaDB connector +RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev mariadb-connector-c-dev + +# Copy Python source files +COPY ./database_functions ./database_functions +COPY ./startup/setup_database_new.py ./startup/setup_database_new.py +COPY ./requirements.txt ./requirements.txt + +# Install Python dependencies including PyInstaller +RUN pip install --no-cache-dir -r requirements.txt pyinstaller + +# Build standalone database setup binary +RUN pyinstaller --onefile \ + --name pinepods-db-setup \ + --hidden-import psycopg \ + --hidden-import mysql.connector \ + --hidden-import cryptography \ + --hidden-import cryptography.fernet \ + --hidden-import passlib \ + --hidden-import passlib.hash \ + --hidden-import passlib.hash.argon2 \ + --hidden-import argon2 \ + --hidden-import argon2.exceptions \ + --hidden-import argon2.profiles \ + --hidden-import argon2._password_hasher \ + --add-data "database_functions:database_functions" \ + --console \ + startup/setup_database_new.py + +# Rust API builder stage +FROM rust:alpine AS rust-api-builder +WORKDIR /rust-api + +# Install build dependencies +RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static + +# Copy Rust API files +COPY ./rust-api/Cargo.toml ./rust-api/Cargo.lock ./ +COPY ./rust-api/src ./src + +# Set environment for static linking +ENV OPENSSL_STATIC=1 +ENV OPENSSL_LIB_DIR=/usr/lib +ENV OPENSSL_INCLUDE_DIR=/usr/include + +# Build the Rust API +RUN cargo build --release && strip target/release/pinepods-api + +# Final stage for setting up runtime environment +FROM alpine +# Metadata +LABEL maintainer="Collin Pendleton " +# Install runtime dependencies +RUN apk add --no-cache tzdata nginx openssl bash mariadb-client postgresql-client curl ffmpeg wget jq mariadb-connector-c-dev + + +# Download and install latest yt-dlp binary (musllinux for Alpine) +RUN LATEST_VERSION=$(curl -s https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest | jq -r .tag_name) && \ + wget -O /usr/local/bin/yt-dlp "https://github.com/yt-dlp/yt-dlp/releases/download/${LATEST_VERSION}/yt-dlp_musllinux" && \ + chmod +x /usr/local/bin/yt-dlp + +# Download and install Horust (x86_64) +RUN wget -O /tmp/horust.tar.gz "https://github.com/FedericoPonzi/Horust/releases/download/v0.1.7/horust-x86_64-unknown-linux-musl.tar.gz" && \ + cd /tmp && tar -xzf horust.tar.gz && \ + mv horust /usr/local/bin/ && \ + chmod +x /usr/local/bin/horust && \ + rm -f /tmp/horust.tar.gz + +ENV TZ=UTC +# Copy compiled database setup binary (replaces Python dependency) +COPY --from=python-builder /build/dist/pinepods-db-setup /usr/local/bin/ +# Copy built files from the builder stage to the Nginx serving directory +COPY --from=builder /app/dist /var/www/html/ +# Copy translation files for the Rust API to access +COPY ./web/src/translations /var/www/html/static/translations +# Copy Go API binary from the go-builder stage +COPY --from=go-builder /gpodder-api/gpodder-api /usr/local/bin/ +# Copy Rust API binary from the rust-api-builder stage +COPY --from=rust-api-builder /rust-api/target/release/pinepods-api /usr/local/bin/ +# Move to the root directory to execute the startup script +WORKDIR / +# Copy startup scripts +COPY startup/startup.sh /startup.sh +RUN chmod +x /startup.sh +# Copy Pinepods runtime files +RUN mkdir -p /pinepods +RUN mkdir -p /var/log/pinepods/ && mkdir -p /etc/horust/services/ +COPY startup/ /pinepods/startup/ +# Legacy cron scripts removed - background tasks now handled by internal Rust scheduler +COPY clients/ /pinepods/clients/ +COPY database_functions/ /pinepods/database_functions/ +RUN chmod +x /pinepods/startup/startup.sh +ENV APP_ROOT=/pinepods +# Define the build argument +ARG PINEPODS_VERSION +# Write the Pinepods version to the current_version file +RUN echo "${PINEPODS_VERSION}" > /pinepods/current_version +# Configure Nginx +COPY startup/nginx.conf /etc/nginx/nginx.conf + +# Copy script to start gpodder API +COPY ./gpodder-api/start-gpodder.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/start-gpodder.sh + +RUN cp /usr/share/zoneinfo/UTC /etc/localtime && \ + echo "UTC" > /etc/timezone + +# Expose ports +EXPOSE 8080 8000 + +# Start everything using the startup script +ENTRYPOINT ["bash", "/startup.sh"] diff --git a/PinePods-0.8.2/dockerfile-arm b/PinePods-0.8.2/dockerfile-arm new file mode 100644 index 0000000..fdf6154 --- /dev/null +++ b/PinePods-0.8.2/dockerfile-arm @@ -0,0 +1,182 @@ +# Builder stage for compiling the Yew application +FROM rust:alpine AS builder +# Install build dependencies +RUN apk update && apk upgrade && \ + apk add --no-cache musl-dev libffi-dev zlib-dev jpeg-dev +RUN apk update && apk upgrade +# Add the Edge Community repository +RUN echo "@edge http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories +# Update the package index +RUN apk update +# Install the desired package from the edge community repository +RUN apk add trunk@edge +# Install wasm target and build tools +RUN rustup target add wasm32-unknown-unknown && \ + cargo install wasm-bindgen-cli && \ + cargo install horust --locked + +# Test wasm-bindgen installation before full build +RUN echo "Testing wasm-bindgen installation..." && \ + which wasm-bindgen && \ + wasm-bindgen --version && \ + ls -la /usr/local/cargo/bin/ && \ + echo "wasm-bindgen test completed" + +# Test trunk installation +RUN echo "Testing trunk installation..." && \ + which trunk && \ + trunk --version && \ + echo "trunk test completed" + +# Add application files to the builder stage +COPY ./web/Cargo.lock ./web/Cargo.toml ./web/dev-info.md ./web/index.html ./web/tailwind.config.js ./web/Trunk.toml /app/ +COPY ./web/src /app/src +COPY ./web/static /app/static +WORKDIR /app + +# Test that trunk can find wasm-bindgen before full build +RUN echo "Testing if trunk can find wasm-bindgen..." && \ + RUST_LOG=debug trunk build --help && \ + echo "trunk can find wasm-bindgen" +# Auto-detect wasm-bindgen version and replace trunk's glibc binary with our musl one +RUN WASM_BINDGEN_VERSION=$(grep -A1 "name = \"wasm-bindgen\"" /app/Cargo.lock | grep "version = " | cut -d'"' -f2) && \ + echo "Detected wasm-bindgen version: $WASM_BINDGEN_VERSION" && \ + RUSTFLAGS="--cfg=web_sys_unstable_apis --cfg getrandom_backend=\"wasm_js\"" timeout 30 trunk build --features server_build --release || \ + (echo "Build failed as expected, replacing downloaded binary..." && \ + mkdir -p /root/.cache/trunk/wasm-bindgen-$WASM_BINDGEN_VERSION && \ + cp /usr/local/cargo/bin/wasm-bindgen /root/.cache/trunk/wasm-bindgen-$WASM_BINDGEN_VERSION/ && \ + echo "Retrying build with musl binary..." && \ + RUSTFLAGS="--cfg=web_sys_unstable_apis --cfg getrandom_backend=\"wasm_js\"" trunk build --features server_build --release) + +# Go builder stage for the gpodder API +FROM golang:alpine AS go-builder +WORKDIR /gpodder-api + +# Install build dependencies +RUN apk add --no-cache git + +# Copy go module files first for better layer caching +COPY ./gpodder-api/go.mod ./gpodder-api/go.sum ./ +RUN go mod download + +# Copy the rest of the source code +COPY ./gpodder-api/cmd ./cmd +COPY ./gpodder-api/config ./config +COPY ./gpodder-api/internal ./internal + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -o gpodder-api ./cmd/server/ + +# Python builder stage for database setup +FROM python:3.11-alpine AS python-builder +WORKDIR /build + +# Install build dependencies for PyInstaller and MariaDB connector +RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev mariadb-connector-c-dev + +# Copy Python source files +COPY ./database_functions ./database_functions +COPY ./startup/setup_database_new.py ./startup/setup_database_new.py +COPY ./requirements.txt ./requirements.txt + +# Install Python dependencies including PyInstaller +RUN pip install --no-cache-dir -r requirements.txt pyinstaller + +# Build standalone database setup binary +RUN pyinstaller --onefile \ + --name pinepods-db-setup \ + --hidden-import psycopg \ + --hidden-import mysql.connector \ + --hidden-import cryptography \ + --hidden-import cryptography.fernet \ + --hidden-import passlib \ + --hidden-import passlib.hash \ + --hidden-import passlib.hash.argon2 \ + --hidden-import argon2 \ + --hidden-import argon2.exceptions \ + --hidden-import argon2.profiles \ + --hidden-import argon2._password_hasher \ + --add-data "database_functions:database_functions" \ + --console \ + startup/setup_database_new.py + +# Rust API builder stage +FROM rust:alpine AS rust-api-builder +WORKDIR /rust-api + +# Install build dependencies +RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static + +# Copy Rust API files +COPY ./rust-api/Cargo.toml ./rust-api/Cargo.lock ./ +COPY ./rust-api/src ./src + +# Set environment for static linking +ENV OPENSSL_STATIC=1 +ENV OPENSSL_LIB_DIR=/usr/lib +ENV OPENSSL_INCLUDE_DIR=/usr/include + +# Build the Rust API +RUN cargo build --release + +# Final stage for setting up runtime environment +FROM alpine +# Metadata +LABEL maintainer="Collin Pendleton " + +# Install runtime dependencies +RUN apk add --no-cache tzdata nginx openssl bash mariadb-client postgresql-client curl ffmpeg wget jq mariadb-connector-c-dev + + +# Download and install latest yt-dlp binary for ARM64 (musllinux for Alpine) +RUN LATEST_VERSION=$(curl -s https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest | jq -r .tag_name) && \ + wget -O /usr/local/bin/yt-dlp "https://github.com/yt-dlp/yt-dlp/releases/download/${LATEST_VERSION}/yt-dlp_musllinux_aarch64" && \ + chmod +x /usr/local/bin/yt-dlp + +# Copy Horust binary from builder stage +COPY --from=builder /usr/local/cargo/bin/horust /usr/local/bin/ + +ENV TZ=UTC +# Copy compiled database setup binary (replaces Python dependency) +COPY --from=python-builder /build/dist/pinepods-db-setup /usr/local/bin/ +# Copy built files from the builder stage to the Nginx serving directory +COPY --from=builder /app/dist /var/www/html/ +# Copy translation files for the Rust API to access +COPY ./web/src/translations /var/www/html/static/translations +# Copy Go API binary from the go-builder stage +COPY --from=go-builder /gpodder-api/gpodder-api /usr/local/bin/ +# Copy Rust API binary from the rust-api-builder stage +COPY --from=rust-api-builder /rust-api/target/release/pinepods-api /usr/local/bin/ +# Move to the root directory to execute the startup script +WORKDIR / +# Copy startup scripts +COPY startup/startup.sh /startup.sh +RUN chmod +x /startup.sh +# Copy Pinepods runtime files +RUN mkdir -p /pinepods +RUN mkdir -p /var/log/pinepods/ && mkdir -p /etc/horust/services/ +COPY startup/ /pinepods/startup/ +# Legacy cron scripts removed - background tasks now handled by internal Rust scheduler +COPY clients/ /pinepods/clients/ +COPY database_functions/ /pinepods/database_functions/ +RUN chmod +x /pinepods/startup/startup.sh +ENV APP_ROOT=/pinepods +# Define the build argument +ARG PINEPODS_VERSION +# Write the Pinepods version to the current_version file +RUN echo "${PINEPODS_VERSION}" > /pinepods/current_version +# Configure Nginx +COPY startup/nginx.conf /etc/nginx/nginx.conf + +# Copy script to start gpodder API +COPY ./gpodder-api/start-gpodder.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/start-gpodder.sh + +RUN cp /usr/share/zoneinfo/UTC /etc/localtime && \ + echo "UTC" > /etc/timezone + +# Expose ports +EXPOSE 8080 8000 + +# Start everything using the startup script +ENTRYPOINT ["bash", "/startup.sh"] diff --git a/PinePods-0.8.2/docs/CNAME b/PinePods-0.8.2/docs/CNAME new file mode 100644 index 0000000..7ae9f3c --- /dev/null +++ b/PinePods-0.8.2/docs/CNAME @@ -0,0 +1 @@ +helm.pinepods.online diff --git a/PinePods-0.8.2/docs/index.html b/PinePods-0.8.2/docs/index.html new file mode 100644 index 0000000..6165baa --- /dev/null +++ b/PinePods-0.8.2/docs/index.html @@ -0,0 +1,118 @@ + + + + + + Pinepods Helm Chart + + + +
+

Pinepods Helm Chart

+ Pinepods Logo +

+ Welcome to the Pinepods Helm chart repository. Follow the + instructions below to use the Helm chart. +

+ +

Adding the Repository

+
helm repo add pinepods http://helm.pinepods.online/
+helm repo update
+ +

Create the namespace

+
kubectl create namespace pinepods-namespace
+ +

Customizing Values

+

+ Create a my-values.yaml file to override default + values: +

+
replicaCount: 2
+
+            image:
+              repository: pinepods
+              tag: latest
+              pullPolicy: IfNotPresent
+
+            service:
+              type: NodePort
+              port: 8040
+              nodePort: 30007
+
+            persistence:
+              enabled: true
+              accessMode: ReadWriteOnce
+              size: 10Gi
+
+            postgresql:
+              enabled: true
+              auth:
+                username: postgres
+                password: "supersecretpassword"
+                database: pinepods_database
+              primary:
+                persistence:
+                  enabled: true
+                  existingClaim: postgres-pvc
+
+            env:
+              SEARCH_API_URL: "https://search.pinepods.online/api/search"
+              USERNAME: "admin"
+              PASSWORD: "password"
+              FULLNAME: "Admin User"
+              EMAIL: "admin@example.com"
+              DB_TYPE: "postgresql"
+              DB_HOST: "pinepods-postgresql.pinepods-namespace.svc.cluster.local"
+              DB_PORT: "5432"
+              DB_USER: "postgres"
+              DB_NAME: "pinepods_database"
+              DEBUG_MODE: "false"
+ +

Installing the Chart

+
helm install pinepods pinepods/pinepods -f my-values.yaml --namespace pinepods-namespace
+ +

More Information

+

+ For more information, visit the + GitHub repository. +

+
+ + diff --git a/PinePods-0.8.2/docs/index.yaml b/PinePods-0.8.2/docs/index.yaml new file mode 100644 index 0000000..498dea2 --- /dev/null +++ b/PinePods-0.8.2/docs/index.yaml @@ -0,0 +1,264 @@ +apiVersion: v1 +entries: + pinepods: + - apiVersion: v2 + created: "2025-10-30T11:52:41.683959727Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 28e32586ecbdfc1749890007055c61add7b78076cee90980d425113b38b13b9c + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.8.1.tgz + version: 0.8.1 + - apiVersion: v2 + created: "2025-10-30T11:52:41.67456305Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: e08c788d3d225ca3caef37c030f27d7c25cd4ecc557f7fe2f32215ff7f164ba8 + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.8.tgz + version: 0.7.8 + - apiVersion: v2 + created: "2025-10-30T11:52:41.665123097Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 2956c727f65099059680638f2529f472affe25cbd9d6ad90b593dbf7444d6648 + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.7.tgz + version: 0.7.7 + - apiVersion: v2 + created: "2025-10-30T11:52:41.655783861Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 7b954cac8ed6cdff756090d56fc3c98342f6ca944922f2c910b7e20fb338ce5a + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.6.tgz + version: 0.7.6 + - apiVersion: v2 + created: "2025-10-30T11:52:41.646315323Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: a69290e9e9051ac4442f19bea78cca08bdd91a831e6c91af06d48eb6b9a07409 + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.5.tgz + version: 0.7.5 + - apiVersion: v2 + created: "2025-10-30T11:52:41.636858843Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 2f5f97abadf581a025315cb288c139274b4411c86f1e46e52944f89e76c23a9c + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.4.tgz + version: 0.7.4 + - apiVersion: v2 + created: "2025-10-30T11:52:41.626710376Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 47208597c5b52c4d8c9fb659416fe7d679f6fc5a099d9b37caaae0ecfeb33dde + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.3.tgz + version: 0.7.3 + - apiVersion: v2 + created: "2025-10-30T11:52:41.61704642Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: ef86847694c2291c9ebcd4ec40d4f4680c5b675aafc7c82693c3119f5ae4b43b + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.2.tgz + version: 0.7.2 + - apiVersion: v2 + created: "2025-10-30T11:52:41.607497209Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 07ba1a2859213a3542e03aa922d0dfd0f61d146cd3938e85216a740c1ee90bd4 + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.1.tgz + version: 0.7.1 + - apiVersion: v2 + created: "2025-10-30T11:52:41.597450971Z" + dependencies: + - condition: postgresql.enabled + name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + - condition: valkey.enabled + name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.0.1 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 1815802cc08ed83c3eaedd2ac28d6fbd044a817e22c7e8cda4af38bffcde9d82 + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.7.0.tgz + version: 0.7.0 + - apiVersion: v2 + created: "2025-10-30T11:52:41.588288452Z" + dependencies: + - name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: c994e0c57c47448718cc849d103ab91f21a427b7580a0ef0b4c4decc613a04ad + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.6.6.tgz + version: 0.6.6 + - apiVersion: v2 + created: "2025-10-30T11:52:41.584369055Z" + dependencies: + - name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 82f6f1d9569626aab1920bc574b3616c77aadbf4c9b6bc1d5cc5f51cc3bc41f2 + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.6.5.tgz + version: 0.6.5 + - apiVersion: v2 + created: "2025-10-30T11:52:41.579496258Z" + dependencies: + - name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: c2ab21d0cb61a2e809432f762aca608723365d87e8840d02b29c08bc105c9a31 + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.6.4.tgz + version: 0.6.4 + - apiVersion: v2 + created: "2025-10-30T11:52:41.574870899Z" + dependencies: + - name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: 3a0a1b86a6fb22888fead9fc12b84e8845a55ff6736775445028d181318860bf + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.6.3.tgz + version: 0.6.3 + - apiVersion: v2 + created: "2025-10-30T11:52:41.57094979Z" + dependencies: + - name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.14 + description: A Helm chart for deploying Pinepods - A complete podcast management + system and allows you to play, download, and keep track of podcasts you enjoy. + All self hosted and enjoyed on your own server! + digest: f7148434be4b395aaab8bb76fbe1584f7de8fb981b2471506b33ff11be85f706 + name: pinepods + urls: + - https://helm.pinepods.online/pinepods-0.6.2.tgz + version: 0.6.2 +generated: "2025-10-30T11:52:41.565858306Z" diff --git a/PinePods-0.8.2/docs/pinepods-0.6.2.tgz b/PinePods-0.8.2/docs/pinepods-0.6.2.tgz new file mode 100644 index 0000000..e7c0807 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.2.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.6.3.tgz b/PinePods-0.8.2/docs/pinepods-0.6.3.tgz new file mode 100644 index 0000000..3e27666 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.3.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.6.4.tgz b/PinePods-0.8.2/docs/pinepods-0.6.4.tgz new file mode 100644 index 0000000..62cd8a1 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.4.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.6.5.tgz b/PinePods-0.8.2/docs/pinepods-0.6.5.tgz new file mode 100644 index 0000000..2327d12 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.5.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.6.6.tgz b/PinePods-0.8.2/docs/pinepods-0.6.6.tgz new file mode 100644 index 0000000..745bddd Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.6.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.0.tgz b/PinePods-0.8.2/docs/pinepods-0.7.0.tgz new file mode 100644 index 0000000..6ab2006 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.0.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.1.tgz b/PinePods-0.8.2/docs/pinepods-0.7.1.tgz new file mode 100644 index 0000000..ae216d1 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.1.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.2.tgz b/PinePods-0.8.2/docs/pinepods-0.7.2.tgz new file mode 100644 index 0000000..b509fd1 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.2.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.3.tgz b/PinePods-0.8.2/docs/pinepods-0.7.3.tgz new file mode 100644 index 0000000..14eae02 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.3.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.4.tgz b/PinePods-0.8.2/docs/pinepods-0.7.4.tgz new file mode 100644 index 0000000..56d6b2a Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.4.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.5.tgz b/PinePods-0.8.2/docs/pinepods-0.7.5.tgz new file mode 100644 index 0000000..830c166 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.5.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.6.tgz b/PinePods-0.8.2/docs/pinepods-0.7.6.tgz new file mode 100644 index 0000000..4649360 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.6.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.7.tgz b/PinePods-0.8.2/docs/pinepods-0.7.7.tgz new file mode 100644 index 0000000..c6e4b25 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.7.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.7.8.tgz b/PinePods-0.8.2/docs/pinepods-0.7.8.tgz new file mode 100644 index 0000000..7b40bcd Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.8.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods-0.8.1.tgz b/PinePods-0.8.2/docs/pinepods-0.8.1.tgz new file mode 100644 index 0000000..81b0d6b Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.8.1.tgz differ diff --git a/PinePods-0.8.2/docs/pinepods.png b/PinePods-0.8.2/docs/pinepods.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods.png differ diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/full_description.txt b/PinePods-0.8.2/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..80dc00a --- /dev/null +++ b/PinePods-0.8.2/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,18 @@ +PinePods is a complete podcast management solution that allows you to host your own podcast server and enjoy a beautiful mobile experience. + +Features: + +• Self-hosted podcast server synchronization +• Beautiful, intuitive mobile interface +• Download episodes for offline listening +• Chapter support with navigation +• Playlist management +• User statistics and listening history +• Multi-device synchronization +• Search and discovery +• Background audio playback +• Sleep timer and playback speed controls + +PinePods gives you complete control over your podcast experience while providing the convenience of modern podcast apps. Perfect for users who want privacy, control, and a great listening experience. + +Note: This app requires a PinePods server to be set up. Visit the PinePods GitHub repository for server installation instructions. diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/featureGraphic.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000..e7aab93 Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/icon.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..4fe781c Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..a5c21ee Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..668c407 Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..6182ba2 Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..1d45d00 Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..4c00442 Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/short_description.txt b/PinePods-0.8.2/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..503b6d1 --- /dev/null +++ b/PinePods-0.8.2/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +A beautiful, self-hosted podcast app with powerful server synchronization \ No newline at end of file diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/title.txt b/PinePods-0.8.2/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..50437f6 --- /dev/null +++ b/PinePods-0.8.2/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +PinePods \ No newline at end of file diff --git a/PinePods-0.8.2/gpodder-api/cmd/server/main.go b/PinePods-0.8.2/gpodder-api/cmd/server/main.go new file mode 100644 index 0000000..62fb1fb --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/cmd/server/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "pinepods/gpodder-api/config" + "pinepods/gpodder-api/internal/api" + "pinepods/gpodder-api/internal/db" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + // Load environment variables from .env file if it exists + _ = godotenv.Load() + + // Debug log environment variables + fmt.Printf("Environment variables:\n") + fmt.Printf("DB_TYPE: %s\n", os.Getenv("DB_TYPE")) + fmt.Printf("DB_HOST: %s\n", os.Getenv("DB_HOST")) + fmt.Printf("DB_PORT: %s\n", os.Getenv("DB_PORT")) + fmt.Printf("DB_USER: %s\n", os.Getenv("DB_USER")) + fmt.Printf("DB_NAME: %s\n", os.Getenv("DB_NAME")) + fmt.Printf("DB_PASSWORD: [hidden]\n") + + // Initialize configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + fmt.Printf("Using database type: %s\n", cfg.Database.Type) + + // Initialize database - use Database type instead of PostgresDB + var database *db.Database + database, err = db.NewDatabase(cfg.Database) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer database.Close() + + // Set Gin mode + if cfg.Environment == "production" { + gin.SetMode(gin.ReleaseMode) + } + + // Initialize router + router := gin.Default() + + // Setup middleware + router.Use(gin.Recovery()) + router.Use(gin.Logger()) + + // Add CORS middleware + router.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + + // Register API routes + apiRoutes := router.Group("/api/2") + api.RegisterRoutes(apiRoutes, database) + + // Register simple API routes (v1) + simpleAPIRoutes := router.Group("") + api.RegisterSimpleRoutes(simpleAPIRoutes, database) + + // Start server + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Server.Port), + Handler: router, + } + + // Graceful shutdown + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start server: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + // Set timeout for shutdown + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exiting") +} diff --git a/PinePods-0.8.2/gpodder-api/config/config.go b/PinePods-0.8.2/gpodder-api/config/config.go new file mode 100644 index 0000000..bf159e8 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/config/config.go @@ -0,0 +1,93 @@ +package config + +import ( + "os" + "strconv" +) + +// Config represents the application configuration +type Config struct { + Server ServerConfig + Database DatabaseConfig + Environment string +} + +// ServerConfig holds server-related configuration +type ServerConfig struct { + Port int +} + +// DatabaseConfig holds database-related configuration +type DatabaseConfig struct { + Host string + Port int + User string + Password string + DBName string + SSLMode string + Type string // "postgresql" or "mysql" +} + +// Load loads configuration from environment variables +func Load() (*Config, error) { + // Server configuration + serverPort, err := strconv.Atoi(getEnv("SERVER_PORT", "8080")) + if err != nil { + serverPort = 8080 + } + + // Database configuration - use DB_* environment variables + dbPort, err := strconv.Atoi(getEnv("DB_PORT", "5432")) + if err != nil { + dbPort = 5432 + } + + // Get database type - defaults to postgresql if not specified + dbType := getEnv("DB_TYPE", "postgresql") + + // Set default port based on database type if not explicitly provided + if os.Getenv("DB_PORT") == "" { + if dbType == "mysql" { + dbPort = 3306 + } else { + dbPort = 5432 + } + } + + // Set default user based on database type if not explicitly provided + dbUser := getEnv("DB_USER", "") + if dbUser == "" { + if dbType == "mysql" { + // Use root user for MySQL by default + dbUser = "root" + } else { + // Use postgres user for PostgreSQL by default + dbUser = "postgres" + } + } + + return &Config{ + Server: ServerConfig{ + Port: serverPort, + }, + Database: DatabaseConfig{ + Host: getEnv("DB_HOST", "localhost"), + Port: dbPort, + User: dbUser, + Password: getEnv("DB_PASSWORD", "password"), + DBName: getEnv("DB_NAME", "pinepods_database"), + SSLMode: getEnv("DB_SSL_MODE", "disable"), + Type: dbType, + }, + Environment: getEnv("ENVIRONMENT", "development"), + }, nil +} + +// getEnv gets an environment variable or returns a default value +func getEnv(key, defaultValue string) string { + value, exists := os.LookupEnv(key) + if !exists { + return defaultValue + } + return value +} diff --git a/PinePods-0.8.2/gpodder-api/go.mod b/PinePods-0.8.2/gpodder-api/go.mod new file mode 100644 index 0000000..d931701 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/go.mod @@ -0,0 +1,46 @@ +module pinepods/gpodder-api + +go 1.24 + +require ( + github.com/alexedwards/argon2id v1.0.0 + github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 + github.com/gin-gonic/gin v1.10.0 + github.com/go-sql-driver/mysql v1.9.2 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + github.com/mmcdole/gofeed v1.3.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mmcdole/goxpp v1.1.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.16.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/PinePods-0.8.2/gpodder-api/go.sum b/PinePods-0.8.2/gpodder-api/go.sum new file mode 100644 index 0000000..0a39cce --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/go.sum @@ -0,0 +1,170 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= +github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 h1:JwYtKJ/DVEoIA5dH45OEU7uoryZY/gjd/BQiwwAOImM= +github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611/go.mod h1:zHMNeYgqrTpKyjawjitDg0Osd1P/FmeA0SZLYK3RfLQ= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= +github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= +golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/PinePods-0.8.2/gpodder-api/internal/api/auth.go b/PinePods-0.8.2/gpodder-api/internal/api/auth.go new file mode 100644 index 0000000..93bffa1 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/auth.go @@ -0,0 +1,850 @@ +// Package api provides the API endpoints for the gpodder API +package api + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "log" + "net/http" + "strings" + "time" + + "pinepods/gpodder-api/internal/db" + + "github.com/alexedwards/argon2id" + "github.com/fernet/fernet-go" + "github.com/gin-gonic/gin" +) + +// Define the parameters we use for Argon2id +type argon2Params struct { + memory uint32 + iterations uint32 + parallelism uint8 + saltLength uint32 + keyLength uint32 +} + +// AuthMiddleware creates a middleware for authentication +func AuthMiddleware(db *db.PostgresDB) gin.HandlerFunc { + return func(c *gin.Context) { + + // Get the username from the URL parameters + username := c.Param("username") + if username == "" { + log.Printf("[ERROR] AuthMiddleware: Username parameter is missing in path") + c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + c.Abort() + return + } + + // Check if this is an internal API call via X-GPodder-Token + gpodderTokenHeader := c.GetHeader("X-GPodder-Token") + if gpodderTokenHeader != "" { + + // Get user data + var userID int + var gpodderToken sql.NullString + var podSyncType string + + err := db.QueryRow(` + SELECT UserID, GpodderToken, Pod_Sync_Type FROM "Users" + WHERE LOWER(Username) = LOWER($1) + `, username).Scan(&userID, &gpodderToken, &podSyncType) + + if err != nil { + log.Printf("[ERROR] AuthMiddleware: Database error: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or token"}) + c.Abort() + return + } + + // Check if gpodder sync is enabled + if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" { + log.Printf("[ERROR] AuthMiddleware: Gpodder API not enabled for user: %s (sync type: %s)", + username, podSyncType) + c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"}) + c.Abort() + return + } + + // For internal calls with X-GPodder-Token header, validate token directly + if gpodderToken.Valid && gpodderToken.String == gpodderTokenHeader { + c.Set("userID", userID) + c.Set("username", username) + c.Next() + return + } + + // If token doesn't match, authentication failed + log.Printf("[ERROR] AuthMiddleware: Invalid X-GPodder-Token for user: %s", username) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + // If no token header found, proceed with standard authentication + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + log.Printf("[ERROR] AuthMiddleware: Authorization header is missing") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) + c.Abort() + return + } + + // Extract credentials + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Basic" { + log.Printf("[ERROR] AuthMiddleware: Invalid Authorization header format") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"}) + c.Abort() + return + } + + // Decode credentials + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + log.Printf("[ERROR] AuthMiddleware: Failed to decode base64 credentials: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"}) + c.Abort() + return + } + + // Extract username and password + credentials := strings.SplitN(string(decoded), ":", 2) + if len(credentials) != 2 { + log.Printf("[ERROR] AuthMiddleware: Invalid credentials format") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"}) + c.Abort() + return + } + + authUsername := credentials[0] + password := credentials[1] + + // Check username match + if strings.ToLower(username) != strings.ToLower(authUsername) { + log.Printf("[ERROR] AuthMiddleware: Username mismatch - URL: %s, Auth: %s", + strings.ToLower(username), strings.ToLower(authUsername)) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"}) + c.Abort() + return + } + + // Query user data + var userID int + var hashedPassword string + var podSyncType string + var gpodderToken sql.NullString + + err = db.QueryRow(` + SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM "Users" + WHERE LOWER(Username) = LOWER($1) + `, username).Scan(&userID, &hashedPassword, &podSyncType, &gpodderToken) + + if err != nil { + if err == sql.ErrNoRows { + log.Printf("[ERROR] AuthMiddleware: User not found: %s", username) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + } else { + log.Printf("[ERROR] AuthMiddleware: Database error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + } + c.Abort() + return + } + + // Check if gpodder sync is enabled + if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" { + log.Printf("[ERROR] AuthMiddleware: Gpodder API not enabled for user: %s (sync type: %s)", + username, podSyncType) + c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"}) + c.Abort() + return + } + + // Flag to track authentication success + authenticated := false + + // Check if this is a gpodder token authentication + // Check if this is a gpodder token authentication + if gpodderToken.Valid && (gpodderToken.String == password || gpodderToken.String == gpodderTokenHeader) { + authenticated = true + } + + // If token auth didn't succeed, try password authentication + if !authenticated && verifyPassword(password, hashedPassword) { + authenticated = true + } + + // If authentication was successful, set context and continue + if authenticated { + c.Set("userID", userID) + c.Set("username", username) + c.Next() + return + } + + // Authentication failed + log.Printf("[ERROR] AuthMiddleware: Invalid credentials for user: %s", username) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + c.Abort() + } +} + +// Helper function to decrypt token +func decryptToken(encryptionKey []byte, encryptedToken string) (string, error) { + // Ensure the encryptionKey is correctly formatted for fernet + // Fernet requires a 32-byte key encoded in base64 + keyStr := base64.StdEncoding.EncodeToString(encryptionKey) + + // Parse the key + key, err := fernet.DecodeKey(keyStr) + if err != nil { + return "", fmt.Errorf("failed to decode key: %w", err) + } + + // Decrypt the token + token := []byte(encryptedToken) + msg := fernet.VerifyAndDecrypt(token, 0, []*fernet.Key{key}) + if msg == nil { + return "", fmt.Errorf("failed to decrypt token or token invalid") + } + + return string(msg), nil +} + +// generateSessionToken generates a random token for sessions +func generateSessionToken() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// createSession creates a new session in the database +func createSession(db *db.Database, userID int, userAgent, clientIP string) (string, time.Time, error) { + // Generate a random session token + token, err := generateSessionToken() + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to generate session token: %w", err) + } + + // Set expiration time (30 days from now) + expires := time.Now().Add(30 * 24 * time.Hour) + + // Insert session into database + _, err = db.Exec(` + INSERT INTO "GpodderSessions" (UserID, SessionToken, ExpiresAt, UserAgent, ClientIP) + VALUES ($1, $2, $3, $4, $5) + `, userID, token, expires, userAgent, clientIP) + + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to create session: %w", err) + } + + return token, expires, nil +} + +// validateSession validates a session token +func validateSession(db *db.Database, token string) (int, bool, error) { + var userID int + var expires time.Time + var query string + + // Format query according to database type + if db.IsPostgreSQLDB() { + query = `SELECT UserID, ExpiresAt FROM "GpodderSessions" WHERE SessionToken = $1` + } else { + query = `SELECT UserID, ExpiresAt FROM GpodderSessions WHERE SessionToken = ?` + } + + err := db.QueryRow(query, token).Scan(&userID, &expires) + + if err != nil { + if err == sql.ErrNoRows { + return 0, false, nil // Session not found + } + return 0, false, fmt.Errorf("error validating session: %w", err) + } + + // Check if session has expired + if time.Now().After(expires) { + // Delete expired session + if db.IsPostgreSQLDB() { + query = `DELETE FROM "GpodderSessions" WHERE SessionToken = $1` + } else { + query = `DELETE FROM GpodderSessions WHERE SessionToken = ?` + } + + _, err = db.Exec(query, token) + if err != nil { + log.Printf("Failed to delete expired session: %v", err) + } + return 0, false, nil + } + + // Update last active time + if db.IsPostgreSQLDB() { + query = `UPDATE "GpodderSessions" SET LastActive = CURRENT_TIMESTAMP WHERE SessionToken = $1` + } else { + query = `UPDATE GpodderSessions SET LastActive = CURRENT_TIMESTAMP WHERE SessionToken = ?` + } + + _, err = db.Exec(query, token) + + if err != nil { + log.Printf("Failed to update session last active time: %v", err) + } + + return userID, true, nil +} + +// deleteSession removes a session from the database +func deleteSession(db *db.Database, token string) error { + _, err := db.Exec(`DELETE FROM "GpodderSessions" WHERE SessionToken = $1`, token) + if err != nil { + return fmt.Errorf("failed to delete session: %w", err) + } + return nil +} + +// deleteUserSessions removes all sessions for a user +func deleteUserSessions(db *db.PostgresDB, userID int) error { + _, err := db.Exec(`DELETE FROM "GpodderSessions" WHERE UserID = $1`, userID) + if err != nil { + return fmt.Errorf("failed to delete user sessions: %w", err) + } + return nil +} + +// handleLogin enhanced with session management +func handleLogin(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Use the AuthMiddleware to authenticate the user + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + return + } + + // Get the Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) + return + } + + // Check if the Authorization header is in the correct format + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Basic" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"}) + return + } + + // Decode the base64-encoded credentials + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"}) + return + } + + // Extract username and password + credentials := strings.SplitN(string(decoded), ":", 2) + if len(credentials) != 2 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"}) + return + } + + authUsername := credentials[0] + password := credentials[1] + + // Verify that the username in the URL matches the one in the Authorization header + if strings.ToLower(username) != strings.ToLower(authUsername) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"}) + return + } + + // Check if the user exists and the password is correct + var userID int + var hashedPassword string + var podSyncType string + + // Make sure to use case-insensitive username lookup + err = database.QueryRow(` + SELECT UserID, Hashed_PW, Pod_Sync_Type FROM "Users" WHERE LOWER(Username) = LOWER($1) + `, username).Scan(&userID, &hashedPassword, &podSyncType) + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + } else { + log.Printf("Database error during login: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + } + return + } + + // Check if gpodder sync is enabled for this user + if podSyncType != "gpodder" && podSyncType != "both" { + c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"}) + return + } + + // Verify password using Pinepods' Argon2 password method + if !verifyPassword(password, hashedPassword) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + return + } + + // Create a new session + userAgent := c.Request.UserAgent() + clientIP := c.ClientIP() + sessionToken, expiresAt, err := createSession(database, userID, userAgent, clientIP) + + if err != nil { + log.Printf("Failed to create session: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) + return + } + + log.Printf("[DEBUG] handleLogin: Login successful for user: %s, created session token (first 8 chars): %s...", + username, sessionToken[:8]) + + // Set session cookie + c.SetCookie( + "sessionid", // name + sessionToken, // value + int(30*24*time.Hour.Seconds()), // max age in seconds (30 days) + "/", // path + "", // domain (empty = current domain) + c.Request.TLS != nil, // secure (HTTPS only) + true, // httpOnly (not accessible via JavaScript) + ) + + log.Printf("[DEBUG] handleLogin: Sending response with session expiry: %s", + expiresAt.Format(time.RFC3339)) + // Return success with info + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "userid": userID, + "username": username, + "session_expires": expiresAt.Format(time.RFC3339), + }) + } +} + +// handleLogout enhanced with session management +func handleLogout(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get username from URL + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + return + } + + // Get the session cookie + sessionToken, err := c.Cookie("sessionid") + if err != nil || sessionToken == "" { + // No session cookie, just return success (idempotent operation) + c.JSON(http.StatusOK, gin.H{ + "status": "logged out", + }) + return + } + + // Delete the session + err = deleteSession(database, sessionToken) + if err != nil { + log.Printf("Error deleting session: %v", err) + // Continue anyway - we still want to invalidate the cookie + } + + // Clear the session cookie + c.SetCookie( + "sessionid", // name + "", // value (empty = delete) + -1, // max age (negative = delete) + "/", // path + "", // domain + c.Request.TLS != nil, // secure + true, // httpOnly + ) + + c.JSON(http.StatusOK, gin.H{ + "status": "logged out", + }) + } +} + +// SessionMiddleware checks if a user is logged in via session +func SessionMiddleware(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + log.Printf("[DEBUG] SessionMiddleware processing request: %s %s", + c.Request.Method, c.Request.URL.Path) + + // First, try to get user from Authorization header for direct API access + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + log.Printf("[DEBUG] SessionMiddleware: Authorization header found, passing to next middleware") + c.Next() + return + } + + // No Authorization header, check for session cookie + sessionToken, err := c.Cookie("sessionid") + if err != nil || sessionToken == "" { + log.Printf("[ERROR] SessionMiddleware: No session cookie found: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"}) + c.Abort() + return + } + + log.Printf("[DEBUG] SessionMiddleware: Found session cookie, validating") + + // Validate the session + userID, valid, err := validateSession(database, sessionToken) + if err != nil { + log.Printf("[ERROR] SessionMiddleware: Error validating session: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Session error"}) + c.Abort() + return + } + + if !valid { + log.Printf("[ERROR] SessionMiddleware: Invalid or expired session") + // Clear the invalid cookie + c.SetCookie("sessionid", "", -1, "/", "", c.Request.TLS != nil, true) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"}) + c.Abort() + return + } + + log.Printf("[DEBUG] SessionMiddleware: Session valid for userID: %d", userID) + + // Get the username for the user ID + var username string + err = database.QueryRow(`SELECT Username FROM "Users" WHERE UserID = $1`, userID).Scan(&username) + if err != nil { + log.Printf("[ERROR] SessionMiddleware: Error getting username for userID %d: %v", + userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "User data error"}) + c.Abort() + return + } + + // Check if gpodder sync is enabled for this user + var podSyncType string + err = database.QueryRow(`SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1`, userID).Scan(&podSyncType) + if err != nil { + log.Printf("[ERROR] SessionMiddleware: Error checking sync type for userID %d: %v", + userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "User data error"}) + c.Abort() + return + } + + if podSyncType != "gpodder" && podSyncType != "both" { + log.Printf("[ERROR] SessionMiddleware: Gpodder API not enabled for user: %s (sync type: %s)", + username, podSyncType) + c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"}) + c.Abort() + return + } + + // Set the user information in the context + c.Set("userID", userID) + c.Set("username", username) + + // Check if the path username matches the session username + pathUsername := c.Param("username") + if pathUsername != "" && strings.ToLower(pathUsername) != strings.ToLower(username) { + log.Printf("[ERROR] SessionMiddleware: Username mismatch - Path: %s, Session: %s", + pathUsername, username) + c.JSON(http.StatusForbidden, gin.H{"error": "Username mismatch"}) + c.Abort() + return + } + + log.Printf("[DEBUG] SessionMiddleware: Session authentication successful for user: %s", username) + c.Next() + } +} + +// AuthenticationMiddleware with GPodder token handling +func AuthenticationMiddleware(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + log.Printf("[DEBUG] AuthenticationMiddleware processing request: %s %s", + c.Request.Method, c.Request.URL.Path) + + // Handle GPodder API standard .json suffix patterns + if strings.HasSuffix(c.Request.URL.Path, ".json") { + parts := strings.Split(c.Request.URL.Path, "/") + var username string + + // Handle /episodes/username.json pattern + if strings.Contains(c.Request.URL.Path, "/episodes/") && len(parts) >= 3 { + usernameWithExt := parts[len(parts)-1] + username = strings.TrimSuffix(usernameWithExt, ".json") + log.Printf("[DEBUG] AuthenticationMiddleware: Extracted username '%s' from episode actions URL", username) + } + + // Handle /devices/username.json pattern + if strings.Contains(c.Request.URL.Path, "/devices/") { + for i, part := range parts { + if part == "devices" && i+1 < len(parts) { + usernameWithExt := parts[i+1] + username = strings.TrimSuffix(usernameWithExt, ".json") + log.Printf("[DEBUG] AuthenticationMiddleware: Extracted username '%s' from devices URL", username) + break + } + } + } + + // Set username parameter if extracted + if username != "" { + c.Params = append(c.Params, gin.Param{Key: "username", Value: username}) + } + } + + // First try session auth + sessionToken, err := c.Cookie("sessionid") + if err == nil && sessionToken != "" { + log.Printf("[DEBUG] AuthenticationMiddleware: Found session cookie, validating") + + userID, valid, err := validateSession(database, sessionToken) + if err == nil && valid { + log.Printf("[DEBUG] AuthenticationMiddleware: Session valid for userID: %d", userID) + + var username string + var query string + + // Format query according to database type + if database.IsPostgreSQLDB() { + query = `SELECT Username FROM "Users" WHERE UserID = $1` + } else { + query = `SELECT Username FROM Users WHERE UserID = ?` + } + + err = database.QueryRow(query, userID).Scan(&username) + if err == nil { + var podSyncType string + + // Format query according to database type + if database.IsPostgreSQLDB() { + query = `SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1` + } else { + query = `SELECT Pod_Sync_Type FROM Users WHERE UserID = ?` + } + + err = database.QueryRow(query, userID).Scan(&podSyncType) + + if err == nil && (podSyncType == "gpodder" || podSyncType == "both") { + // Check if the path username matches the session username + pathUsername := c.Param("username") + if pathUsername == "" || strings.ToLower(pathUsername) == strings.ToLower(username) { + log.Printf("[DEBUG] AuthenticationMiddleware: Session auth successful for user: %s", + username) + c.Set("userID", userID) + c.Set("username", username) + c.Next() + return + } else { + log.Printf("[ERROR] AuthenticationMiddleware: Session username mismatch - Path: %s, Session: %s", + pathUsername, username) + } + } else { + log.Printf("[ERROR] AuthenticationMiddleware: Gpodder not enabled for user: %s", username) + } + } else { + log.Printf("[ERROR] AuthenticationMiddleware: Could not get username for userID %d: %v", + userID, err) + } + } else { + log.Printf("[ERROR] AuthenticationMiddleware: Invalid session: %v", err) + } + } else { + log.Printf("[DEBUG] AuthenticationMiddleware: No session cookie, falling back to basic auth") + } + + // Try basic auth if session auth failed + log.Printf("[DEBUG] AuthenticationMiddleware: Attempting basic auth") + + username := c.Param("username") + if username == "" { + log.Printf("[ERROR] AuthenticationMiddleware: Username parameter is missing in path") + c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + return + } + + // Check if this is an internal API call via X-GPodder-Token + gpodderTokenHeader := c.GetHeader("X-GPodder-Token") + if gpodderTokenHeader != "" { + log.Printf("[DEBUG] AuthenticationMiddleware: Found X-GPodder-Token header") + + // Get user data + var userID int + var gpodderToken sql.NullString + var podSyncType string + var query string + + // Format query according to database type + if database.IsPostgreSQLDB() { + query = `SELECT UserID, GpodderToken, Pod_Sync_Type FROM "Users" + WHERE LOWER(Username) = LOWER($1)` + } else { + query = `SELECT UserID, GpodderToken, Pod_Sync_Type FROM Users + WHERE LOWER(Username) = LOWER(?)` + } + + err := database.QueryRow(query, username).Scan(&userID, &gpodderToken, &podSyncType) + + if err != nil { + log.Printf("[ERROR] AuthenticationMiddleware: Database error: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or token"}) + return + } + + // Check if gpodder sync is enabled + if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" { + log.Printf("[ERROR] AuthenticationMiddleware: Gpodder API not enabled for user: %s (sync type: %s)", + username, podSyncType) + c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"}) + return + } + + // For internal calls with X-GPodder-Token header, validate token directly + if gpodderToken.Valid && gpodderToken.String == gpodderTokenHeader { + log.Printf("[DEBUG] AuthenticationMiddleware: X-GPodder-Token validated for user: %s", username) + c.Set("userID", userID) + c.Set("username", username) + c.Next() + return + } + + // If token doesn't match, authentication failed + log.Printf("[ERROR] AuthenticationMiddleware: Invalid X-GPodder-Token for user: %s", username) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + return + } + + // Standard basic auth handling + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + log.Printf("[ERROR] AuthenticationMiddleware: No Authorization header found") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"}) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Basic" { + log.Printf("[ERROR] AuthenticationMiddleware: Invalid Authorization header format") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"}) + return + } + + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + log.Printf("[ERROR] AuthenticationMiddleware: Failed to decode base64 credentials: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"}) + return + } + + credentials := strings.SplitN(string(decoded), ":", 2) + if len(credentials) != 2 { + log.Printf("[ERROR] AuthenticationMiddleware: Invalid credentials format") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"}) + return + } + + authUsername := credentials[0] + password := credentials[1] + + if strings.ToLower(username) != strings.ToLower(authUsername) { + log.Printf("[ERROR] AuthenticationMiddleware: Username mismatch - URL: %s, Auth: %s", + strings.ToLower(username), strings.ToLower(authUsername)) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"}) + return + } + + var userID int + var hashedPassword string + var podSyncType string + var gpodderToken sql.NullString + var query string + + // Format query according to database type + if database.IsPostgreSQLDB() { + query = `SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM "Users" + WHERE LOWER(Username) = LOWER($1)` + } else { + query = `SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM Users + WHERE LOWER(Username) = LOWER(?)` + } + + err = database.QueryRow(query, username).Scan(&userID, &hashedPassword, &podSyncType, &gpodderToken) + + if err != nil { + if err == sql.ErrNoRows { + log.Printf("[ERROR] AuthenticationMiddleware: User not found: %s", username) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + } else { + log.Printf("[ERROR] AuthenticationMiddleware: Database error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + } + return + } + + if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" { + log.Printf("[ERROR] AuthenticationMiddleware: Gpodder API not enabled for user: %s (sync type: %s)", + username, podSyncType) + c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"}) + return + } + + // Flag to track authentication success + authenticated := false + + // Check if this is a gpodder token authentication + if gpodderToken.Valid && gpodderToken.String == password { + log.Printf("[DEBUG] AuthenticationMiddleware: User authenticated with gpodder token: %s", username) + authenticated = true + } + + // If token auth didn't succeed, try password authentication + if !authenticated && verifyPassword(password, hashedPassword) { + log.Printf("[DEBUG] AuthenticationMiddleware: User authenticated with password: %s", username) + authenticated = true + } + + // If authentication was successful, set context and continue + if authenticated { + c.Set("userID", userID) + c.Set("username", username) + c.Next() + return + } + + // Authentication failed + log.Printf("[ERROR] AuthenticationMiddleware: Invalid credentials for user: %s", username) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + } +} + +// verifyPassword verifies a password against a hash using Argon2 +// This implementation matches the Pinepods authentication mechanism using alexedwards/argon2id +func verifyPassword(password, hashedPassword string) bool { + // Use the alexedwards/argon2id package to compare password and hash + match, err := argon2id.ComparePasswordAndHash(password, hashedPassword) + if err != nil { + // Log the error in a production environment + return false + } + + return match +} diff --git a/PinePods-0.8.2/gpodder-api/internal/api/device.go b/PinePods-0.8.2/gpodder-api/internal/api/device.go new file mode 100644 index 0000000..8850248 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/device.go @@ -0,0 +1,1039 @@ +package api + +import ( + "database/sql" + "fmt" + "log" + "net/http" + "strings" + "time" + + "pinepods/gpodder-api/internal/db" + "pinepods/gpodder-api/internal/models" + + "github.com/gin-gonic/gin" +) + +// ValidDeviceTypes contains the allowed device types according to the gpodder API +var ValidDeviceTypes = map[string]bool{ + "desktop": true, + "laptop": true, + "mobile": true, + "server": true, + "other": true, +} + +func listDevices(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + log.Printf("[DEBUG] listDevices handling request: %s %s", c.Request.Method, c.Request.URL.Path) + + // Log headers for debugging + headers := c.Request.Header + for name, values := range headers { + for _, value := range values { + log.Printf("[DEBUG] Header: %s: %s", name, value) + } + } + + // Log cookies + cookies := c.Request.Cookies() + for _, cookie := range cookies { + log.Printf("[DEBUG] Cookie: %s: %s", cookie.Name, cookie.Value) + } + + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + log.Printf("[ERROR] listDevices: userID not found in context") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Fix for the listDevices function + // Replace the query code in listDevices function with this: + + log.Printf("[DEBUG] listDevices: Querying devices for userID: %v", userID) + + var query string + var rows *sql.Rows + var err error + + // Format query according to database type + if database.IsPostgreSQLDB() { + query = ` + SELECT d.DeviceID, d.DeviceName, d.DeviceType, + COALESCE(d.DeviceCaption, '') as DeviceCaption, d.IsActive, + COALESCE( + (SELECT COUNT(p.PodcastID) + FROM "Podcasts" p + WHERE p.UserID = $1), + 0 + ) as subscription_count + FROM "GpodderDevices" d + WHERE d.UserID = $1 AND d.IsActive = true + ` + rows, err = database.Query(query, userID) + } else { + query = ` + SELECT d.DeviceID, d.DeviceName, d.DeviceType, + COALESCE(d.DeviceCaption, '') as DeviceCaption, d.IsActive, + COALESCE( + (SELECT COUNT(p.PodcastID) + FROM Podcasts p + WHERE p.UserID = ?), + 0 + ) as subscription_count + FROM GpodderDevices d + WHERE d.UserID = ? AND d.IsActive = true + ` + rows, err = database.Query(query, userID, userID) // Note: passing userID twice for MySQL + } + + if err != nil { + log.Printf("[ERROR] listDevices: Error querying devices: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get devices"}) + return + } + defer rows.Close() + + var devices []models.GpodderDevice + for rows.Next() { + var device models.GpodderDevice + var isActive bool + + if err := rows.Scan( + &device.DeviceID, + &device.DeviceName, + &device.DeviceType, + &device.DeviceCaption, + &isActive, + &device.Subscriptions, + ); err != nil { + log.Printf("[ERROR] listDevices: Error scanning device row: %v", err) + continue // Continue instead of returning to try to get at least some devices + } + + // Only add active devices + if isActive { + log.Printf("[DEBUG] listDevices: Found active device: %s (ID: %d)", + device.DeviceName, device.DeviceID) + devices = append(devices, device) + } + } + + if err := rows.Err(); err != nil { + log.Printf("[ERROR] listDevices: Error iterating device rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get devices"}) + return + } + + // If no devices found, return empty array rather than error + if len(devices) == 0 { + log.Printf("[DEBUG] listDevices: No devices found for userID: %v", userID) + c.JSON(http.StatusOK, []models.GpodderDevice{}) + return + } + + log.Printf("[DEBUG] listDevices: Returning %d devices for userID: %v", len(devices), userID) + + // Return the list of devices + c.JSON(http.StatusOK, devices) + } +} + +// updateDeviceData handles POST /api/2/devices/{username}/{deviceid}.json +func updateDeviceData(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + log.Printf("[DEBUG] updateDeviceData handling request: %s %s", c.Request.Method, c.Request.URL.Path) + + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + log.Printf("[ERROR] updateDeviceData: userID not found in context") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get device name from URL with fix for .json suffix + deviceName := c.Param("deviceid") + // Also try alternative parameter name if needed + if deviceName == "" { + deviceName = c.Param("deviceid.json") + } + + // Remove .json suffix if present + if strings.HasSuffix(deviceName, ".json") { + deviceName = strings.TrimSuffix(deviceName, ".json") + } + + log.Printf("[DEBUG] updateDeviceData: Using device name: '%s'", deviceName) + + if deviceName == "" { + log.Printf("[ERROR] updateDeviceData: Device ID is required") + c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) + return + } + + // Parse request body + var req struct { + Caption string `json:"caption"` + Type string `json:"type"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[ERROR] updateDeviceData: Error parsing request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'caption' and 'type'"}) + return + } + + log.Printf("[DEBUG] updateDeviceData: Device info - Name: %s, Caption: %s, Type: %s", + deviceName, req.Caption, req.Type) + + // Validate device type if provided + if req.Type != "" && !ValidDeviceTypes[req.Type] { + log.Printf("[ERROR] updateDeviceData: Invalid device type: %s", req.Type) + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Invalid device type: %s. Valid types are: desktop, laptop, mobile, server, other", req.Type), + }) + return + } + + // If type is empty, set to default 'other' + if req.Type == "" { + req.Type = "other" + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("[ERROR] updateDeviceData: Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Check if device exists + var deviceID int + var query string + + log.Printf("[DEBUG] updateDeviceData: Checking if device exists - UserID: %v, DeviceName: %s", userID, deviceName) + + if database.IsPostgreSQLDB() { + query = `SELECT DeviceID FROM "GpodderDevices" WHERE UserID = $1 AND DeviceName = $2` + } else { + query = `SELECT DeviceID FROM GpodderDevices WHERE UserID = ? AND DeviceName = ?` + } + + err = tx.QueryRow(query, userID, deviceName).Scan(&deviceID) + + if err != nil { + if err == sql.ErrNoRows { + // Device doesn't exist, create it + log.Printf("[DEBUG] updateDeviceData: Creating new device - UserID: %v, DeviceName: %s, Type: %s", + userID, deviceName, req.Type) + + if database.IsPostgreSQLDB() { + query = `INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, DeviceCaption, IsActive, LastSync) + VALUES ($1, $2, $3, $4, true, $5) RETURNING DeviceID` + + err = tx.QueryRow(query, userID, deviceName, req.Type, req.Caption, time.Now()).Scan(&deviceID) + if err != nil { + log.Printf("[ERROR] updateDeviceData: Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + } else { + query = `INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, DeviceCaption, IsActive, LastSync) + VALUES (?, ?, ?, ?, true, ?)` + + result, err := tx.Exec(query, userID, deviceName, req.Type, req.Caption, time.Now()) + if err != nil { + log.Printf("[ERROR] updateDeviceData: Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + // Get the last inserted ID + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("[ERROR] updateDeviceData: Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device ID"}) + return + } + + deviceID = int(lastID) + } + + log.Printf("[DEBUG] updateDeviceData: Created new device with ID: %d", deviceID) + + // Also create entry in device state table, handling both PostgreSQL and MySQL syntax + if database.IsPostgreSQLDB() { + query = `INSERT INTO "GpodderSyncDeviceState" (UserID, DeviceID) + VALUES ($1, $2) ON CONFLICT (UserID, DeviceID) DO NOTHING` + _, err = tx.Exec(query, userID, deviceID) + } else { + // In MySQL, use INSERT IGNORE instead of ON CONFLICT + query = `INSERT IGNORE INTO GpodderSyncDeviceState (UserID, DeviceID) VALUES (?, ?)` + _, err = tx.Exec(query, userID, deviceID) + } + + // Log the device state creation result but don't fail on error + if err != nil { + log.Printf("[WARNING] updateDeviceData: Error creating device state: %v", err) + // Not fatal, continue with response + } + } else { + log.Printf("[ERROR] updateDeviceData: Error checking device existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check device"}) + return + } + } else { + // Device exists, update it + log.Printf("[DEBUG] updateDeviceData: Updating existing device with ID: %d", deviceID) + + if database.IsPostgreSQLDB() { + query = `UPDATE "GpodderDevices" SET DeviceType = $1, DeviceCaption = $2, LastSync = $3, IsActive = true + WHERE DeviceID = $4` + } else { + query = `UPDATE GpodderDevices SET DeviceType = ?, DeviceCaption = ?, LastSync = ?, IsActive = true + WHERE DeviceID = ?` + } + + _, err = tx.Exec(query, req.Type, req.Caption, time.Now(), deviceID) + + if err != nil { + log.Printf("[ERROR] updateDeviceData: Error updating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update device"}) + return + } + } + + // Commit transaction + if err = tx.Commit(); err != nil { + log.Printf("[ERROR] updateDeviceData: Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return empty response with 200 status code as per gpodder API + log.Printf("[DEBUG] updateDeviceData: Successfully processed device request") + c.JSON(http.StatusOK, gin.H{}) + } +} + +// getDeviceUpdates handles GET /api/2/updates/{username}/{deviceid}.json +func getDeviceUpdates(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + log.Printf("[DEBUG] getDeviceUpdates: Processing request: %s %s", + c.Request.Method, c.Request.URL.Path) + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get device name from URL with fix for .json suffix + deviceName := c.Param("deviceid") + // Also try alternative parameter name if needed + if deviceName == "" { + deviceName = c.Param("deviceid.json") + } + + // Remove .json suffix if present + if strings.HasSuffix(deviceName, ".json") { + deviceName = strings.TrimSuffix(deviceName, ".json") + } + + log.Printf("[DEBUG] getDeviceUpdates: Using device name: '%s'", deviceName) + + // Parse query parameters + sinceStr := c.Query("since") + includeActions := c.Query("include_actions") == "true" + + var since int64 = 0 + if sinceStr != "" { + _, err := fmt.Sscanf(sinceStr, "%d", &since) + if err != nil { + log.Printf("Invalid 'since' parameter: %s - %v", sinceStr, err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 'since' parameter: must be a Unix timestamp"}) + return + } + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Get or create the device + var deviceID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true + ` + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? AND IsActive = true + ` + } + + err = tx.QueryRow(query, userID, deviceName).Scan(&deviceID) + + if err != nil { + if err == sql.ErrNoRows { + // Device doesn't exist or is inactive, create it + log.Printf("Creating new device for updates: %s", deviceName) + + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, $3) + RETURNING DeviceID + ` + err = tx.QueryRow(query, userID, deviceName, time.Now()).Scan(&deviceID) + } else { + query = ` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, ?) + ` + result, err := tx.Exec(query, userID, deviceName, time.Now()) + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device ID"}) + return + } + + deviceID = int(lastID) + } + + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + // Also create entry in device state table + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncDeviceState" (UserID, DeviceID) + VALUES ($1, $2) + ON CONFLICT (UserID, DeviceID) DO NOTHING + ` + _, err = tx.Exec(query, userID, deviceID) + } else { + query = ` + INSERT IGNORE INTO GpodderSyncDeviceState (UserID, DeviceID) + VALUES (?, ?) + ` + _, err = tx.Exec(query, userID, deviceID) + } + + if err != nil { + log.Printf("Error creating device state: %v", err) + // Not fatal, continue + } + } else { + log.Printf("Error getting device ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + return + } + } + + // Get the current timestamp for the response + timestamp := time.Now().Unix() + + // Build the response structure + response := models.DeviceUpdateResponse{ + Add: []models.Podcast{}, + Remove: []string{}, + Updates: []models.Episode{}, + Timestamp: timestamp, + } + + // Only process updates if a since timestamp was provided + if since > 0 { + // Get the last sync timestamp for this device + var lastSync int64 + + if database.IsPostgreSQLDB() { + query = ` + SELECT COALESCE(LastTimestamp, 0) + FROM "GpodderSyncState" + WHERE UserID = $1 AND DeviceID = $2 + ` + } else { + query = ` + SELECT COALESCE(LastTimestamp, 0) + FROM GpodderSyncState + WHERE UserID = ? AND DeviceID = ? + ` + } + + err = tx.QueryRow(query, userID, deviceID).Scan(&lastSync) + + if err != nil && err != sql.ErrNoRows { + log.Printf("Error getting last sync timestamp: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sync state"}) + return + } + + // Handle podcasts to add (subscribed on other devices since the timestamp) + var addRows *sql.Rows + + if database.IsPostgreSQLDB() { + query = ` + SELECT DISTINCT p.FeedURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL, + (SELECT COUNT(*) FROM "Podcasts" WHERE FeedURL = p.FeedURL) as subscribers + FROM "Podcasts" p + JOIN "GpodderSyncSubscriptions" s ON p.FeedURL = s.PodcastURL + WHERE s.UserID = $1 + AND s.DeviceID != $2 + AND s.Timestamp > $3 + AND s.Action = 'add' + AND NOT EXISTS ( + SELECT 1 FROM "GpodderSyncSubscriptions" s2 + WHERE s2.UserID = s.UserID + AND s2.PodcastURL = s.PodcastURL + AND s2.DeviceID = $2 + AND s2.Timestamp > s.Timestamp + AND s2.Action = 'add' + ) + ` + addRows, err = tx.Query(query, userID, deviceID, since) + } else { + query = ` + SELECT DISTINCT p.FeedURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL, + (SELECT COUNT(*) FROM Podcasts WHERE FeedURL = p.FeedURL) as subscribers + FROM Podcasts p + JOIN GpodderSyncSubscriptions s ON p.FeedURL = s.PodcastURL + WHERE s.UserID = ? + AND s.DeviceID != ? + AND s.Timestamp > ? + AND s.Action = 'add' + AND NOT EXISTS ( + SELECT 1 FROM GpodderSyncSubscriptions s2 + WHERE s2.UserID = s.UserID + AND s2.PodcastURL = s.PodcastURL + AND s2.DeviceID = ? + AND s2.Timestamp > s.Timestamp + AND s2.Action = 'add' + ) + ` + addRows, err = tx.Query(query, userID, deviceID, since, deviceID) // Note: deviceID is used twice in MySQL + } + + if err != nil { + log.Printf("Error getting podcasts to add: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updates"}) + return + } + defer addRows.Close() + + for addRows.Next() { + var podcast models.Podcast + var podcastName, description, author, artworkURL, websiteURL sql.NullString + var subscribers int + + err := addRows.Scan( + &podcast.URL, + &podcastName, + &description, + &author, + &artworkURL, + &websiteURL, + &subscribers, + ) + if err != nil { + log.Printf("Error scanning podcast row: %v", err) + continue + } + + // Set title - default to URL if name is null + if podcastName.Valid && podcastName.String != "" { + podcast.Title = podcastName.String + } else { + podcast.Title = podcast.URL + } + + // Set optional fields if present + if description.Valid { + podcast.Description = description.String + } + if author.Valid { + podcast.Author = author.String + } + if artworkURL.Valid { + podcast.LogoURL = artworkURL.String + } + if websiteURL.Valid { + podcast.Website = websiteURL.String + } + + podcast.Subscribers = subscribers + podcast.MygpoLink = fmt.Sprintf("/podcast/%s", podcast.URL) + + // Add the podcast to the response + response.Add = append(response.Add, podcast) + } + + if err = addRows.Err(); err != nil { + log.Printf("Error iterating add rows: %v", err) + // Continue processing other updates + } + + // Query podcasts to remove (unsubscribed on other devices) + var removeRows *sql.Rows + + if database.IsPostgreSQLDB() { + query = ` + SELECT DISTINCT s.PodcastURL + FROM "GpodderSyncSubscriptions" s + WHERE s.UserID = $1 + AND s.DeviceID != $2 + AND s.Timestamp > $3 + AND s.Action = 'remove' + AND NOT EXISTS ( + SELECT 1 FROM "GpodderSyncSubscriptions" s2 + WHERE s2.UserID = s.UserID + AND s2.PodcastURL = s.PodcastURL + AND s2.DeviceID = $2 + AND s2.Timestamp > s.Timestamp + AND s2.Action = 'add' + ) + ` + removeRows, err = tx.Query(query, userID, deviceID, since) + } else { + query = ` + SELECT DISTINCT s.PodcastURL + FROM GpodderSyncSubscriptions s + WHERE s.UserID = ? + AND s.DeviceID != ? + AND s.Timestamp > ? + AND s.Action = 'remove' + AND NOT EXISTS ( + SELECT 1 FROM GpodderSyncSubscriptions s2 + WHERE s2.UserID = s.UserID + AND s2.PodcastURL = s.PodcastURL + AND s2.DeviceID = ? + AND s2.Timestamp > s.Timestamp + AND s2.Action = 'add' + ) + ` + removeRows, err = tx.Query(query, userID, deviceID, since, deviceID) // Note: deviceID is used twice + } + + if err != nil { + log.Printf("Error getting podcasts to remove: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updates"}) + return + } + defer removeRows.Close() + + for removeRows.Next() { + var podcastURL string + err := removeRows.Scan(&podcastURL) + if err != nil { + log.Printf("Error scanning podcast URL: %v", err) + continue + } + + // Add the podcast URL to the response + response.Remove = append(response.Remove, podcastURL) + } + + if err = removeRows.Err(); err != nil { + log.Printf("Error iterating remove rows: %v", err) + // Continue processing other updates + } + + // Query episode updates (if includeActions is true) + if includeActions { + var updateRows *sql.Rows + + if database.IsPostgreSQLDB() { + query = ` + SELECT e.EpisodeTitle, e.EpisodeURL, p.PodcastName, p.FeedURL, + e.EpisodeDescription, e.EpisodeURL, e.EpisodePubDate, + a.Action, a.Position, a.Total, a.Started + FROM "GpodderSyncEpisodeActions" a + JOIN "Episodes" e ON a.EpisodeURL = e.EpisodeURL + JOIN "Podcasts" p ON e.PodcastID = p.PodcastID + WHERE a.UserID = $1 + AND a.Timestamp > $2 + AND a.Action != 'new' + ORDER BY a.Timestamp DESC + ` + updateRows, err = tx.Query(query, userID, since) + } else { + query = ` + SELECT e.EpisodeTitle, e.EpisodeURL, p.PodcastName, p.FeedURL, + e.EpisodeDescription, e.EpisodeURL, e.EpisodePubDate, + a.Action, a.Position, a.Total, a.Started + FROM GpodderSyncEpisodeActions a + JOIN Episodes e ON a.EpisodeURL = e.EpisodeURL + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE a.UserID = ? + AND a.Timestamp > ? + AND a.Action != 'new' + ORDER BY a.Timestamp DESC + ` + updateRows, err = tx.Query(query, userID, since) + } + + if err != nil { + log.Printf("Error getting episode updates: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode updates"}) + return + } + defer updateRows.Close() + + for updateRows.Next() { + var episode models.Episode + var pubDate time.Time + var action string + var position, total, started sql.NullInt64 + + err := updateRows.Scan( + &episode.Title, + &episode.URL, + &episode.PodcastTitle, + &episode.PodcastURL, + &episode.Description, + &episode.Website, + &pubDate, + &action, + &position, + &total, + &started, + ) + if err != nil { + log.Printf("Error scanning episode row: %v", err) + continue + } + + // Format the publication date in ISO 8601 format + episode.Released = pubDate.Format(time.RFC3339) + + // Add the episode to the response + response.Updates = append(response.Updates, episode) + } + + if err = updateRows.Err(); err != nil { + log.Printf("Error iterating episode update rows: %v", err) + // Continue with other processing + } + } + } + + // Update the last sync timestamp for this device + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncState" (UserID, DeviceID, LastTimestamp) + VALUES ($1, $2, $3) + ON CONFLICT (UserID, DeviceID) + DO UPDATE SET LastTimestamp = $3 + ` + _, err = tx.Exec(query, userID, deviceID, timestamp) + } else { + // MySQL uses INSERT ... ON DUPLICATE KEY UPDATE syntax + query = ` + INSERT INTO GpodderSyncState (UserID, DeviceID, LastTimestamp) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE LastTimestamp = ? + ` + _, err = tx.Exec(query, userID, deviceID, timestamp, timestamp) + } + + if err != nil { + log.Printf("Error updating device sync state: %v", err) + // Not fatal, continue with response + } + + // Update the device LastSync + if database.IsPostgreSQLDB() { + query = ` + UPDATE "GpodderDevices" + SET LastSync = $1 + WHERE DeviceID = $2 + ` + _, err = tx.Exec(query, time.Now(), deviceID) + } else { + query = ` + UPDATE GpodderDevices + SET LastSync = ? + WHERE DeviceID = ? + ` + _, err = tx.Exec(query, time.Now(), deviceID) + } + + if err != nil { + log.Printf("Error updating device last sync time: %v", err) + // Non-critical error, continue + } + + // Commit transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return the response + c.JSON(http.StatusOK, response) + } +} + +// deactivateDevice handles DELETE /api/2/devices/{username}/{deviceid}.json +// This is an extension to the gpodder API for device management +func deactivateDevice(database *db.PostgresDB) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get device name from URL + // Get device name from URL with fix for .json suffix + deviceName := c.Param("deviceid") + // Also try alternative parameter name if needed + if deviceName == "" { + deviceName = c.Param("deviceid.json") + } + + // Remove .json suffix if present + if strings.HasSuffix(deviceName, ".json") { + deviceName = strings.TrimSuffix(deviceName, ".json") + } + + log.Printf("[DEBUG] deactivateDevice: Using device name: '%s'", deviceName) + + // Get the device ID + var deviceID int + err := database.QueryRow(` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 + `, userID, deviceName).Scan(&deviceID) + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"}) + } else { + log.Printf("Error getting device ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + } + return + } + + // Deactivate the device (rather than delete, to preserve history) + _, err = database.Exec(` + UPDATE "GpodderDevices" + SET IsActive = false + WHERE DeviceID = $1 + `, deviceID) + + if err != nil { + log.Printf("Error deactivating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deactivate device"}) + return + } + + // Return success + c.JSON(http.StatusOK, gin.H{ + "result": "success", + "message": "Device deactivated", + }) + } +} + +// renameDevice handles PUT /api/2/devices/{username}/{deviceid}/rename.json +// This is an extension to the gpodder API for device management +func renameDevice(database *db.PostgresDB) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get device name from URL + oldDeviceName := c.Param("deviceid") + if oldDeviceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) + return + } + + // Parse request body + var req struct { + NewDeviceName string `json:"new_deviceid"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'new_deviceid'"}) + return + } + + if req.NewDeviceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "New device ID is required"}) + return + } + + // Check if the new device name already exists + var existingCount int + err := database.QueryRow(` + SELECT COUNT(*) FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true + `, userID, req.NewDeviceName).Scan(&existingCount) + + if err != nil { + log.Printf("Error checking for existing device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for existing device"}) + return + } + + if existingCount > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "Device with this name already exists"}) + return + } + + // Update the device name + result, err := database.Exec(` + UPDATE "GpodderDevices" + SET DeviceName = $1, LastSync = $2 + WHERE UserID = $3 AND DeviceName = $4 AND IsActive = true + `, req.NewDeviceName, time.Now(), userID, oldDeviceName) + + if err != nil { + log.Printf("Error renaming device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to rename device"}) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + log.Printf("Error getting rows affected: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get operation result"}) + return + } + + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Device not found or not active"}) + return + } + + // Return success + c.JSON(http.StatusOK, gin.H{ + "result": "success", + "message": "Device renamed successfully", + }) + } +} + +// deviceSync represents the synchronization state of a device +type deviceSync struct { + LastSync time.Time `json:"last_sync"` + DeviceID int `json:"-"` + DeviceName string `json:"device_id"` + DeviceType string `json:"device_type"` + IsActive bool `json:"-"` + SyncEnabled bool `json:"sync_enabled"` +} + +// getDeviceSyncStatus handles GET /api/2/devices/{username}/sync.json +// This is an extension to the gpodder API for device sync status +func getDeviceSyncStatus(database *db.PostgresDB) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Query all devices and their sync status + rows, err := database.Query(` + SELECT d.DeviceID, d.DeviceName, d.DeviceType, d.LastSync, d.IsActive, + EXISTS ( + SELECT 1 FROM "GpodderSyncDevicePairs" p + WHERE (p.DeviceID1 = d.DeviceID OR p.DeviceID2 = d.DeviceID) + AND p.UserID = d.UserID + ) as sync_enabled + FROM "GpodderDevices" d + WHERE d.UserID = $1 + ORDER BY d.LastSync DESC + `, userID) + + if err != nil { + log.Printf("Error querying device sync status: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device sync status"}) + return + } + defer rows.Close() + + devices := make([]deviceSync, 0) + for rows.Next() { + var device deviceSync + var lastSync sql.NullTime + + if err := rows.Scan( + &device.DeviceID, + &device.DeviceName, + &device.DeviceType, + &lastSync, + &device.IsActive, + &device.SyncEnabled, + ); err != nil { + log.Printf("Error scanning device sync row: %v", err) + continue + } + + // Set the last sync time if valid + if lastSync.Valid { + device.LastSync = lastSync.Time + } + + // Only include active devices + if device.IsActive { + devices = append(devices, device) + } + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating device sync rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process device sync status"}) + return + } + + // Return the response + c.JSON(http.StatusOK, gin.H{ + "devices": devices, + "timestamp": time.Now().Unix(), + }) + } +} diff --git a/PinePods-0.8.2/gpodder-api/internal/api/directory.go b/PinePods-0.8.2/gpodder-api/internal/api/directory.go new file mode 100644 index 0000000..941e307 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/directory.go @@ -0,0 +1,1117 @@ +package api + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "regexp" + "strconv" + "strings" + + "pinepods/gpodder-api/internal/db" + "pinepods/gpodder-api/internal/models" + + "github.com/gin-gonic/gin" +) + +// Maximum number of items to return in listings +const MAX_DIRECTORY_ITEMS = 100 + +// Common tag categories for podcasts +var commonCategories = []models.Tag{ + {Title: "Technology", Tag: "technology", Usage: 530}, + {Title: "Society & Culture", Tag: "society-culture", Usage: 420}, + {Title: "Arts", Tag: "arts", Usage: 400}, + {Title: "News & Politics", Tag: "news-politics", Usage: 320}, + {Title: "Business", Tag: "business", Usage: 300}, + {Title: "Education", Tag: "education", Usage: 280}, + {Title: "Science", Tag: "science", Usage: 260}, + {Title: "Comedy", Tag: "comedy", Usage: 240}, + {Title: "Health", Tag: "health", Usage: 220}, + {Title: "Sports", Tag: "sports", Usage: 200}, + {Title: "History", Tag: "history", Usage: 180}, + {Title: "Religion & Spirituality", Tag: "religion-spirituality", Usage: 160}, + {Title: "TV & Film", Tag: "tv-film", Usage: 140}, + {Title: "Music", Tag: "music", Usage: 120}, + {Title: "Games & Hobbies", Tag: "games-hobbies", Usage: 100}, +} + +// getTopTags handles GET /api/2/tags/{count}.json +func getTopTags(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Parse count parameter + countStr := c.Param("count") + count, err := strconv.Atoi(countStr) + if err != nil || count < 1 || count > MAX_DIRECTORY_ITEMS { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid count parameter: must be between 1 and %d", MAX_DIRECTORY_ITEMS)}) + return + } + + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + // PostgreSQL specific query using array functions + rows, err = database.Query(` + WITH category_counts AS ( + SELECT + unnest(string_to_array(Categories, ',')) as category, + COUNT(*) as usage + FROM "Podcasts" + WHERE Categories IS NOT NULL AND Categories != '' + GROUP BY category + ) + SELECT + category as tag, + category as title, + usage + FROM category_counts + ORDER BY usage DESC + LIMIT $1 + `, count) + } else { + // MySQL equivalent - need to use different approach since MySQL doesn't have unnest + // Using FIND_IN_SET with a subquery for each common category + // This is a simplified approach - in a real implementation you might want to + // use a more sophisticated method for MySQL to extract and count categories + placeholders := make([]string, len(commonCategories)) + args := make([]interface{}, len(commonCategories)+1) + args[0] = count // First arg is the LIMIT parameter + + for i, category := range commonCategories { + placeholders[i] = fmt.Sprintf(` + SELECT + ?, + ?, + COUNT(*) as usage + FROM Podcasts + WHERE Categories IS NOT NULL AND FIND_IN_SET(?, Categories) > 0 + `) + args[i+1] = category.Tag + // In a real implementation, we would add more parameters here + } + + // In a real implementation, this query would be more sophisticated + // For now, we'll just return results from the commonCategories slice + // and limit it by count + rows = nil + err = fmt.Errorf("MySQL implementation falls back to default categories") + } + + // If query fails or returns no rows, use the default list + if err != nil || rows == nil { + log.Printf("Error querying categories, using default list: %v", err) + result := commonCategories + if len(result) > count { + result = result[:count] + } + c.JSON(http.StatusOK, result) + return + } + defer rows.Close() + + // Process database results + tags := make([]models.Tag, 0, count) + for rows.Next() { + var tag models.Tag + if err := rows.Scan(&tag.Tag, &tag.Title, &tag.Usage); err != nil { + log.Printf("Error scanning tag row: %v", err) + continue + } + // Clean the tag + tag.Tag = strings.ToLower(strings.TrimSpace(tag.Tag)) + tag.Tag = strings.ReplaceAll(tag.Tag, " ", "-") + // Format the title properly + tag.Title = formatTagTitle(tag.Tag) + tags = append(tags, tag) + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating tag rows: %v", err) + } + + // If we got no results from the database, use the default list + if len(tags) == 0 { + result := commonCategories + if len(result) > count { + result = result[:count] + } + c.JSON(http.StatusOK, result) + return + } + + c.JSON(http.StatusOK, tags) + } +} + +// formatTagTitle formats a tag string into a proper title +func formatTagTitle(tag string) string { + // Replace hyphens with spaces + title := strings.ReplaceAll(tag, "-", " ") + + // Convert to title case (capitalize first letter of each word) + words := strings.Fields(title) + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + word[1:] + } + } + + return strings.Join(words, " ") +} + +// getPodcastsForTag handles GET /api/2/tag/{tag}/{count}.json +func getPodcastsForTag(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Parse parameters + tag := c.Param("tag") + countStr := c.Param("count") + count, err := strconv.Atoi(countStr) + if err != nil || count < 1 || count > MAX_DIRECTORY_ITEMS { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid count parameter: must be between 1 and %d", MAX_DIRECTORY_ITEMS)}) + return + } + + // Format tag for searching + searchTag := "%" + strings.ReplaceAll(tag, "-", " ") + "%" + + // Query podcasts with the given tag + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + // PostgreSQL query with DISTINCT ON + rows, err = database.Query(` + SELECT DISTINCT ON (p.PodcastID) + p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL, + COUNT(DISTINCT u.UserID) OVER (PARTITION BY p.PodcastID) as subscribers + FROM "Podcasts" p + JOIN "Users" u ON p.UserID = u.UserID + WHERE + p.Categories ILIKE $1 OR + p.PodcastName ILIKE $1 OR + p.Description ILIKE $1 + ORDER BY p.PodcastID, subscribers DESC + LIMIT $2 + `, searchTag, count) + } else { + // MySQL equivalent without DISTINCT ON and window functions + rows, err = database.Query(` + SELECT + p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers + FROM Podcasts p + JOIN Users u ON p.UserID = u.UserID + WHERE + p.Categories LIKE ? OR + p.PodcastName LIKE ? OR + p.Description LIKE ? + GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL + ORDER BY subscribers DESC + LIMIT ? + `, searchTag, searchTag, searchTag, count) + } + + if err != nil { + log.Printf("Error querying podcasts by tag: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcasts for tag"}) + return + } + defer rows.Close() + + // Build podcast list + podcasts := make([]models.Podcast, 0) + for rows.Next() { + var podcast models.Podcast + var podcastID int + var author, description, websiteURL, artworkURL sql.NullString + var subscribers int + if err := rows.Scan( + &podcastID, + &podcast.Title, + &author, + &description, + &podcast.URL, + &websiteURL, + &artworkURL, + &subscribers, + ); err != nil { + log.Printf("Error scanning podcast: %v", err) + continue + } + // Set optional fields if present + if author.Valid { + podcast.Author = author.String + } + if description.Valid { + podcast.Description = description.String + } + if websiteURL.Valid { + podcast.Website = websiteURL.String + } + if artworkURL.Valid { + podcast.LogoURL = artworkURL.String + } + podcast.Subscribers = subscribers + podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID) + podcasts = append(podcasts, podcast) + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process podcasts"}) + return + } + + c.JSON(http.StatusOK, podcasts) + } +} + +// getPodcastData handles GET /api/2/data/podcast.json +func getPodcastData(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get podcast URL from query parameter + podcastURL := c.Query("url") + if podcastURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "URL parameter is required"}) + return + } + + // Query podcast data + var podcast models.Podcast + var podcastID int + var author, description, websiteURL, artworkURL sql.NullString + var subscribers int + + var err error + + if database.IsPostgreSQLDB() { + // PostgreSQL query + err = database.QueryRow(` + SELECT + p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers + FROM "Podcasts" p + JOIN "Users" u ON p.UserID = u.UserID + WHERE p.FeedURL = $1 + GROUP BY p.PodcastID + LIMIT 1 + `, podcastURL).Scan( + &podcastID, + &podcast.Title, + &author, + &description, + &podcast.URL, + &websiteURL, + &artworkURL, + &subscribers, + ) + } else { + // MySQL query + err = database.QueryRow(` + SELECT + p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers + FROM Podcasts p + JOIN Users u ON p.UserID = u.UserID + WHERE p.FeedURL = ? + GROUP BY p.PodcastID + LIMIT 1 + `, podcastURL).Scan( + &podcastID, + &podcast.Title, + &author, + &description, + &podcast.URL, + &websiteURL, + &artworkURL, + &subscribers, + ) + } + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Podcast not found"}) + } else { + log.Printf("Error querying podcast data: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast data"}) + } + return + } + + // Set optional fields if present + if author.Valid { + podcast.Author = author.String + } + if description.Valid { + podcast.Description = description.String + } + if websiteURL.Valid { + podcast.Website = websiteURL.String + } + if artworkURL.Valid { + podcast.LogoURL = artworkURL.String + } + + podcast.Subscribers = subscribers + podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID) + + c.JSON(http.StatusOK, podcast) + } +} + +// isValidCallbackName checks if a JSONP callback name is valid and safe +func isValidCallbackName(callback string) bool { + // Only allow alphanumeric characters, underscore, and period in callback names + validCallbackRegex := regexp.MustCompile(`^[a-zA-Z0-9_.]+$`) + return validCallbackRegex.MatchString(callback) +} + +// podcastSearch handles GET /search.{format} +func podcastSearch(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get query parameter + query := c.Query("q") + if query == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"}) + return + } + + // Get format parameter + format := c.Param("format") + if format == "" { + format = "json" // Default format + } + + // Parse optional parameters + scaleLogo := c.Query("scale_logo") + var scaleSize int + if scaleLogo != "" { + size, err := strconv.Atoi(scaleLogo) + if err != nil || size < 1 || size > 256 { + scaleSize = 64 // Default size + } else { + scaleSize = size + } + } + + // Limit search terms to prevent performance issues + if len(query) > 100 { + query = query[:100] + } + + // Prepare search query terms for SQL + searchTerms := "%" + strings.ReplaceAll(query, " ", "%") + "%" + + // Search podcasts + var rows *sql.Rows + var err error + + if database.IsPostgreSQLDB() { + // PostgreSQL query with DISTINCT ON and window functions + rows, err = database.Query(` + SELECT DISTINCT ON (p.PodcastID) + p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL, + COUNT(DISTINCT u.UserID) OVER (PARTITION BY p.PodcastID) as subscribers, + CASE + WHEN p.PodcastName ILIKE $1 THEN 1 + WHEN p.Author ILIKE $1 THEN 2 + WHEN p.Description ILIKE $1 THEN 3 + ELSE 4 + END as match_priority + FROM "Podcasts" p + JOIN "Users" u ON p.UserID = u.UserID + WHERE + p.PodcastName ILIKE $1 OR + p.Author ILIKE $1 OR + p.Description ILIKE $1 + ORDER BY p.PodcastID, match_priority, subscribers DESC + LIMIT $2 + `, searchTerms, MAX_DIRECTORY_ITEMS) + } else { + // MySQL query without DISTINCT ON and window functions + rows, err = database.Query(` + SELECT + p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers, + CASE + WHEN p.PodcastName LIKE ? THEN 1 + WHEN p.Author LIKE ? THEN 2 + WHEN p.Description LIKE ? THEN 3 + ELSE 4 + END as match_priority + FROM Podcasts p + JOIN Users u ON p.UserID = u.UserID + WHERE + p.PodcastName LIKE ? OR + p.Author LIKE ? OR + p.Description LIKE ? + GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL, match_priority + ORDER BY match_priority, subscribers DESC + LIMIT ? + `, searchTerms, searchTerms, searchTerms, searchTerms, searchTerms, searchTerms, MAX_DIRECTORY_ITEMS) + } + + if err != nil { + log.Printf("Error searching podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search podcasts"}) + return + } + defer rows.Close() + + // Build podcast list + podcasts := make([]models.Podcast, 0) + for rows.Next() { + var podcast models.Podcast + var podcastID int + var author, description, websiteURL, artworkURL sql.NullString + var subscribers, matchPriority int + + if err := rows.Scan( + &podcastID, + &podcast.Title, + &author, + &description, + &podcast.URL, + &websiteURL, + &artworkURL, + &subscribers, + &matchPriority, + ); err != nil { + log.Printf("Error scanning podcast: %v", err) + continue + } + + // Set optional fields if present + if author.Valid { + podcast.Author = author.String + } + + if description.Valid { + podcast.Description = description.String + } + + if websiteURL.Valid { + podcast.Website = websiteURL.String + } + + if artworkURL.Valid { + podcast.LogoURL = artworkURL.String + + // Add scaled logo URL if requested + if scaleLogo != "" { + podcast.ScaledLogoURL = fmt.Sprintf("/logo/%d/%s", scaleSize, artworkURL.String) + } + } + + podcast.Subscribers = subscribers + podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID) + + podcasts = append(podcasts, podcast) + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process search results"}) + return + } + + // Return in requested format + switch format { + case "json": + c.JSON(http.StatusOK, podcasts) + case "jsonp": + // JSONP callback + callback := c.Query("jsonp") + if callback == "" { + callback = "callback" // Default callback name + } + + // Validate callback name for security + if !isValidCallbackName(callback) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSONP callback name"}) + return + } + + // Convert to JSON using the standard json package + jsonData, err := json.Marshal(podcasts) + if err != nil { + log.Printf("Error marshaling to JSON: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal to JSON"}) + return + } + + // Wrap in callback + c.Header("Content-Type", "application/javascript") + c.String(http.StatusOK, "%s(%s);", callback, string(jsonData)) + case "txt": + // Plain text format - just URLs + var sb strings.Builder + for _, podcast := range podcasts { + sb.WriteString(podcast.URL) + sb.WriteString("\n") + } + c.String(http.StatusOK, sb.String()) + case "opml": + // OPML format + opml := generateOpml(podcasts) + c.Header("Content-Type", "text/xml") + c.String(http.StatusOK, opml) + case "xml": + // XML format + xml := generateXml(podcasts) + c.Header("Content-Type", "text/xml") + c.String(http.StatusOK, xml) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) + } + } +} + +// getToplist handles GET /toplist/{number}.{format} +func getToplist(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Parse count parameter + countStr := c.Param("number") + count, err := strconv.Atoi(countStr) + if err != nil || count < 1 || count > MAX_DIRECTORY_ITEMS { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid number parameter: must be between 1 and %d", MAX_DIRECTORY_ITEMS)}) + return + } + + // Get format parameter + format := c.Param("format") + if format == "" { + format = "json" // Default format + } + + // Parse optional parameters + scaleLogo := c.Query("scale_logo") + var scaleSize int + if scaleLogo != "" { + size, err := strconv.Atoi(scaleLogo) + if err != nil || size < 1 || size > 256 { + scaleSize = 64 // Default size + } else { + scaleSize = size + } + } + + // Query top podcasts + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + // PostgreSQL query with CTE + rows, err = database.Query(` + WITH podcast_stats AS ( + SELECT + p.PodcastID, + p.PodcastName, + p.Author, + p.Description, + p.FeedURL, + p.WebsiteURL, + p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers, + 0 as position_last_week -- Placeholder for now + FROM "Podcasts" p + JOIN "Users" u ON p.UserID = u.UserID + GROUP BY p.PodcastID + ) + SELECT * FROM podcast_stats + ORDER BY subscribers DESC, PodcastID + LIMIT $1 + `, count) + } else { + // MySQL query without CTE + rows, err = database.Query(` + SELECT + p.PodcastID, + p.PodcastName, + p.Author, + p.Description, + p.FeedURL, + p.WebsiteURL, + p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers, + 0 as position_last_week -- Placeholder for now + FROM Podcasts p + JOIN Users u ON p.UserID = u.UserID + GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL + ORDER BY subscribers DESC, PodcastID + LIMIT ? + `, count) + } + + if err != nil { + log.Printf("Error querying top podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get top podcasts"}) + return + } + defer rows.Close() + + // Build podcast list + podcasts := make([]models.Podcast, 0) + for rows.Next() { + var podcast models.Podcast + var podcastID int + var author, description, websiteURL, artworkURL sql.NullString + var subscribers, positionLastWeek int + + if err := rows.Scan( + &podcastID, + &podcast.Title, + &author, + &description, + &podcast.URL, + &websiteURL, + &artworkURL, + &subscribers, + &positionLastWeek, + ); err != nil { + log.Printf("Error scanning podcast: %v", err) + continue + } + + // Set optional fields if present + if author.Valid { + podcast.Author = author.String + } + + if description.Valid { + podcast.Description = description.String + } + + if websiteURL.Valid { + podcast.Website = websiteURL.String + } + + if artworkURL.Valid { + podcast.LogoURL = artworkURL.String + + // Add scaled logo URL if requested + if scaleLogo != "" { + podcast.ScaledLogoURL = fmt.Sprintf("/logo/%d/%s", scaleSize, artworkURL.String) + } + } + + podcast.Subscribers = subscribers + podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID) + + podcasts = append(podcasts, podcast) + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process podcasts"}) + return + } + + // Return in requested format (same as search) + switch format { + case "json": + c.JSON(http.StatusOK, podcasts) + case "jsonp": + // JSONP callback + callback := c.Query("jsonp") + if callback == "" { + callback = "callback" // Default callback name + } + + // Validate callback name for security + if !isValidCallbackName(callback) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSONP callback name"}) + return + } + + // Convert to JSON using the standard json package + jsonData, err := json.Marshal(podcasts) + if err != nil { + log.Printf("Error marshaling to JSON: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal to JSON"}) + return + } + + // Wrap in callback + c.Header("Content-Type", "application/javascript") + c.String(http.StatusOK, "%s(%s);", callback, string(jsonData)) + case "txt": + // Plain text format - just URLs + var sb strings.Builder + for _, podcast := range podcasts { + sb.WriteString(podcast.URL) + sb.WriteString("\n") + } + c.String(http.StatusOK, sb.String()) + case "opml": + // OPML format + opml := generateOpml(podcasts) + c.Header("Content-Type", "text/xml") + c.String(http.StatusOK, opml) + case "xml": + // XML format + xml := generateXml(podcasts) + c.Header("Content-Type", "text/xml") + c.String(http.StatusOK, xml) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) + } + } +} + +// getSuggestions handles GET /suggestions/{count}.{format} +func getSuggestions(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Parse count parameter + countStr := c.Param("count") + count, err := strconv.Atoi(countStr) + if err != nil || count < 1 || count > MAX_DIRECTORY_ITEMS { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid count parameter: must be between 1 and %d", MAX_DIRECTORY_ITEMS)}) + return + } + + // Get format parameter + format := c.Param("format") + if format == "" { + format = "json" // Default format + } + + // Get user's current subscriptions + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + rows, err = database.Query(` + SELECT FeedURL FROM "Podcasts" WHERE UserID = $1 + `, userID) + } else { + rows, err = database.Query(` + SELECT FeedURL FROM Podcasts WHERE UserID = ? + `, userID) + } + + if err != nil { + log.Printf("Error getting user subscriptions: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) + return + } + defer rows.Close() + + // Build map of current subscriptions + currentSubs := make(map[string]bool) + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + log.Printf("Error scanning subscription URL: %v", err) + continue + } + currentSubs[url] = true + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating subscription rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) + return + } + + // Query for similar podcasts based on categories of current subscriptions + if database.IsPostgreSQLDB() { + rows, err = database.Query(` + WITH user_categories AS ( + SELECT DISTINCT unnest(string_to_array(p.Categories, ',')) as category + FROM "Podcasts" p + WHERE p.UserID = $1 AND p.Categories IS NOT NULL AND p.Categories != '' + ), + recommended_podcasts AS ( + SELECT DISTINCT ON (p.PodcastID) + p.PodcastID, + p.PodcastName, + p.Author, + p.Description, + p.FeedURL, + p.WebsiteURL, + p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers + FROM "Podcasts" p + JOIN "Users" u ON p.UserID = u.UserID + WHERE EXISTS ( + SELECT 1 FROM user_categories uc + WHERE p.Categories ILIKE '%' || uc.category || '%' + ) + AND p.FeedURL NOT IN ( + SELECT FeedURL FROM "Podcasts" WHERE UserID = $1 + ) + GROUP BY p.PodcastID + ORDER BY p.PodcastID, subscribers DESC + ) + SELECT * FROM recommended_podcasts + LIMIT $2 + `, userID, count) + } else { + // For MySQL, we use a simpler approach without CTEs and array functions + rows, err = database.Query(` + SELECT DISTINCT + p.PodcastID, + p.PodcastName, + p.Author, + p.Description, + p.FeedURL, + p.WebsiteURL, + p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers + FROM Podcasts p + JOIN Users u ON p.UserID = u.UserID + JOIN ( + SELECT DISTINCT p.Categories + FROM Podcasts p + WHERE p.UserID = ? AND p.Categories IS NOT NULL AND p.Categories != '' + ) as user_cats + WHERE p.Categories LIKE CONCAT('%', user_cats.Categories, '%') + AND p.FeedURL NOT IN ( + SELECT FeedURL FROM Podcasts WHERE UserID = ? + ) + GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL + ORDER BY subscribers DESC, p.PodcastID + LIMIT ? + `, userID, userID, count) + } + + if err != nil { + log.Printf("Error querying suggested podcasts: %v", err) + + // If category-based query fails, fall back to popularity-based suggestions + if database.IsPostgreSQLDB() { + rows, err = database.Query(` + SELECT + p.PodcastID, + p.PodcastName, + p.Author, + p.Description, + p.FeedURL, + p.WebsiteURL, + p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers + FROM "Podcasts" p + JOIN "Users" u ON p.UserID = u.UserID + WHERE p.FeedURL NOT IN ( + SELECT FeedURL FROM "Podcasts" WHERE UserID = $1 + ) + GROUP BY p.PodcastID + ORDER BY subscribers DESC, p.PodcastID + LIMIT $2 + `, userID, count) + } else { + rows, err = database.Query(` + SELECT + p.PodcastID, + p.PodcastName, + p.Author, + p.Description, + p.FeedURL, + p.WebsiteURL, + p.ArtworkURL, + COUNT(DISTINCT u.UserID) as subscribers + FROM Podcasts p + JOIN Users u ON p.UserID = u.UserID + WHERE p.FeedURL NOT IN ( + SELECT FeedURL FROM Podcasts WHERE UserID = ? + ) + GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description, + p.FeedURL, p.WebsiteURL, p.ArtworkURL + ORDER BY subscribers DESC, p.PodcastID + LIMIT ? + `, userID, count) + } + + if err != nil { + log.Printf("Error querying popular podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"}) + return + } + } + defer rows.Close() + + // Build podcast list + podcasts := make([]models.Podcast, 0) + for rows.Next() { + var podcast models.Podcast + var podcastID int + var author, description, websiteURL, artworkURL sql.NullString + var subscribers int + + if err := rows.Scan( + &podcastID, + &podcast.Title, + &author, + &description, + &podcast.URL, + &websiteURL, + &artworkURL, + &subscribers, + ); err != nil { + log.Printf("Error scanning podcast: %v", err) + continue + } + + // Skip if already subscribed (double-check) + if currentSubs[podcast.URL] { + continue + } + + // Set optional fields if present + if author.Valid { + podcast.Author = author.String + } + + if description.Valid { + podcast.Description = description.String + } + + if websiteURL.Valid { + podcast.Website = websiteURL.String + } + + if artworkURL.Valid { + podcast.LogoURL = artworkURL.String + } + + podcast.Subscribers = subscribers + podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID) + + podcasts = append(podcasts, podcast) + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating suggestion rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process suggestions"}) + return + } + + // Return in requested format + switch format { + case "json": + c.JSON(http.StatusOK, podcasts) + case "txt": + // Plain text format - just URLs + var sb strings.Builder + for _, podcast := range podcasts { + sb.WriteString(podcast.URL) + sb.WriteString("\n") + } + c.String(http.StatusOK, sb.String()) + case "opml": + // OPML format + opml := generateOpml(podcasts) + c.Header("Content-Type", "text/xml") + c.String(http.StatusOK, opml) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) + } + } +} + +// generateOpml generates an OPML format document from a list of podcasts +func generateOpml(podcasts []models.Podcast) string { + var sb strings.Builder + + sb.WriteString(` + + + gPodder Subscriptions + + +`) + + for _, podcast := range podcasts { + sb.WriteString(fmt.Sprintf(` \n") + } + + sb.WriteString(` +`) + + return sb.String() +} + +// generateXml generates an XML format document from a list of podcasts +func generateXml(podcasts []models.Podcast) string { + var sb strings.Builder + + sb.WriteString(` + +`) + + for _, podcast := range podcasts { + sb.WriteString(" \n") + sb.WriteString(fmt.Sprintf(" %s\n", escapeXml(podcast.Title))) + sb.WriteString(fmt.Sprintf(" %s\n", escapeXml(podcast.URL))) + + if podcast.Website != "" { + sb.WriteString(fmt.Sprintf(" %s\n", escapeXml(podcast.Website))) + } + + if podcast.MygpoLink != "" { + sb.WriteString(fmt.Sprintf(" %s\n", escapeXml(podcast.MygpoLink))) + } + + if podcast.Author != "" { + sb.WriteString(fmt.Sprintf(" %s\n", escapeXml(podcast.Author))) + } + + if podcast.Description != "" { + sb.WriteString(fmt.Sprintf(" %s\n", escapeXml(podcast.Description))) + } + + sb.WriteString(fmt.Sprintf(" %d\n", podcast.Subscribers)) + + if podcast.LogoURL != "" { + sb.WriteString(fmt.Sprintf(" %s\n", escapeXml(podcast.LogoURL))) + } + + if podcast.ScaledLogoURL != "" { + sb.WriteString(fmt.Sprintf(" %s\n", escapeXml(podcast.ScaledLogoURL))) + } + + sb.WriteString(" \n") + } + + sb.WriteString("") + + return sb.String() +} + +// escapeXml escapes special characters for XML output +func escapeXml(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} diff --git a/PinePods-0.8.2/gpodder-api/internal/api/episode.go b/PinePods-0.8.2/gpodder-api/internal/api/episode.go new file mode 100644 index 0000000..4dc1d0e --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/episode.go @@ -0,0 +1,844 @@ +package api + +import ( + "database/sql" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "pinepods/gpodder-api/internal/db" + "pinepods/gpodder-api/internal/models" + + "github.com/gin-gonic/gin" +) + +// getEpisodeActions handles GET /api/2/episodes/{username}.json +func getEpisodeActions(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + + // Get user ID from middleware + userID, exists := c.Get("userID") + if !exists { + log.Printf("[ERROR] getEpisodeActions: userID not found in context") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Parse query parameters + sinceStr := c.Query("since") + podcastURL := c.Query("podcast") + deviceName := c.Query("device") + aggregated := c.Query("aggregated") == "true" + + // Get device ID if provided + var deviceID *int + if deviceName != "" { + var deviceIDInt int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true + ` + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? AND IsActive = true + ` + } + + err := database.QueryRow(query, userID, deviceName).Scan(&deviceIDInt) + + if err != nil { + if err != sql.ErrNoRows { + log.Printf("[ERROR] getEpisodeActions: Error getting device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + return + } + // Device not found is not fatal if querying by device + } else { + deviceID = &deviceIDInt + } + } + + var since int64 = 0 + if sinceStr != "" { + var err error + since, err = strconv.ParseInt(sinceStr, 10, 64) + if err != nil { + log.Printf("[ERROR] getEpisodeActions: Invalid since parameter: %s", sinceStr) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since parameter: must be a Unix timestamp"}) + return + } + } + + // Get the latest timestamp for the response + var latestTimestamp int64 + var timestampQuery string + + if database.IsPostgreSQLDB() { + timestampQuery = ` + SELECT COALESCE(MAX(Timestamp), EXTRACT(EPOCH FROM NOW())::bigint) + FROM "GpodderSyncEpisodeActions" + WHERE UserID = $1 + ` + } else { + timestampQuery = ` + SELECT COALESCE(MAX(Timestamp), UNIX_TIMESTAMP()) + FROM GpodderSyncEpisodeActions + WHERE UserID = ? + ` + } + + err := database.QueryRow(timestampQuery, userID).Scan(&latestTimestamp) + + if err != nil { + log.Printf("[ERROR] getEpisodeActions: Error getting latest timestamp: %v", err) + latestTimestamp = time.Now().Unix() // Fallback to current time + } + + // Performance optimization: Add limits and optimize query structure + const MAX_EPISODE_ACTIONS = 10000 // Reasonable limit for sync operations + + // Log query performance info + log.Printf("[DEBUG] getEpisodeActions: Query for user %v with since=%d, device=%s, aggregated=%v", + userID, since, deviceName, aggregated) + + // Build query based on parameters with performance optimizations + var queryParts []string + + if database.IsPostgreSQLDB() { + queryParts = []string{ + "SELECT " + + "e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, " + + "e.Action, e.Timestamp, e.Started, e.Position, e.Total, " + + "COALESCE(d.DeviceName, '') as DeviceName " + + "FROM \"GpodderSyncEpisodeActions\" e " + + "LEFT JOIN \"GpodderDevices\" d ON e.DeviceID = d.DeviceID " + + "WHERE e.UserID = $1", + } + } else { + queryParts = []string{ + "SELECT " + + "e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, " + + "e.Action, e.Timestamp, e.Started, e.Position, e.Total, " + + "COALESCE(d.DeviceName, '') as DeviceName " + + "FROM GpodderSyncEpisodeActions e " + + "LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID " + + "WHERE e.UserID = ?", + } + } + + args := []interface{}{userID} + paramCount := 2 + + // For aggregated results, we need a more complex query + var query string + if aggregated { + if database.IsPostgreSQLDB() { + // Build conditions for the subquery + var conditions []string + + if since > 0 { + conditions = append(conditions, fmt.Sprintf("AND e.Timestamp > $%d", paramCount)) + args = append(args, since) + paramCount++ + } + + if podcastURL != "" { + conditions = append(conditions, fmt.Sprintf("AND e.PodcastURL = $%d", paramCount)) + args = append(args, podcastURL) + paramCount++ + } + + if deviceID != nil { + conditions = append(conditions, fmt.Sprintf("AND e.DeviceID = $%d", paramCount)) + args = append(args, *deviceID) + paramCount++ + } + + conditionsStr := strings.Join(conditions, " ") + + query = fmt.Sprintf(` + WITH latest_actions AS ( + SELECT + e.PodcastURL, + e.EpisodeURL, + MAX(e.Timestamp) as max_timestamp + FROM "GpodderSyncEpisodeActions" e + WHERE e.UserID = $1 + %s + GROUP BY e.PodcastURL, e.EpisodeURL + ) + SELECT + e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, + e.Action, e.Timestamp, e.Started, e.Position, e.Total, + d.DeviceName + FROM "GpodderSyncEpisodeActions" e + JOIN latest_actions la ON + e.PodcastURL = la.PodcastURL AND + e.EpisodeURL = la.EpisodeURL AND + e.Timestamp = la.max_timestamp + LEFT JOIN "GpodderDevices" d ON e.DeviceID = d.DeviceID + WHERE e.UserID = $1 + ORDER BY e.Timestamp DESC + LIMIT %d + `, conditionsStr, MAX_EPISODE_ACTIONS) + } else { + // For MySQL, we need to use ? placeholders and rebuild the argument list + args = []interface{}{userID} // Reset args to just include userID for now + + // Build conditions for the subquery + var conditions []string + + if since > 0 { + conditions = append(conditions, "AND e.Timestamp > ?") + args = append(args, since) + } + + if podcastURL != "" { + conditions = append(conditions, "AND e.PodcastURL = ?") + args = append(args, podcastURL) + } + + if deviceID != nil { + conditions = append(conditions, "AND e.DeviceID = ?") + args = append(args, *deviceID) + } + + conditionsStr := strings.Join(conditions, " ") + + // Need to duplicate userID in args for the second part of the query + mysqlArgs := make([]interface{}, len(args)) + copy(mysqlArgs, args) + args = append(args, mysqlArgs...) + + query = fmt.Sprintf(` + WITH latest_actions AS ( + SELECT + e.PodcastURL, + e.EpisodeURL, + MAX(e.Timestamp) as max_timestamp + FROM GpodderSyncEpisodeActions e + WHERE e.UserID = ? + %s + GROUP BY e.PodcastURL, e.EpisodeURL + ) + SELECT + e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, + e.Action, e.Timestamp, e.Started, e.Position, e.Total, + d.DeviceName + FROM GpodderSyncEpisodeActions e + JOIN latest_actions la ON + e.PodcastURL = la.PodcastURL AND + e.EpisodeURL = la.EpisodeURL AND + e.Timestamp = la.max_timestamp + LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID + WHERE e.UserID = ? + ORDER BY e.Timestamp DESC + LIMIT %d + `, conditionsStr, MAX_EPISODE_ACTIONS) + } + } else { + // Simple query with ORDER BY + if database.IsPostgreSQLDB() { + if since > 0 { + queryParts = append(queryParts, fmt.Sprintf("AND e.Timestamp > $%d", paramCount)) + args = append(args, since) + paramCount++ + } + + if podcastURL != "" { + queryParts = append(queryParts, fmt.Sprintf("AND e.PodcastURL = $%d", paramCount)) + args = append(args, podcastURL) + paramCount++ + } + + if deviceID != nil { + queryParts = append(queryParts, fmt.Sprintf("AND e.DeviceID = $%d", paramCount)) + args = append(args, *deviceID) + paramCount++ + } + } else { + if since > 0 { + queryParts = append(queryParts, "AND e.Timestamp > ?") + args = append(args, since) + } + + if podcastURL != "" { + queryParts = append(queryParts, "AND e.PodcastURL = ?") + args = append(args, podcastURL) + } + + if deviceID != nil { + queryParts = append(queryParts, "AND e.DeviceID = ?") + args = append(args, *deviceID) + } + } + + queryParts = append(queryParts, "ORDER BY e.Timestamp DESC") + + // Add LIMIT for performance - prevents returning massive datasets + if database.IsPostgreSQLDB() { + queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS)) + } else { + queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS)) + } + + query = strings.Join(queryParts, " ") + } + + // Execute query with timing + startTime := time.Now() + rows, err := database.Query(query, args...) + queryDuration := time.Since(startTime) + + if err != nil { + log.Printf("[ERROR] getEpisodeActions: Error querying episode actions (took %v): %v", queryDuration, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode actions"}) + return + } + defer rows.Close() + + log.Printf("[DEBUG] getEpisodeActions: Query executed in %v", queryDuration) + + // Build response + actions := make([]models.EpisodeAction, 0) + for rows.Next() { + var action models.EpisodeAction + var deviceIDInt sql.NullInt64 + var deviceName sql.NullString + var started sql.NullInt64 + var position sql.NullInt64 + var total sql.NullInt64 + + if err := rows.Scan( + &action.ActionID, + &action.UserID, + &deviceIDInt, + &action.Podcast, + &action.Episode, + &action.Action, + &action.Timestamp, + &started, + &position, + &total, + &deviceName, + ); err != nil { + log.Printf("[ERROR] getEpisodeActions: Error scanning action row: %v", err) + continue + } + + // Set optional fields if present + if deviceName.Valid { + action.Device = deviceName.String + } + + if started.Valid { + startedInt := int(started.Int64) + action.Started = &startedInt + } + + if position.Valid { + positionInt := int(position.Int64) + action.Position = &positionInt + } + + if total.Valid { + totalInt := int(total.Int64) + action.Total = &totalInt + } + + actions = append(actions, action) + } + + if err = rows.Err(); err != nil { + log.Printf("[ERROR] getEpisodeActions: Error iterating rows: %v", err) + // Continue with what we've got so far + } + + // Log performance results + totalDuration := time.Since(startTime) + log.Printf("[DEBUG] getEpisodeActions: Returning %d actions, total time: %v", len(actions), totalDuration) + + // Return response in gpodder format + c.JSON(http.StatusOK, models.EpisodeActionsResponse{ + Actions: actions, + Timestamp: latestTimestamp, + }) + } +} + +// uploadEpisodeActions handles POST /api/2/episodes/{username}.json +func uploadEpisodeActions(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + + // Get user ID from middleware + userID, exists := c.Get("userID") + if !exists { + log.Printf("[ERROR] uploadEpisodeActions: userID not found in context") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Parse request - try both formats + var actions []models.EpisodeAction + + // First try parsing as array directly + if err := c.ShouldBindJSON(&actions); err != nil { + // If that fails, try parsing as a wrapper object + var wrappedActions struct { + Actions []models.EpisodeAction `json:"actions"` + } + if err2 := c.ShouldBindJSON(&wrappedActions); err2 != nil { + log.Printf("[ERROR] uploadEpisodeActions: Error parsing request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body format"}) + return + } + actions = wrappedActions.Actions + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("[ERROR] uploadEpisodeActions: Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + return + } + }() + + // Process actions + timestamp := time.Now().Unix() + updateURLs := make([][]string, 0) + + for _, action := range actions { + // Validate action + if action.Podcast == "" || action.Episode == "" || action.Action == "" { + log.Printf("[WARNING] uploadEpisodeActions: Skipping invalid action: podcast=%s, episode=%s, action=%s", + action.Podcast, action.Episode, action.Action) + continue + } + + // Clean URLs if needed + cleanPodcastURL, err := sanitizeURL(action.Podcast) + if err != nil { + log.Printf("[WARNING] uploadEpisodeActions: Error sanitizing podcast URL %s: %v", action.Podcast, err) + cleanPodcastURL = action.Podcast // Use original if sanitization fails + } + + cleanEpisodeURL, err := sanitizeURL(action.Episode) + if err != nil { + log.Printf("[WARNING] uploadEpisodeActions: Error sanitizing episode URL %s: %v", action.Episode, err) + cleanEpisodeURL = action.Episode // Use original if sanitization fails + } + + // Get or create device ID if provided + var deviceID sql.NullInt64 + if action.Device != "" { + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 + ` + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? + ` + } + + err := tx.QueryRow(query, userID, action.Device).Scan(&deviceID.Int64) + + if err != nil { + if err == sql.ErrNoRows { + // Create the device if it doesn't exist + + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) + RETURNING DeviceID + ` + err = tx.QueryRow(query, userID, action.Device).Scan(&deviceID.Int64) + } else { + query = ` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) + ` + result, err := tx.Exec(query, userID, action.Device, "other") + if err != nil { + log.Printf("[ERROR] uploadEpisodeActions: Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("[ERROR] uploadEpisodeActions: Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + deviceID.Int64 = lastID + } + + if err != nil { + log.Printf("[ERROR] uploadEpisodeActions: Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + deviceID.Valid = true + } else { + log.Printf("[ERROR] uploadEpisodeActions: Error getting device ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + return + } + } else { + deviceID.Valid = true + } + } + + // Parse timestamp from interface{} to int64 + actionTimestamp := timestamp + if action.Timestamp != nil { + switch t := action.Timestamp.(type) { + case float64: + actionTimestamp = int64(t) + case int64: + actionTimestamp = t + case int: + actionTimestamp = int64(t) + case string: + // First try to parse as Unix timestamp + if ts, err := strconv.ParseInt(t, 10, 64); err == nil { + actionTimestamp = ts + } else { + // Try parsing as ISO date (2025-04-23T12:18:51) + if parsedTime, err := time.Parse(time.RFC3339, t); err == nil { + actionTimestamp = parsedTime.Unix() + log.Printf("[DEBUG] uploadEpisodeActions: Parsed ISO timestamp '%s' to Unix timestamp %d", t, actionTimestamp) + } else { + // Try some other common formats + formats := []string{ + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + + parsed := false + for _, format := range formats { + if parsedTime, err := time.Parse(format, t); err == nil { + actionTimestamp = parsedTime.Unix() + parsed = true + break + } + } + + if !parsed { + log.Printf("[WARNING] uploadEpisodeActions: Could not parse timestamp '%s', using current time", t) + } + } + } + default: + log.Printf("[WARNING] uploadEpisodeActions: Unknown timestamp type, using current time") + } + } + + // Insert action + var insertQuery string + + if database.IsPostgreSQLDB() { + insertQuery = ` + INSERT INTO "GpodderSyncEpisodeActions" + (UserID, DeviceID, PodcastURL, EpisodeURL, Action, Timestamp, Started, Position, Total) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ` + } else { + insertQuery = ` + INSERT INTO GpodderSyncEpisodeActions + (UserID, DeviceID, PodcastURL, EpisodeURL, Action, Timestamp, Started, Position, Total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + } + + _, err = tx.Exec(insertQuery, + userID, + deviceID, + cleanPodcastURL, + cleanEpisodeURL, + action.Action, + actionTimestamp, + action.Started, + action.Position, + action.Total) + + if err != nil { + log.Printf("[ERROR] uploadEpisodeActions: Error inserting episode action: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save episode action"}) + return + } + + // Add to updateURLs if URLs were cleaned + if cleanPodcastURL != action.Podcast { + updateURLs = append(updateURLs, []string{action.Podcast, cleanPodcastURL}) + } + if cleanEpisodeURL != action.Episode { + updateURLs = append(updateURLs, []string{action.Episode, cleanEpisodeURL}) + } + + // For play action with position > 0, update episode status in Pinepods database + if action.Action == "play" && action.Position != nil && *action.Position > 0 { + // Try to find episode ID in Episodes table + var episodeID int + var findEpisodeQuery string + + if database.IsPostgreSQLDB() { + findEpisodeQuery = ` + SELECT e.EpisodeID + FROM "Episodes" e + JOIN "Podcasts" p ON e.PodcastID = p.PodcastID + WHERE p.FeedURL = $1 AND e.EpisodeURL = $2 AND p.UserID = $3 + ` + } else { + findEpisodeQuery = ` + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE p.FeedURL = ? AND e.EpisodeURL = ? AND p.UserID = ? + ` + } + + err := tx.QueryRow(findEpisodeQuery, cleanPodcastURL, cleanEpisodeURL, userID).Scan(&episodeID) + + if err == nil { // Episode found + // Try to update existing history record + var updateHistoryQuery string + + if database.IsPostgreSQLDB() { + updateHistoryQuery = ` + UPDATE "UserEpisodeHistory" + SET ListenDuration = $1, ListenDate = $2 + WHERE UserID = $3 AND EpisodeID = $4 + ` + } else { + updateHistoryQuery = ` + UPDATE UserEpisodeHistory + SET ListenDuration = ?, ListenDate = ? + WHERE UserID = ? AND EpisodeID = ? + ` + } + + result, err := tx.Exec(updateHistoryQuery, action.Position, time.Unix(actionTimestamp, 0), userID, episodeID) + + if err != nil { + log.Printf("[WARNING] uploadEpisodeActions: Error updating episode history: %v", err) + } else { + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + // No history exists, create it + var insertHistoryQuery string + + if database.IsPostgreSQLDB() { + insertHistoryQuery = ` + INSERT INTO "UserEpisodeHistory" + (UserID, EpisodeID, ListenDuration, ListenDate) + VALUES ($1, $2, $3, $4) + ON CONFLICT (UserID, EpisodeID) DO UPDATE + SET ListenDuration = $3, ListenDate = $4 + ` + } else { + insertHistoryQuery = ` + INSERT INTO UserEpisodeHistory + (UserID, EpisodeID, ListenDuration, ListenDate) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + ListenDuration = VALUES(ListenDuration), ListenDate = VALUES(ListenDate) + ` + } + + _, err = tx.Exec(insertHistoryQuery, userID, episodeID, action.Position, time.Unix(actionTimestamp, 0)) + + if err != nil { + log.Printf("[WARNING] uploadEpisodeActions: Error creating episode history: %v", err) + } + } + } + } + } + } + + // Commit transaction + if err = tx.Commit(); err != nil { + log.Printf("[ERROR] uploadEpisodeActions: Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return response + c.JSON(http.StatusOK, models.EpisodeActionResponse{ + Timestamp: timestamp, + UpdateURLs: updateURLs, + }) + } +} + +// getFavoriteEpisodes handles GET /api/2/favorites/{username}.json +func getFavoriteEpisodes(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, _ := c.Get("userID") + + // Query for favorite episodes + // Here we identify favorites by checking for episodes with the "is_favorite" setting + var query string + var rows *sql.Rows + var err error + + if database.IsPostgreSQLDB() { + query = ` + SELECT + e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork, + p.PodcastName, p.FeedURL, e.EpisodePubDate + FROM "Episodes" e + JOIN "Podcasts" p ON e.PodcastID = p.PodcastID + JOIN "GpodderSyncSettings" s ON s.UserID = p.UserID + AND s.PodcastURL = p.FeedURL + AND s.EpisodeURL = e.EpisodeURL + WHERE s.UserID = $1 + AND s.Scope = 'episode' + AND s.SettingKey = 'is_favorite' + AND s.SettingValue = 'true' + ORDER BY e.EpisodePubDate DESC + ` + rows, err = database.Query(query, userID) + } else { + query = ` + SELECT + e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork, + p.PodcastName, p.FeedURL, e.EpisodePubDate + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + JOIN GpodderSyncSettings s ON s.UserID = p.UserID + AND s.PodcastURL = p.FeedURL + AND s.EpisodeURL = e.EpisodeURL + WHERE s.UserID = ? + AND s.Scope = 'episode' + AND s.SettingKey = 'is_favorite' + AND s.SettingValue = 'true' + ORDER BY e.EpisodePubDate DESC + ` + rows, err = database.Query(query, userID) + } + + if err != nil { + log.Printf("Error querying favorite episodes: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get favorite episodes"}) + return + } + defer rows.Close() + + // Build response + favorites := make([]models.Episode, 0) + for rows.Next() { + var episode models.Episode + var pubDate time.Time + if err := rows.Scan( + &episode.Title, + &episode.URL, + &episode.Description, + &episode.Website, // Using EpisodeArtwork for Website for now + &episode.PodcastTitle, + &episode.PodcastURL, + &pubDate, + ); err != nil { + log.Printf("Error scanning favorite episode: %v", err) + continue + } + // Format the publication date in ISO 8601 + episode.Released = pubDate.Format(time.RFC3339) + // Set MygpoLink (just a placeholder for now) + episode.MygpoLink = fmt.Sprintf("/episode/%s", episode.URL) + favorites = append(favorites, episode) + } + c.JSON(http.StatusOK, favorites) + } +} + +// getEpisodeData handles GET /api/2/data/episode.json +// getEpisodeData handles GET /api/2/data/episode.json +func getEpisodeData(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Parse query parameters + podcastURL := c.Query("podcast") + episodeURL := c.Query("url") + if podcastURL == "" || episodeURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Both podcast and url parameters are required"}) + return + } + + // Query for episode data + var episode models.Episode + var pubDate time.Time + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT + e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork, + p.PodcastName, p.FeedURL, e.EpisodePubDate + FROM "Episodes" e + JOIN "Podcasts" p ON e.PodcastID = p.PodcastID + WHERE p.FeedURL = $1 AND e.EpisodeURL = $2 + LIMIT 1 + ` + } else { + query = ` + SELECT + e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork, + p.PodcastName, p.FeedURL, e.EpisodePubDate + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE p.FeedURL = ? AND e.EpisodeURL = ? + LIMIT 1 + ` + } + + err := database.QueryRow(query, podcastURL, episodeURL).Scan( + &episode.Title, + &episode.URL, + &episode.Description, + &episode.Website, // Using EpisodeArtwork for Website for now + &episode.PodcastTitle, + &episode.PodcastURL, + &pubDate, + ) + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Episode not found"}) + } else { + log.Printf("Error querying episode data: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode data"}) + } + return + } + + // Format the publication date in ISO 8601 + episode.Released = pubDate.Format(time.RFC3339) + // Set MygpoLink (just a placeholder for now) + episode.MygpoLink = fmt.Sprintf("/episode/%s", episode.URL) + c.JSON(http.StatusOK, episode) + } +} diff --git a/PinePods-0.8.2/gpodder-api/internal/api/list.go b/PinePods-0.8.2/gpodder-api/internal/api/list.go new file mode 100644 index 0000000..baddea1 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/list.go @@ -0,0 +1,670 @@ +package api + +import ( + "database/sql" + "fmt" + "log" + "net/http" + "regexp" + "strings" + + "pinepods/gpodder-api/internal/db" + "pinepods/gpodder-api/internal/models" + + "github.com/gin-gonic/gin" +) + +// getUserLists handles GET /api/2/lists/{username}.json +func getUserLists(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware (if authenticated) + userID, exists := c.Get("userID") + username := c.Param("username") + + // If not authenticated, get user ID from username + if !exists { + var query string + + if database.IsPostgreSQLDB() { + query = `SELECT UserID FROM "Users" WHERE Username = $1` + } else { + query = `SELECT UserID FROM Users WHERE Username = ?` + } + + err := database.QueryRow(query, username).Scan(&userID) + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + } else { + log.Printf("Error getting user ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) + } + return + } + } + + // Query for user's podcast lists + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT ListID, Name, Title + FROM "GpodderSyncPodcastLists" + WHERE UserID = $1 + ` + } else { + query = ` + SELECT ListID, Name, Title + FROM GpodderSyncPodcastLists + WHERE UserID = ? + ` + } + + rows, err := database.Query(query, userID) + + if err != nil { + log.Printf("Error querying podcast lists: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast lists"}) + return + } + defer rows.Close() + + // Build response + lists := make([]models.PodcastList, 0) + for rows.Next() { + var list models.PodcastList + + if err := rows.Scan(&list.ListID, &list.Name, &list.Title); err != nil { + log.Printf("Error scanning podcast list: %v", err) + continue + } + + // Generate web URL + list.WebURL = fmt.Sprintf("/user/%s/lists/%s", username, list.Name) + + lists = append(lists, list) + } + + c.JSON(http.StatusOK, lists) + } +} + +// createPodcastList handles POST /api/2/lists/{username}/create +func createPodcastList(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, _ := c.Get("userID") + username := c.Param("username") + + // Get title from query parameter + title := c.Query("title") + if title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required"}) + return + } + + // Get format from query parameter or default to json + format := c.Query("format") + if format == "" { + format = "json" + } + + // Parse body for podcast URLs + var podcastURLs []string + + switch format { + case "json": + if err := c.ShouldBindJSON(&podcastURLs); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + case "txt": + body, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + + // Split by newlines + lines := strings.Split(string(body), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + podcastURLs = append(podcastURLs, line) + } + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) + return + } + + // Generate name from title + name := generateNameFromTitle(title) + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Check if a list with this name already exists + var existingID int + var existsQuery string + + if database.IsPostgreSQLDB() { + existsQuery = ` + SELECT ListID FROM "GpodderSyncPodcastLists" + WHERE UserID = $1 AND Name = $2 + ` + } else { + existsQuery = ` + SELECT ListID FROM GpodderSyncPodcastLists + WHERE UserID = ? AND Name = ? + ` + } + + err = tx.QueryRow(existsQuery, userID, name).Scan(&existingID) + + if err == nil { + // List already exists + c.JSON(http.StatusConflict, gin.H{"error": "A podcast list with this name already exists"}) + return + } else if err != sql.ErrNoRows { + log.Printf("Error checking list existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check list existence"}) + return + } + + // Create new list + var listID int + + if database.IsPostgreSQLDB() { + err = tx.QueryRow(` + INSERT INTO "GpodderSyncPodcastLists" (UserID, Name, Title) + VALUES ($1, $2, $3) + RETURNING ListID + `, userID, name, title).Scan(&listID) + } else { + var result sql.Result + result, err = tx.Exec(` + INSERT INTO GpodderSyncPodcastLists (UserID, Name, Title) + VALUES (?, ?, ?) + `, userID, name, title) + + if err != nil { + log.Printf("Error creating podcast list: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"}) + return + } + + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"}) + return + } + + listID = int(lastID) + } + + if err != nil { + log.Printf("Error creating podcast list: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"}) + return + } + + // Add podcasts to list + for _, url := range podcastURLs { + var insertQuery string + + if database.IsPostgreSQLDB() { + insertQuery = ` + INSERT INTO "GpodderSyncPodcastListEntries" (ListID, PodcastURL) + VALUES ($1, $2) + ` + } else { + insertQuery = ` + INSERT INTO GpodderSyncPodcastListEntries (ListID, PodcastURL) + VALUES (?, ?) + ` + } + + _, err = tx.Exec(insertQuery, listID, url) + + if err != nil { + log.Printf("Error adding podcast to list: %v", err) + // Continue with other podcasts + } + } + + // Commit transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return success with redirect location + c.Header("Location", fmt.Sprintf("/api/2/lists/%s/list/%s?format=%s", username, name, format)) + c.Status(http.StatusSeeOther) + } +} + +// getPodcastList handles GET /api/2/lists/{username}/list/{listname} +func getPodcastList(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get username and listname from URL + username := c.Param("username") + listName := c.Param("listname") + + // Get format from query parameter or default to json + format := c.Query("format") + if format == "" { + format = "json" + } + + // Get user ID from username + var userID int + var query string + + if database.IsPostgreSQLDB() { + query = `SELECT UserID FROM "Users" WHERE Username = $1` + } else { + query = `SELECT UserID FROM Users WHERE Username = ?` + } + + err := database.QueryRow(query, username).Scan(&userID) + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + } else { + log.Printf("Error getting user ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) + } + return + } + + // Get list info + var listID int + var title string + + if database.IsPostgreSQLDB() { + query = ` + SELECT ListID, Title FROM "GpodderSyncPodcastLists" + WHERE UserID = $1 AND Name = $2 + ` + } else { + query = ` + SELECT ListID, Title FROM GpodderSyncPodcastLists + WHERE UserID = ? AND Name = ? + ` + } + + err = database.QueryRow(query, userID, listName).Scan(&listID, &title) + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"}) + } else { + log.Printf("Error getting podcast list: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"}) + } + return + } + + // Get podcasts in list + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + query = ` + SELECT e.PodcastURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL + FROM "GpodderSyncPodcastListEntries" e + LEFT JOIN "Podcasts" p ON e.PodcastURL = p.FeedURL + WHERE e.ListID = $1 + ` + rows, err = database.Query(query, listID) + } else { + query = ` + SELECT e.PodcastURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL + FROM GpodderSyncPodcastListEntries e + LEFT JOIN Podcasts p ON e.PodcastURL = p.FeedURL + WHERE e.ListID = ? + ` + rows, err = database.Query(query, listID) + } + + if err != nil { + log.Printf("Error querying podcasts in list: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcasts in list"}) + return + } + defer rows.Close() + + // Build podcast list + podcasts := make([]models.Podcast, 0) + for rows.Next() { + var podcast models.Podcast + var podcastName, description, author, artworkURL, websiteURL sql.NullString + + if err := rows.Scan(&podcast.URL, &podcastName, &description, &author, &artworkURL, &websiteURL); err != nil { + log.Printf("Error scanning podcast: %v", err) + continue + } + + // Set values if present + if podcastName.Valid { + podcast.Title = podcastName.String + } else { + podcast.Title = podcast.URL + } + + if description.Valid { + podcast.Description = description.String + } + + if author.Valid { + podcast.Author = author.String + } + + if artworkURL.Valid { + podcast.LogoURL = artworkURL.String + } + + if websiteURL.Valid { + podcast.Website = websiteURL.String + } + + // Add MygpoLink + podcast.MygpoLink = fmt.Sprintf("/podcast/%s", podcast.URL) + + podcasts = append(podcasts, podcast) + } + + // Return in requested format + switch format { + case "json": + c.JSON(http.StatusOK, podcasts) + case "txt": + // Plain text format - just URLs + var sb strings.Builder + for _, podcast := range podcasts { + sb.WriteString(podcast.URL) + sb.WriteString("\n") + } + c.String(http.StatusOK, sb.String()) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) + } + } +} + +// updatePodcastList handles PUT /api/2/lists/{username}/list/{listname} +// updatePodcastList handles PUT /api/2/lists/{username}/list/{listname} +func updatePodcastList(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, _ := c.Get("userID") + listName := c.Param("listname") + + // Get format from query parameter or default to json + format := c.Query("format") + if format == "" { + format = "json" + } + + // Parse body for podcast URLs + var podcastURLs []string + + switch format { + case "json": + if err := c.ShouldBindJSON(&podcastURLs); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + case "txt": + body, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + + // Split by newlines + lines := strings.Split(string(body), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + podcastURLs = append(podcastURLs, line) + } + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) + return + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Get list ID + var listID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT ListID FROM "GpodderSyncPodcastLists" + WHERE UserID = $1 AND Name = $2 + ` + } else { + query = ` + SELECT ListID FROM GpodderSyncPodcastLists + WHERE UserID = ? AND Name = ? + ` + } + + err = tx.QueryRow(query, userID, listName).Scan(&listID) + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"}) + } else { + log.Printf("Error getting podcast list: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"}) + } + return + } + + // Remove existing entries + if database.IsPostgreSQLDB() { + query = ` + DELETE FROM "GpodderSyncPodcastListEntries" + WHERE ListID = $1 + ` + } else { + query = ` + DELETE FROM GpodderSyncPodcastListEntries + WHERE ListID = ? + ` + } + + _, err = tx.Exec(query, listID) + + if err != nil { + log.Printf("Error removing existing entries: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update podcast list"}) + return + } + + // Add new entries + for _, url := range podcastURLs { + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncPodcastListEntries" (ListID, PodcastURL) + VALUES ($1, $2) + ` + } else { + query = ` + INSERT INTO GpodderSyncPodcastListEntries (ListID, PodcastURL) + VALUES (?, ?) + ` + } + + _, err = tx.Exec(query, listID, url) + + if err != nil { + log.Printf("Error adding podcast to list: %v", err) + // Continue with other podcasts + } + } + + // Commit transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return success + c.Status(http.StatusNoContent) + } +} + +// deletePodcastList handles DELETE /api/2/lists/{username}/list/{listname} +// deletePodcastList handles DELETE /api/2/lists/{username}/list/{listname} +func deletePodcastList(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, _ := c.Get("userID") + listName := c.Param("listname") + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Get list ID + var listID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT ListID FROM "GpodderSyncPodcastLists" + WHERE UserID = $1 AND Name = $2 + ` + } else { + query = ` + SELECT ListID FROM GpodderSyncPodcastLists + WHERE UserID = ? AND Name = ? + ` + } + + err = tx.QueryRow(query, userID, listName).Scan(&listID) + + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"}) + } else { + log.Printf("Error getting podcast list: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"}) + } + return + } + + // Delete list entries first (cascade should handle this, but being explicit) + if database.IsPostgreSQLDB() { + query = ` + DELETE FROM "GpodderSyncPodcastListEntries" + WHERE ListID = $1 + ` + } else { + query = ` + DELETE FROM GpodderSyncPodcastListEntries + WHERE ListID = ? + ` + } + + _, err = tx.Exec(query, listID) + + if err != nil { + log.Printf("Error deleting list entries: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete podcast list"}) + return + } + + // Delete list + if database.IsPostgreSQLDB() { + query = ` + DELETE FROM "GpodderSyncPodcastLists" + WHERE ListID = $1 + ` + } else { + query = ` + DELETE FROM GpodderSyncPodcastLists + WHERE ListID = ? + ` + } + + _, err = tx.Exec(query, listID) + + if err != nil { + log.Printf("Error deleting podcast list: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete podcast list"}) + return + } + + // Commit transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return success + c.Status(http.StatusNoContent) + } +} + +// Helper function to generate a URL-friendly name from a title +func generateNameFromTitle(title string) string { + // Convert to lowercase + name := strings.ToLower(title) + + // Replace spaces with hyphens + name = strings.ReplaceAll(name, " ", "-") + + // Remove special characters + re := regexp.MustCompile(`[^a-z0-9-]`) + name = re.ReplaceAllString(name, "") + + // Ensure name is not empty + if name == "" { + name = "list" + } + + return name +} diff --git a/PinePods-0.8.2/gpodder-api/internal/api/routes.go b/PinePods-0.8.2/gpodder-api/internal/api/routes.go new file mode 100644 index 0000000..2a92f24 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/routes.go @@ -0,0 +1,95 @@ +package api + +import ( + "log" + "pinepods/gpodder-api/internal/db" + + "github.com/gin-gonic/gin" +) + +// Add or update in routes.go to ensure the Episode API routes are registered: + +// RegisterRoutes registers all API routes +func RegisterRoutes(router *gin.RouterGroup, database *db.Database) { + // Authentication endpoints + log.Println("[INFO] Registering API routes...") + authGroup := router.Group("/auth/:username") + { + authGroup.POST("/login.json", handleLogin(database)) + authGroup.POST("/logout.json", handleLogout(database)) + } + // Device API + log.Println("[INFO] Registering device routes") + router.GET("/devices/:username.json", AuthenticationMiddleware(database), listDevices(database)) + router.POST("/devices/:username/:deviceid", AuthenticationMiddleware(database), updateDeviceData(database)) + router.GET("/updates/:username/:deviceid", AuthenticationMiddleware(database), getDeviceUpdates(database)) + + // Subscriptions API + subscriptionsGroup := router.Group("/subscriptions/:username") + subscriptionsGroup.Use(AuthenticationMiddleware(database)) + { + subscriptionsGroup.GET("/:deviceid", getSubscriptions(database)) + subscriptionsGroup.PUT("/:deviceid", updateSubscriptions(database)) + subscriptionsGroup.POST("/:deviceid", uploadSubscriptionChanges(database)) + // All subscriptions endpoint (since 2.11) + subscriptionsGroup.GET(".json", getAllSubscriptions(database)) + } + + // Episode Actions API - FIXED ROUTE PATTERN + log.Println("[INFO] Registering episode actions routes") + // Register directly on the router without a group + router.GET("/episodes/:username.json", AuthenticationMiddleware(database), getEpisodeActions(database)) + router.POST("/episodes/:username.json", AuthenticationMiddleware(database), uploadEpisodeActions(database)) + + // Settings API + settingsGroup := router.Group("/settings/:username") + settingsGroup.Use(AuthenticationMiddleware(database)) + { + settingsGroup.GET("/:scope.json", getSettings(database)) + settingsGroup.POST("/:scope.json", saveSettings(database)) + } + + // Podcast Lists API + listsGroup := router.Group("/lists/:username") + { + listsGroup.GET(".json", getUserLists(database)) + listsGroup.POST("/create", AuthenticationMiddleware(database), createPodcastList(database)) + listGroup := listsGroup.Group("/list/:listname") + { + listGroup.GET("", getPodcastList(database)) + listGroup.PUT("", AuthenticationMiddleware(database), updatePodcastList(database)) + listGroup.DELETE("", AuthenticationMiddleware(database), deletePodcastList(database)) + } + } + + // Favorite Episodes API + router.GET("/favorites/:username.json", AuthenticationMiddleware(database), getFavoriteEpisodes(database)) + + // Device Synchronization API + syncGroup := router.Group("/sync-devices/:username") + syncGroup.Use(AuthenticationMiddleware(database)) + { + syncGroup.GET(".json", getSyncStatus(database)) + syncGroup.POST(".json", updateSyncStatus(database)) + } + + // Directory API (no auth required) + router.GET("/tags/:count.json", getTopTags(database)) + router.GET("/tag/:tag/:count.json", getPodcastsForTag(database)) + router.GET("/data/podcast.json", getPodcastData(database)) + router.GET("/data/episode.json", getEpisodeData(database)) + + // Suggestions API (auth required) + router.GET("/suggestions/:count", AuthenticationMiddleware(database), getSuggestions(database)) +} + +// RegisterSimpleRoutes registers routes for the Simple API (v1) +func RegisterSimpleRoutes(router *gin.RouterGroup, database *db.Database) { + // Toplist + router.GET("/toplist/:number", getToplist(database)) + // Search + router.GET("/search", podcastSearch(database)) + // Subscriptions (Simple API) + router.GET("/subscriptions/:username/:deviceid", AuthenticationMiddleware(database), getSubscriptionsSimple(database)) + router.PUT("/subscriptions/:username/:deviceid", AuthenticationMiddleware(database), updateSubscriptionsSimple(database)) +} diff --git a/PinePods-0.8.2/gpodder-api/internal/api/settings.go b/PinePods-0.8.2/gpodder-api/internal/api/settings.go new file mode 100644 index 0000000..a49ba33 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/settings.go @@ -0,0 +1,991 @@ +package api + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + "unicode/utf8" + + "pinepods/gpodder-api/internal/db" + "pinepods/gpodder-api/internal/models" + + "github.com/gin-gonic/gin" +) + +// Constants for settings +const ( + MAX_SETTING_KEY_LENGTH = 255 + MAX_SETTING_VALUE_LENGTH = 8192 + MAX_SETTINGS_PER_REQUEST = 50 +) + +// Known settings that trigger behavior +var knownSettings = map[string]map[string]bool{ + "account": { + "public_profile": true, + "store_user_agent": true, + "public_subscriptions": true, + "color_theme": true, + "default_subscribe_all": true, + }, + "episode": { + "is_favorite": true, + "played": true, + "current_position": true, + }, + "podcast": { + "public_subscription": true, + "auto_download": true, + "episode_sort": true, + }, + "device": { + "auto_update": true, + "update_interval": true, + "wifi_only_downloads": true, + "max_episodes_per_feed": true, + }, +} + +// Validation interfaces and functions + +// ValueValidator defines interface for validating settings values +type ValueValidator interface { + Validate(value interface{}) bool +} + +// BooleanValidator validates boolean values +type BooleanValidator struct{} + +func (v BooleanValidator) Validate(value interface{}) bool { + _, ok := value.(bool) + return ok +} + +// IntValidator validates integer values +type IntValidator struct { + Min int + Max int +} + +func (v IntValidator) Validate(value interface{}) bool { + num, ok := value.(float64) // JSON numbers are parsed as float64 + if !ok { + return false + } + + // Check if it's a whole number + if num != float64(int(num)) { + return false + } + + // Check range if specified + intVal := int(num) + if v.Min != 0 || v.Max != 0 { + if intVal < v.Min || (v.Max != 0 && intVal > v.Max) { + return false + } + } + + return true +} + +// StringValidator validates string values +type StringValidator struct { + AllowedValues []string + MaxLength int +} + +func (v StringValidator) Validate(value interface{}) bool { + str, ok := value.(string) + if !ok { + return false + } + + // Check maximum length if specified + if v.MaxLength > 0 && utf8.RuneCountInString(str) > v.MaxLength { + return false + } + + // Check allowed values if specified + if len(v.AllowedValues) > 0 { + for _, allowed := range v.AllowedValues { + if str == allowed { + return true + } + } + return false + } + + return true +} + +// validation rules for specific settings +var settingValidators = map[string]map[string]ValueValidator{ + "account": { + "public_profile": BooleanValidator{}, + "store_user_agent": BooleanValidator{}, + "public_subscriptions": BooleanValidator{}, + "default_subscribe_all": BooleanValidator{}, + "color_theme": StringValidator{AllowedValues: []string{"light", "dark", "system"}, MaxLength: 10}, + }, + "episode": { + "is_favorite": BooleanValidator{}, + "played": BooleanValidator{}, + "current_position": IntValidator{Min: 0}, + }, + "podcast": { + "public_subscription": BooleanValidator{}, + "auto_download": BooleanValidator{}, + "episode_sort": StringValidator{AllowedValues: []string{"newest_first", "oldest_first", "title"}, MaxLength: 20}, + }, + "device": { + "auto_update": BooleanValidator{}, + "update_interval": IntValidator{Min: 10, Max: 1440}, // 10 minutes to 24 hours + "wifi_only_downloads": BooleanValidator{}, + "max_episodes_per_feed": IntValidator{Min: 1, Max: 1000}, + }, +} + +// validateSettingValue validates a setting value based on its scope and key +func validateSettingValue(scope, key string, value interface{}) (bool, string) { + // Maximum setting value length check + jsonValue, err := json.Marshal(value) + if err != nil { + return false, "Failed to serialize setting value" + } + + if len(jsonValue) > MAX_SETTING_VALUE_LENGTH { + return false, "Setting value exceeds maximum length" + } + + // Check if we have a specific validator for this setting + if validators, ok := settingValidators[scope]; ok { + if validator, ok := validators[key]; ok { + if !validator.Validate(value) { + return false, "Setting value failed validation for the specified scope and key" + } + } + } + + return true, "" +} + +// getSettings handles GET /api/2/settings/{username}/{scope}.json +func getSettings(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get scope from URL + scope := c.Param("scope") + if scope == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Scope is required"}) + return + } + + // Validate scope + if scope != "account" && scope != "device" && scope != "podcast" && scope != "episode" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid scope. Valid values are: account, device, podcast, episode", + }) + return + } + + // Get optional query parameters + deviceID := c.Query("device") + podcastURL := c.Query("podcast") + episodeURL := c.Query("episode") + + // Validate parameters based on scope + if scope == "device" && deviceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required for device scope"}) + return + } + if scope == "podcast" && podcastURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL is required for podcast scope"}) + return + } + if scope == "episode" && (podcastURL == "" || episodeURL == "") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL and Episode URL are required for episode scope"}) + return + } + + // Build query based on scope + var query string + var args []interface{} + + switch scope { + case "account": + if database.IsPostgreSQLDB() { + query = ` + SELECT SettingKey, SettingValue + FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 + AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope) + } else { + query = ` + SELECT SettingKey, SettingValue + FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? + AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope) + } + case "device": + // Get device ID from name + var deviceIDInt int + var deviceQuery string + + if database.IsPostgreSQLDB() { + deviceQuery = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true + ` + } else { + deviceQuery = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? AND IsActive = true + ` + } + + err := database.QueryRow(deviceQuery, userID, deviceID).Scan(&deviceIDInt) + + if err != nil { + if err == sql.ErrNoRows { + log.Printf("Device not found: %s", deviceID) + c.JSON(http.StatusNotFound, gin.H{"error": "Device not found or not active"}) + } else { + log.Printf("Error getting device ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + } + return + } + + if database.IsPostgreSQLDB() { + query = ` + SELECT SettingKey, SettingValue + FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3 + AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, deviceIDInt) + } else { + query = ` + SELECT SettingKey, SettingValue + FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND DeviceID = ? + AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, deviceIDInt) + } + case "podcast": + // Validate podcast URL + if !isValidURL(podcastURL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast URL"}) + return + } + + if database.IsPostgreSQLDB() { + query = ` + SELECT SettingKey, SettingValue + FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 + AND DeviceID IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, podcastURL) + } else { + query = ` + SELECT SettingKey, SettingValue + FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND PodcastURL = ? + AND DeviceID IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, podcastURL) + } + case "episode": + // Validate URLs + if !isValidURL(podcastURL) || !isValidURL(episodeURL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast or episode URL"}) + return + } + + if database.IsPostgreSQLDB() { + query = ` + SELECT SettingKey, SettingValue + FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4 + AND DeviceID IS NULL + ` + args = append(args, userID, scope, podcastURL, episodeURL) + } else { + query = ` + SELECT SettingKey, SettingValue + FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ? + AND DeviceID IS NULL + ` + args = append(args, userID, scope, podcastURL, episodeURL) + } + } + + // Query settings + rows, err := database.Query(query, args...) + if err != nil { + log.Printf("Error querying settings: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"}) + return + } + defer rows.Close() + + // Build settings map + settings := make(map[string]interface{}) + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + log.Printf("Error scanning setting row: %v", err) + continue + } + + // Try to unmarshal as JSON, fallback to string if not valid JSON + var jsonValue interface{} + if err := json.Unmarshal([]byte(value), &jsonValue); err != nil { + // Not valid JSON, use as string + settings[key] = value + } else { + // Valid JSON, use parsed value + settings[key] = jsonValue + } + } + + if err := rows.Err(); err != nil { + log.Printf("Error iterating setting rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"}) + return + } + + // Return settings + c.JSON(http.StatusOK, settings) + } +} + +// isValidURL performs basic URL validation +func isValidURL(urlStr string) bool { + // Check if empty + if urlStr == "" { + return false + } + + // Must start with http:// or https:// + if !strings.HasPrefix(strings.ToLower(urlStr), "http://") && + !strings.HasPrefix(strings.ToLower(urlStr), "https://") { + return false + } + + // Basic length check + if len(urlStr) < 10 || len(urlStr) > 2048 { + return false + } + + return true +} + +// saveSettings handles POST /api/2/settings/{username}/{scope}.json +func saveSettings(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get scope from URL + scope := c.Param("scope") + if scope == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Scope is required"}) + return + } + + // Validate scope + if scope != "account" && scope != "device" && scope != "podcast" && scope != "episode" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid scope. Valid values are: account, device, podcast, episode", + }) + return + } + + // Get optional query parameters + deviceName := c.Query("device") + podcastURL := c.Query("podcast") + episodeURL := c.Query("episode") + + // Validate parameters based on scope + if scope == "device" && deviceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required for device scope"}) + return + } + if scope == "podcast" && podcastURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL is required for podcast scope"}) + return + } + if scope == "episode" && (podcastURL == "" || episodeURL == "") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL and Episode URL are required for episode scope"}) + return + } + + // Validate URLs + if scope == "podcast" && !isValidURL(podcastURL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast URL"}) + return + } + if scope == "episode" && (!isValidURL(podcastURL) || !isValidURL(episodeURL)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast or episode URL"}) + return + } + + // Parse request body + var req models.SettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("Error parsing request: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'set' and 'remove' properties"}) + return + } + + // Validate request size + if len(req.Set) > MAX_SETTINGS_PER_REQUEST || len(req.Remove) > MAX_SETTINGS_PER_REQUEST { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Too many settings in request. Maximum allowed: %d", MAX_SETTINGS_PER_REQUEST), + }) + return + } + + // Process device ID if needed + var deviceID *int + if scope == "device" { + var deviceIDInt int + var deviceQuery string + + if database.IsPostgreSQLDB() { + deviceQuery = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true + ` + } else { + deviceQuery = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? AND IsActive = true + ` + } + + err := database.QueryRow(deviceQuery, userID, deviceName).Scan(&deviceIDInt) + + if err != nil { + if err == sql.ErrNoRows { + // Create the device if it doesn't exist + if database.IsPostgreSQLDB() { + deviceQuery = ` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, $3) + RETURNING DeviceID + ` + err = database.QueryRow(deviceQuery, userID, deviceName, time.Now()).Scan(&deviceIDInt) + } else { + deviceQuery = ` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, ?) + ` + result, err := database.Exec(deviceQuery, userID, deviceName, "other", time.Now()) + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + deviceIDInt = int(lastID) + } + + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + } else { + log.Printf("Error getting device ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + return + } + } + + deviceID = &deviceIDInt + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + return + } + }() + + // Process settings to set + for key, value := range req.Set { + // Validate key + if len(key) == 0 || len(key) > MAX_SETTING_KEY_LENGTH { + log.Printf("Invalid setting key length: %s", key) + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Invalid setting key: must be between 1 and %d characters", MAX_SETTING_KEY_LENGTH), + }) + return + } + + // Allow only letters, numbers, underscores and hyphens + if !isValidSettingKey(key) { + log.Printf("Invalid setting key: %s", key) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid setting key: must contain only letters, numbers, underscores and hyphens", + }) + return + } + + // Validate value + valid, errMsg := validateSettingValue(scope, key, value) + if !valid { + log.Printf("Invalid setting value for key %s: %s", key, errMsg) + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Invalid value for key '%s': %s", key, errMsg), + }) + return + } + + // Convert value to JSON string + jsonValue, err := json.Marshal(value) + if err != nil { + log.Printf("Error marshaling value to JSON: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid value for key: " + key}) + return + } + + // Build query based on scope + var query string + var args []interface{} + + switch scope { + case "account": + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncSettings" (UserID, Scope, SettingKey, SettingValue, LastUpdated) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (UserID, Scope, SettingKey) + WHERE DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL + DO UPDATE SET SettingValue = $4, LastUpdated = $5 + ` + args = append(args, userID, scope, key, string(jsonValue), time.Now()) + } else { + query = ` + INSERT INTO GpodderSyncSettings (UserID, Scope, SettingKey, SettingValue, LastUpdated) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated) + ` + args = append(args, userID, scope, key, string(jsonValue), time.Now()) + } + case "device": + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncSettings" (UserID, Scope, DeviceID, SettingKey, SettingValue, LastUpdated) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (UserID, Scope, SettingKey, DeviceID) + WHERE PodcastURL IS NULL AND EpisodeURL IS NULL + DO UPDATE SET SettingValue = $5, LastUpdated = $6 + ` + args = append(args, userID, scope, deviceID, key, string(jsonValue), time.Now()) + } else { + query = ` + INSERT INTO GpodderSyncSettings (UserID, Scope, DeviceID, SettingKey, SettingValue, LastUpdated) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated) + ` + args = append(args, userID, scope, deviceID, key, string(jsonValue), time.Now()) + } + case "podcast": + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncSettings" (UserID, Scope, PodcastURL, SettingKey, SettingValue, LastUpdated) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (UserID, Scope, SettingKey, PodcastURL) + WHERE DeviceID IS NULL AND EpisodeURL IS NULL + DO UPDATE SET SettingValue = $5, LastUpdated = $6 + ` + args = append(args, userID, scope, podcastURL, key, string(jsonValue), time.Now()) + } else { + query = ` + INSERT INTO GpodderSyncSettings (UserID, Scope, PodcastURL, SettingKey, SettingValue, LastUpdated) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated) + ` + args = append(args, userID, scope, podcastURL, key, string(jsonValue), time.Now()) + } + case "episode": + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncSettings" (UserID, Scope, PodcastURL, EpisodeURL, SettingKey, SettingValue, LastUpdated) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (UserID, Scope, SettingKey, PodcastURL, EpisodeURL) + WHERE DeviceID IS NULL + DO UPDATE SET SettingValue = $6, LastUpdated = $7 + ` + args = append(args, userID, scope, podcastURL, episodeURL, key, string(jsonValue), time.Now()) + } else { + query = ` + INSERT INTO GpodderSyncSettings (UserID, Scope, PodcastURL, EpisodeURL, SettingKey, SettingValue, LastUpdated) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated) + ` + args = append(args, userID, scope, podcastURL, episodeURL, key, string(jsonValue), time.Now()) + } + } + + // Execute query + _, err = tx.Exec(query, args...) + if err != nil { + log.Printf("Error setting value for key %s: %v", key, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) + return + } + } + + // Process settings to remove + for _, key := range req.Remove { + // Validate key + if len(key) == 0 || len(key) > MAX_SETTING_KEY_LENGTH { + log.Printf("Invalid setting key length: %s", key) + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Invalid setting key: must be between 1 and %d characters", MAX_SETTING_KEY_LENGTH), + }) + return + } + + // Allow only letters, numbers, underscores and hyphens + if !isValidSettingKey(key) { + log.Printf("Invalid setting key: %s", key) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid setting key: must contain only letters, numbers, underscores and hyphens", + }) + return + } + + // Build query based on scope + var query string + var args []interface{} + + switch scope { + case "account": + if database.IsPostgreSQLDB() { + query = ` + DELETE FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND SettingKey = $3 + AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, key) + } else { + query = ` + DELETE FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND SettingKey = ? + AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, key) + } + case "device": + if database.IsPostgreSQLDB() { + query = ` + DELETE FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3 AND SettingKey = $4 + AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, deviceID, key) + } else { + query = ` + DELETE FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND DeviceID = ? AND SettingKey = ? + AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, deviceID, key) + } + case "podcast": + if database.IsPostgreSQLDB() { + query = ` + DELETE FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND SettingKey = $4 + AND DeviceID IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, podcastURL, key) + } else { + query = ` + DELETE FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND SettingKey = ? + AND DeviceID IS NULL AND EpisodeURL IS NULL + ` + args = append(args, userID, scope, podcastURL, key) + } + case "episode": + if database.IsPostgreSQLDB() { + query = ` + DELETE FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4 AND SettingKey = $5 + AND DeviceID IS NULL + ` + args = append(args, userID, scope, podcastURL, episodeURL, key) + } else { + query = ` + DELETE FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ? AND SettingKey = ? + AND DeviceID IS NULL + ` + args = append(args, userID, scope, podcastURL, episodeURL, key) + } + } + + // Execute query + _, err = tx.Exec(query, args...) + if err != nil { + log.Printf("Error removing key %s: %v", key, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) + return + } + } + + // Commit transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) + return + } + + // Query all settings for the updated response + var queryAll string + var argsAll []interface{} + + switch scope { + case "account": + if database.IsPostgreSQLDB() { + queryAll = ` + SELECT SettingKey, SettingValue + FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 + AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + argsAll = append(argsAll, userID, scope) + } else { + queryAll = ` + SELECT SettingKey, SettingValue + FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? + AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + argsAll = append(argsAll, userID, scope) + } + case "device": + if database.IsPostgreSQLDB() { + queryAll = ` + SELECT SettingKey, SettingValue + FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3 + AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + argsAll = append(argsAll, userID, scope, deviceID) + } else { + queryAll = ` + SELECT SettingKey, SettingValue + FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND DeviceID = ? + AND PodcastURL IS NULL AND EpisodeURL IS NULL + ` + argsAll = append(argsAll, userID, scope, deviceID) + } + case "podcast": + if database.IsPostgreSQLDB() { + queryAll = ` + SELECT SettingKey, SettingValue + FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 + AND DeviceID IS NULL AND EpisodeURL IS NULL + ` + argsAll = append(argsAll, userID, scope, podcastURL) + } else { + queryAll = ` + SELECT SettingKey, SettingValue + FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND PodcastURL = ? + AND DeviceID IS NULL AND EpisodeURL IS NULL + ` + argsAll = append(argsAll, userID, scope, podcastURL) + } + case "episode": + if database.IsPostgreSQLDB() { + queryAll = ` + SELECT SettingKey, SettingValue + FROM "GpodderSyncSettings" + WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4 + AND DeviceID IS NULL + ` + argsAll = append(argsAll, userID, scope, podcastURL, episodeURL) + } else { + queryAll = ` + SELECT SettingKey, SettingValue + FROM GpodderSyncSettings + WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ? + AND DeviceID IS NULL + ` + argsAll = append(argsAll, userID, scope, podcastURL, episodeURL) + } + } + + // Query all settings + rows, err := database.Query(queryAll, argsAll...) + if err != nil { + log.Printf("Error querying all settings: %v", err) + c.JSON(http.StatusOK, gin.H{}) // Return empty object in case of error + return + } + defer rows.Close() + + // Build settings map + settings := make(map[string]interface{}) + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + log.Printf("Error scanning setting row: %v", err) + continue + } + + // Try to unmarshal as JSON, fallback to string if not valid JSON + var jsonValue interface{} + if err := json.Unmarshal([]byte(value), &jsonValue); err != nil { + // Not valid JSON, use as string + settings[key] = value + } else { + // Valid JSON, use parsed value + settings[key] = jsonValue + } + } + + if err := rows.Err(); err != nil { + log.Printf("Error iterating setting rows: %v", err) + c.JSON(http.StatusOK, gin.H{}) // Return empty object in case of error + return + } + + // Return updated settings + c.JSON(http.StatusOK, settings) + } +} + +// isValidSettingKey checks if the key contains only valid characters +func isValidSettingKey(key string) bool { + for _, r := range key { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' { + return false + } + } + return true +} + +// toggleGpodderAPI is a Pinepods-specific extension to enable/disable the gpodder API for a user +func toggleGpodderAPI(database *db.PostgresDB) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Parse request body + var req struct { + Enable bool `json:"enable"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Set Pod_Sync_Type based on enable flag + var podSyncType string + if req.Enable { + // Check if external gpodder sync is already enabled + var currentSyncType string + err := database.QueryRow(` + SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1 + `, userID).Scan(¤tSyncType) + + if err != nil { + log.Printf("Error getting current sync type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"}) + return + } + + if currentSyncType == "external" { + podSyncType = "both" + } else { + podSyncType = "gpodder" + } + } else { + // Check if external gpodder sync is enabled + var currentSyncType string + err := database.QueryRow(` + SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1 + `, userID).Scan(¤tSyncType) + + if err != nil { + log.Printf("Error getting current sync type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"}) + return + } + + if currentSyncType == "both" { + podSyncType = "external" + } else { + podSyncType = "None" + } + } + + // Update user's Pod_Sync_Type + _, err := database.Exec(` + UPDATE "Users" SET Pod_Sync_Type = $1 WHERE UserID = $2 + `, podSyncType, userID) + + if err != nil { + log.Printf("Error updating Pod_Sync_Type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"}) + return + } + + // Return success response + c.JSON(http.StatusOK, gin.H{ + "enabled": req.Enable, + "sync_type": podSyncType, + }) + } +} diff --git a/PinePods-0.8.2/gpodder-api/internal/api/subscriptions.go b/PinePods-0.8.2/gpodder-api/internal/api/subscriptions.go new file mode 100644 index 0000000..6993c14 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/subscriptions.go @@ -0,0 +1,1878 @@ +package api + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "pinepods/gpodder-api/internal/db" + "pinepods/gpodder-api/internal/models" + "pinepods/gpodder-api/internal/utils" + + "github.com/gin-gonic/gin" +) + +// Maximum number of subscriptions per user +const MAX_SUBSCRIPTIONS = 5000 + +// Limits for subscription sync to prevent overwhelming responses +const MAX_SUBSCRIPTION_CHANGES = 5000 // Reasonable limit for subscription changes per sync + +// sanitizeURL cleans and validates a URL +func sanitizeURL(rawURL string) (string, error) { + // Trim leading/trailing whitespace + trimmedURL := strings.TrimSpace(rawURL) + + // Check if URL is not empty + if trimmedURL == "" { + return "", fmt.Errorf("empty URL") + } + + // Parse URL to validate format + parsedURL, err := url.Parse(trimmedURL) + if err != nil { + return "", fmt.Errorf("invalid URL format: %w", err) + } + + // Ensure the URL has a scheme, default to https if missing + if parsedURL.Scheme == "" { + parsedURL.Scheme = "https" + } + + // Only allow http and https schemes + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return "", fmt.Errorf("unsupported URL scheme: %s", parsedURL.Scheme) + } + + // Ensure the URL has a host + if parsedURL.Host == "" { + return "", fmt.Errorf("URL missing host") + } + + // Return the sanitized URL + return parsedURL.String(), nil +} + +// Fix for getSubscriptions function in subscriptions.go +// Replace the entire getSubscriptions function with this implementation + +// getSubscriptions handles GET /api/2/subscriptions/{username}/{deviceid} +func getSubscriptions(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + log.Printf("[DEBUG] getSubscriptions: Starting request processing - %s %s", c.Request.Method, c.Request.URL.Path) + + // Get user ID from middleware + userID, exists := c.Get("userID") + if !exists { + log.Printf("[ERROR] getSubscriptions: userID not found in context") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + log.Printf("[DEBUG] getSubscriptions: userID found: %v", userID) + + // Get device ID from URL - with fix for .json suffix + deviceName := c.Param("deviceid") + // Remove .json suffix if present + if strings.HasSuffix(deviceName, ".json") { + deviceName = strings.TrimSuffix(deviceName, ".json") + } + + log.Printf("[DEBUG] getSubscriptions: Using device name: '%s'", deviceName) + + if deviceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) + return + } + + // Check if this is a subscription changes request (has 'since' parameter) + sinceStr := c.Query("since") + if sinceStr != "" { + // This is a subscription changes request + var since int64 = 0 + var err error + since, err = strconv.ParseInt(sinceStr, 10, 64) + if err != nil { + log.Printf("[ERROR] getSubscriptions: Invalid since parameter: %s", sinceStr) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since parameter"}) + return + } + + log.Printf("[DEBUG] getSubscriptions: Processing as subscription changes request with since: %d", since) + + // Get device ID from database + var deviceID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 + ` + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? + ` + } + + err = database.QueryRow(query, userID, deviceName).Scan(&deviceID) + + if err != nil { + if err == sql.ErrNoRows { + // Device doesn't exist, create it + log.Printf("[DEBUG] getSubscriptions: Device not found, creating new device") + + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) + RETURNING DeviceID + ` + err = database.QueryRow(query, userID, deviceName).Scan(&deviceID) + } else { + query = ` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) + ` + result, err := database.Exec(query, userID, deviceName) + if err != nil { + log.Printf("[ERROR] getSubscriptions: Failed to create device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("[ERROR] getSubscriptions: Failed to get last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + deviceID = int(lastID) + } + + if err != nil { + log.Printf("[ERROR] getSubscriptions: Failed to create device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + } else { + log.Printf("[ERROR] getSubscriptions: Error getting device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + return + } + } + + // If since is 0, this is likely the initial request and we should return all subscriptions + if since == 0 { + // Get all podcasts for this user + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + query = `SELECT FeedURL FROM "Podcasts" WHERE UserID = $1` + } else { + query = `SELECT FeedURL FROM Podcasts WHERE UserID = ?` + } + + rows, err = database.Query(query, userID) + + if err != nil { + log.Printf("[ERROR] getSubscriptions: Error querying podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) + return + } + defer rows.Close() + + // Build subscription list - ensure never nil + podcasts := make([]string, 0) + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + log.Printf("[ERROR] getSubscriptions: Error scanning podcast URL: %v", err) + continue + } + podcasts = append(podcasts, url) + } + + if err = rows.Err(); err != nil { + log.Printf("[ERROR] getSubscriptions: Error iterating podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) + return + } + + // Update device's last sync time + if database.IsPostgreSQLDB() { + query = ` + UPDATE "GpodderDevices" + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = $1 + ` + } else { + query = ` + UPDATE GpodderDevices + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = ? + ` + } + + _, err = database.Exec(query, deviceID) + + if err != nil { + // Non-critical error, just log it + log.Printf("[WARNING] Error updating device last sync time: %v", err) + } + + // Return subscriptions in gpodder format, ensuring backward compatibility + response := gin.H{ + "add": podcasts, + "remove": []string{}, + "timestamp": time.Now().Unix(), + } + + log.Printf("[DEBUG] getSubscriptions: Returning initial subscription list with %d podcasts", len(podcasts)) + c.Header("Content-Type", "application/json") + c.JSON(http.StatusOK, response) + return + } + + // Process actual changes since the timestamp + // Query subscriptions added since the given timestamp - simplified for performance + var addRows *sql.Rows + + if database.IsPostgreSQLDB() { + query = ` + SELECT s.PodcastURL + FROM "GpodderSyncSubscriptions" s + WHERE s.UserID = $1 + AND s.DeviceID != $2 + AND s.Timestamp > $3 + AND s.Action = 'add' + GROUP BY s.PodcastURL + ORDER BY MAX(s.Timestamp) DESC + LIMIT $4 + ` + log.Printf("[DEBUG] getSubscriptions: Executing add query with limit %d", MAX_SUBSCRIPTION_CHANGES) + addRows, err = database.Query(query, userID, deviceID, since, MAX_SUBSCRIPTION_CHANGES) + } else { + query = ` + SELECT s.PodcastURL + FROM GpodderSyncSubscriptions s + WHERE s.UserID = ? + AND s.DeviceID != ? + AND s.Timestamp > ? + AND s.Action = 'add' + GROUP BY s.PodcastURL + ORDER BY MAX(s.Timestamp) DESC + LIMIT ? + ` + log.Printf("[DEBUG] getSubscriptions: Executing add query with limit %d", MAX_SUBSCRIPTION_CHANGES) + addRows, err = database.Query(query, userID, deviceID, since, MAX_SUBSCRIPTION_CHANGES) + } + + if err != nil { + log.Printf("[ERROR] getSubscriptions: Error querying podcasts to add: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscription changes"}) + return + } + defer addRows.Close() + + // Ensure addList is never nil + addList := make([]string, 0) + for addRows.Next() { + var url string + if err := addRows.Scan(&url); err != nil { + log.Printf("[ERROR] getSubscriptions: Error scanning podcast URL: %v", err) + continue + } + addList = append(addList, url) + } + + // Query subscriptions removed since the given timestamp - simplified for performance + var removeRows *sql.Rows + + if database.IsPostgreSQLDB() { + query = ` + SELECT s.PodcastURL + FROM "GpodderSyncSubscriptions" s + WHERE s.UserID = $1 + AND s.DeviceID != $2 + AND s.Timestamp > $3 + AND s.Action = 'remove' + GROUP BY s.PodcastURL + ORDER BY MAX(s.Timestamp) DESC + LIMIT $4 + ` + removeRows, err = database.Query(query, userID, deviceID, since, MAX_SUBSCRIPTION_CHANGES) + } else { + query = ` + SELECT s.PodcastURL + FROM GpodderSyncSubscriptions s + WHERE s.UserID = ? + AND s.DeviceID != ? + AND s.Timestamp > ? + AND s.Action = 'remove' + GROUP BY s.PodcastURL + ORDER BY MAX(s.Timestamp) DESC + LIMIT ? + ` + removeRows, err = database.Query(query, userID, deviceID, since, MAX_SUBSCRIPTION_CHANGES) + } + + if err != nil { + log.Printf("[ERROR] getSubscriptions: Error querying podcasts to remove: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscription changes"}) + return + } + defer removeRows.Close() + + // Ensure removeList is never nil + removeList := make([]string, 0) + for removeRows.Next() { + var url string + if err := removeRows.Scan(&url); err != nil { + log.Printf("[ERROR] getSubscriptions: Error scanning podcast URL: %v", err) + continue + } + removeList = append(removeList, url) + } + + timestamp := time.Now().Unix() + + // Update device's last sync time + if database.IsPostgreSQLDB() { + query = ` + UPDATE "GpodderDevices" + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = $1 + ` + } else { + query = ` + UPDATE GpodderDevices + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = ? + ` + } + + _, err = database.Exec(query, deviceID) + + if err != nil { + // Non-critical error, just log it + log.Printf("[WARNING] Error updating device last sync time: %v", err) + } + + response := gin.H{ + "add": addList, + "remove": removeList, + "timestamp": timestamp, + } + + log.Printf("[DEBUG] getSubscriptions: Returning subscription changes - add: %d, remove: %d, timestamp: %d", + len(addList), len(removeList), timestamp) + + c.Header("Content-Type", "application/json") + c.JSON(http.StatusOK, response) + return + } + + // Regular subscription list request + // Get device ID from database + var deviceID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true + ` + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? AND IsActive = true + ` + } + + err := database.QueryRow(query, userID, deviceName).Scan(&deviceID) + + if err != nil { + if err == sql.ErrNoRows { + // Device doesn't exist or is inactive + log.Printf("[INFO] Device not found or inactive: UserID=%v, DeviceName=%s", userID, deviceName) + + // Create device automatically if it doesn't exist + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) + RETURNING DeviceID + ` + err = database.QueryRow(query, userID, deviceName).Scan(&deviceID) + } else { + query = ` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) + ` + result, err := database.Exec(query, userID, deviceName) + if err != nil { + log.Printf("[ERROR] Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("[ERROR] Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + deviceID = int(lastID) + } + + if err != nil { + log.Printf("[ERROR] Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + log.Printf("[INFO] Created new device: UserID=%v, DeviceName=%s, DeviceID=%d", userID, deviceName, deviceID) + + // Return empty list for new device + c.JSON(http.StatusOK, []string{}) + return + } + + log.Printf("[ERROR] Error getting device ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + return + } + + // Get podcasts for this user + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + query = `SELECT FeedURL FROM "Podcasts" WHERE UserID = $1` + } else { + query = `SELECT FeedURL FROM Podcasts WHERE UserID = ?` + } + + rows, err = database.Query(query, userID) + + if err != nil { + log.Printf("Error getting podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) + return + } + defer rows.Close() + + // Build response - ensure never nil + urls := make([]string, 0) + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + log.Printf("[ERROR] Error scanning podcast URL: %v", err) + continue + } + urls = append(urls, url) + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) + return + } + + log.Printf("[DEBUG] Found %d podcast subscriptions in database for userID %v", len(urls), userID) + + // Update device's last sync time + if database.IsPostgreSQLDB() { + query = ` + UPDATE "GpodderDevices" + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = $1 + ` + } else { + query = ` + UPDATE GpodderDevices + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = ? + ` + } + + _, err = database.Exec(query, deviceID) + + if err != nil { + // Non-critical error, just log it + log.Printf("Error updating device last sync time: %v", err) + } + + // Log before returning + log.Printf("[DEBUG] getSubscriptions: Returning %d subscription URLs to client", len(urls)) + for i, url := range urls { + if i < 5 { // Only log first 5 to avoid flooding logs + log.Printf("[DEBUG] Subscription URL %d: %s", i, url) + } + } + + c.JSON(http.StatusOK, urls) + } +} + +// updateSubscriptions handles PUT /api/2/subscriptions/{username}/{deviceid}.json +func updateSubscriptions(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get device ID from URL + deviceName := c.Param("deviceid") + if deviceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) + return + } + + // Parse request body - should be a list of URLs + var urls []string + if err := c.ShouldBindJSON(&urls); err != nil { + log.Printf("Error parsing request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON array of URLs"}) + return + } + + // Validate number of subscriptions + if len(urls) > MAX_SUBSCRIPTIONS { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Too many subscriptions. Maximum allowed: %d", MAX_SUBSCRIPTIONS), + }) + return + } + + // Get or create device + var deviceID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 + ` + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? + ` + } + + err := database.QueryRow(query, userID, deviceName).Scan(&deviceID) + + if err != nil { + if err == sql.ErrNoRows { + // Device doesn't exist, create it + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) + RETURNING DeviceID + ` + err = database.QueryRow(query, userID, deviceName).Scan(&deviceID) + } else { + query = ` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) + ` + result, err := database.Exec(query, userID, deviceName) + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + deviceID = int(lastID) + } + + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + } else { + log.Printf("Error checking device existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check device"}) + return + } + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Get existing subscriptions + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + query = `SELECT FeedURL FROM "Podcasts" WHERE UserID = $1` + } else { + query = `SELECT FeedURL FROM Podcasts WHERE UserID = ?` + } + + rows, err = tx.Query(query, userID) + + if err != nil { + log.Printf("Error getting existing podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get existing subscriptions"}) + return + } + + // Build existing subscriptions map + existing := make(map[string]bool) + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + log.Printf("Error scanning existing podcast URL: %v", err) + continue + } + existing[url] = true + } + rows.Close() + + if err = rows.Err(); err != nil { + log.Printf("Error iterating existing podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process existing subscriptions"}) + return + } + + // Find URLs to add and remove + toAdd := make([]string, 0) + cleanURLMap := make(map[string]string) // Maps original URL to cleaned URL + + for _, url := range urls { + // Clean and validate the URL + cleanURL, err := sanitizeURL(url) + if err != nil { + log.Printf("Skipping invalid URL '%s': %v", url, err) + continue + } + + cleanURLMap[url] = cleanURL + + if !existing[cleanURL] { + toAdd = append(toAdd, cleanURL) + } + + // Remove from existing map to track what's left to delete + delete(existing, cleanURL) + } + + // Remaining URLs in 'existing' need to be removed + toRemove := make([]string, 0, len(existing)) + for url := range existing { + toRemove = append(toRemove, url) + } + + // Record subscription changes + timestamp := time.Now().Unix() + + // Add new podcasts + for _, url := range toAdd { + // Insert into Podcasts table with minimal info + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "Podcasts" (PodcastName, FeedURL, UserID) + VALUES ($1, $2, $3) + ON CONFLICT (UserID, FeedURL) DO NOTHING + ` + _, err = tx.Exec(query, url, url, userID) + } else { + query = ` + INSERT IGNORE INTO Podcasts (PodcastName, FeedURL, UserID) + VALUES (?, ?, ?) + ` + _, err = tx.Exec(query, url, url, userID) + } + + if err != nil { + log.Printf("Error adding podcast: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add podcast"}) + return + } + + // Record subscription change + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES ($1, $2, $3, 'add', $4) + ` + } else { + query = ` + INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES (?, ?, ?, 'add', ?) + ` + } + + _, err = tx.Exec(query, userID, deviceID, url, timestamp) + + if err != nil { + log.Printf("Error recording subscription add: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) + return + } + } + + // Remove podcasts + for _, url := range toRemove { + // First delete related episodes and their dependencies to avoid foreign key constraint violations + if database.IsPostgreSQLDB() { + // Delete related data in correct order for PostgreSQL + deleteQueries := []string{ + `DELETE FROM "PlaylistContents" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "UserEpisodeHistory" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "DownloadedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "SavedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "EpisodeQueue" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "YouTubeVideos" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, + `DELETE FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, + `DELETE FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2`, + } + for _, deleteQuery := range deleteQueries { + _, err = tx.Exec(deleteQuery, userID, url) + if err != nil { + log.Printf("Error executing delete query: %v", err) + break + } + } + } else { + // Delete related data in correct order for MySQL/MariaDB + deleteQueries := []string{ + `DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM YouTubeVideos WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, + `DELETE FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, + `DELETE FROM Podcasts WHERE UserID = ? AND FeedURL = ?`, + } + for _, deleteQuery := range deleteQueries { + _, err = tx.Exec(deleteQuery, userID, url) + if err != nil { + log.Printf("Error executing delete query: %v", err) + break + } + } + } + + if err != nil { + log.Printf("Error removing podcast: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove podcast"}) + return + } + + // Record subscription change + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES ($1, $2, $3, 'remove', $4) + ` + } else { + query = ` + INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES (?, ?, ?, 'remove', ?) + ` + } + + _, err = tx.Exec(query, userID, deviceID, url, timestamp) + + if err != nil { + log.Printf("Error recording subscription remove: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) + return + } + } + + // Update device's last sync time + if database.IsPostgreSQLDB() { + query = ` + UPDATE "GpodderDevices" + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = $1 + ` + } else { + query = ` + UPDATE GpodderDevices + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = ? + ` + } + + _, err = tx.Exec(query, deviceID) + + if err != nil { + log.Printf("Error updating device last sync time: %v", err) + // Non-critical error, continue with transaction + } + + // Commit transaction + if err := tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return success + c.Status(http.StatusOK) + } +} + +// Updated version of uploadSubscriptionChanges to ensure update_urls is always in the response +func uploadSubscriptionChanges(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + log.Printf("[DEBUG] uploadSubscriptionChanges: Processing request: %s %s", + c.Request.Method, c.Request.URL.Path) + + // Get parameters + userID, exists := c.Get("userID") + if !exists { + log.Printf("[ERROR] uploadSubscriptionChanges: userID not found in context") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + username := c.Param("username") + deviceName := c.Param("deviceid") + + // Remove .json suffix if present + if strings.HasSuffix(deviceName, ".json") { + deviceName = strings.TrimSuffix(deviceName, ".json") + } + + log.Printf("[DEBUG] uploadSubscriptionChanges: For user %s (ID: %v), device: %s", + username, userID, deviceName) + + // Parse request + var changes models.SubscriptionChange + if err := c.ShouldBindJSON(&changes); err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Failed to parse request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'add' and 'remove' arrays"}) + return + } + + log.Printf("[DEBUG] uploadSubscriptionChanges: Received changes - add: %d, remove: %d", + len(changes.Add), len(changes.Remove)) + + // Validate request (ensure no duplicate URLs between add and remove) + addMap := make(map[string]bool) + for _, url := range changes.Add { + addMap[url] = true + } + + for _, url := range changes.Remove { + if addMap[url] { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("URL appears in both 'add' and 'remove' arrays: %s", url), + }) + return + } + } + + // Validate number of subscriptions + if len(changes.Add) > MAX_SUBSCRIPTIONS || len(changes.Remove) > MAX_SUBSCRIPTIONS { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Too many subscriptions in request. Maximum allowed: %d", MAX_SUBSCRIPTIONS), + }) + return + } + + // Get or create device + var deviceID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 + ` + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? + ` + } + + err := database.QueryRow(query, userID, deviceName).Scan(&deviceID) + + if err != nil { + if err == sql.ErrNoRows { + // Device doesn't exist, create it + log.Printf("[DEBUG] uploadSubscriptionChanges: Creating new device for user %v: %s", userID, deviceName) + + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) + RETURNING DeviceID + ` + err = database.QueryRow(query, userID, deviceName).Scan(&deviceID) + } else { + query = ` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) + ` + result, err := database.Exec(query, userID, deviceName) + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + deviceID = int(lastID) + } + + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + log.Printf("[DEBUG] uploadSubscriptionChanges: Created new device with ID: %d", deviceID) + } else { + log.Printf("[ERROR] uploadSubscriptionChanges: Error checking device existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check device"}) + return + } + } else { + log.Printf("[DEBUG] uploadSubscriptionChanges: Using existing device with ID: %d", deviceID) + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Process subscriptions to add + timestamp := time.Now().Unix() + updateURLs := make([][]string, 0) // Ensure never nil + + for _, url := range changes.Add { + // Clean URL + cleanURL, err := sanitizeURL(url) + if err != nil { + log.Printf("[WARNING] uploadSubscriptionChanges: Skipping invalid URL in 'add' array: %s - %v", url, err) + continue + } + + // Record changes to database + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES ($1, $2, $3, 'add', $4) + ` + } else { + query = ` + INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES (?, ?, ?, 'add', ?) + ` + } + + _, err = tx.Exec(query, userID, deviceID, cleanURL, timestamp) + + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error recording subscription add: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) + return + } + + // Check if podcast already exists for this user + var podcastExists bool + + if database.IsPostgreSQLDB() { + query = ` + SELECT EXISTS(SELECT 1 FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2) + ` + } else { + query = ` + SELECT EXISTS(SELECT 1 FROM Podcasts WHERE UserID = ? AND FeedURL = ?) + ` + } + + err = tx.QueryRow(query, userID, cleanURL).Scan(&podcastExists) + + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error checking podcast existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check podcast existence"}) + return + } + + // Add to Podcasts table if it doesn't exist + if !podcastExists { + // Fetch podcast metadata from the feed + podcastValues, err := utils.GetPodcastValues(cleanURL, userID.(int), "", "") + if err != nil { + log.Printf("[WARNING] uploadSubscriptionChanges: Error fetching podcast metadata from %s: %v", cleanURL, err) + // Continue with minimal data if we can't fetch full metadata + } + + // Use default values if fetch failed + if podcastValues == nil { + // Insert minimal data + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "Podcasts" (PodcastName, FeedURL, UserID) + VALUES ($1, $2, $3) + ` + } else { + query = ` + INSERT INTO Podcasts (PodcastName, FeedURL, UserID) + VALUES (?, ?, ?) + ` + } + + _, err = tx.Exec(query, cleanURL, cleanURL, userID) + } else { + // Insert with full metadata + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "Podcasts" ( + PodcastName, ArtworkURL, Author, Categories, + Description, EpisodeCount, FeedURL, WebsiteURL, + Explicit, UserID + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ` + } else { + query = ` + INSERT INTO Podcasts ( + PodcastName, ArtworkURL, Author, Categories, + Description, EpisodeCount, FeedURL, WebsiteURL, + Explicit, UserID + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + } + + explicit := 0 + if podcastValues.Explicit { + explicit = 1 + } + + _, err = tx.Exec( + query, + podcastValues.Title, + podcastValues.ArtworkURL, + podcastValues.Author, + podcastValues.Categories, + podcastValues.Description, + podcastValues.EpisodeCount, + cleanURL, + podcastValues.WebsiteURL, + explicit, + userID, + ) + } + + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error adding podcast: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add podcast"}) + return + } + } + + // If URL was cleaned, add to updateURLs + if cleanURL != url { + updateURLs = append(updateURLs, []string{url, cleanURL}) + } + } + + // Process subscriptions to remove + for _, url := range changes.Remove { + // Clean URL + cleanURL, err := sanitizeURL(url) + if err != nil { + log.Printf("[WARNING] uploadSubscriptionChanges: Skipping invalid URL in 'remove' array: %s - %v", url, err) + continue + } + + // Record changes to database + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES ($1, $2, $3, 'remove', $4) + ` + } else { + query = ` + INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES (?, ?, ?, 'remove', ?) + ` + } + + _, err = tx.Exec(query, userID, deviceID, cleanURL, timestamp) + + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error recording subscription remove: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) + return + } + + // First delete related episodes and their dependencies to avoid foreign key constraint violations + if database.IsPostgreSQLDB() { + // Delete related data in correct order for PostgreSQL + deleteQueries := []string{ + `DELETE FROM "PlaylistContents" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "UserEpisodeHistory" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "DownloadedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "SavedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "EpisodeQueue" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "YouTubeVideos" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, + `DELETE FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, + `DELETE FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2`, + } + for _, deleteQuery := range deleteQueries { + _, err = tx.Exec(deleteQuery, userID, cleanURL) + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error executing delete query: %v", err) + break + } + } + } else { + // Delete related data in correct order for MySQL/MariaDB + deleteQueries := []string{ + `DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM YouTubeVideos WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, + `DELETE FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, + `DELETE FROM Podcasts WHERE UserID = ? AND FeedURL = ?`, + } + for _, deleteQuery := range deleteQueries { + _, err = tx.Exec(deleteQuery, userID, cleanURL) + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error executing delete query: %v", err) + break + } + } + } + + if err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error removing podcast: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove podcast"}) + return + } + + // If URL was cleaned, add to updateURLs + if cleanURL != url { + updateURLs = append(updateURLs, []string{url, cleanURL}) + } + } + + // Update device's last sync time + if database.IsPostgreSQLDB() { + query = ` + UPDATE "GpodderDevices" + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = $1 + ` + } else { + query = ` + UPDATE GpodderDevices + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = ? + ` + } + + _, err = tx.Exec(query, deviceID) + + if err != nil { + log.Printf("[WARNING] uploadSubscriptionChanges: Error updating device last sync time: %v", err) + // Non-critical error, continue with transaction + } + + // Commit transaction + if err := tx.Commit(); err != nil { + log.Printf("[ERROR] uploadSubscriptionChanges: Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + log.Printf("[DEBUG] uploadSubscriptionChanges: Successfully processed changes - add: %d, remove: %d", + len(changes.Add), len(changes.Remove)) + + // CRITICAL: Always include update_urls in response, even if empty + // AntennaPod specifically checks for existence of this field + var response gin.H + if updateURLs == nil || len(updateURLs) == 0 { + // Ensure an empty array is returned, not null or missing + response = gin.H{ + "timestamp": timestamp, + "update_urls": [][]string{}, // Empty array + } + } else { + response = gin.H{ + "timestamp": timestamp, + "update_urls": updateURLs, + } + } + + log.Printf("[DEBUG] uploadSubscriptionChanges: Returning response with timestamp %d and %d update URLs", + timestamp, len(updateURLs)) + + // Return response + c.JSON(http.StatusOK, response) + } +} + +// getAllSubscriptions handles GET /api/2/subscriptions/{username}.json +func getAllSubscriptions(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get all podcasts for this user + var query string + + if database.IsPostgreSQLDB() { + query = `SELECT FeedURL FROM "Podcasts" WHERE UserID = $1` + } else { + query = `SELECT FeedURL FROM Podcasts WHERE UserID = ?` + } + + rows, err := database.Query(query, userID) + if err != nil { + log.Printf("Error getting podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) + return + } + defer rows.Close() + + // Build response - ensure never nil + urls := make([]string, 0) + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + log.Printf("Error scanning podcast URL: %v", err) + continue + } + urls = append(urls, url) + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) + return + } + + c.JSON(http.StatusOK, urls) + } +} + +// getSubscriptionsSimple handles GET /subscriptions/{username}/{deviceid}.{format} +func getSubscriptionsSimple(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get format from URL + format := c.Param("format") + if format == "" { + format = "json" // Default format + } + + // Get user ID from middleware + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get device ID from URL + deviceName := c.Param("deviceid") + if deviceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) + return + } + + // Get device ID from database + var deviceID int + var err error + + if database.IsPostgreSQLDB() { + err = database.QueryRow(` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true + `, userID, deviceName).Scan(&deviceID) + } else { + err = database.QueryRow(` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? AND IsActive = true + `, userID, deviceName).Scan(&deviceID) + } + + if err != nil { + if err == sql.ErrNoRows { + // Device doesn't exist or is inactive, create it + if database.IsPostgreSQLDB() { + err = database.QueryRow(` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) + RETURNING DeviceID + `, userID, deviceName).Scan(&deviceID) + } else { + // For MySQL, define the query string first + var query string = ` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) + ` + result, err := database.Exec(query, userID, deviceName) + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + lastID, err := result.LastInsertId() + if err != nil { + log.Printf("Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + deviceID = int(lastID) + } + + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + // Return empty list for new device + c.JSON(http.StatusOK, []string{}) + return + } + + log.Printf("Error getting device ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) + return + } + + // Get podcasts for this user + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + rows, err = database.Query(` + SELECT FeedURL FROM "Podcasts" WHERE UserID = $1 + `, userID) + } else { + rows, err = database.Query(` + SELECT FeedURL FROM Podcasts WHERE UserID = ? + `, userID) + } + + if err != nil { + log.Printf("Error getting podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) + return + } + defer rows.Close() + + // Build response - ensure never nil + urls := make([]string, 0) + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + log.Printf("Error scanning podcast URL: %v", err) + continue + } + urls = append(urls, url) + } + + if err = rows.Err(); err != nil { + log.Printf("Error iterating podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) + return + } + + // Update device's last sync time + if database.IsPostgreSQLDB() { + _, err = database.Exec(` + UPDATE "GpodderDevices" + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = $1 + `, deviceID) + } else { + _, err = database.Exec(` + UPDATE GpodderDevices + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = ? + `, deviceID) + } + + if err != nil { + // Non-critical error, just log it + log.Printf("Error updating device last sync time: %v", err) + } + + // Return in requested format + switch format { + case "json", "jsonp": + if format == "jsonp" { + // JSONP callback + callback := c.Query("jsonp") + if callback == "" { + callback = "callback" // Default callback name + } + c.Header("Content-Type", "application/javascript") + jsonData, err := json.Marshal(urls) + if err != nil { + log.Printf("Error marshaling JSON: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal response"}) + return + } + c.String(http.StatusOK, "%s(%s);", callback, string(jsonData)) + } else { + c.JSON(http.StatusOK, urls) + } + case "txt": + // Plain text format + c.Header("Content-Type", "text/plain") + var sb strings.Builder + for _, url := range urls { + sb.WriteString(url) + sb.WriteString("\n") + } + c.String(http.StatusOK, sb.String()) + case "opml": + // OPML format + c.Header("Content-Type", "text/xml") + var sb strings.Builder + sb.WriteString(` + + + gPodder Subscriptions + + +`) + for _, url := range urls { + sb.WriteString(fmt.Sprintf(` +`, url, url)) + } + sb.WriteString(` +`) + c.String(http.StatusOK, sb.String()) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) + } + } +} + +// updateSubscriptionsSimple handles PUT /subscriptions/{username}/{deviceid}.{format} +func updateSubscriptionsSimple(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get format from URL + format := c.Param("format") + if format == "" { + format = "json" // Default format + } + + // Get user ID from middleware + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Get device ID from URL + deviceName := c.Param("deviceid") + if deviceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) + return + } + + // Parse request body based on format + var urls []string + + switch format { + case "json", "jsonp": + if err := c.ShouldBindJSON(&urls); err != nil { + log.Printf("Error parsing JSON request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON array of URLs"}) + return + } + case "txt": + // Read as plain text, split by lines + body, err := c.GetRawData() + if err != nil { + log.Printf("Error reading request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + + lines := strings.Split(string(body), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + urls = append(urls, line) + } + } + case "opml": + // Parse OPML format + body, err := c.GetRawData() + if err != nil { + log.Printf("Error reading request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + + // Simple regex-based OPML parser (for a robust implementation, use proper XML parsing) + opmlContent := string(body) + matches := opmlOutlineRegex.FindAllStringSubmatch(opmlContent, -1) + + for _, match := range matches { + if len(match) > 1 { + url := match[1] + if url != "" { + urls = append(urls, url) + } + } + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) + return + } + + // Validate number of subscriptions + if len(urls) > MAX_SUBSCRIPTIONS { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Too many subscriptions. Maximum allowed: %d", MAX_SUBSCRIPTIONS), + }) + return + } + + // From here, use the same logic as updateSubscriptions + // Get or create device, process changes, etc. + var deviceID int + var err error + + if database.IsPostgreSQLDB() { + err = database.QueryRow(` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 + `, userID, deviceName).Scan(&deviceID) + } else { + err = database.QueryRow(` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? + `, userID, deviceName).Scan(&deviceID) + } + + if err != nil { + if err == sql.ErrNoRows { + // Device doesn't exist, create it + if database.IsPostgreSQLDB() { + err = database.QueryRow(` + INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) + RETURNING DeviceID + `, userID, deviceName).Scan(&deviceID) + } else { + // For MySQL, we need to use a different approach without RETURNING + res, err := database.Exec(` + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) + VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) + `, userID, deviceName) + + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + + // Get the last inserted ID + lastID, err := res.LastInsertId() + if err != nil { + log.Printf("Error getting last insert ID: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device ID"}) + return + } + + deviceID = int(lastID) + } + + if err != nil { + log.Printf("Error creating device: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) + return + } + } else { + log.Printf("Error checking device existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check device"}) + return + } + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Get existing subscriptions + var rows *sql.Rows + + if database.IsPostgreSQLDB() { + rows, err = tx.Query(` + SELECT FeedURL FROM "Podcasts" WHERE UserID = $1 + `, userID) + } else { + rows, err = tx.Query(` + SELECT FeedURL FROM Podcasts WHERE UserID = ? + `, userID) + } + + if err != nil { + log.Printf("Error getting existing podcasts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get existing subscriptions"}) + return + } + + // Build existing subscriptions map + existing := make(map[string]bool) + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + log.Printf("Error scanning existing podcast URL: %v", err) + continue + } + existing[url] = true + } + rows.Close() + + if err = rows.Err(); err != nil { + log.Printf("Error iterating existing podcast rows: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process existing subscriptions"}) + return + } + + // Find URLs to add and remove + toAdd := make([]string, 0) + cleanURLMap := make(map[string]string) // Maps original URL to cleaned URL + + for _, url := range urls { + // Clean and validate the URL + cleanURL, err := sanitizeURL(url) + if err != nil { + log.Printf("Skipping invalid URL '%s': %v", url, err) + continue + } + + cleanURLMap[url] = cleanURL + + if !existing[cleanURL] { + toAdd = append(toAdd, cleanURL) + } + + // Remove from existing map to track what's left to delete + delete(existing, cleanURL) + } + + // Remaining URLs in 'existing' need to be removed + toRemove := make([]string, 0, len(existing)) + for url := range existing { + toRemove = append(toRemove, url) + } + + // Record subscription changes + timestamp := time.Now().Unix() + + // Add new podcasts + for _, url := range toAdd { + // Insert into Podcasts table with minimal info + if database.IsPostgreSQLDB() { + _, err = tx.Exec(` + INSERT INTO "Podcasts" (PodcastName, FeedURL, UserID) + VALUES ($1, $2, $3) + ON CONFLICT (UserID, FeedURL) DO NOTHING + `, url, url, userID) + } else { + // For MySQL, use INSERT IGNORE + _, err = tx.Exec(` + INSERT IGNORE INTO Podcasts (PodcastName, FeedURL, UserID) + VALUES (?, ?, ?) + `, url, url, userID) + } + + if err != nil { + log.Printf("Error adding podcast: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add podcast"}) + return + } + + // Record subscription change + if database.IsPostgreSQLDB() { + _, err = tx.Exec(` + INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES ($1, $2, $3, 'add', $4) + `, userID, deviceID, url, timestamp) + } else { + _, err = tx.Exec(` + INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES (?, ?, ?, 'add', ?) + `, userID, deviceID, url, timestamp) + } + + if err != nil { + log.Printf("Error recording subscription add: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) + return + } + } + + // Remove podcasts + for _, url := range toRemove { + // First delete related episodes and their dependencies to avoid foreign key constraint violations + if database.IsPostgreSQLDB() { + // Delete related data in correct order for PostgreSQL + deleteQueries := []string{ + `DELETE FROM "PlaylistContents" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "UserEpisodeHistory" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "DownloadedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "SavedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "EpisodeQueue" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, + `DELETE FROM "YouTubeVideos" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, + `DELETE FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, + `DELETE FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2`, + } + for _, deleteQuery := range deleteQueries { + _, err = tx.Exec(deleteQuery, userID, url) + if err != nil { + log.Printf("Error executing delete query: %v", err) + break + } + } + } else { + // Delete related data in correct order for MySQL/MariaDB + deleteQueries := []string{ + `DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, + `DELETE FROM YouTubeVideos WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, + `DELETE FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, + `DELETE FROM Podcasts WHERE UserID = ? AND FeedURL = ?`, + } + for _, deleteQuery := range deleteQueries { + _, err = tx.Exec(deleteQuery, userID, url) + if err != nil { + log.Printf("Error executing delete query: %v", err) + break + } + } + } + + if err != nil { + log.Printf("Error removing podcast: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove podcast"}) + return + } + + // Record subscription change + if database.IsPostgreSQLDB() { + _, err = tx.Exec(` + INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES ($1, $2, $3, 'remove', $4) + `, userID, deviceID, url, timestamp) + } else { + _, err = tx.Exec(` + INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) + VALUES (?, ?, ?, 'remove', ?) + `, userID, deviceID, url, timestamp) + } + + if err != nil { + log.Printf("Error recording subscription remove: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) + return + } + } + + // Update device's last sync time + if database.IsPostgreSQLDB() { + _, err = tx.Exec(` + UPDATE "GpodderDevices" + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = $1 + `, deviceID) + } else { + _, err = tx.Exec(` + UPDATE GpodderDevices + SET LastSync = CURRENT_TIMESTAMP + WHERE DeviceID = ? + `, deviceID) + } + + if err != nil { + log.Printf("Error updating device last sync time: %v", err) + // Non-critical error, continue with transaction + } + + // Commit transaction + if err := tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return success + c.Status(http.StatusOK) + } +} + +// Regex for parsing OPML outline tags +var opmlOutlineRegex = regexp.MustCompile(`]*xmlUrl="([^"]+)"[^>]*/>`) diff --git a/PinePods-0.8.2/gpodder-api/internal/api/sync.go b/PinePods-0.8.2/gpodder-api/internal/api/sync.go new file mode 100644 index 0000000..cc752ef --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/api/sync.go @@ -0,0 +1,267 @@ +package api + +import ( + "database/sql" + "log" + "net/http" + + "pinepods/gpodder-api/internal/db" + "pinepods/gpodder-api/internal/models" + + "github.com/gin-gonic/gin" +) + +// getSyncStatus handles GET /api/2/sync-devices/{username}.json +func getSyncStatus(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, _ := c.Get("userID") + + // Query for device sync pairs + var query string + var rows *sql.Rows + var err error + + if database.IsPostgreSQLDB() { + query = ` + SELECT d1.DeviceName, d2.DeviceName + FROM "GpodderSyncDevicePairs" p + JOIN "GpodderDevices" d1 ON p.DeviceID1 = d1.DeviceID + JOIN "GpodderDevices" d2 ON p.DeviceID2 = d2.DeviceID + WHERE p.UserID = $1 + ` + rows, err = database.Query(query, userID) + } else { + query = ` + SELECT d1.DeviceName, d2.DeviceName + FROM GpodderSyncDevicePairs p + JOIN GpodderDevices d1 ON p.DeviceID1 = d1.DeviceID + JOIN GpodderDevices d2 ON p.DeviceID2 = d2.DeviceID + WHERE p.UserID = ? + ` + rows, err = database.Query(query, userID) + } + + if err != nil { + log.Printf("Error querying device sync pairs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sync status"}) + return + } + + // Build sync pairs + syncPairs := make([][]string, 0) + for rows.Next() { + var device1, device2 string + if err := rows.Scan(&device1, &device2); err != nil { + log.Printf("Error scanning device pair: %v", err) + continue + } + syncPairs = append(syncPairs, []string{device1, device2}) + } + rows.Close() + + // Query for devices not in any sync pair + if database.IsPostgreSQLDB() { + query = ` + SELECT d.DeviceName + FROM "GpodderDevices" d + WHERE d.UserID = $1 + AND d.DeviceID NOT IN ( + SELECT DeviceID1 FROM "GpodderSyncDevicePairs" WHERE UserID = $1 + UNION + SELECT DeviceID2 FROM "GpodderSyncDevicePairs" WHERE UserID = $1 + ) + ` + rows, err = database.Query(query, userID) + } else { + query = ` + SELECT d.DeviceName + FROM GpodderDevices d + WHERE d.UserID = ? + AND d.DeviceID NOT IN ( + SELECT DeviceID1 FROM GpodderSyncDevicePairs WHERE UserID = ? + UNION + SELECT DeviceID2 FROM GpodderSyncDevicePairs WHERE UserID = ? + ) + ` + rows, err = database.Query(query, userID, userID, userID) + } + + if err != nil { + log.Printf("Error querying non-synced devices: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sync status"}) + return + } + + // Build non-synced devices list + nonSynced := make([]string, 0) + for rows.Next() { + var deviceName string + if err := rows.Scan(&deviceName); err != nil { + log.Printf("Error scanning non-synced device: %v", err) + continue + } + nonSynced = append(nonSynced, deviceName) + } + rows.Close() + + // Return response + c.JSON(http.StatusOK, models.SyncDevicesResponse{ + Synchronized: syncPairs, + NotSynchronized: nonSynced, + }) + } +} + +// updateSyncStatus handles POST /api/2/sync-devices/{username}.json +func updateSyncStatus(database *db.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user ID from middleware + userID, _ := c.Get("userID") + + // Parse request + var req models.SyncDevicesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Begin transaction + tx, err := database.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) + return + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Process synchronize pairs + for _, pair := range req.Synchronize { + if len(pair) != 2 { + continue + } + + // Get device IDs + var device1ID, device2ID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 + ` + err = tx.QueryRow(query, userID, pair[0]).Scan(&device1ID) + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? + ` + err = tx.QueryRow(query, userID, pair[0]).Scan(&device1ID) + } + + if err != nil { + log.Printf("Error getting device ID for %s: %v", pair[0], err) + continue + } + + if database.IsPostgreSQLDB() { + err = tx.QueryRow(query, userID, pair[1]).Scan(&device2ID) + } else { + err = tx.QueryRow(query, userID, pair[1]).Scan(&device2ID) + } + + if err != nil { + log.Printf("Error getting device ID for %s: %v", pair[1], err) + continue + } + + // Ensure device1ID < device2ID for consistency + if device1ID > device2ID { + device1ID, device2ID = device2ID, device1ID + } + + // Insert sync pair if it doesn't exist + if database.IsPostgreSQLDB() { + query = ` + INSERT INTO "GpodderSyncDevicePairs" (UserID, DeviceID1, DeviceID2) + VALUES ($1, $2, $3) + ON CONFLICT (UserID, DeviceID1, DeviceID2) DO NOTHING + ` + _, err = tx.Exec(query, userID, device1ID, device2ID) + } else { + query = ` + INSERT IGNORE INTO GpodderSyncDevicePairs (UserID, DeviceID1, DeviceID2) + VALUES (?, ?, ?) + ` + _, err = tx.Exec(query, userID, device1ID, device2ID) + } + + if err != nil { + log.Printf("Error creating sync pair: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create sync pair"}) + return + } + } + + // Process stop-synchronize devices + for _, deviceName := range req.StopSynchronize { + // Get device ID + var deviceID int + var query string + + if database.IsPostgreSQLDB() { + query = ` + SELECT DeviceID FROM "GpodderDevices" + WHERE UserID = $1 AND DeviceName = $2 + ` + err = tx.QueryRow(query, userID, deviceName).Scan(&deviceID) + } else { + query = ` + SELECT DeviceID FROM GpodderDevices + WHERE UserID = ? AND DeviceName = ? + ` + err = tx.QueryRow(query, userID, deviceName).Scan(&deviceID) + } + + if err != nil { + log.Printf("Error getting device ID for %s: %v", deviceName, err) + continue + } + + // Remove all sync pairs involving this device + if database.IsPostgreSQLDB() { + query = ` + DELETE FROM "GpodderSyncDevicePairs" + WHERE UserID = $1 AND (DeviceID1 = $2 OR DeviceID2 = $2) + ` + _, err = tx.Exec(query, userID, deviceID) + } else { + query = ` + DELETE FROM GpodderSyncDevicePairs + WHERE UserID = ? AND (DeviceID1 = ? OR DeviceID2 = ?) + ` + _, err = tx.Exec(query, userID, deviceID, deviceID) + } + + if err != nil { + log.Printf("Error removing sync pairs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove sync pairs"}) + return + } + } + + // Commit transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + // Return updated sync status by reusing the getSyncStatus handler + getSyncStatus(database)(c) + } +} diff --git a/PinePods-0.8.2/gpodder-api/internal/db/database.go b/PinePods-0.8.2/gpodder-api/internal/db/database.go new file mode 100644 index 0000000..4b4aa54 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/db/database.go @@ -0,0 +1,290 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "log" + "net/url" + "os" + "pinepods/gpodder-api/config" + "regexp" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" // MySQL driver + _ "github.com/lib/pq" // PostgreSQL driver +) + +// Database represents a database connection that can be either PostgreSQL or MySQL +type Database struct { + *sql.DB + Type string // "postgresql" or "mysql" +} + +// NewDatabase creates a new database connection based on the DB_TYPE environment variable +func NewDatabase(cfg config.DatabaseConfig) (*Database, error) { + // Print connection details for debugging (hide password for security) + fmt.Printf("Connecting to %s database: host=%s port=%d user=%s dbname=%s\n", + cfg.Type, cfg.Host, cfg.Port, cfg.User, cfg.DBName) + + var db *sql.DB + var err error + + switch cfg.Type { + case "postgresql": + db, err = connectPostgreSQL(cfg) + case "mysql", "mariadb": + db, err = connectMySQL(cfg) + default: + return nil, fmt.Errorf("unsupported database type: %s", cfg.Type) + } + + if err != nil { + return nil, err + } + + // Test the connection + if err := db.Ping(); err != nil { + db.Close() + if strings.Contains(err.Error(), "password authentication failed") { + // Print environment variables (hide password) + fmt.Println("Password authentication failed. Environment variables:") + fmt.Printf("DB_HOST=%s\n", os.Getenv("DB_HOST")) + fmt.Printf("DB_PORT=%s\n", os.Getenv("DB_PORT")) + fmt.Printf("DB_USER=%s\n", os.Getenv("DB_USER")) + fmt.Printf("DB_NAME=%s\n", os.Getenv("DB_NAME")) + fmt.Printf("DB_PASSWORD=*** (length: %d)\n", len(os.Getenv("DB_PASSWORD"))) + } + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + fmt.Println("Successfully connected to the database") + + // Migrations are now handled by the Python migration system + // Skip Go migrations to avoid conflicts + log.Println("Skipping Go migrations - now handled by Python migration system") + + return &Database{DB: db, Type: cfg.Type}, nil +} + +// runMigrationsWithRetry - DISABLED: migrations now handled by Python system +// func runMigrationsWithRetry(db *sql.DB, dbType string) error { +// All migration logic has been moved to the Python migration system +// to ensure consistency and centralized management +// This function is kept for reference but is no longer used +// } + +// connectPostgreSQL connects to a PostgreSQL database +func connectPostgreSQL(cfg config.DatabaseConfig) (*sql.DB, error) { + // Escape special characters in password + escapedPassword := url.QueryEscape(cfg.Password) + + // Use a connection string without password for logging + logConnStr := fmt.Sprintf( + "host=%s port=%d user=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode, + ) + fmt.Printf("PostgreSQL connection string (without password): %s\n", logConnStr) + + // Build the actual connection string with password + connStr := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, + ) + + // Try standard connection string first + db, err := sql.Open("postgres", connStr) + if err != nil { + // Try URL format connection string + urlConnStr := fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=%s", + cfg.User, escapedPassword, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode, + ) + fmt.Println("First connection attempt failed, trying URL format...") + db, err = sql.Open("postgres", urlConnStr) + } + + return db, err +} + +// Replace the existing connectMySQL function with this version +func connectMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { + // Add needed parameters for MySQL authentication + connStr := fmt.Sprintf( + "%s:%s@tcp(%s:%d)/%s?parseTime=true&allowNativePasswords=true&multiStatements=true", + cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName, + ) + + fmt.Printf("Attempting MySQL connection to %s:%d as user '%s'\n", + cfg.Host, cfg.Port, cfg.User) + + // Open the connection + db, err := sql.Open("mysql", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open MySQL connection: %w", err) + } + + // Configure connection pool + db.SetConnMaxLifetime(time.Minute * 3) + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + + // Explicitly test the connection + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + fmt.Println("Testing MySQL connection with ping...") + if err := db.PingContext(ctx); err != nil { + db.Close() + fmt.Printf("MySQL connection failed: %v\n", err) + return nil, fmt.Errorf("failed to ping MySQL database: %w", err) + } + + fmt.Println("MySQL connection successful!") + return db, nil +} + +// Close closes the database connection +func (db *Database) Close() error { + return db.DB.Close() +} + +// IsMySQLDB returns true if the database is MySQL/MariaDB +func (db *Database) IsMySQLDB() bool { + return db.Type == "mysql" +} + +// IsPostgreSQLDB returns true if the database is PostgreSQL +func (db *Database) IsPostgreSQLDB() bool { + return db.Type == "postgresql" +} + +// FormatQuery formats a query for the specific database type +func (db *Database) FormatQuery(query string) string { + if db.Type == "postgresql" { + return query // PostgreSQL queries already have correct format + } + + // For MySQL: + result := query + + // First, replace quoted table names + knownTables := []string{ + "Users", "GpodderDevices", "GpodderSyncSettings", + "GpodderSyncSubscriptions", "GpodderSyncEpisodeActions", + "GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions", + "GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes", + "UserEpisodeHistory", "UserSettings", "APIKeys", + } + + for _, table := range knownTables { + quoted := fmt.Sprintf("\"%s\"", table) + result = strings.ReplaceAll(result, quoted, table) + } + + // Replace column quotes (double quotes to backticks) + re := regexp.MustCompile(`"([^"]+)"`) + result = re.ReplaceAllString(result, "`$1`") + + // Then replace placeholders + for i := 10; i > 0; i-- { + old := fmt.Sprintf("$%d", i) + result = strings.ReplaceAll(result, old, "?") + } + + return result +} + +// Exec executes a query with the correct formatting for the database type +func (db *Database) Exec(query string, args ...interface{}) (sql.Result, error) { + formattedQuery := db.FormatQuery(query) + return db.DB.Exec(formattedQuery, args...) +} + +// Query executes a query with the correct formatting for the database type +func (db *Database) Query(query string, args ...interface{}) (*sql.Rows, error) { + formattedQuery := db.FormatQuery(query) + return db.DB.Query(formattedQuery, args...) +} + +// QueryRow executes a query with the correct formatting for the database type +func (db *Database) QueryRow(query string, args ...interface{}) *sql.Row { + formattedQuery := db.FormatQuery(query) + return db.DB.QueryRow(formattedQuery, args...) +} + +// Begin starts a transaction with the correct formatting for the database type +func (db *Database) Begin() (*Transaction, error) { + tx, err := db.DB.Begin() + if err != nil { + return nil, err + } + + return &Transaction{tx: tx, dbType: db.Type}, nil +} + +// Transaction is a wrapper around sql.Tx that formats queries correctly +type Transaction struct { + tx *sql.Tx + dbType string +} + +// Commit commits the transaction +func (tx *Transaction) Commit() error { + return tx.tx.Commit() +} + +// Rollback rolls back the transaction +func (tx *Transaction) Rollback() error { + return tx.tx.Rollback() +} + +// Exec executes a query in the transaction with correct formatting +func (tx *Transaction) Exec(query string, args ...interface{}) (sql.Result, error) { + formattedQuery := formatQuery(query, tx.dbType) + return tx.tx.Exec(formattedQuery, args...) +} + +// Query executes a query in the transaction with correct formatting +func (tx *Transaction) Query(query string, args ...interface{}) (*sql.Rows, error) { + formattedQuery := formatQuery(query, tx.dbType) + return tx.tx.Query(formattedQuery, args...) +} + +// QueryRow executes a query in the transaction with correct formatting +func (tx *Transaction) QueryRow(query string, args ...interface{}) *sql.Row { + formattedQuery := formatQuery(query, tx.dbType) + return tx.tx.QueryRow(formattedQuery, args...) +} + +// Helper function to format queries +func formatQuery(query string, dbType string) string { + if dbType == "postgresql" { + return query + } + + // For MySQL: + // Same logic as FormatQuery method + result := query + + knownTables := []string{ + "Users", "GpodderDevices", "GpodderSyncSettings", + "GpodderSyncSubscriptions", "GpodderSyncEpisodeActions", + "GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions", + "GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes", + "UserEpisodeHistory", "UserSettings", "APIKeys", + } + + for _, table := range knownTables { + quoted := fmt.Sprintf("\"%s\"", table) + result = strings.ReplaceAll(result, quoted, table) + } + + for i := 10; i > 0; i-- { + old := fmt.Sprintf("$%d", i) + result = strings.ReplaceAll(result, old, "?") + } + + return result +} diff --git a/PinePods-0.8.2/gpodder-api/internal/db/helpers.go b/PinePods-0.8.2/gpodder-api/internal/db/helpers.go new file mode 100644 index 0000000..e8b5f4a --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/db/helpers.go @@ -0,0 +1,176 @@ +package db + +import ( + "fmt" + "strings" +) + +// GetTableName returns the properly formatted table name based on DB type +func GetTableName(tableName string, dbType string) string { + if dbType == "postgresql" { + return fmt.Sprintf("\"%s\"", tableName) + } + return tableName +} + +// GetPlaceholder returns the correct parameter placeholder based on DB type and index +func GetPlaceholder(index int, dbType string) string { + if dbType == "postgresql" { + return fmt.Sprintf("$%d", index) + } + return "?" +} + +// GetPlaceholders returns a comma-separated list of placeholders +func GetPlaceholders(count int, dbType string) string { + placeholders := make([]string, count) + + for i := 0; i < count; i++ { + if dbType == "postgresql" { + placeholders[i] = fmt.Sprintf("$%d", i+1) + } else { + placeholders[i] = "?" + } + } + + return strings.Join(placeholders, ", ") +} + +// GetColumnDefinition returns the appropriate column definition +func GetColumnDefinition(columnName, dataType string, dbType string) string { + // Handle special cases for different database types + switch dataType { + case "serial": + if dbType == "postgresql" { + return fmt.Sprintf("%s SERIAL", columnName) + } + return fmt.Sprintf("%s INT AUTO_INCREMENT", columnName) + case "boolean": + if dbType == "postgresql" { + return fmt.Sprintf("%s BOOLEAN", columnName) + } + return fmt.Sprintf("%s TINYINT(1)", columnName) + case "timestamp": + if dbType == "postgresql" { + return fmt.Sprintf("%s TIMESTAMP", columnName) + } + return fmt.Sprintf("%s TIMESTAMP", columnName) + default: + return fmt.Sprintf("%s %s", columnName, dataType) + } +} + +// GetSerialPrimaryKey returns a serial primary key definition +func GetSerialPrimaryKey(columnName string, dbType string) string { + if dbType == "postgresql" { + return fmt.Sprintf("%s SERIAL PRIMARY KEY", columnName) + } + return fmt.Sprintf("%s INT AUTO_INCREMENT PRIMARY KEY", columnName) +} + +// GetTimestampDefault returns a timestamp with default value +func GetTimestampDefault(columnName string, dbType string) string { + if dbType == "postgresql" { + return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName) + } + return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName) +} + +// GetAutoUpdateTimestamp returns a timestamp that updates automatically +func GetAutoUpdateTimestamp(columnName string, dbType string) string { + if dbType == "postgresql" { + // PostgreSQL doesn't have a direct equivalent to MySQL's ON UPDATE + // In PostgreSQL this would typically be handled with a trigger + return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName) + } + return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", columnName) +} + +// BuildInsertQuery builds an INSERT query with the correct placeholder syntax +func BuildInsertQuery(tableName string, columns []string, dbType string) string { + columnsStr := strings.Join(columns, ", ") + placeholders := GetPlaceholders(len(columns), dbType) + + if dbType == "postgresql" { + return fmt.Sprintf("INSERT INTO \"%s\" (%s) VALUES (%s)", tableName, columnsStr, placeholders) + } + + return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", tableName, columnsStr, placeholders) +} + +// BuildSelectQuery builds a SELECT query with the correct table name syntax +func BuildSelectQuery(tableName string, columns []string, whereClause string, dbType string) string { + columnsStr := strings.Join(columns, ", ") + + if dbType == "postgresql" { + if whereClause != "" { + return fmt.Sprintf("SELECT %s FROM \"%s\" WHERE %s", columnsStr, tableName, whereClause) + } + return fmt.Sprintf("SELECT %s FROM \"%s\"", columnsStr, tableName) + } + + if whereClause != "" { + return fmt.Sprintf("SELECT %s FROM %s WHERE %s", columnsStr, tableName, whereClause) + } + return fmt.Sprintf("SELECT %s FROM %s", columnsStr, tableName) +} + +// BuildUpdateQuery builds an UPDATE query with the correct syntax +func BuildUpdateQuery(tableName string, setColumns []string, whereClause string, dbType string) string { + setClauses := make([]string, len(setColumns)) + + for i, col := range setColumns { + if dbType == "postgresql" { + setClauses[i] = fmt.Sprintf("%s = $%d", col, i+1) + } else { + setClauses[i] = fmt.Sprintf("%s = ?", col) + } + } + + setClauseStr := strings.Join(setClauses, ", ") + + if dbType == "postgresql" { + return fmt.Sprintf("UPDATE \"%s\" SET %s WHERE %s", tableName, setClauseStr, whereClause) + } + + return fmt.Sprintf("UPDATE %s SET %s WHERE %s", tableName, setClauseStr, whereClause) +} + +// RewriteQuery rewrites a PostgreSQL query to MySQL syntax +func RewriteQuery(query, dbType string) string { + if dbType == "postgresql" { + return query + } + + // Replace placeholders + rewritten := query + + // Replace placeholders first, starting from highest number to avoid conflicts + for i := 20; i > 0; i-- { + placeholder := fmt.Sprintf("$%d", i) + rewritten = strings.ReplaceAll(rewritten, placeholder, "?") + } + + // Replace quoted table names + knownTables := []string{ + "Users", "GpodderDevices", "GpodderSyncSettings", + "GpodderSyncSubscriptions", "GpodderSyncEpisodeActions", + "GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions", + "GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes", + "UserEpisodeHistory", "UserSettings", "APIKeys", "UserVideoHistory", + "SavedVideos", "DownloadedEpisodes", "DownloadedVideos", "EpisodeQueue", + } + + for _, table := range knownTables { + quotedTable := fmt.Sprintf("\"%s\"", table) + rewritten = strings.ReplaceAll(rewritten, quotedTable, table) + } + + // Handle RETURNING clause (MySQL doesn't support it) + returningIdx := strings.Index(strings.ToUpper(rewritten), "RETURNING") + if returningIdx > 0 { + rewritten = rewritten[:returningIdx] + } + + return rewritten +} diff --git a/PinePods-0.8.2/gpodder-api/internal/db/migrations.go b/PinePods-0.8.2/gpodder-api/internal/db/migrations.go new file mode 100644 index 0000000..0bdec92 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/db/migrations.go @@ -0,0 +1,538 @@ +package db + +import ( + "database/sql" + "fmt" + "log" + "time" +) + +// Migration represents a database migration +type Migration struct { + Version int + Description string + PostgreSQLSQL string + MySQLSQL string +} + +// MigrationRecord represents a record of an applied migration +type MigrationRecord struct { + Version int + Description string + AppliedAt time.Time +} + +// EnsureMigrationsTable creates the migrations table if it doesn't exist +func EnsureMigrationsTable(db *sql.DB, dbType string) error { + log.Println("Creating GpodderSyncMigrations table if it doesn't exist...") + + var query string + if dbType == "postgresql" { + query = ` + CREATE TABLE IF NOT EXISTS "GpodderSyncMigrations" ( + Version INT PRIMARY KEY, + Description TEXT NOT NULL, + AppliedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + ` + } else { + query = ` + CREATE TABLE IF NOT EXISTS GpodderSyncMigrations ( + Version INT PRIMARY KEY, + Description TEXT NOT NULL, + AppliedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + ` + } + + _, err := db.Exec(query) + if err != nil { + log.Printf("Error creating migrations table: %v", err) + return err + } + log.Println("GpodderSyncMigrations table is ready") + return nil +} + +// GetAppliedMigrations returns a list of already applied migrations +func GetAppliedMigrations(db *sql.DB, dbType string) ([]MigrationRecord, error) { + log.Println("Checking previously applied migrations...") + + var query string + if dbType == "postgresql" { + query = ` + SELECT Version, Description, AppliedAt + FROM "GpodderSyncMigrations" + ORDER BY Version ASC + ` + } else { + query = ` + SELECT Version, Description, AppliedAt + FROM GpodderSyncMigrations + ORDER BY Version ASC + ` + } + + rows, err := db.Query(query) + if err != nil { + log.Printf("Error checking applied migrations: %v", err) + return nil, err + } + defer rows.Close() + + var migrations []MigrationRecord + for rows.Next() { + var m MigrationRecord + if err := rows.Scan(&m.Version, &m.Description, &m.AppliedAt); err != nil { + log.Printf("Error scanning migration record: %v", err) + return nil, err + } + migrations = append(migrations, m) + } + + if len(migrations) > 0 { + log.Printf("Found %d previously applied migrations", len(migrations)) + } else { + log.Println("No previously applied migrations found") + } + return migrations, nil +} + +// ApplyMigration applies a single migration +func ApplyMigration(db *sql.DB, migration Migration, dbType string) error { + log.Printf("Applying migration %d: %s", migration.Version, migration.Description) + + // Select the appropriate SQL based on database type + var sql string + if dbType == "postgresql" { + sql = migration.PostgreSQLSQL + } else { + sql = migration.MySQLSQL + } + + // Begin transaction + tx, err := db.Begin() + if err != nil { + log.Printf("Error beginning transaction for migration %d: %v", migration.Version, err) + return err + } + defer func() { + if err != nil { + log.Printf("Rolling back migration %d due to error", migration.Version) + tx.Rollback() + return + } + }() + + // Execute the migration SQL + _, err = tx.Exec(sql) + if err != nil { + log.Printf("Failed to apply migration %d: %v", migration.Version, err) + return fmt.Errorf("failed to apply migration %d: %w", migration.Version, err) + } + + // Record the migration + var insertQuery string + if dbType == "postgresql" { + insertQuery = ` + INSERT INTO "GpodderSyncMigrations" (Version, Description) + VALUES ($1, $2) + ` + } else { + insertQuery = ` + INSERT INTO GpodderSyncMigrations (Version, Description) + VALUES (?, ?) + ` + } + + _, err = tx.Exec(insertQuery, migration.Version, migration.Description) + if err != nil { + log.Printf("Failed to record migration %d: %v", migration.Version, err) + return fmt.Errorf("failed to record migration %d: %w", migration.Version, err) + } + + // Commit the transaction + err = tx.Commit() + if err != nil { + log.Printf("Failed to commit migration %d: %v", migration.Version, err) + return err + } + + log.Printf("Successfully applied migration %d", migration.Version) + return nil +} + +// checkRequiredTables verifies that required PinePods tables exist before running migrations +func checkRequiredTables(db *sql.DB, dbType string) error { + log.Println("Checking for required PinePods tables...") + + requiredTables := []string{"Users", "GpodderDevices"} + + for _, table := range requiredTables { + var query string + if dbType == "postgresql" { + query = `SELECT 1 FROM "` + table + `" LIMIT 1` + } else { + query = `SELECT 1 FROM ` + table + ` LIMIT 1` + } + + _, err := db.Exec(query) + if err != nil { + log.Printf("Required table %s does not exist or is not accessible: %v", table, err) + return fmt.Errorf("required table %s does not exist - please ensure PinePods main migrations have run first", table) + } + log.Printf("Required table %s exists", table) + } + + log.Println("All required tables found") + return nil +} + +// RunMigrations runs all pending migrations +func RunMigrations(db *sql.DB, dbType string) error { + log.Println("Starting gpodder API migrations...") + + // Check that required PinePods tables exist first + if err := checkRequiredTables(db, dbType); err != nil { + return fmt.Errorf("prerequisite check failed: %w", err) + } + + // Ensure migrations table exists + if err := EnsureMigrationsTable(db, dbType); err != nil { + return fmt.Errorf("failed to create migrations table: %w", err) + } + + // Get applied migrations + appliedMigrations, err := GetAppliedMigrations(db, dbType) + if err != nil { + return fmt.Errorf("failed to get applied migrations: %w", err) + } + + // Build a map of applied migration versions for quick lookup + appliedVersions := make(map[int]bool) + for _, m := range appliedMigrations { + appliedVersions[m.Version] = true + } + + // Get all migrations + migrations := GetMigrations() + log.Printf("Found %d total migrations to check", len(migrations)) + + // Apply pending migrations + appliedCount := 0 + for _, migration := range migrations { + if appliedVersions[migration.Version] { + // Migration already applied, skip + log.Printf("Migration %d already applied, skipping", migration.Version) + continue + } + + log.Printf("Applying migration %d: %s", migration.Version, migration.Description) + if err := ApplyMigration(db, migration, dbType); err != nil { + return err + } + appliedCount++ + } + + if appliedCount > 0 { + log.Printf("Successfully applied %d new migrations", appliedCount) + } else { + log.Println("No new migrations to apply") + } + + return nil +} + +// GetMigrations returns all migrations with SQL variants for both database types +func GetMigrations() []Migration { + return []Migration{ + { + Version: 1, + Description: "Initial schema creation", + PostgreSQLSQL: ` + -- Device sync state for the API + CREATE TABLE IF NOT EXISTS "GpodderSyncDeviceState" ( + DeviceStateID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + SubscriptionCount INT DEFAULT 0, + LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ); + + -- Subscription changes + CREATE TABLE IF NOT EXISTS "GpodderSyncSubscriptions" ( + SubscriptionID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + PodcastURL TEXT NOT NULL, + Action VARCHAR(10) NOT NULL, + Timestamp BIGINT NOT NULL, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE + ); + + -- Episode actions + CREATE TABLE IF NOT EXISTS "GpodderSyncEpisodeActions" ( + ActionID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT, + PodcastURL TEXT NOT NULL, + EpisodeURL TEXT NOT NULL, + Action VARCHAR(20) NOT NULL, + Timestamp BIGINT NOT NULL, + Started INT, + Position INT, + Total INT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE + ); + + -- Podcast lists + CREATE TABLE IF NOT EXISTS "GpodderSyncPodcastLists" ( + ListID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + Name VARCHAR(255) NOT NULL, + Title VARCHAR(255) NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Name) + ); + + -- Podcast list entries + CREATE TABLE IF NOT EXISTS "GpodderSyncPodcastListEntries" ( + EntryID SERIAL PRIMARY KEY, + ListID INT NOT NULL, + PodcastURL TEXT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ListID) REFERENCES "GpodderSyncPodcastLists"(ListID) ON DELETE CASCADE + ); + + -- Synchronization relationships between devices + CREATE TABLE IF NOT EXISTS "GpodderSyncDevicePairs" ( + PairID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID1 INT NOT NULL, + DeviceID2 INT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID1) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID2) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID1, DeviceID2) + ); + + -- Settings storage + CREATE TABLE IF NOT EXISTS "GpodderSyncSettings" ( + SettingID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + Scope VARCHAR(20) NOT NULL, + DeviceID INT, + PodcastURL TEXT, + EpisodeURL TEXT, + SettingKey VARCHAR(255) NOT NULL, + SettingValue TEXT, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE + ); + + -- Create indexes for faster queries + CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subscriptions_userid ON "GpodderSyncSubscriptions"(UserID); + CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subscriptions_deviceid ON "GpodderSyncSubscriptions"(DeviceID); + CREATE INDEX IF NOT EXISTS idx_gpodder_sync_episode_actions_userid ON "GpodderSyncEpisodeActions"(UserID); + CREATE INDEX IF NOT EXISTS idx_gpodder_sync_podcast_lists_userid ON "GpodderSyncPodcastLists"(UserID); + `, + MySQLSQL: ` + -- Device sync state for the API + CREATE TABLE IF NOT EXISTS GpodderSyncDeviceState ( + DeviceStateID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + SubscriptionCount INT DEFAULT 0, + LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ); + + -- Subscription changes + CREATE TABLE IF NOT EXISTS GpodderSyncSubscriptions ( + SubscriptionID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + PodcastURL TEXT NOT NULL, + Action VARCHAR(10) NOT NULL, + Timestamp BIGINT NOT NULL, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE + ); + + -- Episode actions + CREATE TABLE IF NOT EXISTS GpodderSyncEpisodeActions ( + ActionID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT, + PodcastURL TEXT NOT NULL, + EpisodeURL TEXT NOT NULL, + Action VARCHAR(20) NOT NULL, + Timestamp BIGINT NOT NULL, + Started INT, + Position INT, + Total INT, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE + ); + + -- Podcast lists + CREATE TABLE IF NOT EXISTS GpodderSyncPodcastLists ( + ListID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + Name VARCHAR(255) NOT NULL, + Title VARCHAR(255) NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Name) + ); + + -- Podcast list entries + CREATE TABLE IF NOT EXISTS GpodderSyncPodcastListEntries ( + EntryID INT AUTO_INCREMENT PRIMARY KEY, + ListID INT NOT NULL, + PodcastURL TEXT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ListID) REFERENCES GpodderSyncPodcastLists(ListID) ON DELETE CASCADE + ); + + -- Synchronization relationships between devices + CREATE TABLE IF NOT EXISTS GpodderSyncDevicePairs ( + PairID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID1 INT NOT NULL, + DeviceID2 INT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID1) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID2) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID1, DeviceID2) + ); + + -- Settings storage + CREATE TABLE IF NOT EXISTS GpodderSyncSettings ( + SettingID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + Scope VARCHAR(20) NOT NULL, + DeviceID INT, + PodcastURL TEXT, + EpisodeURL TEXT, + SettingKey VARCHAR(255) NOT NULL, + SettingValue TEXT, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE + ); + + -- Create indexes for faster queries + CREATE INDEX idx_gpodder_sync_subscriptions_userid ON GpodderSyncSubscriptions(UserID); + CREATE INDEX idx_gpodder_sync_subscriptions_deviceid ON GpodderSyncSubscriptions(DeviceID); + CREATE INDEX idx_gpodder_sync_episode_actions_userid ON GpodderSyncEpisodeActions(UserID); + CREATE INDEX idx_gpodder_sync_podcast_lists_userid ON GpodderSyncPodcastLists(UserID); + `, + }, + { + Version: 2, + Description: "Add API version column to GpodderSyncSettings", + PostgreSQLSQL: ` + ALTER TABLE "GpodderSyncSettings" + ADD COLUMN IF NOT EXISTS APIVersion VARCHAR(10) DEFAULT '2.0'; + `, + MySQLSQL: ` + -- Check if column exists first + SET @s = (SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE GpodderSyncSettings ADD COLUMN APIVersion VARCHAR(10) DEFAULT "2.0"', + 'SELECT 1' + ) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'GpodderSyncSettings' + AND COLUMN_NAME = 'APIVersion'); + + PREPARE stmt FROM @s; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + `, + }, + { + Version: 3, + Description: "Create GpodderSessions table for API sessions", + PostgreSQLSQL: ` + CREATE TABLE IF NOT EXISTS "GpodderSessions" ( + SessionID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + SessionToken TEXT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ExpiresAt TIMESTAMP NOT NULL, + LastActive TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UserAgent TEXT, + ClientIP TEXT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(SessionToken) + ); + + CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_token ON "GpodderSessions"(SessionToken); + CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_userid ON "GpodderSessions"(UserID); + CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_expires ON "GpodderSessions"(ExpiresAt); + `, + MySQLSQL: ` + CREATE TABLE IF NOT EXISTS GpodderSessions ( + SessionID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + SessionToken TEXT NOT NULL, + CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ExpiresAt TIMESTAMP NOT NULL, + LastActive TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UserAgent TEXT, + ClientIP TEXT, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE + ); + + CREATE INDEX idx_gpodder_sessions_userid ON GpodderSessions(UserID); + CREATE INDEX idx_gpodder_sessions_expires ON GpodderSessions(ExpiresAt); + `, + }, + { + Version: 4, + Description: "Add sync state table for tracking device sync status", + PostgreSQLSQL: ` + CREATE TABLE IF NOT EXISTS "GpodderSyncState" ( + SyncStateID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + LastTimestamp BIGINT DEFAULT 0, + LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ); + + CREATE INDEX IF NOT EXISTS idx_gpodder_syncstate_userid_deviceid ON "GpodderSyncState"(UserID, DeviceID); + `, + MySQLSQL: ` + CREATE TABLE IF NOT EXISTS GpodderSyncState ( + SyncStateID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + LastTimestamp BIGINT DEFAULT 0, + LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ); + + CREATE INDEX idx_gpodder_syncstate_userid_deviceid ON GpodderSyncState(UserID, DeviceID); + `, + }, + } +} diff --git a/PinePods-0.8.2/gpodder-api/internal/db/postgres.go b/PinePods-0.8.2/gpodder-api/internal/db/postgres.go new file mode 100644 index 0000000..08b5bcb --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/db/postgres.go @@ -0,0 +1,91 @@ +package db + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "pinepods/gpodder-api/config" + "strings" + + _ "github.com/lib/pq" +) + +// PostgresDB represents a connection to the PostgreSQL database +type PostgresDB struct { + *sql.DB +} + +// NewPostgresDB creates a new PostgreSQL database connection +func NewPostgresDB(cfg config.DatabaseConfig) (*PostgresDB, error) { + // Print connection details for debugging (hide password for security) + fmt.Printf("Connecting to database: host=%s port=%d user=%s dbname=%s sslmode=%s\n", + cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode) + + // Get password directly from environment to handle special characters + password := os.Getenv("DB_PASSWORD") + if password == "" { + // Fall back to config if env var is empty + password = cfg.Password + } + + // Escape special characters in password + escapedPassword := url.QueryEscape(password) + + // Use a connection string without password for logging + logConnStr := fmt.Sprintf( + "host=%s port=%d user=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode, + ) + fmt.Printf("Connection string (without password): %s\n", logConnStr) + + // Build the actual connection string with password + connStr := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, password, cfg.DBName, cfg.SSLMode, + ) + + // Try alternate connection string format if the first fails + db, err := sql.Open("postgres", connStr) + if err != nil { + // Try URL format connection string + urlConnStr := fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=%s", + cfg.User, escapedPassword, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode, + ) + fmt.Println("First connection attempt failed, trying URL format...") + db, err = sql.Open("postgres", urlConnStr) + if err != nil { + return nil, fmt.Errorf("failed to open database connection: %w", err) + } + } + + // Test the connection + if err := db.Ping(); err != nil { + db.Close() + // Check if error contains password authentication failure + if strings.Contains(err.Error(), "password authentication failed") { + // Print environment variables (hide password) + fmt.Println("Password authentication failed. Environment variables:") + fmt.Printf("DB_HOST=%s\n", os.Getenv("DB_HOST")) + fmt.Printf("DB_PORT=%s\n", os.Getenv("DB_PORT")) + fmt.Printf("DB_USER=%s\n", os.Getenv("DB_USER")) + fmt.Printf("DB_NAME=%s\n", os.Getenv("DB_NAME")) + fmt.Printf("DB_PASSWORD=*** (length: %d)\n", len(os.Getenv("DB_PASSWORD"))) + } + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + fmt.Println("Successfully connected to the database") + + // Migrations are now handled by the Python migration system + // Skip Go migrations to avoid conflicts + fmt.Println("Skipping Go migrations - now handled by Python migration system") + + return &PostgresDB{DB: db}, nil +} + +// Close closes the database connection +func (db *PostgresDB) Close() error { + return db.DB.Close() +} diff --git a/PinePods-0.8.2/gpodder-api/internal/models/models.go b/PinePods-0.8.2/gpodder-api/internal/models/models.go new file mode 100644 index 0000000..c225a08 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/models/models.go @@ -0,0 +1,171 @@ +package models + +import ( + "time" +) + +// Device represents a user device +type Device struct { + ID int `json:"-"` + UserID int `json:"-"` + DeviceID string `json:"id"` + Caption string `json:"caption"` + Type string `json:"type"` + Subscriptions int `json:"subscriptions"` + CreatedAt time.Time `json:"-"` + LastUpdated time.Time `json:"-"` +} + +// GpodderDevice represents a device from the GpodderDevices table +type GpodderDevice struct { + DeviceID int `json:"-"` + UserID int `json:"-"` + DeviceName string `json:"id"` + DeviceType string `json:"type"` + DeviceCaption string `json:"caption"` + IsDefault bool `json:"-"` + LastSync time.Time `json:"-"` + IsActive bool `json:"-"` + // Additional field for API responses + Subscriptions int `json:"subscriptions"` +} + +// Subscription represents a podcast subscription +type Subscription struct { + SubscriptionID int `json:"-"` + UserID int `json:"-"` + DeviceID int `json:"-"` + PodcastURL string `json:"url"` + Action string `json:"-"` + Timestamp int64 `json:"-"` +} + +// SubscriptionChange represents a change to subscriptions +type SubscriptionChange struct { + Add []string `json:"add"` + Remove []string `json:"remove"` +} + +// SubscriptionResponse represents a response to subscription change request +type SubscriptionResponse struct { + Add []string `json:"add"` + Remove []string `json:"remove"` + Timestamp int64 `json:"timestamp"` + UpdateURLs [][]string `json:"update_urls"` // Removed omitempty to ensure field is always present +} + +// EpisodeAction represents an action performed on an episode +// First, create a struct for the JSON request format +type EpisodeActionRequest struct { + Actions []EpisodeAction `json:"actions"` +} + +// Then modify the EpisodeAction struct to use a flexible type for timestamp +type EpisodeAction struct { + ActionID int `json:"-"` + UserID int `json:"-"` + DeviceID int `json:"-"` + Podcast string `json:"podcast"` + Episode string `json:"episode"` + Device string `json:"device,omitempty"` + Action string `json:"action"` + Timestamp interface{} `json:"timestamp"` // Accept any type + Started *int `json:"started,omitempty"` + Position *int `json:"position,omitempty"` + Total *int `json:"total,omitempty"` +} + +// EpisodeActionResponse represents a response to episode action upload +type EpisodeActionResponse struct { + Timestamp int64 `json:"timestamp"` + UpdateURLs [][]string `json:"update_urls"` // Removed omitempty +} + +// EpisodeActionsResponse represents a response for episode actions retrieval +type EpisodeActionsResponse struct { + Actions []EpisodeAction `json:"actions"` + Timestamp int64 `json:"timestamp"` +} + +// PodcastList represents a user's podcast list +type PodcastList struct { + ListID int `json:"-"` + UserID int `json:"-"` + Name string `json:"name"` + Title string `json:"title"` + CreatedAt time.Time `json:"-"` + WebURL string `json:"web"` + Podcasts []Podcast `json:"-"` +} + +// Podcast represents a podcast +type Podcast struct { + URL string `json:"url"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Website string `json:"website,omitempty"` + Subscribers int `json:"subscribers,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + ScaledLogoURL string `json:"scaled_logo_url,omitempty"` + Author string `json:"author,omitempty"` + MygpoLink string `json:"mygpo_link,omitempty"` +} + +// Episode represents a podcast episode +type Episode struct { + Title string `json:"title"` + URL string `json:"url"` + PodcastTitle string `json:"podcast_title"` + PodcastURL string `json:"podcast_url"` + Description string `json:"description"` + Website string `json:"website"` + Released string `json:"released"` // ISO 8601 format + MygpoLink string `json:"mygpo_link"` +} + +// Setting represents a user setting +type Setting struct { + SettingID int `json:"-"` + UserID int `json:"-"` + Scope string `json:"-"` + DeviceID int `json:"-"` + PodcastURL string `json:"-"` + EpisodeURL string `json:"-"` + SettingKey string `json:"-"` + SettingValue string `json:"-"` + CreatedAt time.Time `json:"-"` + LastUpdated time.Time `json:"-"` +} + +// SettingsRequest represents a settings update request +type SettingsRequest struct { + Set map[string]interface{} `json:"set"` + Remove []string `json:"remove"` +} + +// Tag represents a tag +type Tag struct { + Title string `json:"title"` + Tag string `json:"tag"` + Usage int `json:"usage"` +} + +// SyncDevicesResponse represents the sync status response +type SyncDevicesResponse struct { + Synchronized [][]string `json:"synchronized"` + NotSynchronized []string `json:"not-synchronized"` +} + +// SyncDevicesRequest represents a sync status update request +type SyncDevicesRequest struct { + Synchronize [][]string `json:"synchronize"` + StopSynchronize []string `json:"stop-synchronize"` +} + +// DeviceUpdateResponse represents a response to device updates request +type DeviceUpdateResponse struct { + Add []Podcast `json:"add"` + Remove []string `json:"remove"` + Updates []Episode `json:"updates"` + Timestamp int64 `json:"timestamp"` +} diff --git a/PinePods-0.8.2/gpodder-api/internal/utils/podcast_parser.go b/PinePods-0.8.2/gpodder-api/internal/utils/podcast_parser.go new file mode 100644 index 0000000..83376dc --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/internal/utils/podcast_parser.go @@ -0,0 +1,163 @@ +package utils + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/mmcdole/gofeed" +) + +// PodcastValues represents metadata extracted from a podcast feed +type PodcastValues struct { + Title string `json:"title"` + ArtworkURL string `json:"artwork_url"` + Author string `json:"author"` + Categories string `json:"categories"` + Description string `json:"description"` + EpisodeCount int `json:"episode_count"` + FeedURL string `json:"feed_url"` + WebsiteURL string `json:"website_url"` + Explicit bool `json:"explicit"` + UserID int `json:"user_id"` +} + +// GetPodcastValues fetches and parses a podcast feed +func GetPodcastValues(feedURL string, userID int, username string, password string) (*PodcastValues, error) { + log.Printf("[INFO] Fetching podcast data from feed: %s", feedURL) + + // Create a feed parser with custom configuration + fp := gofeed.NewParser() + + // Set a reasonable timeout to prevent hanging + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Parse the feed + feed, err := fp.ParseURLWithContext(feedURL, ctx) + if err != nil { + log.Printf("[ERROR] Failed to parse feed %s: %v", feedURL, err) + + // Return minimal data even when failing + return &PodcastValues{ + Title: feedURL, + Description: fmt.Sprintf("Podcast with feed: %s", feedURL), + FeedURL: feedURL, + UserID: userID, + EpisodeCount: 0, + }, err + } + + // Initialize podcast values + podcastValues := &PodcastValues{ + Title: feed.Title, + FeedURL: feedURL, + UserID: userID, + EpisodeCount: len(feed.Items), + } + + // Extract basic data + if feed.Description != "" { + podcastValues.Description = feed.Description + } + + if feed.Author != nil && feed.Author.Name != "" { + podcastValues.Author = feed.Author.Name + } + + if feed.Link != "" { + podcastValues.WebsiteURL = feed.Link + } + + // Extract artwork URL + if feed.Image != nil && feed.Image.URL != "" { + podcastValues.ArtworkURL = feed.Image.URL + } + + // Process iTunes extensions if available + extensions := feed.Extensions + if extensions != nil { + if itunesExt, ok := extensions["itunes"]; ok { + // Check for iTunes author + if itunesAuthor, exists := itunesExt["author"]; exists && len(itunesAuthor) > 0 { + if podcastValues.Author == "" && itunesAuthor[0].Value != "" { + podcastValues.Author = itunesAuthor[0].Value + } + } + + // Check for iTunes image + if itunesImage, exists := itunesExt["image"]; exists && len(itunesImage) > 0 { + if podcastValues.ArtworkURL == "" && itunesImage[0].Attrs["href"] != "" { + podcastValues.ArtworkURL = itunesImage[0].Attrs["href"] + } + } + + // Check for explicit content + if itunesExplicit, exists := itunesExt["explicit"]; exists && len(itunesExplicit) > 0 { + explicitValue := strings.ToLower(itunesExplicit[0].Value) + podcastValues.Explicit = explicitValue == "yes" || explicitValue == "true" + } + + // Check for categories + if itunesCategories, exists := itunesExt["category"]; exists && len(itunesCategories) > 0 { + categories := make(map[string]string) + + for i, category := range itunesCategories { + if category.Attrs["text"] != "" { + categories[fmt.Sprintf("%d", i+1)] = category.Attrs["text"] + + // A simplified approach for subcategories + // Many iTunes category extensions have nested category elements + // directly within them as attributes + if subCategoryText, hasSubCategory := category.Attrs["subcategory"]; hasSubCategory { + categories[fmt.Sprintf("%d.1", i+1)] = subCategoryText + } + } + } + + // Serialize categories to JSON string if we found any + if len(categories) > 0 { + categoriesJSON, err := json.Marshal(categories) + if err == nil { + podcastValues.Categories = string(categoriesJSON) + } else { + log.Printf("[WARNING] Failed to serialize categories: %v", err) + podcastValues.Categories = "{}" + } + } + } + + // Check for iTunes summary + if itunesSummary, exists := itunesExt["summary"]; exists && len(itunesSummary) > 0 { + if podcastValues.Description == "" && itunesSummary[0].Value != "" { + podcastValues.Description = itunesSummary[0].Value + } + } + } + } + + // Fill in defaults for missing values + if podcastValues.Title == "" { + podcastValues.Title = feedURL + } + + if podcastValues.Description == "" { + podcastValues.Description = fmt.Sprintf("Podcast feed: %s", feedURL) + } + + if podcastValues.Author == "" { + podcastValues.Author = "Unknown Author" + } + + if podcastValues.Categories == "" { + podcastValues.Categories = "{}" + } + + log.Printf("[INFO] Successfully parsed podcast feed: %s, title: %s, episodes: %d", + feedURL, podcastValues.Title, podcastValues.EpisodeCount) + + return podcastValues, nil +} diff --git a/PinePods-0.8.2/gpodder-api/start-gpodder.sh b/PinePods-0.8.2/gpodder-api/start-gpodder.sh new file mode 100644 index 0000000..22bfa74 --- /dev/null +++ b/PinePods-0.8.2/gpodder-api/start-gpodder.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# /usr/local/bin/start-gpodder.sh + +# This script is kept for backward compatibility, but shouldn't be needed +# as the gpodder-api is now managed by supervisord + +# Check if the gpodder-api is already running under supervisor +if supervisorctl status gpodder_api | grep -q "RUNNING"; then + echo "gpodder-api is already running under supervisor, exiting" + exit 0 +fi + +# Start the gpodder-api only if it's not already managed by supervisor +echo "Starting gpodder-api (standalone mode) with PID logging" +nohup /usr/local/bin/gpodder-api > /var/log/gpodder-api.log 2>&1 & +PID=$! +echo "Started gpodder-api with PID $PID" +echo $PID > /var/run/gpodder-api.pid diff --git a/PinePods-0.8.2/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg b/PinePods-0.8.2/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg new file mode 100755 index 0000000..072b425 --- /dev/null +++ b/PinePods-0.8.2/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PinePods-0.8.2/images/apple-touch-icon-192.png b/PinePods-0.8.2/images/apple-touch-icon-192.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/images/apple-touch-icon-192.png differ diff --git a/PinePods-0.8.2/images/badge_obtainium.png b/PinePods-0.8.2/images/badge_obtainium.png new file mode 100644 index 0000000..a4cf4f9 Binary files /dev/null and b/PinePods-0.8.2/images/badge_obtainium.png differ diff --git a/PinePods-0.8.2/images/favicon.png b/PinePods-0.8.2/images/favicon.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/images/favicon.png differ diff --git a/PinePods-0.8.2/images/icon-192.png b/PinePods-0.8.2/images/icon-192.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/images/icon-192.png differ diff --git a/PinePods-0.8.2/images/icon-512.png b/PinePods-0.8.2/images/icon-512.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/images/icon-512.png differ diff --git a/PinePods-0.8.2/images/icon-maskable-192.png b/PinePods-0.8.2/images/icon-maskable-192.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/images/icon-maskable-192.png differ diff --git a/PinePods-0.8.2/images/icon-maskable-512.png b/PinePods-0.8.2/images/icon-maskable-512.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/images/icon-maskable-512.png differ diff --git a/PinePods-0.8.2/images/loading-animation (copy).png b/PinePods-0.8.2/images/loading-animation (copy).png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/images/loading-animation (copy).png differ diff --git a/PinePods-0.8.2/images/loading-animation.png b/PinePods-0.8.2/images/loading-animation.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/images/loading-animation.png differ diff --git a/PinePods-0.8.2/images/logo1024actualsquare-monochrome.png b/PinePods-0.8.2/images/logo1024actualsquare-monochrome.png new file mode 100644 index 0000000..58b9da3 Binary files /dev/null and b/PinePods-0.8.2/images/logo1024actualsquare-monochrome.png differ diff --git a/PinePods-0.8.2/images/logo1024actualsquare.png b/PinePods-0.8.2/images/logo1024actualsquare.png new file mode 100644 index 0000000..2e5f4f4 Binary files /dev/null and b/PinePods-0.8.2/images/logo1024actualsquare.png differ diff --git a/PinePods-0.8.2/images/logo1024square.png b/PinePods-0.8.2/images/logo1024square.png new file mode 100644 index 0000000..2bad8d3 Binary files /dev/null and b/PinePods-0.8.2/images/logo1024square.png differ diff --git a/PinePods-0.8.2/images/logo_random/1.jpeg b/PinePods-0.8.2/images/logo_random/1.jpeg new file mode 100644 index 0000000..3ce1afa Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/1.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/10.jpeg b/PinePods-0.8.2/images/logo_random/10.jpeg new file mode 100644 index 0000000..c5d97ba Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/10.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/11.jpeg b/PinePods-0.8.2/images/logo_random/11.jpeg new file mode 100644 index 0000000..4fd1f5d Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/11.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/12.jpeg b/PinePods-0.8.2/images/logo_random/12.jpeg new file mode 100644 index 0000000..14337b5 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/12.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/13.jpeg b/PinePods-0.8.2/images/logo_random/13.jpeg new file mode 100644 index 0000000..90dd943 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/13.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/2.jpeg b/PinePods-0.8.2/images/logo_random/2.jpeg new file mode 100644 index 0000000..c944d2c Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/2.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/3.jpeg b/PinePods-0.8.2/images/logo_random/3.jpeg new file mode 100644 index 0000000..aca8fc1 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/3.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/4.jpeg b/PinePods-0.8.2/images/logo_random/4.jpeg new file mode 100644 index 0000000..0decf2e Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/4.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/5.jpeg b/PinePods-0.8.2/images/logo_random/5.jpeg new file mode 100644 index 0000000..4106477 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/5.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/6.jpeg b/PinePods-0.8.2/images/logo_random/6.jpeg new file mode 100644 index 0000000..7b804e0 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/6.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/7.jpeg b/PinePods-0.8.2/images/logo_random/7.jpeg new file mode 100644 index 0000000..0d3c474 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/7.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/8.jpeg b/PinePods-0.8.2/images/logo_random/8.jpeg new file mode 100644 index 0000000..942c4bf Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/8.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/9.jpeg b/PinePods-0.8.2/images/logo_random/9.jpeg new file mode 100644 index 0000000..80113da Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/9.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_transparent.png new file mode 100644 index 0000000..8980d58 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/logo_info.txt new file mode 100644 index 0000000..59d78ea --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(1)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: 495FA6,F6AE84 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_transparent.png new file mode 100644 index 0000000..2200410 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/logo_info.txt new file mode 100644 index 0000000..07adc98 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(10)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: F29765,F6F6F7 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_transparent.png new file mode 100644 index 0000000..cb8eba5 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/logo_info.txt new file mode 100644 index 0000000..ee369bc --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(11)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: F79B26,0E67B4 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_transparent.png new file mode 100644 index 0000000..078bca3 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/logo_info.txt new file mode 100644 index 0000000..0443f04 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(12)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: 539D8B,F5C5BE + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_transparent.png new file mode 100644 index 0000000..bfb40c0 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/logo_info.txt new file mode 100644 index 0000000..0a5858e --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(13)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: AAAAA1,F7E108 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_transparent.png new file mode 100644 index 0000000..a034d3f Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/logo_info.txt new file mode 100644 index 0000000..56e93d0 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(14)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: F2EEE4,5065A8 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_transparent.png new file mode 100644 index 0000000..5cc43ed Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/logo_info.txt new file mode 100644 index 0000000..b7444d1 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(2)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: E79115,2E2929 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos.jpeg b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos.jpeg new file mode 100644 index 0000000..4ed3daf Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_transparent.png new file mode 100644 index 0000000..d24a521 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/logo_info.txt new file mode 100644 index 0000000..8f9d351 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(3)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: 0E67B4,F7F3E8 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_transparent.png new file mode 100644 index 0000000..28cfa86 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/logo_info.txt new file mode 100644 index 0000000..bc63696 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(4)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: E5E5E5,E87121 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_transparent.png new file mode 100644 index 0000000..2231d8b Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/logo_info.txt new file mode 100644 index 0000000..db6254f --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(5)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: F5756B,353D60 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos.jpeg b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos.jpeg new file mode 100644 index 0000000..6623e4c Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos.jpeg differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_transparent.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/logo_info.txt new file mode 100644 index 0000000..bb3f551 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(6)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: E74E35,FFFFFF + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_transparent.png new file mode 100644 index 0000000..8ce5742 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/logo_info.txt new file mode 100644 index 0000000..bc252eb --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(7)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: 77BB3F,F7F7F6 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_transparent.png new file mode 100644 index 0000000..40daf15 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/logo_info.txt new file mode 100644 index 0000000..93c63b8 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(8)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: E2E2E2,171717 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_transparent.png new file mode 100644 index 0000000..ea3b16e Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/logo_info.txt new file mode 100644 index 0000000..c825aac --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos(9)/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: BDD3EF,107CF1 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_black.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_black.png new file mode 100644 index 0000000..5a0ca00 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_black.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_transparent.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_transparent.png new file mode 100644 index 0000000..2abb306 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_transparent.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_white.png b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_white.png new file mode 100644 index 0000000..960b056 Binary files /dev/null and b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/PinePods-logos_white.png differ diff --git a/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/logo_info.txt b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/logo_info.txt new file mode 100644 index 0000000..fc97229 --- /dev/null +++ b/PinePods-0.8.2/images/logo_random/OtherLogos/PinePods-logos/logo_info.txt @@ -0,0 +1,7 @@ + + Fonts used: CallunaSans-Italic,LeagueGothic-Italic + + Colors used: 66A3BB,253A47 + + Icon url: https://thenounproject.com/term/pine/1111055 + \ No newline at end of file diff --git a/PinePods-0.8.2/images/manifest.json b/PinePods-0.8.2/images/manifest.json new file mode 100644 index 0000000..22a87b9 --- /dev/null +++ b/PinePods-0.8.2/images/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Pinepods", + "short_name": "Pinepods", + "display": "A Forest of Podcasts, Rooted in the Spirit of Self-Hosting", + "background_color": "#539e8a" + } + \ No newline at end of file diff --git a/PinePods-0.8.2/images/pinepods-logo.jpeg b/PinePods-0.8.2/images/pinepods-logo.jpeg new file mode 100644 index 0000000..4fd1f5d Binary files /dev/null and b/PinePods-0.8.2/images/pinepods-logo.jpeg differ diff --git a/PinePods-0.8.2/images/screenshots/home.png b/PinePods-0.8.2/images/screenshots/home.png new file mode 100644 index 0000000..2610a99 Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/home.png differ diff --git a/PinePods-0.8.2/images/screenshots/homegreen.png b/PinePods-0.8.2/images/screenshots/homegreen.png new file mode 100644 index 0000000..652bec8 Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/homegreen.png differ diff --git a/PinePods-0.8.2/images/screenshots/homelight.png b/PinePods-0.8.2/images/screenshots/homelight.png new file mode 100644 index 0000000..80edd3e Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/homelight.png differ diff --git a/PinePods-0.8.2/images/screenshots/homethemed.png b/PinePods-0.8.2/images/screenshots/homethemed.png new file mode 100644 index 0000000..d606237 Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/homethemed.png differ diff --git a/PinePods-0.8.2/images/screenshots/markdownview.png b/PinePods-0.8.2/images/screenshots/markdownview.png new file mode 100644 index 0000000..7b00204 Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/markdownview.png differ diff --git a/PinePods-0.8.2/images/screenshots/mobile.png b/PinePods-0.8.2/images/screenshots/mobile.png new file mode 100644 index 0000000..70b1bf1 Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/mobile.png differ diff --git a/PinePods-0.8.2/images/screenshots/mobileepisode.png b/PinePods-0.8.2/images/screenshots/mobileepisode.png new file mode 100644 index 0000000..42968d5 Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/mobileepisode.png differ diff --git a/PinePods-0.8.2/images/screenshots/podpage.png b/PinePods-0.8.2/images/screenshots/podpage.png new file mode 100644 index 0000000..9becf20 Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/podpage.png differ diff --git a/PinePods-0.8.2/images/screenshots/podview.png b/PinePods-0.8.2/images/screenshots/podview.png new file mode 100644 index 0000000..4d4dc7a Binary files /dev/null and b/PinePods-0.8.2/images/screenshots/podview.png differ diff --git a/PinePods-0.8.2/metadata/com.gooseberrydevelopment.pinepods.yml b/PinePods-0.8.2/metadata/com.gooseberrydevelopment.pinepods.yml new file mode 100644 index 0000000..e9e0fd3 --- /dev/null +++ b/PinePods-0.8.2/metadata/com.gooseberrydevelopment.pinepods.yml @@ -0,0 +1,43 @@ +Categories: + - Multimedia + - Podcast +License: GPL-3.0-or-later +AuthorName: Collin Pendleton +AuthorEmail: help@gooseberrydevelopment.com +WebSite: https://www.pinepods.online/ +SourceCode: https://github.com/madeofpendletonwool/pinepods +IssueTracker: https://github.com/madeofpendletonwool/pinepods/issues +Changelog: https://github.com/madeofpendletonwool/pinepods/releases + +AutoName: Pinepods + +RepoType: git +Repo: https://github.com/madeofpendletonwool/pinepods + +Builds: + - versionName: 0.7.10 + versionCode: 20250714 + commit: 1397b4a59c518482076e25b5e202276b4110d281 + subdir: mobile + sudo: + - apt-get update + - apt-get install -y openjdk-17-jdk-headless + - update-java-alternatives -a + output: build/app/outputs/apk/release/app-release-unsigned.apk + srclibs: + - flutter@3.32.6 + scanignore: + - mobile/assets/fonts/*.otf + - Backend + - deployment + - docs + build: + - $$flutter$$/bin/flutter config --no-analytics + - $$flutter$$/bin/flutter packages pub get + - $$flutter$$/bin/flutter build apk + +AutoUpdateMode: Version +UpdateCheckMode: Tags ^[\d.]+$ +UpdateCheckData: mobile/pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+ +CurrentVersion: 0.7.10 +CurrentVersionCode: 20250714 diff --git a/PinePods-0.8.2/mobile/.metadata b/PinePods-0.8.2/mobile/.metadata new file mode 100644 index 0000000..7fccfe9 --- /dev/null +++ b/PinePods-0.8.2/mobile/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 985ccb6d14c6ce5ce74823a4d366df2438eac44f + channel: beta + +project_type: app diff --git a/PinePods-0.8.2/mobile/FUNDING.yml b/PinePods-0.8.2/mobile/FUNDING.yml new file mode 100644 index 0000000..39ee2b5 --- /dev/null +++ b/PinePods-0.8.2/mobile/FUNDING.yml @@ -0,0 +1,2 @@ +github: madeofpendletonwool +buy_me_a_coffee: collinscoffee diff --git a/PinePods-0.8.2/mobile/LICENSE.BenHills b/PinePods-0.8.2/mobile/LICENSE.BenHills new file mode 100644 index 0000000..3a08bee --- /dev/null +++ b/PinePods-0.8.2/mobile/LICENSE.BenHills @@ -0,0 +1,27 @@ +This license applies to the Mobile App Source Code specifically + +Copyright (c) 2020 Ben Hills and the project contributors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used + to endorse or promote products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/LICENSE.anytime b/PinePods-0.8.2/mobile/LICENSE.anytime new file mode 100644 index 0000000..90dbd2d --- /dev/null +++ b/PinePods-0.8.2/mobile/LICENSE.anytime @@ -0,0 +1,25 @@ +Copyright (c) 2020 Ben Hills and the project contributors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used + to endorse or promote products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/README.md b/PinePods-0.8.2/mobile/README.md new file mode 100644 index 0000000..c11d011 --- /dev/null +++ b/PinePods-0.8.2/mobile/README.md @@ -0,0 +1,3 @@ +# PinePods Mobile App + +Based on Anytime Podcast Player (https://github.com/amugofjava/anytime_podcast_player) diff --git a/PinePods-0.8.2/mobile/STORE_DEPLOYMENT.md b/PinePods-0.8.2/mobile/STORE_DEPLOYMENT.md new file mode 100644 index 0000000..72e4db1 --- /dev/null +++ b/PinePods-0.8.2/mobile/STORE_DEPLOYMENT.md @@ -0,0 +1,433 @@ +# 🚀 PinePods Mobile - Complete Store Deployment Guide + +This guide covers deployment to **Google Play Store**, **iOS App Store**, **F-Droid**, and **IzzyOnDroid**. + +## 📋 Overview + +- **Google Play Store**: Official Android distribution +- **iOS App Store**: Official iOS distribution +- **F-Droid**: Open-source Android app repository +- **IzzyOnDroid**: F-Droid compatible repository with faster updates + +--- + +## 🔐 Step 1: Create Signing Certificates & Keys + +### **Android Keystore (Required for Google Play, F-Droid, IzzyOnDroid)** + +```bash +# Create upload keystore +keytool -genkey -v -keystore upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload + +# Follow prompts to set: +# - Keystore password (save as ANDROID_STORE_PASSWORD) +# - Key password (save as ANDROID_KEY_PASSWORD) +# - Alias: "upload" (save as ANDROID_KEY_ALIAS) +# - Your name/organization details + +# Convert keystore to base64 for GitHub secrets +base64 upload-keystore.jks > keystore.base64.txt +# Copy contents for ANDROID_KEYSTORE_BASE64 secret +``` + +### **Google Play Console API Key** + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create new project or select existing project +3. Enable **Google Play Developer API** +4. Go to IAM & Admin → Service Accounts +5. Create Service Account with name "github-actions" +6. Grant **Service Account User** role +7. Create key → JSON format → Download +8. Convert to base64: `base64 service-account.json > gplay-api.base64.txt` +9. Copy contents for `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` secret + +### **iOS Distribution Certificate (Apple Developer Account Required - $99/year)** + +1. **Get Apple Developer Account**: https://developer.apple.com/programs/ +2. **Create Distribution Certificate**: + - Open Xcode → Preferences → Accounts + - Add your Apple ID → Select team + - Manage Certificates → "+" → iOS Distribution + - Export certificate as .p12 file with password + - Convert: `base64 certificate.p12 > ios-cert.base64.txt` + - Use for `IOS_CERTIFICATE_BASE64` and password for `IOS_CERTIFICATE_PASSWORD` + +3. **Create App Store Provisioning Profile**: + - Go to [Apple Developer Portal](https://developer.apple.com/) + - Certificates, Identifiers & Profiles + - Create App ID: `com.gooseberrydevelopment.pinepods` + - Create App Store Provisioning Profile linked to your App ID + - Download .mobileprovision file + - Convert: `base64 profile.mobileprovision > ios-profile.base64.txt` + - Use for `IOS_PROVISIONING_PROFILE_BASE64` + +4. **App Store Connect API Key**: + - Go to [App Store Connect](https://appstoreconnect.apple.com/) + - Users and Access → Keys → "+" + - Create key with "App Manager" role + - Download .p8 file and note Key ID + Issuer ID + - Convert: `base64 AuthKey_XXXXXXXXXX.p8 > app-store-api.base64.txt` + - Key ID → `APP_STORE_CONNECT_API_KEY_ID` + - Issuer ID → `APP_STORE_CONNECT_ISSUER_ID` + - Base64 content → `APP_STORE_CONNECT_API_KEY_BASE64` + +5. **Get Team ID**: + - In Apple Developer Portal → Membership + - Copy 10-character Team ID → `IOS_TEAM_ID` + - Set any password for `KEYCHAIN_PASSWORD` (used temporarily in CI) + +--- + +## 🔑 Step 2: Add GitHub Secrets + +Go to your repo → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** + +### **Android Secrets:** +- `ANDROID_KEYSTORE_BASE64` - Base64 encoded upload-keystore.jks +- `ANDROID_STORE_PASSWORD` - Keystore password +- `ANDROID_KEY_PASSWORD` - Key password +- `ANDROID_KEY_ALIAS` - Key alias (usually "upload") +- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` - Base64 encoded service account JSON + +### **iOS Secrets:** +- `IOS_CERTIFICATE_BASE64` - Base64 encoded distribution certificate +- `IOS_CERTIFICATE_PASSWORD` - Certificate password +- `IOS_PROVISIONING_PROFILE_BASE64` - Base64 encoded provisioning profile +- `IOS_TEAM_ID` - Apple Developer team ID +- `KEYCHAIN_PASSWORD` - Any secure password for temporary keychain +- `APP_STORE_CONNECT_API_KEY_ID` - App Store Connect API key ID +- `APP_STORE_CONNECT_ISSUER_ID` - Issuer ID +- `APP_STORE_CONNECT_API_KEY_BASE64` - Base64 encoded API key file + +--- + +## 📱 Step 3: Google Play Store + +### **Setup:** +1. **Create Google Play Console Account** ($25 one-time fee): https://play.google.com/console +2. **Create App Listing**: + - Create app → Package name: `com.gooseberrydevelopment.pinepods` + - App name: "PinePods" + - Select "App" (not game) + +### **Required Assets:** +Create these images and place in `mobile/fastlane/metadata/android/en-US/images/`: + +- **App Icon**: `icon/icon.png` (512x512px) +- **Feature Graphic**: `featureGraphic/feature.png` (1024x500px) +- **Phone Screenshots**: `phoneScreenshots/` (4-8 screenshots, 16:9 or 9:16 ratio) +- **Tablet Screenshots**: `tenInchScreenshots/` (4-8 screenshots, landscape recommended) + +### **App Information:** +- **Privacy Policy URL**: Required (create one at https://privacypolicytemplate.net/) +- **Content Rating**: Complete questionnaire in Play Console +- **Target Audience**: 13+ (contains user-generated content) +- **Data Safety**: Declare what data your app collects + +### **Deployment:** +```bash +# Test build locally +cd mobile +flutter build appbundle --release + +# Deploy via GitHub Actions +git tag v0.7.9001 +git push origin v0.7.9 +# This triggers automatic build and upload to Play Store +``` + +--- + +## 🍎 Step 4: iOS App Store + +### **Setup:** +1. **App Store Connect**: https://appstoreconnect.apple.com/ +2. **Create App Record**: + - Apps → "+" → New App + - Bundle ID: `com.gooseberrydevelopment.pinepods` + - App name: "PinePods" + +### **Required Assets:** +Create these images and place in `mobile/fastlane/metadata/ios/en-US/images/`: + +- **App Icon**: Various sizes (handled by flutter_launcher_icons) +- **iPhone Screenshots**: 6.7" display (1290x2796px) - 6-10 screenshots +- **iPad Screenshots**: 12.9" display (2048x2732px) - 6-10 screenshots + +### **App Information:** +- **App Privacy**: Complete privacy questionnaire +- **Age Rating**: 12+ (realistic infrequent violence due to podcast content) +- **App Review Information**: Provide test account credentials +- **Export Compliance**: Select "No" unless app uses encryption + +### **Deployment:** +```bash +# Test build locally (macOS only) +cd mobile +flutter build ios --release --no-codesign + +# Deploy via GitHub Actions +git tag v0.7.9001 +git push origin v0.7.9 +# This triggers automatic build and upload to App Store Connect +``` + +--- + +## 🤖 Step 5: F-Droid + +F-Droid builds apps from source automatically. No signing required from your end. + +### **Requirements Met ✅:** +- ✅ Open source (GitHub repository) +- ✅ No proprietary dependencies +- ✅ Metadata files created in `mobile/metadata/` +- ✅ Build workflow for unsigned APK + +### **Submission Process:** +1. **Fork F-Droid Data Repository**: https://gitlab.com/fdroid/fdroiddata +2. **Create App Metadata**: + ```bash + # Clone your fork + git clone https://gitlab.com/yourusername/fdroiddata.git + cd fdroiddata + + # Create app directory + mkdir metadata/com.gooseberrydevelopment.pinepods.yml + ``` + +3. **Create Metadata File** (`metadata/com.gooseberrydevelopment.pinepods.yml`): + ```yaml + Categories: + - Multimedia + License: GPL-3.0-or-later + AuthorName: Collin Pendleton + AuthorEmail: your-email@example.com + SourceCode: https://github.com/madeofpendletonwool/PinePods + IssueTracker: https://github.com/madeofpendletonwool/PinePods/issues + + AutoName: PinePods + Description: |- + A beautiful, self-hosted podcast app with powerful server synchronization. + + Features: + * Self-hosted podcast server synchronization + * Beautiful, intuitive mobile interface + * Download episodes for offline listening + * Chapter support with navigation + * Playlist management + * User statistics and listening history + * Multi-device synchronization + * Search and discovery + * Background audio playback + * Sleep timer and playback speed controls + + Note: This app requires a PinePods server to be set up. + + RepoType: git + Repo: https://github.com/madeofpendletonwool/PinePods.git + Binaries: https://github.com/madeofpendletonwool/PinePods/releases/download/v%v/PinePods-fdroid-%v.apk + + Builds: + - versionName: 0.7.9 + versionCode: 20250714 + commit: v0.7.9 + subdir: mobile + output: build/app/outputs/flutter-apk/app-release.apk + build: + - $$flutter$$/bin/flutter config --no-analytics + - $$flutter$$/bin/flutter pub get + - $$flutter$$/bin/flutter build apk --release + + AutoUpdateMode: Version v%v + UpdateCheckMode: Tags + CurrentVersion: 0.7.9 + CurrentVersionCode: 20250714 + ``` + +4. **Submit Merge Request**: + ```bash + git add metadata/com.gooseberrydevelopment.pinepods.yml + git commit -m "Add PinePods podcast app" + git push origin master + # Create merge request in GitLab + ``` + +5. **F-Droid Review Process**: + - Review can take 2-8 weeks + - Maintainers will test build and review code + - Address any feedback in follow-up commits + +--- + +## ⚡ Step 6: IzzyOnDroid + +IzzyOnDroid accepts APKs directly and offers faster updates than F-Droid. + +### **Requirements:** +- ✅ Signed APK (using your Android keystore) +- ✅ Open source repository +- ✅ No tracking libraries (F-Droid friendly) + +### **Submission Process:** + +1. **Build Signed APK**: + ```bash + cd mobile/android + # Create key.properties file + echo "storePassword=YOUR_STORE_PASSWORD" > key.properties + echo "keyPassword=YOUR_KEY_PASSWORD" >> key.properties + echo "keyAlias=upload" >> key.properties + echo "storeFile=../upload-keystore.jks" >> key.properties + + # Copy your keystore + cp /path/to/upload-keystore.jks ./ + + # Build signed APK + cd .. + flutter build apk --release + ``` + +2. **Create GitHub Release**: + ```bash + git tag v0.7.9 + git push origin v0.7.9 + # Upload the signed APK to GitHub releases + ``` + +3. **Submit to IzzyOnDroid**: + - **Email**: android@izzysoft.de + - **Subject**: "New app submission: PinePods" + - **Include**: + - App name: PinePods + - Package name: com.gooseberrydevelopment.pinepods + - Source code: https://github.com/madeofpendletonwool/PinePods + - APK download: Link to GitHub release + - Brief description of your app + - License: GPL-3.0-or-later + +4. **IzzyOnDroid Review**: + - Usually processed within 1-2 weeks + - Much faster than F-Droid + - Compatible with F-Droid client + +--- + +## 📸 Step 7: Create Screenshots & Assets + +### **Required Screenshots:** + +**For Google Play & IzzyOnDroid:** +- Phone: 4-8 screenshots (minimum 1080px on shortest side) +- Tablet: 4-8 screenshots (minimum 1200px on shortest side) + +**For iOS App Store:** +- iPhone: 6-10 screenshots (6.7" display: 1290x2796px) +- iPad: 6-10 screenshots (12.9" display: 2048x2732px) + +**For F-Droid:** +- Phone: 2-6 screenshots (place in `fastlane/metadata/android/en-US/images/phoneScreenshots/`) + +### **Screenshot Ideas:** +1. Home screen with episode list +2. Now playing screen with controls +3. Podcast discovery/search +4. Downloads/offline content +5. Settings/preferences +6. Player with chapters +7. Playlist management +8. User statistics + +### **Tools for Screenshots:** +- **Android**: Use Android Studio Device Manager or physical device +- **iOS**: Use iOS Simulator or physical device +- **Design**: Use Figma/Canva for feature graphics + +--- + +## 🚀 Step 8: Deploy Everything + +### **1. Create Release**: +```bash +# Ensure all secrets are set in GitHub +# Ensure screenshots are added to fastlane/metadata directories +# Create and push tag +git tag v0.7.9 +git push origin v0.7.9 +``` + +### **2. Automated Deployments**: +- ✅ **Google Play**: Automatic upload via GitHub Actions +- ✅ **iOS App Store**: Automatic upload via GitHub Actions +- ✅ **F-Droid**: Builds automatically after merge request accepted +- ✅ **IzzyOnDroid**: Manual submission of signed APK + +### **3. Monitor Builds**: +- Check GitHub Actions for build status +- Monitor store review processes +- Respond to any review feedback + +--- + +## 📋 Checklist + +### **Pre-Deployment:** +- [ ] Android keystore created and base64 encoded +- [ ] Google Play Console account created ($25) +- [ ] Apple Developer account created ($99/year) +- [ ] All GitHub secrets configured +- [ ] Screenshots created for all platforms +- [ ] Privacy policy created and published +- [ ] App descriptions finalized + +### **Store Setup:** +- [ ] Google Play Console app listing created +- [ ] App Store Connect app record created +- [ ] F-Droid metadata file created +- [ ] IzzyOnDroid submission email prepared + +### **Deploy:** +- [ ] Create GitHub release with tag +- [ ] Verify builds complete successfully +- [ ] Submit to F-Droid (create merge request) +- [ ] Submit to IzzyOnDroid (send email) +- [ ] Monitor store review processes + +### **Post-Deployment:** +- [ ] Test apps on real devices +- [ ] Respond to user reviews +- [ ] Plan update process for future releases +- [ ] Monitor crash reports and analytics + +--- + +## 🔄 Future Updates + +For subsequent releases: + +1. **Update version** in `pubspec.yaml` (e.g., 0.7.9002) +2. **Create new release tag** +3. **All platforms auto-update** except F-Droid (needs manual merge request for new versions) + +--- + +## 📞 Support & Resources + +- **Google Play Console**: https://support.google.com/googleplay/android-developer/ +- **App Store Connect**: https://developer.apple.com/support/app-store-connect/ +- **F-Droid Documentation**: https://f-droid.org/docs/ +- **IzzyOnDroid**: https://apt.izzysoft.de/fdroid/ +- **Flutter Deployment**: https://docs.flutter.dev/deployment + +--- + +## 🎉 Success! + +Once deployed, your app will be available on: +- **Google Play Store**: Official Android users +- **iOS App Store**: iPhone/iPad users +- **F-Droid**: Privacy-focused Android users +- **IzzyOnDroid**: F-Droid users who want faster updates + +Your app will reach the maximum possible audience across all major distribution channels! 🚀 diff --git a/PinePods-0.8.2/mobile/analysis_options.yaml b/PinePods-0.8.2/mobile/analysis_options.yaml new file mode 100644 index 0000000..97568e9 --- /dev/null +++ b/PinePods-0.8.2/mobile/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: [ build/**, lib/**/*.g.dart, lib/l10n/** ] diff --git a/PinePods-0.8.2/mobile/android/app/build.gradle b/PinePods-0.8.2/mobile/android/app/build.gradle new file mode 100644 index 0000000..25daa62 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/build.gradle @@ -0,0 +1,104 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + compileSdk 36 + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId "com.gooseberrydevelopment.pinepods" + minSdkVersion flutter.minSdkVersion + targetSdkVersion 36 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + + dependenciesInfo { + // Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid) + includeInApk = false + // Disables dependency metadata when building Android App Bundles (for Google Play) + includeInBundle = false + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + + buildTypes { + debug { + signingConfig signingConfigs.debug + shrinkResources false + } + + release { + signingConfig signingConfigs.release + shrinkResources false + proguardFiles getDefaultProguardFile('proguard-android.txt') + } + } + + namespace 'com.gooseberrydevelopment.pinepods' + + lint { + abortOnError false + disable 'InvalidPackage' + } + + // Disable PNG crunching for reproducible builds + aaptOptions { + cruncherEnabled = false + } +} + +flutter { + source '../..' +} + +dependencies { + implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' +} diff --git a/PinePods-0.8.2/mobile/android/app/src/debug/res/xml/network_security_config.xml b/PinePods-0.8.2/mobile/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..10ef44a --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/AndroidManifest.xml b/PinePods-0.8.2/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..526a703 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/ic_launcher-playstore.png b/PinePods-0.8.2/mobile/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..4fe781c Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/ic_launcher-playstore.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_fastforward.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_fastforward.xml new file mode 100644 index 0000000..2f3b842 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_fastforward.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_pause_circle_outline.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_pause_circle_outline.xml new file mode 100644 index 0000000..4f0f3af --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_pause_circle_outline.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_play_circle_outline.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_play_circle_outline.xml new file mode 100644 index 0000000..4f9c77b --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_play_circle_outline.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_rewind.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_rewind.xml new file mode 100644 index 0000000..1bb487e --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-anydpi-v24/ic_action_rewind.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_fastforward.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_fastforward.png new file mode 100644 index 0000000..bf42524 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_fastforward.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_pause.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_pause.png new file mode 100644 index 0000000..55f33b2 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_pause.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_pause_circle_outline.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_pause_circle_outline.png new file mode 100644 index 0000000..2edaa50 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_pause_circle_outline.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_play_arrow.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_play_arrow.png new file mode 100644 index 0000000..87d5743 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_play_arrow.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_play_circle_outline.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_play_circle_outline.png new file mode 100644 index 0000000..8ba8e86 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_play_circle_outline.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_rewind.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_rewind.png new file mode 100644 index 0000000..ae5129f Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_rewind.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_stop.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_stop.png new file mode 100644 index 0000000..5435114 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_action_stop.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_anytime_logo_notification.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_anytime_logo_notification.png new file mode 100644 index 0000000..e8dbbbc Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_anytime_logo_notification.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_name.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_name.png new file mode 100644 index 0000000..3993d61 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-hdpi/ic_stat_name.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_fastforward.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_fastforward.png new file mode 100644 index 0000000..4679b73 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_fastforward.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_pause.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_pause.png new file mode 100644 index 0000000..e8ff072 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_pause.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_pause_circle_outline.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_pause_circle_outline.png new file mode 100644 index 0000000..f6aead8 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_pause_circle_outline.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_play_arrow.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_play_arrow.png new file mode 100644 index 0000000..71fff1d Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_play_arrow.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_play_circle_outline.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_play_circle_outline.png new file mode 100644 index 0000000..22bea05 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_play_circle_outline.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_rewind.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_rewind.png new file mode 100644 index 0000000..c51cebb Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_rewind.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_stop.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_stop.png new file mode 100644 index 0000000..95e837d Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_action_stop.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_anytime_logo_notification.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_anytime_logo_notification.png new file mode 100644 index 0000000..fce6086 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_anytime_logo_notification.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_name.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_name.png new file mode 100644 index 0000000..208dcb2 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-mdpi/ic_stat_name.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_fastforward.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_fastforward.png new file mode 100644 index 0000000..35c9f8f Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_fastforward.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_pause.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_pause.png new file mode 100644 index 0000000..fbdee83 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_pause.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_pause_circle_outline.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_pause_circle_outline.png new file mode 100644 index 0000000..f4d7a5f Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_pause_circle_outline.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_play_arrow.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_play_arrow.png new file mode 100644 index 0000000..62d2067 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_play_arrow.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_play_circle_outline.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_play_circle_outline.png new file mode 100644 index 0000000..45a1018 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_play_circle_outline.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_rewind.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_rewind.png new file mode 100644 index 0000000..c835148 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_rewind.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_stop.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_stop.png new file mode 100644 index 0000000..3f7f54d Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_action_stop.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_anytime_logo_notification.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_anytime_logo_notification.png new file mode 100644 index 0000000..642f523 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_anytime_logo_notification.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png new file mode 100644 index 0000000..9dd9fdc Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_fastforward.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_fastforward.png new file mode 100644 index 0000000..99b82c0 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_fastforward.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_pause.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_pause.png new file mode 100644 index 0000000..8ac598d Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_pause.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_pause_circle_outline.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_pause_circle_outline.png new file mode 100644 index 0000000..8bf0053 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_pause_circle_outline.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_play_arrow.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_play_arrow.png new file mode 100644 index 0000000..47ce73a Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_play_arrow.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_play_circle_outline.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_play_circle_outline.png new file mode 100644 index 0000000..d6b51da Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_play_circle_outline.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_rewind.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_rewind.png new file mode 100644 index 0000000..e11d673 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_rewind.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_stop.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_stop.png new file mode 100644 index 0000000..17da4a3 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_action_stop.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_anytime_logo_notification.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_anytime_logo_notification.png new file mode 100644 index 0000000..8f593ec Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_anytime_logo_notification.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png new file mode 100644 index 0000000..e7fb196 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_pause.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_pause.png new file mode 100644 index 0000000..4343502 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_pause.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_play_arrow.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_play_arrow.png new file mode 100644 index 0000000..e9f9281 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_play_arrow.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_stop.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_stop.png new file mode 100644 index 0000000..20ee1b7 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_action_stop.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_anytime_logo_notification.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_anytime_logo_notification.png new file mode 100644 index 0000000..c5005cc Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_anytime_logo_notification.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png new file mode 100644 index 0000000..e7fb196 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_action_fastforward_30.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_action_fastforward_30.xml new file mode 100644 index 0000000..090537b --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_action_fastforward_30.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_action_rewind_10.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_action_rewind_10.xml new file mode 100644 index 0000000..06db412 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_action_rewind_10.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/launch_background.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..63302a8 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/pinepods_splash_logo.png b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/pinepods_splash_logo.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/drawable/pinepods_splash_logo.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2c64952 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monochrome.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monochrome.xml new file mode 100644 index 0000000..d6e9f95 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monochrome.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monochrome_round.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monochrome_round.xml new file mode 100644 index 0000000..d6e9f95 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monochrome_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..9cdca6e --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b9ea35d Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5851b83 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome_foreground.png new file mode 100644 index 0000000..0b78a2e Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..9baf9bc Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c5250a7 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..75a31ac Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome_foreground.png new file mode 100644 index 0000000..6532800 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..28aeb57 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..5daa297 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..657f427 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome_foreground.png new file mode 100644 index 0000000..b195386 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..24e7122 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..cd639db Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d73c7fc Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome_foreground.png new file mode 100644 index 0000000..e16262b Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4eefa59 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..8c2f856 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3167cd9 Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome_foreground.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome_foreground.png new file mode 100644 index 0000000..d760add Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome_foreground.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8f720fa Binary files /dev/null and b/PinePods-0.8.2/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/values/color.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/values/color.xml new file mode 100644 index 0000000..f304943 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/values/color.xml @@ -0,0 +1,3 @@ + + #539e8a + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/values/ic_launcher_monochrome_background.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/values/ic_launcher_monochrome_background.xml new file mode 100644 index 0000000..0f02cd5 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/values/ic_launcher_monochrome_background.xml @@ -0,0 +1,4 @@ + + + #3DDC84 + \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/values/styles.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..74dce76 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/PinePods-0.8.2/mobile/android/app/src/main/res/xml/network_security_config.xml b/PinePods-0.8.2/mobile/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..10ef44a --- /dev/null +++ b/PinePods-0.8.2/mobile/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PinePods-0.8.2/mobile/android/build.gradle b/PinePods-0.8.2/mobile/android/build.gradle new file mode 100644 index 0000000..bc157bd --- /dev/null +++ b/PinePods-0.8.2/mobile/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/PinePods-0.8.2/mobile/android/gradle.properties b/PinePods-0.8.2/mobile/android/gradle.properties new file mode 100644 index 0000000..fcabba9 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/PinePods-0.8.2/mobile/android/gradle/wrapper/gradle-wrapper.properties b/PinePods-0.8.2/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..cb403af --- /dev/null +++ b/PinePods-0.8.2/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Oct 18 15:14:44 BST 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/PinePods-0.8.2/mobile/android/settings.gradle b/PinePods-0.8.2/mobile/android/settings.gradle new file mode 100644 index 0000000..4f52071 --- /dev/null +++ b/PinePods-0.8.2/mobile/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.6.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} + +include ":app" diff --git a/PinePods-0.8.2/mobile/android/settings_aar.gradle b/PinePods-0.8.2/mobile/android/settings_aar.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/PinePods-0.8.2/mobile/android/settings_aar.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/PinePods-0.8.2/mobile/assets/ca/globalsign-gcc-r6-alphassl-ca-2023.pem b/PinePods-0.8.2/mobile/assets/ca/globalsign-gcc-r6-alphassl-ca-2023.pem new file mode 100644 index 0000000..2c2de9a --- /dev/null +++ b/PinePods-0.8.2/mobile/assets/ca/globalsign-gcc-r6-alphassl-ca-2023.pem @@ -0,0 +1,64 @@ +-----BEGIN CERTIFICATE----- +MIIFjDCCA3SgAwIBAgIQfx8skC6D0OO2+zvuR4tegDANBgkqhkiG9w0BAQsFADBM +MSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSNjETMBEGA1UEChMKR2xv +YmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjAeFw0yMzA3MTkwMzQzMjVaFw0y +NjA3MTkwMDAwMDBaMFUxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWdu +IG52LXNhMSswKQYDVQQDEyJHbG9iYWxTaWduIEdDQyBSNiBBbHBoYVNTTCBDQSAy +MDIzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA00Jvk5ADppO0rgDn +j1M14XIb032Aas409JJFAb8cUjipFOth7ySLdaWLe3s63oSs5x3eWwzTpX4BFkzZ +bxT1eoJSHfT2M0wZ5QOPcCIjsr+YB8TAvV2yJSyq+emRrN/FtgCSTaWXSJ5jipW8 +SJ/VAuXPMzuAP2yYpuPcjjQ5GyrssDXgu+FhtYxqyFP7BSvx9jQhh5QV5zhLycua +n8n+J0Uw09WRQK6JGQ5HzDZQinkNel+fZZNRG1gE9Qeh+tHBplrkalB1g85qJkPO +J7SoEvKsmDkajggk/sSq7NPyzFaa/VBGZiRRG+FkxCBniGD5618PQ4trcwHyMojS +FObOHQIDAQABo4IBXzCCAVswDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG +AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS9 +BbfzipM8c8t5+g+FEqF3lhiRdDAfBgNVHSMEGDAWgBSubAWjkxPioufi1xzWx/B/ +yGdToDB7BggrBgEFBQcBAQRvMG0wLgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwMi5n +bG9iYWxzaWduLmNvbS9yb290cjYwOwYIKwYBBQUHMAKGL2h0dHA6Ly9zZWN1cmUu +Z2xvYmFsc2lnbi5jb20vY2FjZXJ0L3Jvb3QtcjYuY3J0MDYGA1UdHwQvMC0wK6Ap +oCeGJWh0dHA6Ly9jcmwuZ2xvYmFsc2lnbi5jb20vcm9vdC1yNi5jcmwwIQYDVR0g +BBowGDAIBgZngQwBAgEwDAYKKwYBBAGgMgoBAzANBgkqhkiG9w0BAQsFAAOCAgEA +fMkkMo5g4mn1ft4d4xR2kHzYpDukhC1XYPwfSZN3A9nEBadjdKZMH7iuS1vF8uSc +g26/30DRPen2fFRsr662ECyUCR4OfeiiGNdoQvcesM9Xpew3HLQP4qHg+s774hNL +vGRD4aKSKwFqLMrcqCw6tEAfX99tFWsD4jzbC6k8tjSLzEl0fTUlfkJaWpvLVkpg +9et8tD8d51bymCg5J6J6wcXpmsSGnksBobac1+nXmgB7jQC9edU8Z41FFo87BV3k +CtrWWsdkQavObMsXUPl/AO8y/jOuAWz0wyvPnKom+o6W4vKDY6/6XPypNdebOJ6m +jyaILp0quoQvhjx87BzENh5s57AIOyIGpS0sDEChVDPzLEfRsH2FJ8/W5woF0nvs +BTqfYSCqblQbHeDDtCj7Mlf8JfqaMuqcbE4rMSyfeHyCdZQwnc/r9ujnth691AJh +xyYeCM04metJIe7cB6d4dFm+Pd5ervY4x32r0uQ1Q0spy1VjNqUJjussYuXNyMmF +HSuLQQ6PrePmH5lcSMQpYKzPoD/RiNVD/PK0O3vuO5vh3o7oKb1FfzoanDsFFTrw +0aLOdRW/tmLPWVNVlAb8ad+B80YJsL4HXYnQG8wYAFb8LhwSDyT9v+C1C1lcIHE7 +nE0AAp9JSHxDYsma9pi4g0Phg3BgOm2euTRzw7R0SzU= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIORea7A4Mzw4VlSOb/RVEwDQYJKoZIhvcNAQEMBQAwTDEg +MB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkdsb2Jh +bFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMTQxMjEwMDAwMDAwWhcNMzQx +MjEwMDAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSNjET +MBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAJUH6HPKZvnsFMp7PPcNCPG0RQssgrRI +xutbPK6DuEGSMxSkb3/pKszGsIhrxbaJ0cay/xTOURQh7ErdG1rG1ofuTToVBu1k +ZguSgMpE3nOUTvOniX9PeGMIyBJQbUJmL025eShNUhqKGoC3GYEOfsSKvGRMIRxD +aNc9PIrFsmbVkJq3MQbFvuJtMgamHvm566qjuL++gmNQ0PAYid/kD3n16qIfKtJw +LnvnvJO7bVPiSHyMEAc4/2ayd2F+4OqMPKq0pPbzlUoSB239jLKJz9CgYXfIWHSw +1CM69106yqLbnQneXUQtkPGBzVeS+n68UARjNN9rkxi+azayOeSsJDa38O+2HBNX +k7besvjihbdzorg1qkXy4J02oW9UivFyVm4uiMVRQkQVlO6jxTiWm05OWgtH8wY2 +SXcwvHE35absIQh1/OZhFj931dmRl4QKbNQCTXTAFO39OfuD8l4UoQSwC+n+7o/h +bguyCLNhZglqsQY6ZZZZwPA1/cnaKI0aEYdwgQqomnUdnjqGBQCe24DWJfncBZ4n +WUx2OVvq+aWh2IMP0f/fMBH5hc8zSPXKbWQULHpYT9NLCEnFlWQaYw55PfWzjMpY +rZxCRXluDocZXFSxZba/jJvcE+kNb7gu3GduyYsRtYQUigAZcIN5kZeR1Bonvzce +MgfYFGM8KEyvAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBSubAWjkxPioufi1xzWx/B/yGdToDAfBgNVHSMEGDAWgBSu +bAWjkxPioufi1xzWx/B/yGdToDANBgkqhkiG9w0BAQwFAAOCAgEAgyXt6NH9lVLN +nsAEoJFp5lzQhN7craJP6Ed41mWYqVuoPId8AorRbrcWc+ZfwFSY1XS+wc3iEZGt +Ixg93eFyRJa0lV7Ae46ZeBZDE1ZXs6KzO7V33EByrKPrmzU+sQghoefEQzd5Mr61 +55wsTLxDKZmOMNOsIeDjHfrYBzN2VAAiKrlNIC5waNrlU/yDXNOd8v9EDERm8tLj +vUYAGm0CuiVdjaExUd1URhxN25mW7xocBFymFe944Hn+Xds+qkxV/ZoVqW/hpvvf +cDDpw+5CRu3CkwWJ+n1jez/QcYF8AOiYrg54NMMl+68KnyBr3TsTjxKM4kEaSHpz +oHdpx7Zcf4LIHv5YGygrqGytXm3ABdJ7t+uA/iU3/gKbaKxCXcPu9czc8FB10jZp +nOZ7BN9uBmm23goJSFmH63sUYHpkqmlD75HHTOwY3WzvUy2MmeFe8nI+z1TIvWfs +pA9MRf/TuTAjB0yPEL+GltmZWrSZVxykzLsViVO6LAUP5MSeGbEYNNVMnbrt9x+v +JJUEeKgDu+6B5dpffItKoZB0JaezPkvILFa9x8jvOOJckvB595yEunQtYQEgfn7R +8k8HWV+LLUNS60YMlOH1Zkd5d9VUWx+tJDfLRVpOoERIyNiwmcUVhAn21klJwGW4 +5hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/assets/ca/lets-encrypt-r3.pem b/PinePods-0.8.2/mobile/assets/ca/lets-encrypt-r3.pem new file mode 100644 index 0000000..185aa7c --- /dev/null +++ b/PinePods-0.8.2/mobile/assets/ca/lets-encrypt-r3.pem @@ -0,0 +1,61 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw +WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP +R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx +sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm +NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg +Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG +/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB +Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA +FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw +AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw +Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB +gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W +PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl +ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz +CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm +lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4 +avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2 +yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O +yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids +hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+ +HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv +MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX +nLRbwHOoq7hHwg== +-----END CERTIFICATE----- diff --git a/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Bold.otf b/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Bold.otf new file mode 100755 index 0000000..cdfb83d Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Bold.otf differ diff --git a/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Medium.otf b/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Medium.otf new file mode 100755 index 0000000..6c323c1 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Medium.otf differ diff --git a/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Regular.otf b/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Regular.otf new file mode 100755 index 0000000..c1d1ee3 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/fonts/Montserrat-Regular.otf differ diff --git a/PinePods-0.8.2/mobile/assets/images/1.webp b/PinePods-0.8.2/mobile/assets/images/1.webp new file mode 100644 index 0000000..80149dc Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/1.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/2.webp b/PinePods-0.8.2/mobile/assets/images/2.webp new file mode 100644 index 0000000..0f7e348 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/2.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/3.webp b/PinePods-0.8.2/mobile/assets/images/3.webp new file mode 100644 index 0000000..2234446 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/3.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/4.webp b/PinePods-0.8.2/mobile/assets/images/4.webp new file mode 100644 index 0000000..b699f70 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/4.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/5.webp b/PinePods-0.8.2/mobile/assets/images/5.webp new file mode 100644 index 0000000..56bc8ea Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/5.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/6.webp b/PinePods-0.8.2/mobile/assets/images/6.webp new file mode 100644 index 0000000..2293f66 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/6.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/7.webp b/PinePods-0.8.2/mobile/assets/images/7.webp new file mode 100644 index 0000000..fd7bcad Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/7.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/8.webp b/PinePods-0.8.2/mobile/assets/images/8.webp new file mode 100644 index 0000000..df8923a Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/8.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/9.webp b/PinePods-0.8.2/mobile/assets/images/9.webp new file mode 100644 index 0000000..62ff88e Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/9.webp differ diff --git a/PinePods-0.8.2/mobile/assets/images/favicon.png b/PinePods-0.8.2/mobile/assets/images/favicon.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/favicon.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/icon-192.png b/PinePods-0.8.2/mobile/assets/images/icon-192.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/icon-192.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-hdpi-v11/pinepods-notification-logo.png b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-hdpi-v11/pinepods-notification-logo.png new file mode 100644 index 0000000..ba2d481 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-hdpi-v11/pinepods-notification-logo.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-hdpi/pinepods-notification-logo.png b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-hdpi/pinepods-notification-logo.png new file mode 100644 index 0000000..3993d61 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-hdpi/pinepods-notification-logo.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-mdpi-v11/pinepods-notification-logo.png b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-mdpi-v11/pinepods-notification-logo.png new file mode 100644 index 0000000..7de0b94 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-mdpi-v11/pinepods-notification-logo.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-mdpi/pinepods-notification-logo.png b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-mdpi/pinepods-notification-logo.png new file mode 100644 index 0000000..208dcb2 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-mdpi/pinepods-notification-logo.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xhdpi-v11/pinepods-notification-logo.png b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xhdpi-v11/pinepods-notification-logo.png new file mode 100644 index 0000000..c56e360 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xhdpi-v11/pinepods-notification-logo.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xhdpi/pinepods-notification-logo.png b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xhdpi/pinepods-notification-logo.png new file mode 100644 index 0000000..9dd9fdc Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xhdpi/pinepods-notification-logo.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xxhdpi-v11/pinepods-notification-logo.png b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xxhdpi-v11/pinepods-notification-logo.png new file mode 100644 index 0000000..664b386 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xxhdpi-v11/pinepods-notification-logo.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xxhdpi/pinepods-notification-logo.png b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xxhdpi/pinepods-notification-logo.png new file mode 100644 index 0000000..e7fb196 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/notication-logo/drawable-xxhdpi/pinepods-notification-logo.png differ diff --git a/PinePods-0.8.2/mobile/assets/images/pinepods-logo.png b/PinePods-0.8.2/mobile/assets/images/pinepods-logo.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/mobile/assets/images/pinepods-logo.png differ diff --git a/PinePods-0.8.2/mobile/ios/Flutter/AppFrameworkInfo.plist b/PinePods-0.8.2/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..d57061d --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/PinePods-0.8.2/mobile/ios/Flutter/Debug.xcconfig b/PinePods-0.8.2/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..e8efba1 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/PinePods-0.8.2/mobile/ios/Flutter/Release.xcconfig b/PinePods-0.8.2/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..399e934 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/PinePods-0.8.2/mobile/ios/Flutter/flutter_export_environment.sh b/PinePods-0.8.2/mobile/ios/Flutter/flutter_export_environment.sh new file mode 100755 index 0000000..9e4d5f5 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Flutter/flutter_export_environment.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/Users/collin_pendleton/development/flutter" +export "FLUTTER_APPLICATION_PATH=/Users/collin_pendleton/Documents/github/PinePods/mobile" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=/Users/collin_pendleton/Documents/github/PinePods/mobile/lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=0.8.0" +export "FLUTTER_BUILD_NUMBER=20252161" +export "FLUTTER_CLI_BUILD_MODE=debug" +export "DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuMzUuMg==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049MDVkYjk2ODkwOA==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049YThiZmRmYzM5NA==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My45LjA=" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=/Users/collin_pendleton/Documents/github/PinePods/mobile/.dart_tool/package_config.json" diff --git a/PinePods-0.8.2/mobile/ios/Podfile b/PinePods-0.8.2/mobile/ios/Podfile new file mode 100644 index 0000000..37078cf --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Podfile @@ -0,0 +1,97 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + # Temporary workaround for DK issue + #pod 'DKImagePickerController', '4.3.4' + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + + target.build_configurations.each do |config| + # Ensure minimum deployment target is 13.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + + ## Audio session - disable microphone + 'AUDIO_SESSION_MICROPHONE=0', + + ## dart: PermissionGroup.calendar + 'PERMISSION_EVENTS=0', + + ## dart: PermissionGroup.reminders + 'PERMISSION_REMINDERS=0', + + ## dart: PermissionGroup.contacts + 'PERMISSION_CONTACTS=0', + + ## dart: PermissionGroup.camera + 'PERMISSION_CAMERA=0', + + ## dart: PermissionGroup.microphone + 'PERMISSION_MICROPHONE=0', + + ## dart: PermissionGroup.speech + 'PERMISSION_SPEECH_RECOGNIZER=0', + + ## dart: PermissionGroup.photos + 'PERMISSION_PHOTOS=0', + + ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] + 'PERMISSION_LOCATION=0', + + ## dart: PermissionGroup.notification + 'PERMISSION_NOTIFICATIONS=0', + + ## dart: PermissionGroup.mediaLibrary + 'PERMISSION_MEDIA_LIBRARY=0', + + ## dart: PermissionGroup.sensors + 'PERMISSION_SENSORS=0', + + ## dart: PermissionGroup.bluetooth + 'PERMISSION_BLUETOOTH=0', + + ## dart: PermissionGroup.appTrackingTransparency + 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', + + ## dart: PermissionGroup.criticalAlerts + 'PERMISSION_CRITICAL_ALERTS=0' + ] + end + end +end diff --git a/PinePods-0.8.2/mobile/ios/Podfile.lock b/PinePods-0.8.2/mobile/ios/Podfile.lock new file mode 100644 index 0000000..65b4555 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Podfile.lock @@ -0,0 +1,165 @@ +PODS: + - app_links (0.0.2): + - Flutter + - audio_service (0.0.1): + - Flutter + - FlutterMacOS + - audio_session (0.0.1): + - Flutter + - connectivity_plus (0.0.1): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - flutter_downloader (0.0.1): + - Flutter + - just_audio (0.0.1): + - Flutter + - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - SDWebImage (5.21.1): + - SDWebImage/Core (= 5.21.1) + - SDWebImage/Core (5.21.1) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) + - url_launcher_ios (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - app_links (from `.symlinks/plugins/app_links/ios`) + - audio_service (from `.symlinks/plugins/audio_service/darwin`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - flutter_downloader (from `.symlinks/plugins/flutter_downloader/ios`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" + audio_service: + :path: ".symlinks/plugins/audio_service/darwin" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + flutter_downloader: + :path: ".symlinks/plugins/flutter_downloader/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" + +SPEC CHECKSUMS: + app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 + audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_downloader: 78da0da1084e709cbfd3b723c7ea349c71681f09 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + SDWebImage: f29024626962457f3470184232766516dee8dfea + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + +PODFILE CHECKSUM: 0ab06865a10aced8dcbecd5fae08a60eea944bfe + +COCOAPODS: 1.16.2 diff --git a/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.pbxproj b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a3c5b47 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,608 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 5A74CCA825AC436F00F672EE /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A74CCA725AC42E100F672EE /* libsqlite3.tbd */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7D6B03D432E2A931A9304FCA /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374DEB1E679FF15DA0F81545 /* Pods_Runner.framework */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0B8DA3831323CE6D757BC84D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 374DEB1E679FF15DA0F81545 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5A74CCA725AC42E100F672EE /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 853FFEBEFA1CA08522AA3781 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DC42F1E77E7C14B957699586 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A74CCA825AC436F00F672EE /* libsqlite3.tbd in Frameworks */, + 7D6B03D432E2A931A9304FCA /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 49E1EA768EB807B242ABF569 /* Pods */ = { + isa = PBXGroup; + children = ( + 0B8DA3831323CE6D757BC84D /* Pods-Runner.debug.xcconfig */, + 853FFEBEFA1CA08522AA3781 /* Pods-Runner.release.xcconfig */, + DC42F1E77E7C14B957699586 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 8ADBD6A8A237FA1BF44D9D1B /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5A74CCA725AC42E100F672EE /* libsqlite3.tbd */, + 374DEB1E679FF15DA0F81545 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 49E1EA768EB807B242ABF569 /* Pods */, + 8ADBD6A8A237FA1BF44D9D1B /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 34151487091224FAC7C55971 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + B2A05C7B67BBE001F257F90D /* [CP] Embed Pods Frameworks */, + CC2509D368CED1139BF56A54 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + preferredProjectObjectVersion = 77; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 34151487091224FAC7C55971 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 12; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + }; + B2A05C7B67BBE001F257F90D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + CC2509D368CED1139BF56A54 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 879LYRSYW9; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 1.1.2; + PRODUCT_BUNDLE_IDENTIFIER = com.gooseberrydevelopment.pinepods; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 879LYRSYW9; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 1.1.2; + PRODUCT_BUNDLE_IDENTIFIER = com.gooseberrydevelopment.pinepods; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 879LYRSYW9; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 1.1.2; + PRODUCT_BUNDLE_IDENTIFIER = com.gooseberrydevelopment.pinepods; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..9c12df5 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner/AppDelegate.swift b/PinePods-0.8.2/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..d3aa069 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner/AppDelegate.swift @@ -0,0 +1,21 @@ +import UIKit +import Flutter +import flutter_downloader + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + FlutterDownloaderPlugin.setPluginRegistrantCallback(registerPlugins) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + +private func registerPlugins(registry: FlutterPluginRegistry) { + if (!registry.hasPlugin("FlutterDownloaderPlugin")) { + FlutterDownloaderPlugin.register(with: registry.registrar(forPlugin: "FlutterDownloaderPlugin")!) + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100755 index 0000000..5c8064e --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images": [ + { + "idiom": "iphone", + "size": "20x20", + "scale": "2x", + "filename": "Icon-App-20x20@2x.png" + }, + { + "idiom": "iphone", + "size": "20x20", + "scale": "3x", + "filename": "Icon-App-20x20@3x.png" + }, + { + "idiom": "iphone", + "size": "29x29", + "scale": "1x", + "filename": "Icon-App-29x29@1x.png" + }, + { + "idiom": "iphone", + "size": "29x29", + "scale": "2x", + "filename": "Icon-App-29x29@2x.png" + }, + { + "idiom": "iphone", + "size": "29x29", + "scale": "3x", + "filename": "Icon-App-29x29@3x.png" + }, + { + "idiom": "iphone", + "size": "40x40", + "scale": "2x", + "filename": "Icon-App-40x40@2x.png" + }, + { + "idiom": "iphone", + "size": "40x40", + "scale": "3x", + "filename": "Icon-App-40x40@3x.png" + }, + { + "idiom": "iphone", + "size": "60x60", + "scale": "2x", + "filename": "Icon-App-60x60@2x.png" + }, + { + "idiom": "iphone", + "size": "60x60", + "scale": "3x", + "filename": "Icon-App-60x60@3x.png" + }, + { + "idiom": "ipad", + "size": "20x20", + "scale": "1x", + "filename": "Icon-App-20x20@1x.png" + }, + { + "idiom": "ipad", + "size": "20x20", + "scale": "2x", + "filename": "Icon-App-20x20@2x.png" + }, + { + "idiom": "ipad", + "size": "29x29", + "scale": "1x", + "filename": "Icon-App-29x29@1x.png" + }, + { + "idiom": "ipad", + "size": "29x29", + "scale": "2x", + "filename": "Icon-App-29x29@2x.png" + }, + { + "idiom": "ipad", + "size": "40x40", + "scale": "1x", + "filename": "Icon-App-40x40@1x.png" + }, + { + "idiom": "ipad", + "size": "40x40", + "scale": "2x", + "filename": "Icon-App-40x40@2x.png" + }, + { + "idiom": "ipad", + "size": "76x76", + "scale": "1x", + "filename": "Icon-App-76x76@1x.png" + }, + { + "idiom": "ipad", + "size": "76x76", + "scale": "2x", + "filename": "Icon-App-76x76@2x.png" + }, + { + "idiom": "ipad", + "size": "83.5x83.5", + "scale": "2x", + "filename": "Icon-App-83.5x83.5@2x.png" + }, + { + "size": "1024x1024", + "idiom": "ios-marketing", + "scale": "1x", + "filename": "ItunesArtwork@2x.png" + } + ], + "info": { + "version": 1, + "author": "makeappicon" + } +} diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..361f120 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..8a92928 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..cc7db3f Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..f544fa8 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..7311177 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..b95f6b7 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..8a92928 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..dd20ec5 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..4430837 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..4430837 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..8e0423e Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..824edbc Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..b1a08b2 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..8dac1bb Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png new file mode 100644 index 0000000..9a07517 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..e218d09 Binary files /dev/null and b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/PinePods-0.8.2/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..ec0a81b --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner/Base.lproj/Main.storyboard b/PinePods-0.8.2/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner/Info.plist b/PinePods-0.8.2/mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..aa88be5 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner/Info.plist @@ -0,0 +1,118 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Pinepods + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + pinepods + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.gooseberrydevelopment.pinepods + CFBundleURLSchemes + + pinepods-subscribe + + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.gooseberrydevelopment.pinepods.auth + CFBundleURLSchemes + + pinepods + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FDMaximumConcurrentTasks + 1 + FlutterDeepLinkingEnabled + YES + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + https + http + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSPhotoLibraryUsageDescription + File access is required to facilitate locating and importing OPML files + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + audio + fetch + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.xml + + UTTypeDescription + OPML ( Outline Processor Markup Language) is an XML format for outlines. + UTTypeIdentifier + podcast.opml + UTTypeTagSpecification + + public.filename-extension + + opml + + + + + + diff --git a/PinePods-0.8.2/mobile/ios/Runner/Runner-Bridging-Header.h b/PinePods-0.8.2/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/PinePods-0.8.2/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/PinePods-0.8.2/mobile/lib/api/podcast/mobile_podcast_api.dart b/PinePods-0.8.2/mobile/lib/api/podcast/mobile_podcast_api.dart new file mode 100644 index 0000000..aa67a3c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/api/podcast/mobile_podcast_api.dart @@ -0,0 +1,247 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:pinepods_mobile/api/podcast/podcast_api.dart'; +import 'package:pinepods_mobile/core/environment.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:flutter/foundation.dart'; +import 'package:podcast_search/podcast_search.dart' as podcast_search; +import 'package:http/http.dart' as http; +import 'package:html/parser.dart' as html; + +/// An implementation of the [PodcastApi]. +/// +/// A simple wrapper class that interacts with the iTunes/PodcastIndex search API +/// via the podcast_search package. +class MobilePodcastApi extends PodcastApi { + /// Set when using a custom certificate authority. + SecurityContext? _defaultSecurityContext; + + /// Bytes containing a custom certificate authority. + List _certificateAuthorityBytes = []; + + @override + Future search( + String term, { + String? country, + String? attribute, + int? limit, + String? language, + int version = 0, + bool explicit = false, + String? searchProvider, + }) async { + var searchParams = { + 'term': term, + 'searchProvider': searchProvider, + }; + + return compute(_search, searchParams); + } + + @override + Future charts({ + int? size = 20, + String? genre, + String? searchProvider, + String? countryCode = '', + String? languageCode = '', + }) async { + var searchParams = { + 'size': size.toString(), + 'genre': genre, + 'searchProvider': searchProvider, + 'countryCode': countryCode, + 'languageCode': languageCode, + }; + + return compute(_charts, searchParams); + } + + @override + List genres(String searchProvider) { + var provider = searchProvider == 'itunes' + ? const podcast_search.ITunesProvider() + : podcast_search.PodcastIndexProvider( + key: podcastIndexKey, + secret: podcastIndexSecret, + ); + + return podcast_search.Search( + userAgent: Environment.userAgent(), + searchProvider: provider, + ).genres(); + } + + @override + Future loadFeed(String url) async { + return _loadFeed(url); + } + + @override + Future loadChapters(String url) async { + // In podcast_search 0.7.11, load chapters using Feed.loadChaptersByUrl + try { + return await podcast_search.Feed.loadChaptersByUrl(url: url); + } catch (e) { + // Fallback: create empty chapters if loading fails + return podcast_search.Chapters(url: url); + } + } + + @override + Future loadTranscript(TranscriptUrl transcriptUrl) async { + // Handle HTML transcripts with custom parser + if (transcriptUrl.type == TranscriptFormat.html) { + return await _loadHtmlTranscript(transcriptUrl); + } + + late podcast_search.TranscriptFormat format; + + switch (transcriptUrl.type) { + case TranscriptFormat.subrip: + format = podcast_search.TranscriptFormat.subrip; + break; + case TranscriptFormat.json: + format = podcast_search.TranscriptFormat.json; + break; + case TranscriptFormat.html: + // This case is now handled above + format = podcast_search.TranscriptFormat.unsupported; + break; + case TranscriptFormat.unsupported: + format = podcast_search.TranscriptFormat.unsupported; + break; + } + + // In podcast_search 0.7.11, load transcript using Feed.loadTranscriptByUrl + try { + // Create a podcast_search.TranscriptUrl from our local TranscriptUrl + final searchTranscriptUrl = podcast_search.TranscriptUrl( + url: transcriptUrl.url, + type: format, + language: transcriptUrl.language ?? '', + rel: transcriptUrl.rel ?? '', + ); + + return await podcast_search.Feed.loadTranscriptByUrl( + transcriptUrl: searchTranscriptUrl + ); + } catch (e) { + // Fallback: create empty transcript if loading fails + return podcast_search.Transcript(); + } + } + + /// Parse HTML transcript content into a transcript object + Future _loadHtmlTranscript(TranscriptUrl transcriptUrl) async { + try { + final response = await http.get(Uri.parse(transcriptUrl.url)); + + if (response.statusCode != 200) { + return podcast_search.Transcript(); + } + + final document = html.parse(response.body); + final subtitles = []; + + // For HTML transcripts, find the main content area and render as a single block + String transcriptContent = ''; + + // Try to find the main transcript content area + final transcriptContainer = document.querySelector('.transcript, .content, main, article') ?? + document.querySelector('body'); + + if (transcriptContainer != null) { + transcriptContent = transcriptContainer.innerHtml; + + // Clean up common unwanted elements + final cleanDoc = html.parse(transcriptContent); + + // Remove navigation, headers, footers, ads, etc. + for (final selector in ['nav', 'header', 'footer', '.nav', '.navigation', '.ads', '.advertisement', '.sidebar']) { + cleanDoc.querySelectorAll(selector).forEach((el) => el.remove()); + } + + transcriptContent = cleanDoc.body?.innerHtml ?? transcriptContent; + + // Process markdown-style links [text](url) -> text + transcriptContent = transcriptContent.replaceAllMapped( + RegExp(r'\[([^\]]+)\]\(([^)]+)\)'), + (match) => '${match.group(1)}', + ); + + // Create a single subtitle entry for the entire HTML transcript + subtitles.add(podcast_search.Subtitle( + index: 0, + start: const Duration(seconds: 0), + end: const Duration(seconds: 1), // Minimal duration since timing doesn't matter + data: '{{HTMLFULL}}$transcriptContent', + speaker: '', + )); + } + + return podcast_search.Transcript(subtitles: subtitles); + } catch (e) { + debugPrint('Error parsing HTML transcript: $e'); + return podcast_search.Transcript(); + } + } + + static Future _search(Map searchParams) { + var term = searchParams['term']!; + var provider = searchParams['searchProvider'] == 'itunes' + ? const podcast_search.ITunesProvider() + : podcast_search.PodcastIndexProvider( + key: podcastIndexKey, + secret: podcastIndexSecret, + ); + + return podcast_search.Search( + userAgent: Environment.userAgent(), + searchProvider: provider, + ).search(term).timeout(const Duration(seconds: 30)); + } + + static Future _charts(Map searchParams) { + var provider = searchParams['searchProvider'] == 'itunes' + ? const podcast_search.ITunesProvider() + : podcast_search.PodcastIndexProvider( + key: podcastIndexKey, + secret: podcastIndexSecret, + ); + + var countryCode = searchParams['countryCode']; + var languageCode = searchParams['languageCode'] ?? ''; + var country = podcast_search.Country.none; + + if (countryCode != null && countryCode.isNotEmpty) { + country = podcast_search.Country.values.where((element) => element.code == countryCode).first; + } + + return podcast_search.Search(userAgent: Environment.userAgent(), searchProvider: provider) + .charts(genre: searchParams['genre']!, country: country, language: languageCode, limit: 50) + .timeout(const Duration(seconds: 30)); + } + + Future _loadFeed(String url) { + _setupSecurityContext(); + // In podcast_search 0.7.11, use Feed.loadFeed or create a Feed instance + return podcast_search.Feed.loadFeed(url: url, userAgent: Environment.userAgent()); + } + + void _setupSecurityContext() { + if (_certificateAuthorityBytes.isNotEmpty && _defaultSecurityContext == null) { + SecurityContext.defaultContext.setTrustedCertificatesBytes(_certificateAuthorityBytes); + _defaultSecurityContext = SecurityContext.defaultContext; + } + } + + @override + void addClientAuthorityBytes(List certificateAuthorityBytes) { + _certificateAuthorityBytes = certificateAuthorityBytes; + } +} diff --git a/PinePods-0.8.2/mobile/lib/api/podcast/podcast_api.dart b/PinePods-0.8.2/mobile/lib/api/podcast/podcast_api.dart new file mode 100644 index 0000000..2e68134 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/api/podcast/podcast_api.dart @@ -0,0 +1,51 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:podcast_search/podcast_search.dart' as pslib; + +/// A simple wrapper class that interacts with the search API via +/// the podcast_search package. +/// +/// TODO: Make this more generic so it's not tied to podcast_search +abstract class PodcastApi { + /// Search for podcasts matching the search criteria. Returns a + /// [SearchResult] instance. + Future search( + String term, { + String? country, + String? attribute, + int? limit, + String? language, + int version = 0, + bool explicit = false, + String? searchProvider, + }); + + /// Request the top podcast charts from iTunes, and at most [size] records. + Future charts({ + int? size, + String? searchProvider, + String? genre, + String? countryCode, + String? languageCode, + }); + + List genres( + String searchProvider, + ); + + /// URL representing the RSS feed for a podcast. + Future loadFeed(String url); + + /// Load episode chapters via JSON file. + Future loadChapters(String url); + + /// Load episode transcript via SRT or JSON file. + Future loadTranscript(TranscriptUrl transcriptUrl); + + /// Allow adding of custom certificates. Required as default context + /// does not apply when running in separate Isolate. + void addClientAuthorityBytes(List certificateAuthorityBytes); +} diff --git a/PinePods-0.8.2/mobile/lib/bloc/bloc.dart b/PinePods-0.8.2/mobile/lib/bloc/bloc.dart new file mode 100644 index 0000000..267f214 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/bloc.dart @@ -0,0 +1,43 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Base class for all BLoCs to give each a hook into the mobile +/// lifecycle state of paused, resume or detached. +abstract class Bloc { + /// Handle lifecycle events + final PublishSubject _lifecycleSubject = PublishSubject(sync: true); + + Bloc() { + _init(); + } + + void _init() { + _lifecycleSubject.listen((state) async { + if (state == LifecycleState.resume) { + resume(); + } else if (state == LifecycleState.pause) { + pause(); + } else if (state == LifecycleState.detach) { + detach(); + } + }); + } + + void dispose() { + if (_lifecycleSubject.hasListener) { + _lifecycleSubject.close(); + } + } + + void resume() {} + + void pause() {} + + void detach() {} + + void Function(LifecycleState) get transitionLifecycleState => _lifecycleSubject.sink.add; +} diff --git a/PinePods-0.8.2/mobile/lib/bloc/podcast/audio_bloc.dart b/PinePods-0.8.2/mobile/lib/bloc/podcast/audio_bloc.dart new file mode 100644 index 0000000..ec77881 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/podcast/audio_bloc.dart @@ -0,0 +1,238 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/bloc.dart'; +import 'package:pinepods_mobile/core/extensions.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/sleep.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/state/transcript_state_event.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; + +enum TransitionState { + play, + pause, + stop, + fastforward, + rewind, +} + +enum LifecycleState { + pause, + resume, + detach, +} + +/// A BLoC to handle interactions between the audio service and the client. +class AudioBloc extends Bloc { + final log = Logger('AudioBloc'); + + /// Listen for new episode play requests. + final BehaviorSubject _play = BehaviorSubject(); + + /// Move from one playing state to another such as from paused to play + final PublishSubject _transitionPlayingState = PublishSubject(); + + /// Sink to update our position + final PublishSubject _transitionPosition = PublishSubject(); + + /// Handles persisting data to storage. + final AudioPlayerService audioPlayerService; + + /// Listens for playback speed change requests. + final PublishSubject _playbackSpeedSubject = PublishSubject(); + + /// Listen for toggling of trim silence requests. + final PublishSubject _trimSilence = PublishSubject(); + + /// Listen for toggling of volume boost silence requests. + final PublishSubject _volumeBoost = PublishSubject(); + + /// Listen for transcript filtering events. + final PublishSubject _transcriptEvent = PublishSubject(); + + final BehaviorSubject _sleepEvent = BehaviorSubject(); + + AudioBloc({ + required this.audioPlayerService, + }) { + /// Listen for transition events from the client. + _handlePlayingStateTransitions(); + + /// Listen for events requesting the start of a new episode. + _handleEpisodeRequests(); + + /// Listen for requests to move the play position within the episode. + _handlePositionTransitions(); + + /// Listen for playback speed changes + _handlePlaybackSpeedTransitions(); + + /// Listen to trim silence requests + _handleTrimSilenceTransitions(); + + /// Listen to volume boost silence requests + _handleVolumeBoostTransitions(); + + /// Listen to transcript filtering events + _handleTranscriptEvents(); + + /// Listen to sleep timer events; + _handleSleepTimer(); + } + + /// Listens to events from the UI (or any client) to transition from one + /// audio state to another. For example, to pause the current playback + /// a [TransitionState.pause] event should be sent. To ensure the underlying + /// audio service processes one state request at a time we push events + /// on to a queue and execute them sequentially. Each state maps to a call + /// to the Audio Service plugin. + void _handlePlayingStateTransitions() { + _transitionPlayingState.asyncMap((event) => Future.value(event)).listen((state) async { + switch (state) { + case TransitionState.play: + await audioPlayerService.play(); + break; + case TransitionState.pause: + await audioPlayerService.pause(); + break; + case TransitionState.fastforward: + await audioPlayerService.fastForward(); + break; + case TransitionState.rewind: + await audioPlayerService.rewind(); + break; + case TransitionState.stop: + await audioPlayerService.stop(); + break; + } + }); + } + + /// Setup a listener for episode requests and then connect to the + /// underlying audio service. + void _handleEpisodeRequests() async { + _play.listen((episode) { + audioPlayerService.playEpisode(episode: episode!, resume: true); + }); + } + + /// Listen for requests to change the position of the current episode. + void _handlePositionTransitions() async { + _transitionPosition.listen((pos) async { + await audioPlayerService.seek(position: pos.ceil()); + }); + } + + /// Listen for requests to adjust the playback speed. + void _handlePlaybackSpeedTransitions() { + _playbackSpeedSubject.listen((double speed) async { + await audioPlayerService.setPlaybackSpeed(speed.toTenth); + }); + } + + /// Listen for requests to toggle trim silence mode. This is currently disabled until + /// [issue](https://github.com/ryanheise/just_audio/issues/558) is resolved. + void _handleTrimSilenceTransitions() { + _trimSilence.listen((bool trim) async { + await audioPlayerService.trimSilence(trim); + }); + } + + /// Listen for requests to toggle the volume boost feature. Android only. + void _handleVolumeBoostTransitions() { + _volumeBoost.listen((bool boost) async { + await audioPlayerService.volumeBoost(boost); + }); + } + + void _handleTranscriptEvents() { + _transcriptEvent.listen((TranscriptEvent event) { + if (event is TranscriptFilterEvent) { + audioPlayerService.searchTranscript(event.search); + } else if (event is TranscriptClearEvent) { + audioPlayerService.clearTranscript(); + } + }); + } + + void _handleSleepTimer() { + _sleepEvent.listen((Sleep sleep) { + audioPlayerService.sleep(sleep); + }); + } + + @override + void pause() async { + log.fine('Audio lifecycle pause'); + await audioPlayerService.suspend(); + } + + @override + void resume() async { + log.fine('Audio lifecycle resume'); + var ep = await audioPlayerService.resume(); + + if (ep != null) { + log.fine('Resuming with episode ${ep.title} - ${ep.position} - ${ep.played}'); + } else { + log.fine('Resuming without an episode'); + } + } + + /// Play the specified track now + void Function(Episode?) get play => _play.add; + + /// Transition the state from connecting, to play, pause, stop etc. + void Function(TransitionState) get transitionState => _transitionPlayingState.add; + + /// Move the play position. + void Function(double) get transitionPosition => _transitionPosition.sink.add; + + /// Get the current playing state + Stream? get playingState => audioPlayerService.playingState; + + /// Listen for any playback errors + Stream? get playbackError => audioPlayerService.playbackError; + + /// Get the current playing episode + ValueStream? get nowPlaying => audioPlayerService.episodeEvent; + + /// Get the current transcript (if there is one). + Stream? get nowPlayingTranscript => audioPlayerService.transcriptEvent; + + /// Get position and percentage played of playing episode + ValueStream? get playPosition => audioPlayerService.playPosition; + + Stream? get sleepStream => audioPlayerService.sleepStream; + + /// Change playback speed + void Function(double) get playbackSpeed => _playbackSpeedSubject.sink.add; + + /// Toggle trim silence + void Function(bool) get trimSilence => _trimSilence.sink.add; + + /// Toggle volume boost silence + void Function(bool) get volumeBoost => _volumeBoost.sink.add; + + /// Handle filtering & searching of the current transcript. + void Function(TranscriptEvent) get filterTranscript => _transcriptEvent.sink.add; + + void Function(Sleep) get sleep => _sleepEvent.sink.add; + + @override + void dispose() { + _play.close(); + _transitionPlayingState.close(); + _transitionPosition.close(); + _playbackSpeedSubject.close(); + _trimSilence.close(); + _volumeBoost.close(); + + super.dispose(); + } +} diff --git a/PinePods-0.8.2/mobile/lib/bloc/podcast/episode_bloc.dart b/PinePods-0.8.2/mobile/lib/bloc/podcast/episode_bloc.dart new file mode 100644 index 0000000..86f7c4d --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/podcast/episode_bloc.dart @@ -0,0 +1,125 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/bloc.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; + +/// The BLoC provides access to [Episode] details outside the direct scope +/// of a [Podcast]. +class EpisodeBloc extends Bloc { + final log = Logger('EpisodeBloc'); + final PodcastService podcastService; + final AudioPlayerService audioPlayerService; + + /// Add to sink to fetch list of current downloaded episodes. + final BehaviorSubject _downloadsInput = BehaviorSubject(); + + /// Add to sink to fetch list of current episodes. + final BehaviorSubject _episodesInput = BehaviorSubject(); + + /// Add to sink to delete the passed [Episode] from storage. + final PublishSubject _deleteDownload = PublishSubject(); + + /// Add to sink to toggle played status of the [Episode]. + final PublishSubject _togglePlayed = PublishSubject(); + + /// Stream of currently downloaded episodes + Stream>>? _downloadsOutput; + + /// Stream of current episodes + Stream>>? _episodesOutput; + + /// Cache of our currently downloaded episodes. + List? _episodes; + + EpisodeBloc({ + required this.podcastService, + required this.audioPlayerService, + }) { + _init(); + } + + void _init() { + _downloadsOutput = _downloadsInput.switchMap>>((bool silent) => _loadDownloads(silent)); + _episodesOutput = _episodesInput.switchMap>>((bool silent) => _loadEpisodes(silent)); + + _handleDeleteDownloads(); + _handleMarkAsPlayed(); + _listenEpisodeEvents(); + } + + void _handleDeleteDownloads() async { + _deleteDownload.stream.listen((episode) async { + var nowPlaying = audioPlayerService.nowPlaying?.guid == episode?.guid; + + /// If we are attempting to delete the episode we are currently playing, we need to stop the audio. + if (nowPlaying) { + await audioPlayerService.stop(); + } + + await podcastService.deleteDownload(episode!); + + fetchDownloads(true); + }); + } + + void _handleMarkAsPlayed() async { + _togglePlayed.stream.listen((episode) async { + await podcastService.toggleEpisodePlayed(episode!); + + fetchDownloads(true); + }); + } + + void _listenEpisodeEvents() { + // Listen for episode updates. If the episode is downloaded, we need to update. + podcastService.episodeListener!.where((event) => event.episode.downloaded || event.episode.played).listen((event) => fetchDownloads(true)); + } + + Stream>> _loadDownloads(bool silent) async* { + if (!silent) { + yield BlocLoadingState(); + } + + _episodes = await podcastService.loadDownloads(); + + yield BlocPopulatedState>(results: _episodes); + } + + Stream>> _loadEpisodes(bool silent) async* { + if (!silent) { + yield BlocLoadingState(); + } + + _episodes = await podcastService.loadEpisodes(); + + yield BlocPopulatedState>(results: _episodes); + } + + @override + void dispose() { + _downloadsInput.close(); + _deleteDownload.close(); + _togglePlayed.close(); + } + + void Function(bool) get fetchDownloads => _downloadsInput.add; + + void Function(bool) get fetchEpisodes => _episodesInput.add; + + Stream>>? get downloads => _downloadsOutput; + + Stream>>? get episodes => _episodesOutput; + + void Function(Episode?) get deleteDownload => _deleteDownload.add; + + void Function(Episode?) get togglePlayed => _togglePlayed.add; +} diff --git a/PinePods-0.8.2/mobile/lib/bloc/podcast/podcast_bloc.dart b/PinePods-0.8.2/mobile/lib/bloc/podcast/podcast_bloc.dart new file mode 100644 index 0000000..35067b9 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/podcast/podcast_bloc.dart @@ -0,0 +1,517 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/bloc.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/feed.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/download/download_service.dart'; +import 'package:pinepods_mobile/services/download/mobile_download_service.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/services/settings/settings_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; + +enum PodcastEvent { + subscribe, + unsubscribe, + markAllPlayed, + clearAllPlayed, + reloadSubscriptions, + refresh, + // Filter + episodeFilterNone, + episodeFilterStarted, + episodeFilterNotFinished, + episodeFilterFinished, + // Sort + episodeSortDefault, + episodeSortLatest, + episodeSortEarliest, + episodeSortAlphabeticalAscending, + episodeSortAlphabeticalDescending, +} + +/// This BLoC provides access to the details of a given Podcast. +/// +/// It takes a feed URL and creates a [Podcast] instance. There are several listeners that +/// handle actions on a podcast such as requesting an episode download, following/unfollowing +/// a podcast and marking/un-marking all episodes as played. +class PodcastBloc extends Bloc { + final log = Logger('PodcastBloc'); + final PodcastService podcastService; + final AudioPlayerService audioPlayerService; + final DownloadService downloadService; + final SettingsService settingsService; + final BehaviorSubject _podcastFeed = BehaviorSubject(sync: true); + + /// Add to sink to start an Episode download + final PublishSubject _downloadEpisode = PublishSubject(); + + /// Listen to this subject's stream to obtain list of current subscriptions. + late PublishSubject> _subscriptions; + + /// Stream containing details of the current podcast. + final BehaviorSubject> _podcastStream = BehaviorSubject>(sync: true); + + /// A separate stream that allows us to listen to changes in the podcast's episodes. + final BehaviorSubject?> _episodesStream = BehaviorSubject?>(); + + /// Receives subscription and mark/clear as played events. + final PublishSubject _podcastEvent = PublishSubject(); + + final BehaviorSubject _podcastSearchEvent = BehaviorSubject(); + + final BehaviorSubject> _backgroundLoadStream = BehaviorSubject>(); + + Podcast? _podcast; + List _episodes = []; + String _searchTerm = ''; + late Feed lastFeed; + bool first = true; + + PodcastBloc({ + required this.podcastService, + required this.audioPlayerService, + required this.downloadService, + required this.settingsService, + }) { + _init(); + } + + void _init() { + /// When someone starts listening for subscriptions, load them. + _subscriptions = PublishSubject>(onListen: _loadSubscriptions); + + /// When we receive a load podcast request, send back a BlocState. + _listenPodcastLoad(); + + /// Listen to an Episode download request + _listenDownloadRequest(); + + /// Listen to active downloads + _listenDownloads(); + + /// Listen to episode change events sent by the [Repository] + _listenEpisodeRepositoryEvents(); + + /// Listen to Podcast subscription, mark/cleared played events + _listenPodcastStateEvents(); + + /// Listen for episode search requests + _listenPodcastSearchEvents(); + } + + void _loadSubscriptions() async { + _subscriptions.add(await podcastService.subscriptions()); + } + + /// Sets up a listener to handle Podcast load requests. We first push a [BlocLoadingState] to + /// indicate that the Podcast is being loaded, before calling the [PodcastService] to handle + /// the loading. Once loaded, we extract the episodes from the Podcast and push them out via + /// the episode stream before pushing a [BlocPopulatedState] containing the Podcast. + void _listenPodcastLoad() async { + _podcastFeed.listen((feed) async { + var silent = false; + lastFeed = feed; + + _episodes = []; + _refresh(); + + _podcastStream.sink.add(BlocLoadingState(feed.podcast)); + + try { + await _loadEpisodes(feed, feed.refresh); + + /// Do we also need to perform a background refresh? + if (feed.podcast.id != null && feed.backgroundFresh && _shouldAutoRefresh()) { + silent = feed.silently; + log.fine('Performing background refresh of ${feed.podcast.url}'); + _backgroundLoadStream.sink.add(BlocLoadingState()); + + await _loadNewEpisodes(feed); + } + + _backgroundLoadStream.sink.add(BlocSuccessfulState()); + } catch (e) { + _backgroundLoadStream.sink.add(BlocDefaultState()); + + // For now we'll assume a network error as this is the most likely. + if ((_podcast == null || lastFeed.podcast.url == _podcast!.url) && !silent) { + _podcastStream.sink.add(BlocErrorState()); + log.fine('Error loading podcast', e); + log.fine(e); + } + } + }); + } + + /// Determines if the current feed should be updated in the background. + /// + /// If the autoUpdatePeriod is -1 this means never; 0 means always and any other + /// value is the time in minutes. + bool _shouldAutoRefresh() { + /// If we are currently following this podcast it will have an id. At + /// this point we can compare the last updated time to the update + /// after setting time. + if (settingsService.autoUpdateEpisodePeriod == -1) { + return false; + } else if (_podcast == null || settingsService.autoUpdateEpisodePeriod == 0) { + return true; + } else if (_podcast != null && _podcast!.id != null) { + var currentTime = DateTime.now().subtract(Duration(minutes: settingsService.autoUpdateEpisodePeriod)); + var lastUpdated = _podcast!.lastUpdated; + + return currentTime.isAfter(lastUpdated); + } + + return false; + } + + Future _loadEpisodes(Feed feed, bool force) async { + _podcast = await podcastService.loadPodcast( + podcast: feed.podcast, + refresh: force, + ); + + /// Only populate episodes if the ID we started the load with is the + /// same as the one we have ended up with. + if (_podcast != null && _podcast?.url != null) { + if (lastFeed.podcast.url == _podcast!.url) { + _episodes = _podcast!.episodes; + _refresh(); + + _podcastStream.sink.add(BlocPopulatedState(results: _podcast)); + } + } + } + + void _refresh() { + applySearchFilter(); + } + + Future _loadNewEpisodes(Feed feed) async { + _podcast = await podcastService.loadPodcast( + podcast: feed.podcast, + highlightNewEpisodes: true, + refresh: true, + ); + + /// Only populate episodes if the ID we started the load with is the + /// same as the one we have ended up with. + if (_podcast != null && lastFeed.podcast.url == _podcast!.url) { + _episodes = _podcast!.episodes; + + if (_podcast!.newEpisodes) { + log.fine('We have new episodes to display'); + _backgroundLoadStream.sink.add(BlocPopulatedState()); + _podcastStream.sink.add(BlocPopulatedState(results: _podcast)); + } else if (_podcast!.updatedEpisodes) { + log.fine('We have updated episodes to re-display'); + _refresh(); + } + } + + log.fine('Background loading successful state'); + _backgroundLoadStream.sink.add(BlocSuccessfulState()); + } + + Future _loadFilteredEpisodes() async { + if (_podcast != null) { + _podcast = await podcastService.loadPodcast( + podcast: _podcast!, + highlightNewEpisodes: false, + refresh: false, + ); + + _episodes = _podcast!.episodes; + _podcastStream.add(BlocPopulatedState(results: _podcast)); + _refresh(); + } + } + + /// Sets up a listener to handle requests to download an episode. + void _listenDownloadRequest() { + _downloadEpisode.listen((Episode? e) async { + log.fine('Received download request for ${e!.title}'); + + // To prevent a pause between the user tapping the download icon and + // the UI showing some sort of progress, set it to queued now. + var episode = _episodes.firstWhereOrNull((ep) => ep.guid == e.guid); + + if (episode != null) { + episode.downloadState = e.downloadState = DownloadState.queued; + + _refresh(); + + var result = await downloadService.downloadEpisode(e); + + // If there was an error downloading the episode, push an error state + // and then restore to none. + if (!result) { + episode.downloadState = e.downloadState = DownloadState.failed; + _refresh(); + episode.downloadState = e.downloadState = DownloadState.none; + _refresh(); + } + } + }); + } + + /// Sets up a listener to listen for status updates from any currently downloading episode. + /// + /// If the ID of a current download matches that of an episode currently in + /// use, we update the status of the episode and push it back into the episode stream. + void _listenDownloads() { + // Listen to download progress + MobileDownloadService.downloadProgress.listen((downloadProgress) { + downloadService.findEpisodeByTaskId(downloadProgress.id).then((downloadable) { + if (downloadable != null) { + // If the download matches a current episode push the update back into the stream. + var episode = _episodes.firstWhereOrNull((e) => e.downloadTaskId == downloadProgress.id); + + if (episode != null) { + // Update the stream. + _refresh(); + } + } else { + log.severe('Downloadable not found with id ${downloadProgress.id}'); + } + }); + }); + } + + /// Listen to episode change events sent by the [Repository] + void _listenEpisodeRepositoryEvents() { + podcastService.episodeListener!.listen((state) { + // Do we have this episode? + var eidx = _episodes.indexWhere((e) => e.guid == state.episode.guid && e.pguid == state.episode.pguid); + + if (eidx != -1) { + _episodes[eidx] = state.episode; + _refresh(); + } + }); + } + + // TODO: This needs refactoring to simplify the long switch statement. + void _listenPodcastStateEvents() async { + _podcastEvent.listen((event) async { + switch (event) { + case PodcastEvent.subscribe: + if (_podcast != null) { + // Emit loading state for subscription + _podcastStream.add(BlocLoadingState(_podcast)); + + // First, subscribe locally + _podcast = await podcastService.subscribe(_podcast!); + + // Check if we're in a PinePods environment and also add to server + if (_podcast != null) { + try { + final settings = settingsService.settings; + if (settings != null && + settings.pinepodsServer != null && + settings.pinepodsApiKey != null && + settings.pinepodsUserId != null) { + + // Also add to PinePods server + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + final unifiedPodcast = UnifiedPinepodsPodcast( + id: 0, + indexId: 0, + title: _podcast!.title, + url: _podcast!.url ?? '', + originalUrl: _podcast!.url ?? '', + link: _podcast!.link ?? '', + description: _podcast!.description ?? '', + author: _podcast!.copyright ?? '', + ownerName: _podcast!.copyright ?? '', + image: _podcast!.imageUrl ?? '', + artwork: _podcast!.imageUrl ?? '', + lastUpdateTime: 0, + explicit: false, + episodeCount: 0, + ); + + await pinepodsService.addPodcast(unifiedPodcast, settings.pinepodsUserId!); + log.fine('Added podcast to PinePods server'); + } + } catch (e) { + log.warning('Failed to add podcast to PinePods server: $e'); + // Continue with local subscription even if server add fails + } + + _episodes = _podcast!.episodes; + _podcastStream.add(BlocPopulatedState(results: _podcast)); + _loadSubscriptions(); + _refresh(); // Use _refresh to apply filters and update episode stream properly + } + } + break; + case PodcastEvent.unsubscribe: + if (_podcast != null) { + await podcastService.unsubscribe(_podcast!); + _podcast!.id = null; + _episodes = _podcast!.episodes; + _podcastStream.add(BlocPopulatedState(results: _podcast)); + _loadSubscriptions(); + _refresh(); // Use _refresh to apply filters and update episode stream properly + } + break; + case PodcastEvent.markAllPlayed: + if (_podcast != null && _podcast?.episodes != null) { + final changedEpisodes = []; + + for (var e in _podcast!.episodes) { + if (!e.played) { + e.played = true; + e.position = 0; + + changedEpisodes.add(e); + } + } + + await podcastService.saveEpisodes(changedEpisodes); + _episodesStream.add(_podcast!.episodes); + } + break; + case PodcastEvent.clearAllPlayed: + if (_podcast != null && _podcast?.episodes != null) { + final changedEpisodes = []; + + for (var e in _podcast!.episodes) { + if (e.played) { + e.played = false; + e.position = 0; + + changedEpisodes.add(e); + } + } + + await podcastService.saveEpisodes(changedEpisodes); + _episodesStream.add(_podcast!.episodes); + } + break; + case PodcastEvent.reloadSubscriptions: + _loadSubscriptions(); + break; + case PodcastEvent.refresh: + _refresh(); + break; + case PodcastEvent.episodeFilterNone: + if (_podcast != null) { + _podcast!.filter = PodcastEpisodeFilter.none; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + } + break; + case PodcastEvent.episodeFilterStarted: + _podcast!.filter = PodcastEpisodeFilter.started; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + break; + case PodcastEvent.episodeFilterFinished: + _podcast!.filter = PodcastEpisodeFilter.played; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + break; + case PodcastEvent.episodeFilterNotFinished: + _podcast!.filter = PodcastEpisodeFilter.notPlayed; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + break; + case PodcastEvent.episodeSortDefault: + _podcast!.sort = PodcastEpisodeSort.none; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + break; + case PodcastEvent.episodeSortLatest: + _podcast!.sort = PodcastEpisodeSort.latestFirst; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + break; + case PodcastEvent.episodeSortEarliest: + _podcast!.sort = PodcastEpisodeSort.earliestFirst; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + break; + case PodcastEvent.episodeSortAlphabeticalAscending: + _podcast!.sort = PodcastEpisodeSort.alphabeticalAscending; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + break; + case PodcastEvent.episodeSortAlphabeticalDescending: + _podcast!.sort = PodcastEpisodeSort.alphabeticalDescending; + _podcast = await podcastService.save(_podcast!, withEpisodes: false); + await _loadFilteredEpisodes(); + break; + } + }); + } + + void _listenPodcastSearchEvents() { + _podcastSearchEvent.debounceTime(const Duration(milliseconds: 200)).listen((search) { + _searchTerm = search; + applySearchFilter(); + }); + } + + void applySearchFilter() { + if (_searchTerm.isEmpty) { + _episodesStream.add(_episodes); + } else { + var searchFilteredEpisodes = + _episodes.where((e) => e.title!.toLowerCase().contains(_searchTerm.trim().toLowerCase())).toList(); + _episodesStream.add(searchFilteredEpisodes); + } + } + + @override + void detach() { + downloadService.dispose(); + } + + @override + void dispose() { + _podcastFeed.close(); + _downloadEpisode.close(); + _subscriptions.close(); + _podcastStream.close(); + _episodesStream.close(); + _podcastEvent.close(); + MobileDownloadService.downloadProgress.close(); + downloadService.dispose(); + super.dispose(); + } + + /// Sink to load a podcast. + void Function(Feed) get load => _podcastFeed.add; + + /// Sink to trigger an episode download. + void Function(Episode?) get downloadEpisode => _downloadEpisode.add; + + void Function(PodcastEvent) get podcastEvent => _podcastEvent.add; + + void Function(String) get podcastSearchEvent => _podcastSearchEvent.add; + + /// Stream containing the current state of the podcast load. + Stream> get details => _podcastStream.stream; + + Stream> get backgroundLoading => _backgroundLoadStream.stream; + + /// Stream containing the current list of Podcast episodes. + Stream?> get episodes => _episodesStream; + + /// Obtain a list of podcast currently subscribed to. + Stream> get subscriptions => _subscriptions.stream; +} diff --git a/PinePods-0.8.2/mobile/lib/bloc/podcast/queue_bloc.dart b/PinePods-0.8.2/mobile/lib/bloc/podcast/queue_bloc.dart new file mode 100644 index 0000000..ddae6f3 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/podcast/queue_bloc.dart @@ -0,0 +1,63 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/bloc.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/state/queue_event_state.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Handles interaction with the Queue via an [AudioPlayerService]. +class QueueBloc extends Bloc { + final AudioPlayerService audioPlayerService; + final PodcastService podcastService; + final PublishSubject _queueEvent = PublishSubject(); + + QueueBloc({ + required this.audioPlayerService, + required this.podcastService, + }) { + _handleQueueEvents(); + } + + void _handleQueueEvents() { + _queueEvent.listen((QueueEvent event) async { + if (event is QueueAddEvent) { + var e = event.episode; + if (e != null) { + await audioPlayerService.addUpNextEpisode(e); + } + } else if (event is QueueRemoveEvent) { + var e = event.episode; + if (e != null) { + await audioPlayerService.removeUpNextEpisode(e); + } + } else if (event is QueueMoveEvent) { + var e = event.episode; + if (e != null) { + await audioPlayerService.moveUpNextEpisode(e, event.oldIndex, event.newIndex); + } + } else if (event is QueueClearEvent) { + await audioPlayerService.clearUpNext(); + } + }); + + audioPlayerService.queueState!.debounceTime(const Duration(seconds: 2)).listen((event) { + podcastService.saveQueue(event.queue).then((value) { + /// Queue saved. + }); + }); + } + + Function(QueueEvent) get queueEvent => _queueEvent.sink.add; + + Stream? get queue => audioPlayerService.queueState; + + @override + void dispose() { + _queueEvent.close(); + + super.dispose(); + } +} diff --git a/PinePods-0.8.2/mobile/lib/bloc/search/search_bloc.dart b/PinePods-0.8.2/mobile/lib/bloc/search/search_bloc.dart new file mode 100644 index 0000000..8d73ddf --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/search/search_bloc.dart @@ -0,0 +1,96 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/bloc.dart'; +import 'package:pinepods_mobile/bloc/search/search_state_event.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:logging/logging.dart'; +import 'package:podcast_search/podcast_search.dart' as pcast; +import 'package:rxdart/rxdart.dart'; + +/// This BLoC interacts with the [PodcastService] to search for podcasts for +/// a given term and to fetch the current podcast charts. +class SearchBloc extends Bloc { + final log = Logger('SearchBloc'); + final PodcastService podcastService; + + /// Add to the Sink to trigger a search using the [SearchEvent]. + final BehaviorSubject _searchInput = BehaviorSubject(); + + /// Add to the Sink to fetch the current podcast top x. + final BehaviorSubject _chartsInput = BehaviorSubject(); + + /// Stream of the current search results, be it from search or charts. + Stream>? _searchResults; + + /// Cache of last results. + pcast.SearchResult? _resultsCache; + + SearchBloc({required this.podcastService}) { + _init(); + } + + void _init() { + _searchResults = _searchInput.switchMap>( + (SearchEvent event) => _search(event), + ); + } + + /// Takes the [SearchEvent] to perform either a search, chart fetch or clearing + /// of the current results cache. + /// + /// To improve resilience, when performing a search the current network status is + /// checked. a [BlocErrorState] is pushed if we have no connectivity. + Stream> _search(SearchEvent event) async* { + if (event is SearchClearEvent) { + yield BlocDefaultState(); + } else if (event is SearchChartsEvent) { + yield BlocLoadingState(); + + _resultsCache ??= await podcastService.charts(size: 10); + + yield BlocPopulatedState(results: _resultsCache); + } else if (event is SearchTermEvent) { + final term = event.term; + + if (term.isEmpty) { + yield BlocNoInputState(); + } else { + yield BlocLoadingState(); + + // Check we have network + var connectivityResult = await Connectivity().checkConnectivity(); + + // TODO: Docs do not recommend this approach as a reliable way to + // determine if network is available. + if (connectivityResult.contains(ConnectivityResult.none)) { + yield BlocErrorState(error: BlocErrorType.connectivity); + } else { + final results = await podcastService.search(term: term); + + // Was the search successful? + if (results.successful) { + yield BlocPopulatedState(results: results); + } else { + yield BlocErrorState(); + } + } + } + } + } + + @override + void dispose() { + _searchInput.close(); + _chartsInput.close(); + } + + void Function(SearchEvent) get search => _searchInput.add; + + Stream? get results => _searchResults; +} diff --git a/PinePods-0.8.2/mobile/lib/bloc/search/search_state_event.dart b/PinePods-0.8.2/mobile/lib/bloc/search/search_state_event.dart new file mode 100644 index 0000000..00c829c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/search/search_state_event.dart @@ -0,0 +1,21 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Events +class SearchEvent {} + +class SearchTermEvent extends SearchEvent { + final String term; + + SearchTermEvent(this.term); +} + +class SearchChartsEvent extends SearchEvent {} + +class SearchClearEvent extends SearchEvent {} + +/// States +class SearchState {} + +class SearchLoadingState extends SearchState {} diff --git a/PinePods-0.8.2/mobile/lib/bloc/settings/settings_bloc.dart b/PinePods-0.8.2/mobile/lib/bloc/settings/settings_bloc.dart new file mode 100644 index 0000000..22dbb3e --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/settings/settings_bloc.dart @@ -0,0 +1,362 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/bloc.dart'; +import 'package:pinepods_mobile/core/environment.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/entities/search_providers.dart'; +import 'package:pinepods_mobile/services/settings/settings_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; + +class SettingsBloc extends Bloc { + final log = Logger('SettingsBloc'); + final SettingsService _settingsService; + final BehaviorSubject _settings = BehaviorSubject.seeded(AppSettings.sensibleDefaults()); + final BehaviorSubject _darkMode = BehaviorSubject(); + final BehaviorSubject _theme = BehaviorSubject(); + final BehaviorSubject _markDeletedAsPlayed = BehaviorSubject(); + final BehaviorSubject _deleteDownloadedPlayedEpisodes = BehaviorSubject(); + final BehaviorSubject _storeDownloadOnSDCard = BehaviorSubject(); + final BehaviorSubject _playbackSpeed = BehaviorSubject(); + final BehaviorSubject _searchProvider = BehaviorSubject(); + final BehaviorSubject _externalLinkConsent = BehaviorSubject(); + final BehaviorSubject _autoOpenNowPlaying = BehaviorSubject(); + final BehaviorSubject _showFunding = BehaviorSubject(); + final BehaviorSubject _trimSilence = BehaviorSubject(); + final BehaviorSubject _volumeBoost = BehaviorSubject(); + final BehaviorSubject _autoUpdatePeriod = BehaviorSubject(); + final BehaviorSubject _layoutMode = BehaviorSubject(); + final BehaviorSubject _pinepodsServer = BehaviorSubject(); + final BehaviorSubject _pinepodsApiKey = BehaviorSubject(); + final BehaviorSubject _pinepodsUserId = BehaviorSubject(); + final BehaviorSubject _pinepodsUsername = BehaviorSubject(); + final BehaviorSubject _pinepodsEmail = BehaviorSubject(); + final BehaviorSubject> _bottomBarOrder = BehaviorSubject>(); + var _currentSettings = AppSettings.sensibleDefaults(); + + SettingsBloc(this._settingsService) { + _init(); + // Check if we need to fetch user details for existing login + _fetchUserDetailsIfNeeded(); + } + + Future _fetchUserDetailsIfNeeded() async { + // Only fetch if we have server/api key but no username + if (_currentSettings.pinepodsServer != null && + _currentSettings.pinepodsApiKey != null && + (_currentSettings.pinepodsUsername == null || _currentSettings.pinepodsUsername!.isEmpty)) { + + try { + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials(_currentSettings.pinepodsServer!, _currentSettings.pinepodsApiKey!); + + // Use stored user ID if available, otherwise we need to get it somehow + final userId = _currentSettings.pinepodsUserId; + print('DEBUG: User ID from settings: $userId'); + if (userId != null) { + final userDetails = await pinepodsService.getUserDetails(userId); + print('DEBUG: User details response: $userDetails'); + if (userDetails != null) { + // Update settings with user details + final username = userDetails['Username'] ?? userDetails['username'] ?? ''; + final email = userDetails['Email'] ?? userDetails['email'] ?? ''; + print('DEBUG: Parsed username: "$username", email: "$email"'); + setPinepodsUsername(username); + setPinepodsEmail(email); + } + } + } catch (e) { + // Silently fail - don't break the app if this fails + print('Failed to fetch user details on startup: $e'); + } + } + } + + void _init() { + /// Load all settings + // Add our available search providers. + var providers = [SearchProvider(key: 'itunes', name: 'iTunes')]; + + if (podcastIndexKey.isNotEmpty) { + providers.add(SearchProvider(key: 'podcastindex', name: 'PodcastIndex')); + } + + _currentSettings = AppSettings( + theme: _settingsService.theme, + markDeletedEpisodesAsPlayed: _settingsService.markDeletedEpisodesAsPlayed, + deleteDownloadedPlayedEpisodes: _settingsService.deleteDownloadedPlayedEpisodes, + storeDownloadsSDCard: _settingsService.storeDownloadsSDCard, + playbackSpeed: _settingsService.playbackSpeed, + searchProvider: _settingsService.searchProvider, + searchProviders: providers, + externalLinkConsent: _settingsService.externalLinkConsent, + autoOpenNowPlaying: _settingsService.autoOpenNowPlaying, + showFunding: _settingsService.showFunding, + autoUpdateEpisodePeriod: _settingsService.autoUpdateEpisodePeriod, + trimSilence: _settingsService.trimSilence, + volumeBoost: _settingsService.volumeBoost, + layout: _settingsService.layoutMode, + pinepodsServer: _settingsService.pinepodsServer, + pinepodsApiKey: _settingsService.pinepodsApiKey, + pinepodsUserId: _settingsService.pinepodsUserId, + pinepodsUsername: _settingsService.pinepodsUsername, + pinepodsEmail: _settingsService.pinepodsEmail, + bottomBarOrder: _settingsService.bottomBarOrder, + ); + + _settings.add(_currentSettings); + + _darkMode.listen((bool darkMode) { + _currentSettings = _currentSettings.copyWith(theme: darkMode ? 'Dark' : 'Light'); + _settings.add(_currentSettings); + _settingsService.themeDarkMode = darkMode; + }); + + _theme.listen((String theme) { + _currentSettings = _currentSettings.copyWith(theme: theme); + _settings.add(_currentSettings); + _settingsService.theme = theme; + + // Sync with server if authenticated + _syncThemeToServer(theme); + }); + + _markDeletedAsPlayed.listen((bool mark) { + _currentSettings = _currentSettings.copyWith(markDeletedEpisodesAsPlayed: mark); + _settings.add(_currentSettings); + _settingsService.markDeletedEpisodesAsPlayed = mark; + }); + + _deleteDownloadedPlayedEpisodes.listen((bool delete) { + _currentSettings = _currentSettings.copyWith(deleteDownloadedPlayedEpisodes: delete); + _settings.add(_currentSettings); + _settingsService.deleteDownloadedPlayedEpisodes = delete; + }); + + _storeDownloadOnSDCard.listen((bool sdcard) { + _currentSettings = _currentSettings.copyWith(storeDownloadsSDCard: sdcard); + _settings.add(_currentSettings); + _settingsService.storeDownloadsSDCard = sdcard; + }); + + _playbackSpeed.listen((double speed) { + _currentSettings = _currentSettings.copyWith(playbackSpeed: speed); + _settings.add(_currentSettings); + _settingsService.playbackSpeed = speed; + }); + + _autoOpenNowPlaying.listen((bool autoOpen) { + _currentSettings = _currentSettings.copyWith(autoOpenNowPlaying: autoOpen); + _settings.add(_currentSettings); + _settingsService.autoOpenNowPlaying = autoOpen; + }); + + _showFunding.listen((show) { + // If the setting has not changed, don't bother updating it + if (show != _currentSettings.showFunding) { + _currentSettings = _currentSettings.copyWith(showFunding: show); + _settingsService.showFunding = show; + } + + _settings.add(_currentSettings); + }); + + _searchProvider.listen((search) { + _currentSettings = _currentSettings.copyWith(searchProvider: search); + _settings.add(_currentSettings); + _settingsService.searchProvider = search; + }); + + _externalLinkConsent.listen((consent) { + // If the setting has not changed, don't bother updating it + if (consent != _settingsService.externalLinkConsent) { + _currentSettings = _currentSettings.copyWith(externalLinkConsent: consent); + _settingsService.externalLinkConsent = consent; + } + + _settings.add(_currentSettings); + }); + + _autoUpdatePeriod.listen((period) { + _currentSettings = _currentSettings.copyWith(autoUpdateEpisodePeriod: period); + _settings.add(_currentSettings); + _settingsService.autoUpdateEpisodePeriod = period; + }); + + _trimSilence.listen((trim) { + _currentSettings = _currentSettings.copyWith(trimSilence: trim); + _settings.add(_currentSettings); + _settingsService.trimSilence = trim; + }); + + _volumeBoost.listen((boost) { + _currentSettings = _currentSettings.copyWith(volumeBoost: boost); + _settings.add(_currentSettings); + _settingsService.volumeBoost = boost; + }); + + _pinepodsServer.listen((server) { + _currentSettings = _currentSettings.copyWith(pinepodsServer: server); + _settings.add(_currentSettings); + _settingsService.pinepodsServer = server; + }); + + _pinepodsApiKey.listen((apiKey) { + _currentSettings = _currentSettings.copyWith(pinepodsApiKey: apiKey); + _settings.add(_currentSettings); + _settingsService.pinepodsApiKey = apiKey; + }); + + _pinepodsUserId.listen((userId) { + _currentSettings = _currentSettings.copyWith(pinepodsUserId: userId); + _settings.add(_currentSettings); + _settingsService.pinepodsUserId = userId; + }); + + _pinepodsUsername.listen((username) { + _currentSettings = _currentSettings.copyWith(pinepodsUsername: username); + _settings.add(_currentSettings); + _settingsService.pinepodsUsername = username; + }); + + _pinepodsEmail.listen((email) { + _currentSettings = _currentSettings.copyWith(pinepodsEmail: email); + _settings.add(_currentSettings); + _settingsService.pinepodsEmail = email; + }); + + _layoutMode.listen((mode) { + _currentSettings = _currentSettings.copyWith(layout: mode); + _settings.add(_currentSettings); + _settingsService.layoutMode = mode; + }); + + _bottomBarOrder.listen((order) { + _currentSettings = _currentSettings.copyWith(bottomBarOrder: order); + _settings.add(_currentSettings); + _settingsService.bottomBarOrder = order; + }); + } + + Stream get settings => _settings.stream; + + void Function(bool) get darkMode => _darkMode.add; + + void Function(bool) get storeDownloadonSDCard => _storeDownloadOnSDCard.add; + + void Function(bool) get markDeletedAsPlayed => _markDeletedAsPlayed.add; + + void Function(bool) get deleteDownloadedPlayedEpisodes => _deleteDownloadedPlayedEpisodes.add; + + void Function(double) get setPlaybackSpeed => _playbackSpeed.add; + + void Function(bool) get setAutoOpenNowPlaying => _autoOpenNowPlaying.add; + + void Function(String) get setSearchProvider => _searchProvider.add; + + void Function(bool) get setExternalLinkConsent => _externalLinkConsent.add; + + void Function(bool) get setShowFunding => _showFunding.add; + + void Function(int) get autoUpdatePeriod => _autoUpdatePeriod.add; + + void Function(bool) get trimSilence => _trimSilence.add; + + void Function(bool) get volumeBoost => _volumeBoost.add; + + void Function(int) get layoutMode => _layoutMode.add; + + void Function(String?) get setPinepodsServer => _pinepodsServer.add; + + void Function(String?) get setPinepodsApiKey => _pinepodsApiKey.add; + + void Function(int?) get setPinepodsUserId => _pinepodsUserId.add; + + void Function(String?) get setPinepodsUsername => _pinepodsUsername.add; + + void Function(String?) get setPinepodsEmail => _pinepodsEmail.add; + + void Function(List) get setBottomBarOrder => _bottomBarOrder.add; + + void Function(String) get setTheme => _theme.add; + + AppSettings get currentSettings => _settings.value; + + Future _syncThemeToServer(String theme) async { + try { + // Only sync if we have PinePods credentials + if (_currentSettings.pinepodsServer != null && + _currentSettings.pinepodsApiKey != null && + _currentSettings.pinepodsUserId != null) { + + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials( + _currentSettings.pinepodsServer!, + _currentSettings.pinepodsApiKey!, + ); + + await pinepodsService.setUserTheme(_currentSettings.pinepodsUserId!, theme); + log.info('Theme synced to server: $theme'); + } + } catch (e) { + log.warning('Failed to sync theme to server: $e'); + // Don't throw - theme should still work locally + } + } + + Future fetchThemeFromServer() async { + try { + // Only fetch if we have PinePods credentials + if (_currentSettings.pinepodsServer != null && + _currentSettings.pinepodsApiKey != null && + _currentSettings.pinepodsUserId != null) { + + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials( + _currentSettings.pinepodsServer!, + _currentSettings.pinepodsApiKey!, + ); + + final serverTheme = await pinepodsService.getUserTheme(_currentSettings.pinepodsUserId!); + if (serverTheme != null && serverTheme.isNotEmpty) { + // Update local theme without syncing back to server + _settingsService.theme = serverTheme; + _currentSettings = _currentSettings.copyWith(theme: serverTheme); + _settings.add(_currentSettings); + log.info('Theme fetched from server: $serverTheme'); + } + } + } catch (e) { + log.warning('Failed to fetch theme from server: $e'); + // Don't throw - continue with local theme + } + } + + @override + void dispose() { + _darkMode.close(); + _theme.close(); + _markDeletedAsPlayed.close(); + _deleteDownloadedPlayedEpisodes.close(); + _storeDownloadOnSDCard.close(); + _playbackSpeed.close(); + _searchProvider.close(); + _externalLinkConsent.close(); + _autoOpenNowPlaying.close(); + _showFunding.close(); + _trimSilence.close(); + _volumeBoost.close(); + _autoUpdatePeriod.close(); + _layoutMode.close(); + _pinepodsServer.close(); + _pinepodsApiKey.close(); + _pinepodsUserId.close(); + _pinepodsUsername.close(); + _pinepodsEmail.close(); + _bottomBarOrder.close(); + _settings.close(); + } +} diff --git a/PinePods-0.8.2/mobile/lib/bloc/ui/pager_bloc.dart b/PinePods-0.8.2/mobile/lib/bloc/ui/pager_bloc.dart new file mode 100644 index 0000000..b4cbbf8 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/bloc/ui/pager_bloc.dart @@ -0,0 +1,19 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rxdart/rxdart.dart'; + +/// This BLoC provides a sink and stream to set and listen for the current +/// page/tab on a bottom navigation bar. +class PagerBloc { + final BehaviorSubject page = BehaviorSubject.seeded(0); + + Function(int) get changePage => page.add; + + Stream get currentPage => page.stream; + + void dispose() { + page.close(); + } +} diff --git a/PinePods-0.8.2/mobile/lib/core/annotations.dart b/PinePods-0.8.2/mobile/lib/core/annotations.dart new file mode 100644 index 0000000..3d4a9b4 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/core/annotations.dart @@ -0,0 +1,8 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Simple marker to indicate a field is transient and is not intended to be persisted +class Transient { + const Transient(); +} diff --git a/PinePods-0.8.2/mobile/lib/core/environment.dart b/PinePods-0.8.2/mobile/lib/core/environment.dart new file mode 100644 index 0000000..121ec0d --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/core/environment.dart @@ -0,0 +1,54 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +/// The key required when searching via PodcastIndex.org. +const podcastIndexKey = String.fromEnvironment('PINDEX_KEY', defaultValue: ''); + +/// The secret required when searching via PodcastIndex.org. +const podcastIndexSecret = String.fromEnvironment( + 'PINDEX_SECRET', + defaultValue: '', +); + +/// Allows a user to override the default user agent string. +const userAgentAppString = String.fromEnvironment( + 'USER_AGENT', + defaultValue: '', +); + +/// Link to a feedback form. This will be shown in the main overflow menu if set +const feedbackUrl = String.fromEnvironment('FEEDBACK_URL', defaultValue: ''); + +/// This class stores version information for PinePods, including project version and +/// build number. This is then used for user agent strings when interacting with +/// APIs and RSS feeds. +/// +/// The user agent string can be overridden by passing in the USER_AGENT variable +/// using dart-define. +class Environment { + static const _applicationName = 'Pinepods'; + static const _applicationUrl = + 'https://github.com/madeofpendletonwool/pinepods'; + static const _projectVersion = '0.8.1'; + static const _build = '20252203'; + + static var _agentString = userAgentAppString; + + static String userAgent() { + if (_agentString.isEmpty) { + var platform = + '${Platform.operatingSystem} ${Platform.operatingSystemVersion}' + .trim(); + + _agentString = + '$_applicationName/$_projectVersion b$_build (phone;$platform) $_applicationUrl'; + } + + return _agentString; + } + + static String get projectVersion => '$_projectVersion b$_build'; +} diff --git a/PinePods-0.8.2/mobile/lib/core/extensions.dart b/PinePods-0.8.2/mobile/lib/core/extensions.dart new file mode 100644 index 0000000..859621c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/core/extensions.dart @@ -0,0 +1,60 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math'; + +extension IterableExtensions on Iterable { + Iterable> chunk(int size) sync* { + if (length <= 0) { + yield []; + return; + } + + var skip = 0; + + while (skip < length) { + final chunk = this.skip(skip).take(size); + + yield chunk.toList(growable: false); + + skip += size; + + if (chunk.length < size) { + return; + } + } + } +} + +extension ExtString on String? { + String get forceHttps { + if (this != null) { + final url = Uri.tryParse(this!); + + if (url == null || !url.isScheme('http')) return this!; + + // Don't force HTTPS for localhost or local IP addresses to support self-hosted development + final host = url.host.toLowerCase(); + if (host == 'localhost' || + host == '127.0.0.1' || + host.startsWith('10.') || + host.startsWith('192.168.') || + host.startsWith('172.') || + host.endsWith('.local')) { + return this!; + } + + return url.replace(scheme: 'https').toString(); + } + + return this ?? ''; + } +} + +extension ExtDouble on double { + double get toTenth { + var mod = pow(10.0, 1).toDouble(); + return ((this * mod).round().toDouble() / mod); + } +} diff --git a/PinePods-0.8.2/mobile/lib/core/utils.dart b/PinePods-0.8.2/mobile/lib/core/utils.dart new file mode 100644 index 0000000..e013f1c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/core/utils.dart @@ -0,0 +1,140 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/services/settings/mobile_settings_service.dart'; +import 'package:pinepods_mobile/services/settings/settings_service.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// Returns the storage directory for the current platform. +/// +/// On iOS, the directory that the app has available to it for storing episodes may +/// change between updates, whereas on Android we are able to save the full path. To +/// ensure we can handle the directory name change on iOS without breaking existing +/// Android installations we have created the following three functions to help with +/// resolving the various paths correctly depending upon platform. +Future resolvePath(Episode episode) async { + if (Platform.isIOS) { + return Future.value(join(await getStorageDirectory(), episode.filepath, episode.filename)); + } + + return Future.value(join(episode.filepath!, episode.filename)); +} + +Future resolveDirectory({required Episode episode, bool full = false}) async { + if (full || Platform.isAndroid) { + return Future.value(join(await getStorageDirectory(), safePath(episode.podcast!))); + } + + return Future.value(safePath(episode.podcast!)); +} + +Future createDownloadDirectory(Episode episode) async { + var path = join(await getStorageDirectory(), safePath(episode.podcast!)); + + Directory(path).createSync(recursive: true); +} + +Future hasStoragePermission() async { + SettingsService? settings = await MobileSettingsService.instance(); + + if (Platform.isIOS || !settings!.storeDownloadsSDCard) { + return Future.value(true); + } else { + final permissionStatus = await Permission.storage.request(); + + return Future.value(permissionStatus.isGranted); + } +} + +Future getStorageDirectory() async { + SettingsService? settings = await MobileSettingsService.instance(); + Directory directory; + + if (Platform.isIOS) { + directory = await getApplicationDocumentsDirectory(); + } else if (settings!.storeDownloadsSDCard) { + directory = await _getSDCard(); + } else { + directory = await getApplicationSupportDirectory(); + } + + return join(directory.path, 'PinePods'); +} + +Future hasExternalStorage() async { + try { + await _getSDCard(); + + return Future.value(true); + } catch (e) { + return Future.value(false); + } +} + +Future _getSDCard() async { + final appDocumentDir = (await getExternalStorageDirectories(type: StorageDirectory.podcasts))!; + + Directory? path; + + // If the directory contains the word 'emulated' we are + // probably looking at a mapped user partition rather than + // an actual SD card - so skip those and find the first + // non-emulated directory. + if (appDocumentDir.isNotEmpty) { + // See if we can find the last card without emulated + for (var d in appDocumentDir) { + if (!d.path.contains('emulated')) { + path = d.absolute; + } + } + } + + if (path == null) { + throw ('No SD card found'); + } + + return path; +} + +/// Strips characters that are invalid for file and directory names. +String? safePath(String? s) { + return s?.replaceAll(RegExp(r'[^\w\s]+'), '').trim(); +} + +String? safeFile(String? s) { + return s?.replaceAll(RegExp(r'[^\w\s\.]+'), '').trim(); +} + +Future resolveUrl(String url, {bool forceHttps = false}) async { + final client = HttpClient(); + var uri = Uri.parse(url); + var request = await client.getUrl(uri); + + request.followRedirects = false; + + var response = await request.close(); + + while (response.isRedirect) { + response.drain(0); + final location = response.headers.value(HttpHeaders.locationHeader); + if (location != null) { + uri = uri.resolve(location); + request = await client.getUrl(uri); + // Set the body or headers as desired. + request.followRedirects = false; + response = await request.close(); + } + } + + if (uri.scheme == 'http') { + uri = uri.replace(scheme: 'https'); + } + + return uri.toString(); +} diff --git a/PinePods-0.8.2/mobile/lib/entities/app_settings.dart b/PinePods-0.8.2/mobile/lib/entities/app_settings.dart new file mode 100644 index 0000000..60c18ba --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/app_settings.dart @@ -0,0 +1,152 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/search_providers.dart'; + +class AppSettings { + /// The current theme name. + final String theme; + + /// True if episodes are marked as played when deleted. + final bool markDeletedEpisodesAsPlayed; + + /// True if downloaded played episodes must be deleted automatically. + final bool deleteDownloadedPlayedEpisodes; + + /// True if downloads should be saved to the SD card. + final bool storeDownloadsSDCard; + + /// The default playback speed. + final double playbackSpeed; + + /// The search provider: itunes or podcastindex. + final String? searchProvider; + + /// List of search providers: currently itunes or podcastindex. + final List searchProviders; + + /// True if the user has confirmed dialog accepting funding links. + final bool externalLinkConsent; + + /// If true the main player window will open as soon as an episode starts. + final bool autoOpenNowPlaying; + + /// If true the funding link icon will appear (if the podcast supports it). + final bool showFunding; + + /// If -1 never; 0 always; otherwise time in minutes. + final int autoUpdateEpisodePeriod; + + /// If true, silence in audio playback is trimmed. Currently Android only. + final bool trimSilence; + + /// If true, volume is boosted. Currently Android only. + final bool volumeBoost; + + /// If 0, list view; else grid view + final int layout; + + final String? pinepodsServer; + + final String? pinepodsApiKey; + + final int? pinepodsUserId; + + final String? pinepodsUsername; + + final String? pinepodsEmail; + + /// Custom order for bottom navigation bar items + final List bottomBarOrder; + + AppSettings({ + required this.theme, + required this.markDeletedEpisodesAsPlayed, + required this.deleteDownloadedPlayedEpisodes, + required this.storeDownloadsSDCard, + required this.playbackSpeed, + required this.searchProvider, + required this.searchProviders, + required this.externalLinkConsent, + required this.autoOpenNowPlaying, + required this.showFunding, + required this.autoUpdateEpisodePeriod, + required this.trimSilence, + required this.volumeBoost, + required this.layout, + this.pinepodsServer, + this.pinepodsApiKey, + this.pinepodsUserId, + this.pinepodsUsername, + this.pinepodsEmail, + required this.bottomBarOrder, + }); + + AppSettings.sensibleDefaults() + : theme = 'Dark', + markDeletedEpisodesAsPlayed = false, + deleteDownloadedPlayedEpisodes = false, + storeDownloadsSDCard = false, + playbackSpeed = 1.0, + searchProvider = 'itunes', + searchProviders = [], + externalLinkConsent = false, + autoOpenNowPlaying = false, + showFunding = true, + autoUpdateEpisodePeriod = -1, + trimSilence = false, + volumeBoost = false, + layout = 0, + pinepodsServer = null, + pinepodsApiKey = null, + pinepodsUserId = null, + pinepodsUsername = null, + pinepodsEmail = null, + bottomBarOrder = const ['Home', 'Feed', 'Saved', 'Podcasts', 'Downloads', 'History', 'Playlists', 'Search']; + + AppSettings copyWith({ + String? theme, + bool? markDeletedEpisodesAsPlayed, + bool? deleteDownloadedPlayedEpisodes, + bool? storeDownloadsSDCard, + double? playbackSpeed, + String? searchProvider, + List? searchProviders, + bool? externalLinkConsent, + bool? autoOpenNowPlaying, + bool? showFunding, + int? autoUpdateEpisodePeriod, + bool? trimSilence, + bool? volumeBoost, + int? layout, + String? pinepodsServer, + String? pinepodsApiKey, + int? pinepodsUserId, + String? pinepodsUsername, + String? pinepodsEmail, + List? bottomBarOrder, + }) => + AppSettings( + theme: theme ?? this.theme, + markDeletedEpisodesAsPlayed: markDeletedEpisodesAsPlayed ?? this.markDeletedEpisodesAsPlayed, + deleteDownloadedPlayedEpisodes: deleteDownloadedPlayedEpisodes ?? this.deleteDownloadedPlayedEpisodes, + storeDownloadsSDCard: storeDownloadsSDCard ?? this.storeDownloadsSDCard, + playbackSpeed: playbackSpeed ?? this.playbackSpeed, + searchProvider: searchProvider ?? this.searchProvider, + searchProviders: searchProviders ?? this.searchProviders, + externalLinkConsent: externalLinkConsent ?? this.externalLinkConsent, + autoOpenNowPlaying: autoOpenNowPlaying ?? this.autoOpenNowPlaying, + showFunding: showFunding ?? this.showFunding, + autoUpdateEpisodePeriod: autoUpdateEpisodePeriod ?? this.autoUpdateEpisodePeriod, + trimSilence: trimSilence ?? this.trimSilence, + volumeBoost: volumeBoost ?? this.volumeBoost, + layout: layout ?? this.layout, + pinepodsServer: pinepodsServer ?? this.pinepodsServer, + pinepodsApiKey: pinepodsApiKey ?? this.pinepodsApiKey, + pinepodsUserId: pinepodsUserId ?? this.pinepodsUserId, + pinepodsUsername: pinepodsUsername ?? this.pinepodsUsername, + pinepodsEmail: pinepodsEmail ?? this.pinepodsEmail, + bottomBarOrder: bottomBarOrder ?? this.bottomBarOrder, + ); +} diff --git a/PinePods-0.8.2/mobile/lib/entities/chapter.dart b/PinePods-0.8.2/mobile/lib/entities/chapter.dart new file mode 100644 index 0000000..0b093b9 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/chapter.dart @@ -0,0 +1,71 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/core/extensions.dart'; + +/// A class that represents an individual chapter within an [Episode]. +/// +/// Chapters may, or may not, exist for an episode. +/// +/// Part of the [podcast namespace](https://github.com/Podcastindex-org/podcast-namespace) +class Chapter { + /// Title of this chapter. + final String title; + + /// URL for the chapter image if one is available. + final String? imageUrl; + + /// URL of an external link for this chapter if available. + final String? url; + + /// Table of contents flag. If this is false the chapter should be treated as + /// meta data only and not be displayed. + final bool toc; + + /// The start time of the chapter in seconds. + final double startTime; + + /// The optional end time of the chapter in seconds. + final double? endTime; + + Chapter({ + required this.title, + required String? imageUrl, + required this.startTime, + String? url, + this.toc = true, + this.endTime, + }) : imageUrl = imageUrl?.forceHttps, + url = url?.forceHttps; + + Map toMap() { + return { + 'title': title, + 'imageUrl': imageUrl, + 'url': url, + 'toc': toc ? 'true' : 'false', + 'startTime': startTime.toString(), + 'endTime': endTime.toString(), + }; + } + + static Chapter fromMap(Map chapter) { + return Chapter( + title: chapter['title'] as String, + imageUrl: chapter['imageUrl'] as String?, + url: chapter['url'] as String?, + toc: chapter['toc'] == 'false' ? false : true, + startTime: double.tryParse(chapter['startTime'] as String? ?? '0') ?? 0.0, + endTime: double.tryParse(chapter['endTime'] as String? ?? '0') ?? 0.0, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Chapter && runtimeType == other.runtimeType && title == other.title && startTime == other.startTime; + + @override + int get hashCode => title.hashCode ^ startTime.hashCode; +} diff --git a/PinePods-0.8.2/mobile/lib/entities/downloadable.dart b/PinePods-0.8.2/mobile/lib/entities/downloadable.dart new file mode 100644 index 0000000..ee69127 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/downloadable.dart @@ -0,0 +1,98 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +enum DownloadState { none, queued, downloading, failed, cancelled, paused, downloaded } + +/// A Downloadble is an object that holds information about a podcast episode +/// and its download status. +/// +/// Downloadables can be used to determine if a download has been successful and +/// if an episode can be played from the filesystem. +class Downloadable { + /// Database ID + int? id; + + /// Unique identifier for the download + final String guid; + + /// URL of the file to download + final String url; + + /// Destination directory + String directory; + + /// Name of file + String filename; + + /// Current task ID for the download + String taskId; + + /// Current state of the download + DownloadState state; + + /// Percentage of MP3 downloaded + int? percentage; + + Downloadable({ + required this.guid, + required this.url, + required this.directory, + required this.filename, + required this.taskId, + required this.state, + this.percentage, + }); + + Map toMap() { + return { + 'guid': guid, + 'url': url, + 'filename': filename, + 'directory': directory, + 'taskId': taskId, + 'state': state.index, + 'percentage': percentage.toString(), + }; + } + + static Downloadable fromMap(Map downloadable) { + return Downloadable( + guid: downloadable['guid'] as String, + url: downloadable['url'] as String, + directory: downloadable['directory'] as String, + filename: downloadable['filename'] as String, + taskId: downloadable['taskId'] as String, + state: _determineState(downloadable['state'] as int?), + percentage: int.parse(downloadable['percentage'] as String), + ); + } + + static DownloadState _determineState(int? index) { + switch (index) { + case 0: + return DownloadState.none; + case 1: + return DownloadState.queued; + case 2: + return DownloadState.downloading; + case 3: + return DownloadState.failed; + case 4: + return DownloadState.cancelled; + case 5: + return DownloadState.paused; + case 6: + return DownloadState.downloaded; + } + + return DownloadState.none; + } + + @override + bool operator ==(Object other) => + identical(this, other) || other is Downloadable && runtimeType == other.runtimeType && guid == other.guid; + + @override + int get hashCode => guid.hashCode; +} diff --git a/PinePods-0.8.2/mobile/lib/entities/episode.dart b/PinePods-0.8.2/mobile/lib/entities/episode.dart new file mode 100644 index 0000000..64f8246 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/episode.dart @@ -0,0 +1,420 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/core/annotations.dart'; +import 'package:pinepods_mobile/core/extensions.dart'; +import 'package:pinepods_mobile/entities/chapter.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/entities/person.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:flutter/foundation.dart'; +import 'package:html/parser.dart' show parseFragment; +import 'package:logging/logging.dart'; + +/// An object that represents an individual episode of a Podcast. +/// +/// An Episode can be used in conjunction with a [Downloadable] to +/// determine if the Episode is available on the local filesystem. +class Episode { + final log = Logger('Episode'); + + /// Database ID + int? id; + + /// A String GUID for the episode. + final String guid; + + /// The GUID for an associated podcast. If an episode has been downloaded + /// without subscribing to a podcast this may be null. + String? pguid; + + /// If the episode is currently being downloaded, this contains the unique + /// ID supplied by the download manager for the episode. + String? downloadTaskId; + + /// The path to the directory containing the download for this episode; or null. + String? filepath; + + /// The filename of the downloaded episode; or null. + String? filename; + + /// The current downloading state of the episode. + DownloadState downloadState = DownloadState.none; + + /// The name of the podcast the episode is part of. + String? podcast; + + /// The episode title. + String? title; + + /// The episode description. This could be plain text or HTML. + String? description; + + /// More detailed description - optional. + String? content; + + /// External link + String? link; + + /// URL to the episode artwork image. + String? imageUrl; + + /// URL to a thumbnail version of the episode artwork image. + String? thumbImageUrl; + + /// The date the episode was published (if known). + DateTime? publicationDate; + + /// The URL for the episode location. + String? contentUrl; + + /// Author of the episode if known. + String? author; + + /// The season the episode is part of if available. + int season; + + /// The episode number within a season if available. + int episode; + + /// The duration of the episode in milliseconds. This can be populated either from + /// the RSS if available, or determined from the MP3 file at stream/download time. + int duration; + + /// Stores the current position within the episode in milliseconds. Used for resuming. + int position; + + /// Stores the progress of the current download progress if available. + int? downloadPercentage; + + /// True if this episode is 'marked as played'. + bool played; + + /// URL pointing to a JSON file containing chapter information if available. + String? chaptersUrl; + + /// List of chapters for the episode if available. + List chapters; + + /// List of transcript URLs for the episode if available. + List transcriptUrls; + + List persons; + + /// Currently downloaded or in use transcript for the episode.To minimise memory + /// use, this is cleared when an episode download is deleted, or a streamed episode stopped. + Transcript? transcript; + + /// Link to a currently stored transcript for this episode. + int? transcriptId; + + /// Date and time episode was last updated and persisted. + DateTime? lastUpdated; + + /// Processed version of episode description. + String? _descriptionText; + + /// Index of the currently playing chapter it available. Transient. + int? chapterIndex; + + /// Current chapter we are listening to if this episode has chapters. Transient. + Chapter? currentChapter; + + /// Set to true if chapter data is currently being loaded. + @Transient() + bool chaptersLoading = false; + + @Transient() + bool highlight = false; + + @Transient() + bool queued = false; + + @Transient() + bool streaming = true; + + Episode({ + required this.guid, + this.pguid, + required this.podcast, + this.id, + this.downloadTaskId, + this.filepath, + this.filename, + this.downloadState = DownloadState.none, + this.title, + this.description, + this.content, + this.link, + String? imageUrl, + String? thumbImageUrl, + this.publicationDate, + String? contentUrl, + this.author, + this.season = 0, + this.episode = 0, + this.duration = 0, + this.position = 0, + this.downloadPercentage = 0, + this.played = false, + this.highlight = false, + String? chaptersUrl, + this.chapters = const [], + this.transcriptUrls = const [], + this.persons = const [], + this.transcriptId = 0, + this.lastUpdated, + }) : imageUrl = imageUrl?.forceHttps, + thumbImageUrl = thumbImageUrl?.forceHttps, + contentUrl = contentUrl?.forceHttps, + chaptersUrl = chaptersUrl?.forceHttps; + + Map toMap() { + return { + 'guid': guid, + 'pguid': pguid, + 'downloadTaskId': downloadTaskId, + 'filepath': filepath, + 'filename': filename, + 'downloadState': downloadState.index, + 'podcast': podcast, + 'title': title, + 'description': description, + 'content': content, + 'link': link, + 'imageUrl': imageUrl, + 'thumbImageUrl': thumbImageUrl, + 'publicationDate': publicationDate?.millisecondsSinceEpoch.toString(), + 'contentUrl': contentUrl, + 'author': author, + 'season': season.toString(), + 'episode': episode.toString(), + 'duration': duration.toString(), + 'position': position.toString(), + 'downloadPercentage': downloadPercentage.toString(), + 'played': played ? 'true' : 'false', + 'chaptersUrl': chaptersUrl, + 'chapters': (chapters).map((chapter) => chapter.toMap()).toList(growable: false), + 'tid': transcriptId ?? 0, + 'transcriptUrls': (transcriptUrls).map((tu) => tu.toMap()).toList(growable: false), + 'persons': (persons).map((person) => person.toMap()).toList(growable: false), + 'lastUpdated': lastUpdated?.millisecondsSinceEpoch.toString() ?? '', + }; + } + + static Episode fromMap(int? key, Map episode) { + var chapters = []; + var transcriptUrls = []; + var persons = []; + + // We need to perform an 'is' on each loop to prevent Dart + // from complaining that we have not set the type for chapter. + if (episode['chapters'] != null) { + for (var chapter in (episode['chapters'] as List)) { + if (chapter is Map) { + chapters.add(Chapter.fromMap(chapter)); + } + } + } + + if (episode['transcriptUrls'] != null) { + for (var transcriptUrl in (episode['transcriptUrls'] as List)) { + if (transcriptUrl is Map) { + transcriptUrls.add(TranscriptUrl.fromMap(transcriptUrl)); + } + } + } + + if (episode['persons'] != null) { + for (var person in (episode['persons'] as List)) { + if (person is Map) { + persons.add(Person.fromMap(person)); + } + } + } + + return Episode( + id: key, + guid: episode['guid'] as String, + pguid: episode['pguid'] as String?, + downloadTaskId: episode['downloadTaskId'] as String?, + filepath: episode['filepath'] as String?, + filename: episode['filename'] as String?, + downloadState: _determineState(episode['downloadState'] as int?), + podcast: episode['podcast'] as String?, + title: episode['title'] as String?, + description: episode['description'] as String?, + content: episode['content'] as String?, + link: episode['link'] as String?, + imageUrl: episode['imageUrl'] as String?, + thumbImageUrl: episode['thumbImageUrl'] as String?, + publicationDate: episode['publicationDate'] == null || episode['publicationDate'] == 'null' + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(int.parse(episode['publicationDate'] as String)), + contentUrl: episode['contentUrl'] as String?, + author: episode['author'] as String?, + season: int.parse(episode['season'] as String? ?? '0'), + episode: int.parse(episode['episode'] as String? ?? '0'), + duration: int.parse(episode['duration'] as String? ?? '0'), + position: int.parse(episode['position'] as String? ?? '0'), + downloadPercentage: int.parse(episode['downloadPercentage'] as String? ?? '0'), + played: episode['played'] == 'true' ? true : false, + chaptersUrl: episode['chaptersUrl'] as String?, + chapters: chapters, + transcriptUrls: transcriptUrls, + persons: persons, + transcriptId: episode['tid'] == null ? 0 : episode['tid'] as int?, + lastUpdated: episode['lastUpdated'] == null || episode['lastUpdated'] == 'null' + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(int.parse(episode['lastUpdated'] as String)), + ); + } + + static DownloadState _determineState(int? index) { + switch (index) { + case 0: + return DownloadState.none; + case 1: + return DownloadState.queued; + case 2: + return DownloadState.downloading; + case 3: + return DownloadState.failed; + case 4: + return DownloadState.cancelled; + case 5: + return DownloadState.paused; + case 6: + return DownloadState.downloaded; + } + + return DownloadState.none; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is Episode && + runtimeType == other.runtimeType && + guid == other.guid && + pguid == other.pguid && + downloadTaskId == other.downloadTaskId && + filepath == other.filepath && + filename == other.filename && + downloadState == other.downloadState && + podcast == other.podcast && + title == other.title && + description == other.description && + content == other.content && + link == other.link && + imageUrl == other.imageUrl && + thumbImageUrl == other.thumbImageUrl && + publicationDate?.millisecondsSinceEpoch == other.publicationDate?.millisecondsSinceEpoch && + contentUrl == other.contentUrl && + author == other.author && + season == other.season && + episode == other.episode && + duration == other.duration && + position == other.position && + downloadPercentage == other.downloadPercentage && + played == other.played && + chaptersUrl == other.chaptersUrl && + transcriptId == other.transcriptId && + listEquals(persons, other.persons) && + listEquals(chapters, other.chapters); + } + + @override + int get hashCode => + id.hashCode ^ + guid.hashCode ^ + pguid.hashCode ^ + downloadTaskId.hashCode ^ + filepath.hashCode ^ + filename.hashCode ^ + downloadState.hashCode ^ + podcast.hashCode ^ + title.hashCode ^ + description.hashCode ^ + content.hashCode ^ + link.hashCode ^ + imageUrl.hashCode ^ + thumbImageUrl.hashCode ^ + publicationDate.hashCode ^ + contentUrl.hashCode ^ + author.hashCode ^ + season.hashCode ^ + episode.hashCode ^ + duration.hashCode ^ + position.hashCode ^ + downloadPercentage.hashCode ^ + played.hashCode ^ + chaptersUrl.hashCode ^ + chapters.hashCode ^ + transcriptId.hashCode ^ + lastUpdated.hashCode; + + @override + String toString() { + return 'Episode{id: $id, guid: $guid, pguid: $pguid, filepath: $filepath, title: $title, contentUrl: $contentUrl, episode: $episode, duration: $duration, position: $position, downloadPercentage: $downloadPercentage, played: $played, queued: $queued}'; + } + + bool get downloaded => downloadPercentage == 100; + + Duration get timeRemaining { + if (position > 0 && duration > 0) { + var currentPosition = Duration(milliseconds: position); + + var tr = duration - currentPosition.inSeconds; + + return Duration(seconds: tr); + } + + return const Duration(seconds: 0); + } + + double get percentagePlayed { + if (position > 0 && duration > 0) { + var pc = (position / (duration * 1000)) * 100; + + if (pc > 100.0) { + pc = 100.0; + } + + return pc; + } + + return 0.0; + } + + String? get descriptionText { + if (_descriptionText == null || _descriptionText!.isEmpty) { + if (description == null || description!.isEmpty) { + _descriptionText = ''; + } else { + // Replace break tags with space character for readability + var formattedDescription = description!.replaceAll(RegExp(r'(
)+'), ' '); + _descriptionText = parseFragment(formattedDescription).text; + } + } + + return _descriptionText; + } + + bool get hasChapters => (chaptersUrl != null && chaptersUrl!.isNotEmpty) || chapters.isNotEmpty; + + bool get hasTranscripts => transcriptUrls.isNotEmpty; + + bool get chaptersAreLoaded => chaptersLoading == false && chapters.isNotEmpty; + + bool get chaptersAreNotLoaded => chaptersLoading == true && chapters.isEmpty; + + String? get positionalImageUrl { + if (currentChapter != null && currentChapter!.imageUrl != null && currentChapter!.imageUrl!.isNotEmpty) { + return currentChapter!.imageUrl; + } + + return imageUrl; + } +} diff --git a/PinePods-0.8.2/mobile/lib/entities/feed.dart b/PinePods-0.8.2/mobile/lib/entities/feed.dart new file mode 100644 index 0000000..ad35e0c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/feed.dart @@ -0,0 +1,39 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/podcast.dart'; + +/// This class is used when loading a [Podcast] feed. +/// +/// The key information is contained within the [Podcast] instance, but as the +/// iTunes API also returns large and thumbnail artwork within its search results +/// this class also contains properties to represent those. +class Feed { + /// The podcast to load + final Podcast podcast; + + /// The full-size artwork for the podcast. + String? imageUrl; + + /// The thumbnail artwork for the podcast, + String? thumbImageUrl; + + /// If true the podcast is loaded regardless of if it's currently cached. + bool refresh; + + /// If true, will also perform an additional background refresh. + bool backgroundFresh; + + /// If true any error can be ignored. + bool silently; + + Feed({ + required this.podcast, + this.imageUrl, + this.thumbImageUrl, + this.refresh = false, + this.backgroundFresh = false, + this.silently = false, + }); +} diff --git a/PinePods-0.8.2/mobile/lib/entities/funding.dart b/PinePods-0.8.2/mobile/lib/entities/funding.dart new file mode 100644 index 0000000..a451c72 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/funding.dart @@ -0,0 +1,35 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/core/extensions.dart'; + +/// part of a [Podcast]. +/// +/// Part of the [podcast namespace](https://github.com/Podcastindex-org/podcast-namespace) +class Funding { + /// The URL to the funding/donation/information page. + final String url; + + /// The label for the link which will be presented to the user. + final String value; + + Funding({ + required String url, + required this.value, + }) : url = url.forceHttps; + + Map toMap() { + return { + 'url': url, + 'value': value, + }; + } + + static Funding fromMap(Map chapter) { + return Funding( + url: chapter['url'] as String, + value: chapter['value'] as String, + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/entities/home_data.dart b/PinePods-0.8.2/mobile/lib/entities/home_data.dart new file mode 100644 index 0000000..452047f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/home_data.dart @@ -0,0 +1,238 @@ +// lib/entities/home_data.dart + +class HomePodcast { + final int podcastId; + final String podcastName; + final int? podcastIndexId; + final String? artworkUrl; + final String? author; + final String? categories; + final String? description; + final int? episodeCount; + final String? feedUrl; + final String? websiteUrl; + final bool? explicit; + final bool isYoutube; + final int playCount; + final int? totalListenTime; + + HomePodcast({ + required this.podcastId, + required this.podcastName, + this.podcastIndexId, + this.artworkUrl, + this.author, + this.categories, + this.description, + this.episodeCount, + this.feedUrl, + this.websiteUrl, + this.explicit, + required this.isYoutube, + required this.playCount, + this.totalListenTime, + }); + + factory HomePodcast.fromJson(Map json) { + return HomePodcast( + podcastId: json['podcastid'] ?? 0, + podcastName: json['podcastname'] ?? '', + podcastIndexId: json['podcastindexid'], + artworkUrl: json['artworkurl'], + author: json['author'], + categories: _parseCategories(json['categories']), + description: json['description'], + episodeCount: json['episodecount'], + feedUrl: json['feedurl'], + websiteUrl: json['websiteurl'], + explicit: json['explicit'], + isYoutube: json['is_youtube'] ?? false, + playCount: json['play_count'] ?? 0, + totalListenTime: json['total_listen_time'], + ); + } + + /// Parse categories from either string or Map format + static String? _parseCategories(dynamic categories) { + if (categories == null) return null; + + if (categories is String) { + // Old format - return as is + return categories; + } else if (categories is Map) { + // New format - convert map values to comma-separated string + if (categories.isEmpty) return null; + return categories.values.join(', '); + } + + return null; + } +} + +class HomeEpisode { + final int episodeId; + final int podcastId; + final String episodeTitle; + final String episodeDescription; + final String episodeUrl; + final String episodeArtwork; + final String episodePubDate; + final int episodeDuration; + final bool completed; + final String podcastName; + final bool isYoutube; + final int? listenDuration; + final bool saved; + final bool queued; + final bool downloaded; + + HomeEpisode({ + required this.episodeId, + required this.podcastId, + required this.episodeTitle, + required this.episodeDescription, + required this.episodeUrl, + required this.episodeArtwork, + required this.episodePubDate, + required this.episodeDuration, + required this.completed, + required this.podcastName, + required this.isYoutube, + this.listenDuration, + this.saved = false, + this.queued = false, + this.downloaded = false, + }); + + factory HomeEpisode.fromJson(Map json) { + return HomeEpisode( + episodeId: json['episodeid'] ?? 0, + podcastId: json['podcastid'] ?? 0, + episodeTitle: json['episodetitle'] ?? '', + episodeDescription: json['episodedescription'] ?? '', + episodeUrl: json['episodeurl'] ?? '', + episodeArtwork: json['episodeartwork'] ?? '', + episodePubDate: json['episodepubdate'] ?? '', + episodeDuration: json['episodeduration'] ?? 0, + completed: json['completed'] ?? false, + podcastName: json['podcastname'] ?? '', + isYoutube: json['is_youtube'] ?? false, + listenDuration: json['listenduration'], + saved: json['saved'] ?? false, + queued: json['queued'] ?? false, + downloaded: json['downloaded'] ?? false, + ); + } + + /// Format duration in seconds to MM:SS or HH:MM:SS format + String get formattedDuration { + if (episodeDuration <= 0) return '--:--'; + + final hours = episodeDuration ~/ 3600; + final minutes = (episodeDuration % 3600) ~/ 60; + final seconds = episodeDuration % 60; + + if (hours > 0) { + return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + } + + /// Format listen duration if available + String? get formattedListenDuration { + if (listenDuration == null || listenDuration! <= 0) return null; + + final duration = listenDuration!; + final hours = duration ~/ 3600; + final minutes = (duration % 3600) ~/ 60; + final seconds = duration % 60; + + if (hours > 0) { + return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + } + + /// Calculate progress percentage for progress bar + double get progressPercentage { + if (episodeDuration <= 0 || listenDuration == null) return 0.0; + return (listenDuration! / episodeDuration) * 100.0; + } +} + +class HomeOverview { + final List recentEpisodes; + final List inProgressEpisodes; + final List topPodcasts; + final int savedCount; + final int downloadedCount; + final int queueCount; + + HomeOverview({ + required this.recentEpisodes, + required this.inProgressEpisodes, + required this.topPodcasts, + required this.savedCount, + required this.downloadedCount, + required this.queueCount, + }); + + factory HomeOverview.fromJson(Map json) { + return HomeOverview( + recentEpisodes: (json['recent_episodes'] as List? ?? []) + .map((e) => HomeEpisode.fromJson(e)) + .toList(), + inProgressEpisodes: (json['in_progress_episodes'] as List? ?? []) + .map((e) => HomeEpisode.fromJson(e)) + .toList(), + topPodcasts: (json['top_podcasts'] as List? ?? []) + .map((p) => HomePodcast.fromJson(p)) + .toList(), + savedCount: json['saved_count'] ?? 0, + downloadedCount: json['downloaded_count'] ?? 0, + queueCount: json['queue_count'] ?? 0, + ); + } +} + +class Playlist { + final int playlistId; + final String name; + final String? description; + final String iconName; + final int? episodeCount; + + Playlist({ + required this.playlistId, + required this.name, + this.description, + required this.iconName, + this.episodeCount, + }); + + factory Playlist.fromJson(Map json) { + return Playlist( + playlistId: json['playlist_id'] ?? 0, + name: json['name'] ?? '', + description: json['description'], + iconName: json['icon_name'] ?? 'ph-music-notes', + episodeCount: json['episode_count'], + ); + } +} + +class PlaylistResponse { + final List playlists; + + PlaylistResponse({required this.playlists}); + + factory PlaylistResponse.fromJson(Map json) { + return PlaylistResponse( + playlists: (json['playlists'] as List? ?? []) + .map((p) => Playlist.fromJson(p)) + .toList(), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/entities/persistable.dart b/PinePods-0.8.2/mobile/lib/entities/persistable.dart new file mode 100644 index 0000000..c93bcf0 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/persistable.dart @@ -0,0 +1,81 @@ +// Copyright 2020 Ben Hills. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +enum LastState { none, completed, stopped, paused } + +/// This class is used to persist information about the currently playing episode to disk. +/// +/// This allows the background audio service to persist state (whilst the UI is not visible) +/// and for the episode play and position details to be restored when the UI becomes visible +/// again - either when bringing it to the foreground or upon next start. +class Persistable { + /// The Podcast GUID. + String pguid; + + /// The episode ID (provided by the DB layer). + int episodeId; + + /// The current position in seconds; + int position; + + /// The current playback state. + LastState state; + + /// Date & time episode was last updated. + DateTime? lastUpdated; + + Persistable({ + required this.pguid, + required this.episodeId, + required this.position, + required this.state, + this.lastUpdated, + }); + + Persistable.empty() + : pguid = '', + episodeId = 0, + position = 0, + state = LastState.none, + lastUpdated = DateTime.now(); + + Map toMap() { + return { + 'pguid': pguid, + 'episodeId': episodeId, + 'position': position, + 'state': state.toString(), + 'lastUpdated': lastUpdated == null ? DateTime.now().millisecondsSinceEpoch : lastUpdated!.millisecondsSinceEpoch, + }; + } + + static Persistable fromMap(Map persistable) { + var stateString = persistable['state'] as String?; + var state = LastState.none; + + if (stateString != null) { + switch (stateString) { + case 'LastState.completed': + state = LastState.completed; + break; + case 'LastState.stopped': + state = LastState.stopped; + break; + case 'LastState.paused': + state = LastState.paused; + break; + } + } + + var lastUpdated = persistable['lastUpdated'] as int?; + + return Persistable( + pguid: persistable['pguid'] as String, + episodeId: persistable['episodeId'] as int, + position: persistable['position'] as int, + state: state, + lastUpdated: lastUpdated == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(lastUpdated), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/entities/person.dart b/PinePods-0.8.2/mobile/lib/entities/person.dart new file mode 100644 index 0000000..66a5f8d --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/person.dart @@ -0,0 +1,64 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/core/extensions.dart'; + +/// This class represents a person of interest to the podcast. +/// +/// It is primarily intended to identify people like hosts, co-hosts and guests. +class Person { + final String name; + final String role; + final String group; + final String? image; + final String? link; + + Person({ + required this.name, + this.role = '', + this.group = '', + String? image = '', + String? link = '', + }) : image = image?.forceHttps, + link = link?.forceHttps; + + Map toMap() { + return { + 'name': name, + 'role': role, + 'group': group, + 'image': image, + 'link': link, + }; + } + + static Person fromMap(Map chapter) { + return Person( + name: chapter['name'] as String? ?? '', + role: chapter['role'] as String? ?? '', + group: chapter['group'] as String? ?? '', + image: chapter['image'] as String? ?? '', + link: chapter['link'] as String? ?? '', + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Person && + runtimeType == other.runtimeType && + name == other.name && + role == other.role && + group == other.group && + image == other.image && + link == other.link; + + @override + int get hashCode => name.hashCode ^ role.hashCode ^ group.hashCode ^ image.hashCode ^ link.hashCode; + + @override + String toString() { + return 'Person{name: $name, role: $role, group: $group, image: $image, link: $link}'; + } +} diff --git a/PinePods-0.8.2/mobile/lib/entities/pinepods_episode.dart b/PinePods-0.8.2/mobile/lib/entities/pinepods_episode.dart new file mode 100644 index 0000000..b6c351c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/pinepods_episode.dart @@ -0,0 +1,142 @@ +class PinepodsEpisode { + final String podcastName; + final String episodeTitle; + final String episodePubDate; + final String episodeDescription; + final String episodeArtwork; + final String episodeUrl; + final int episodeDuration; + final int? listenDuration; + final int episodeId; + final bool completed; + final bool saved; + final bool queued; + final bool downloaded; + final bool isYoutube; + final int? podcastId; + + PinepodsEpisode({ + required this.podcastName, + required this.episodeTitle, + required this.episodePubDate, + required this.episodeDescription, + required this.episodeArtwork, + required this.episodeUrl, + required this.episodeDuration, + this.listenDuration, + required this.episodeId, + required this.completed, + required this.saved, + required this.queued, + required this.downloaded, + required this.isYoutube, + this.podcastId, + }); + + factory PinepodsEpisode.fromJson(Map json) { + return PinepodsEpisode( + podcastName: json['Podcastname'] ?? json['podcastname'] ?? '', + episodeTitle: json['Episodetitle'] ?? json['episodetitle'] ?? '', + episodePubDate: json['Episodepubdate'] ?? json['episodepubdate'] ?? '', + episodeDescription: json['Episodedescription'] ?? json['episodedescription'] ?? '', + episodeArtwork: json['Episodeartwork'] ?? json['episodeartwork'] ?? '', + episodeUrl: json['Episodeurl'] ?? json['episodeurl'] ?? '', + episodeDuration: json['Episodeduration'] ?? json['episodeduration'] ?? 0, + listenDuration: json['Listenduration'] ?? json['listenduration'], + episodeId: json['Episodeid'] ?? json['episodeid'] ?? 0, + completed: json['Completed'] ?? json['completed'] ?? false, + saved: json['Saved'] ?? json['saved'] ?? false, + queued: json['Queued'] ?? json['queued'] ?? false, + downloaded: json['Downloaded'] ?? json['downloaded'] ?? false, + isYoutube: json['Is_youtube'] ?? json['is_youtube'] ?? false, + podcastId: json['Podcastid'] ?? json['podcastid'], + ); + } + + Map toJson() { + return { + 'podcastname': podcastName, + 'episodetitle': episodeTitle, + 'episodepubdate': episodePubDate, + 'episodedescription': episodeDescription, + 'episodeartwork': episodeArtwork, + 'episodeurl': episodeUrl, + 'episodeduration': episodeDuration, + 'listenduration': listenDuration, + 'episodeid': episodeId, + 'completed': completed, + 'saved': saved, + 'queued': queued, + 'downloaded': downloaded, + 'is_youtube': isYoutube, + 'podcastid': podcastId, + }; + } + + /// Format duration from seconds to MM:SS or HH:MM:SS + String get formattedDuration { + if (episodeDuration <= 0) return '0:00'; + + final hours = episodeDuration ~/ 3600; + final minutes = (episodeDuration % 3600) ~/ 60; + final seconds = episodeDuration % 60; + + if (hours > 0) { + return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + } + + /// Get progress percentage (0-100) + double get progressPercentage { + if (episodeDuration <= 0 || listenDuration == null) return 0.0; + return (listenDuration! / episodeDuration * 100).clamp(0.0, 100.0); + } + + /// Check if episode has been started (has some listen duration) + bool get isStarted { + return listenDuration != null && listenDuration! > 0; + } + + /// Format listen duration from seconds to MM:SS or HH:MM:SS + String get formattedListenDuration { + if (listenDuration == null || listenDuration! <= 0) return '0:00'; + + final duration = listenDuration!; + final hours = duration ~/ 3600; + final minutes = (duration % 3600) ~/ 60; + final seconds = duration % 60; + + if (hours > 0) { + return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + } + + /// Format the publish date to a more readable format + String get formattedPubDate { + try { + final date = DateTime.parse(episodePubDate); + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + return 'Today'; + } else if (difference.inDays == 1) { + return 'Yesterday'; + } else if (difference.inDays < 7) { + return '${difference.inDays} days ago'; + } else if (difference.inDays < 30) { + final weeks = (difference.inDays / 7).floor(); + return weeks == 1 ? '1 week ago' : '$weeks weeks ago'; + } else { + final months = (difference.inDays / 30).floor(); + return months == 1 ? '1 month ago' : '$months months ago'; + } + } catch (e) { + return episodePubDate; + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/entities/pinepods_search.dart b/PinePods-0.8.2/mobile/lib/entities/pinepods_search.dart new file mode 100644 index 0000000..9ec8052 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/pinepods_search.dart @@ -0,0 +1,359 @@ +// lib/entities/pinepods_search.dart + +class PinepodsSearchResult { + final String? status; + final int? resultCount; + final List? feeds; + final List? results; + + PinepodsSearchResult({ + this.status, + this.resultCount, + this.feeds, + this.results, + }); + + factory PinepodsSearchResult.fromJson(Map json) { + return PinepodsSearchResult( + status: json['status'] as String?, + resultCount: json['resultCount'] as int?, + feeds: json['feeds'] != null + ? (json['feeds'] as List) + .map((item) => PinepodsPodcast.fromJson(item as Map)) + .toList() + : null, + results: json['results'] != null + ? (json['results'] as List) + .map((item) => PinepodsITunesPodcast.fromJson(item as Map)) + .toList() + : null, + ); + } + + Map toJson() { + return { + 'status': status, + 'resultCount': resultCount, + 'feeds': feeds?.map((item) => item.toJson()).toList(), + 'results': results?.map((item) => item.toJson()).toList(), + }; + } + + List getUnifiedPodcasts() { + final List unified = []; + + // Add PodcastIndex results + if (feeds != null) { + unified.addAll(feeds!.map((podcast) => UnifiedPinepodsPodcast.fromPodcast(podcast))); + } + + // Add iTunes results + if (results != null) { + unified.addAll(results!.map((podcast) => UnifiedPinepodsPodcast.fromITunesPodcast(podcast))); + } + + return unified; + } +} + +class PinepodsPodcast { + final int id; + final String title; + final String url; + final String originalUrl; + final String link; + final String description; + final String author; + final String ownerName; + final String image; + final String artwork; + final int lastUpdateTime; + final Map? categories; + final bool explicit; + final int episodeCount; + + PinepodsPodcast({ + required this.id, + required this.title, + required this.url, + required this.originalUrl, + required this.link, + required this.description, + required this.author, + required this.ownerName, + required this.image, + required this.artwork, + required this.lastUpdateTime, + this.categories, + required this.explicit, + required this.episodeCount, + }); + + factory PinepodsPodcast.fromJson(Map json) { + return PinepodsPodcast( + id: json['id'] as int, + title: json['title'] as String? ?? '', + url: json['url'] as String? ?? '', + originalUrl: json['originalUrl'] as String? ?? '', + link: json['link'] as String? ?? '', + description: json['description'] as String? ?? '', + author: json['author'] as String? ?? '', + ownerName: json['ownerName'] as String? ?? '', + image: json['image'] as String? ?? '', + artwork: json['artwork'] as String? ?? '', + lastUpdateTime: json['lastUpdateTime'] as int? ?? 0, + categories: json['categories'] != null + ? Map.from(json['categories'] as Map) + : null, + explicit: json['explicit'] as bool? ?? false, + episodeCount: json['episodeCount'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'url': url, + 'originalUrl': originalUrl, + 'link': link, + 'description': description, + 'author': author, + 'ownerName': ownerName, + 'image': image, + 'artwork': artwork, + 'lastUpdateTime': lastUpdateTime, + 'categories': categories, + 'explicit': explicit, + 'episodeCount': episodeCount, + }; + } +} + +class PinepodsITunesPodcast { + final String wrapperType; + final String kind; + final int collectionId; + final int trackId; + final String artistName; + final String trackName; + final String collectionViewUrl; + final String feedUrl; + final String artworkUrl100; + final String releaseDate; + final List genres; + final String collectionExplicitness; + final int? trackCount; + + PinepodsITunesPodcast({ + required this.wrapperType, + required this.kind, + required this.collectionId, + required this.trackId, + required this.artistName, + required this.trackName, + required this.collectionViewUrl, + required this.feedUrl, + required this.artworkUrl100, + required this.releaseDate, + required this.genres, + required this.collectionExplicitness, + this.trackCount, + }); + + factory PinepodsITunesPodcast.fromJson(Map json) { + return PinepodsITunesPodcast( + wrapperType: json['wrapperType'] as String? ?? '', + kind: json['kind'] as String? ?? '', + collectionId: json['collectionId'] as int? ?? 0, + trackId: json['trackId'] as int? ?? 0, + artistName: json['artistName'] as String? ?? '', + trackName: json['trackName'] as String? ?? '', + collectionViewUrl: json['collectionViewUrl'] as String? ?? '', + feedUrl: json['feedUrl'] as String? ?? '', + artworkUrl100: json['artworkUrl100'] as String? ?? '', + releaseDate: json['releaseDate'] as String? ?? '', + genres: json['genres'] != null + ? List.from(json['genres'] as List) + : [], + collectionExplicitness: json['collectionExplicitness'] as String? ?? '', + trackCount: json['trackCount'] as int?, + ); + } + + Map toJson() { + return { + 'wrapperType': wrapperType, + 'kind': kind, + 'collectionId': collectionId, + 'trackId': trackId, + 'artistName': artistName, + 'trackName': trackName, + 'collectionViewUrl': collectionViewUrl, + 'feedUrl': feedUrl, + 'artworkUrl100': artworkUrl100, + 'releaseDate': releaseDate, + 'genres': genres, + 'collectionExplicitness': collectionExplicitness, + 'trackCount': trackCount, + }; + } +} + +class UnifiedPinepodsPodcast { + final int id; + final int indexId; + final String title; + final String url; + final String originalUrl; + final String link; + final String description; + final String author; + final String ownerName; + final String image; + final String artwork; + final int lastUpdateTime; + final Map? categories; + final bool explicit; + final int episodeCount; + + UnifiedPinepodsPodcast({ + required this.id, + required this.indexId, + required this.title, + required this.url, + required this.originalUrl, + required this.link, + required this.description, + required this.author, + required this.ownerName, + required this.image, + required this.artwork, + required this.lastUpdateTime, + this.categories, + required this.explicit, + required this.episodeCount, + }); + + factory UnifiedPinepodsPodcast.fromJson(Map json) { + return UnifiedPinepodsPodcast( + id: json['id'] as int? ?? 0, + indexId: json['indexId'] as int? ?? 0, + title: json['title'] as String? ?? '', + url: json['url'] as String? ?? '', + originalUrl: json['originalUrl'] as String? ?? '', + link: json['link'] as String? ?? '', + description: json['description'] as String? ?? '', + author: json['author'] as String? ?? '', + ownerName: json['ownerName'] as String? ?? '', + image: json['image'] as String? ?? '', + artwork: json['artwork'] as String? ?? '', + lastUpdateTime: json['lastUpdateTime'] as int? ?? 0, + categories: json['categories'] != null + ? Map.from(json['categories'] as Map) + : null, + explicit: json['explicit'] as bool? ?? false, + episodeCount: json['episodeCount'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'indexId': indexId, + 'title': title, + 'url': url, + 'originalUrl': originalUrl, + 'link': link, + 'description': description, + 'author': author, + 'ownerName': ownerName, + 'image': image, + 'artwork': artwork, + 'lastUpdateTime': lastUpdateTime, + 'categories': categories, + 'explicit': explicit, + 'episodeCount': episodeCount, + }; + } + + factory UnifiedPinepodsPodcast.fromPodcast(PinepodsPodcast podcast) { + return UnifiedPinepodsPodcast( + id: 0, // Internal database ID - will be fetched when needed + indexId: podcast.id, // Podcast index ID + title: podcast.title, + url: podcast.url, + originalUrl: podcast.originalUrl, + author: podcast.author, + ownerName: podcast.ownerName, + description: podcast.description, + image: podcast.image, + link: podcast.link, + artwork: podcast.artwork, + lastUpdateTime: podcast.lastUpdateTime, + categories: podcast.categories, + explicit: podcast.explicit, + episodeCount: podcast.episodeCount, + ); + } + + factory UnifiedPinepodsPodcast.fromITunesPodcast(PinepodsITunesPodcast podcast) { + // Convert genres list to map + final Map genreMap = {}; + for (int i = 0; i < podcast.genres.length; i++) { + genreMap[i.toString()] = podcast.genres[i]; + } + + // Parse release date to timestamp + int timestamp = 0; + try { + final dateTime = DateTime.parse(podcast.releaseDate); + timestamp = dateTime.millisecondsSinceEpoch ~/ 1000; + } catch (e) { + // Default to 0 if parsing fails + } + + return UnifiedPinepodsPodcast( + id: podcast.trackId, + indexId: 0, + title: podcast.trackName, + url: podcast.feedUrl, + originalUrl: podcast.feedUrl, + author: podcast.artistName, + ownerName: podcast.artistName, + description: 'Descriptions not provided by iTunes', + image: podcast.artworkUrl100, + link: podcast.collectionViewUrl, + artwork: podcast.artworkUrl100, + lastUpdateTime: timestamp, + categories: genreMap, + explicit: podcast.collectionExplicitness == 'explicit', + episodeCount: podcast.trackCount ?? 0, + ); + } +} + +enum SearchProvider { + podcastIndex, + itunes, +} + +extension SearchProviderExtension on SearchProvider { + String get name { + switch (this) { + case SearchProvider.podcastIndex: + return 'Podcast Index'; + case SearchProvider.itunes: + return 'iTunes'; + } + } + + String get value { + switch (this) { + case SearchProvider.podcastIndex: + return 'podcast_index'; + case SearchProvider.itunes: + return 'itunes'; + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/entities/podcast.dart b/PinePods-0.8.2/mobile/lib/entities/podcast.dart new file mode 100644 index 0000000..acbda18 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/podcast.dart @@ -0,0 +1,248 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/core/extensions.dart'; +import 'package:pinepods_mobile/entities/funding.dart'; +import 'package:pinepods_mobile/entities/person.dart'; +import 'package:podcast_search/podcast_search.dart' as search; + +import 'episode.dart'; + +enum PodcastEpisodeFilter { + none(id: 0), + started(id: 1), + played(id: 2), + notPlayed(id: 3); + + const PodcastEpisodeFilter({required this.id}); + + final int id; +} + +enum PodcastEpisodeSort { + none(id: 0), + latestFirst(id: 1), + earliestFirst(id: 2), + alphabeticalAscending(id: 3), + alphabeticalDescending(id: 4); + + const PodcastEpisodeSort({required this.id}); + + final int id; +} + +/// A class that represents an instance of a podcast. +/// +/// When persisted to disk this represents a podcast that is being followed. +class Podcast { + /// Database ID + int? id; + + /// Unique identifier for podcast. + final String? guid; + + /// The link to the podcast RSS feed. + final String url; + + /// RSS link URL. + final String? link; + + /// Podcast title. + final String title; + + /// Podcast description. Can be either plain text or HTML. + final String? description; + + /// URL to the full size artwork image. + final String? imageUrl; + + /// URL for thumbnail version of artwork image. Not contained within + /// the RSS but may be calculated or provided within search results. + final String? thumbImageUrl; + + /// Copyright owner of the podcast. + final String? copyright; + + /// Zero or more funding links. + final List? funding; + + PodcastEpisodeFilter filter; + + PodcastEpisodeSort sort; + + /// Date and time user subscribed to the podcast. + DateTime? subscribedDate; + + /// Date and time podcast was last updated/refreshed. + DateTime? _lastUpdated; + + /// One or more episodes for this podcast. + List episodes; + + final List? persons; + + bool newEpisodes; + bool updatedEpisodes = false; + + Podcast({ + required this.guid, + required String url, + required this.link, + required this.title, + this.id, + this.description, + String? imageUrl, + String? thumbImageUrl, + this.copyright, + this.subscribedDate, + this.funding, + this.filter = PodcastEpisodeFilter.none, + this.sort = PodcastEpisodeSort.none, + this.episodes = const [], + this.newEpisodes = false, + this.persons, + DateTime? lastUpdated, + }) : url = url.forceHttps, + imageUrl = imageUrl?.forceHttps, + thumbImageUrl = thumbImageUrl?.forceHttps { + _lastUpdated = lastUpdated; + } + + factory Podcast.fromUrl({required String url}) => Podcast( + url: url, + guid: '', + link: '', + title: '', + description: '', + thumbImageUrl: null, + imageUrl: null, + copyright: '', + funding: [], + persons: [], + ); + + factory Podcast.fromSearchResultItem(search.Item item) => Podcast( + guid: item.guid ?? '', + url: item.feedUrl ?? '', + link: item.feedUrl, + title: item.trackName!, + description: '', + imageUrl: item.bestArtworkUrl ?? item.artworkUrl, + thumbImageUrl: item.thumbnailArtworkUrl, + funding: const [], + copyright: item.artistName, + ); + + Map toMap() { + return { + 'guid': guid, + 'title': title, + 'copyright': copyright ?? '', + 'description': description ?? '', + 'url': url, + 'link': link ?? '', + 'imageUrl': imageUrl ?? '', + 'thumbImageUrl': thumbImageUrl ?? '', + 'subscribedDate': subscribedDate?.millisecondsSinceEpoch.toString() ?? '', + 'filter': filter.id, + 'sort': sort.id, + 'funding': (funding ?? []).map((funding) => funding.toMap()).toList(growable: false), + 'person': (persons ?? []).map((persons) => persons.toMap()).toList(growable: false), + 'lastUpdated': _lastUpdated?.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch, + }; + } + + static Podcast fromMap(int key, Map podcast) { + final sds = podcast['subscribedDate'] as String?; + final lus = podcast['lastUpdated'] as int?; + final funding = []; + final persons = []; + var filter = PodcastEpisodeFilter.none; + var sort = PodcastEpisodeSort.none; + + var sd = DateTime.now(); + var lastUpdated = DateTime(1971, 1, 1); + + if (sds != null && sds.isNotEmpty && int.tryParse(sds) != null) { + sd = DateTime.fromMillisecondsSinceEpoch(int.parse(sds)); + } + + if (lus != null) { + lastUpdated = DateTime.fromMillisecondsSinceEpoch(lus); + } + + if (podcast['funding'] != null) { + for (var chapter in (podcast['funding'] as List)) { + if (chapter is Map) { + funding.add(Funding.fromMap(chapter)); + } + } + } + + if (podcast['persons'] != null) { + for (var person in (podcast['persons'] as List)) { + if (person is Map) { + persons.add(Person.fromMap(person)); + } + } + } + + if (podcast['filter'] != null) { + var filterValue = (podcast['filter'] as int); + + filter = switch (filterValue) { + 1 => PodcastEpisodeFilter.started, + 2 => PodcastEpisodeFilter.played, + 3 => PodcastEpisodeFilter.notPlayed, + _ => PodcastEpisodeFilter.none, + }; + } + + if (podcast['sort'] != null) { + var sortValue = (podcast['sort'] as int); + + sort = switch (sortValue) { + 1 => PodcastEpisodeSort.latestFirst, + 2 => PodcastEpisodeSort.earliestFirst, + 3 => PodcastEpisodeSort.alphabeticalAscending, + 4 => PodcastEpisodeSort.alphabeticalDescending, + _ => PodcastEpisodeSort.none, + }; + } + + return Podcast( + id: key, + guid: podcast['guid'] as String, + link: podcast['link'] as String?, + title: podcast['title'] as String, + copyright: podcast['copyright'] as String?, + description: podcast['description'] as String?, + url: podcast['url'] as String, + imageUrl: podcast['imageUrl'] as String?, + thumbImageUrl: podcast['thumbImageUrl'] as String?, + filter: filter, + sort: sort, + funding: funding, + persons: persons, + subscribedDate: sd, + lastUpdated: lastUpdated, + ); + } + + bool get subscribed => id != null; + + DateTime get lastUpdated => _lastUpdated ?? DateTime(1970, 1, 1); + + set lastUpdated(DateTime value) { + _lastUpdated = value; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Podcast && runtimeType == other.runtimeType && guid == other.guid && url == other.url; + + @override + int get hashCode => guid.hashCode ^ url.hashCode; +} diff --git a/PinePods-0.8.2/mobile/lib/entities/queue.dart b/PinePods-0.8.2/mobile/lib/entities/queue.dart new file mode 100644 index 0000000..c7f814e --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/queue.dart @@ -0,0 +1,31 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// The current persistable queue. +class Queue { + List guids = []; + + Queue({ + required this.guids, + }); + + Map toMap() { + return { + 'q': guids, + }; + } + + static Queue fromMap(int key, Map guids) { + var g = guids['q'] as List?; + var result = []; + + if (g != null) { + result = g.map((dynamic e) => e.toString()).toList(); + } + + return Queue( + guids: result, + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/entities/search_providers.dart b/PinePods-0.8.2/mobile/lib/entities/search_providers.dart new file mode 100644 index 0000000..f34b092 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/search_providers.dart @@ -0,0 +1,16 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// PinePods can support multiple search providers. +/// +/// This class represents a provider. +class SearchProvider { + final String key; + final String name; + + SearchProvider({ + required this.key, + required this.name, + }); +} diff --git a/PinePods-0.8.2/mobile/lib/entities/sleep.dart b/PinePods-0.8.2/mobile/lib/entities/sleep.dart new file mode 100644 index 0000000..21630f3 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/sleep.dart @@ -0,0 +1,32 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +enum SleepType { + none, + time, + episode, +} + +final class Sleep { + final SleepType type; + final Duration duration; + late DateTime endTime; + + Sleep({ + required this.type, + this.duration = const Duration(milliseconds: 0), + }) { + endTime = DateTime.now().add(duration); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Sleep && runtimeType == other.runtimeType && type == other.type && duration == other.duration; + + @override + int get hashCode => type.hashCode ^ duration.hashCode; + + Duration get timeRemaining => endTime.difference(DateTime.now()); +} diff --git a/PinePods-0.8.2/mobile/lib/entities/transcript.dart b/PinePods-0.8.2/mobile/lib/entities/transcript.dart new file mode 100644 index 0000000..5ca85ff --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/transcript.dart @@ -0,0 +1,213 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/core/extensions.dart'; +import 'package:flutter/foundation.dart'; + +enum TranscriptFormat { + json, + subrip, + html, + unsupported, +} + +/// This class represents a Podcasting 2.0 transcript URL. +/// +/// [docs](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#transcript) +class TranscriptUrl { + final String url; + final TranscriptFormat type; + final String? language; + final String? rel; + final DateTime? lastUpdated; + + TranscriptUrl({ + required String url, + required this.type, + this.language = '', + this.rel = '', + this.lastUpdated, + }) : url = url.forceHttps; + + Map toMap() { + var t = 0; + + switch (type) { + case TranscriptFormat.subrip: + t = 0; + break; + case TranscriptFormat.json: + t = 1; + break; + case TranscriptFormat.html: + t = 2; + break; + case TranscriptFormat.unsupported: + t = 3; + break; + } + + return { + 'url': url, + 'type': t, + 'lang': language, + 'rel': rel, + 'lastUpdated': DateTime.now().millisecondsSinceEpoch, + }; + } + + static TranscriptUrl fromMap(Map transcript) { + var ts = transcript['type'] as int? ?? 2; + var t = TranscriptFormat.unsupported; + + switch (ts) { + case 0: + t = TranscriptFormat.subrip; + break; + case 1: + t = TranscriptFormat.json; + break; + case 2: + t = TranscriptFormat.html; + break; + case 3: + t = TranscriptFormat.unsupported; + break; + } + + return TranscriptUrl( + url: transcript['url'] as String, + language: transcript['lang'] as String?, + rel: transcript['rel'] as String?, + type: t, + lastUpdated: transcript['lastUpdated'] == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(transcript['lastUpdated'] as int), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TranscriptUrl && + runtimeType == other.runtimeType && + url == other.url && + type == other.type && + language == other.language && + rel == other.rel; + + @override + int get hashCode => url.hashCode ^ type.hashCode ^ language.hashCode ^ rel.hashCode; +} + +/// This class represents a Podcasting 2.0 transcript container. +/// [docs](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#transcript) +class Transcript { + int? id; + String? guid; + final List subtitles; + DateTime? lastUpdated; + bool filtered; + + Transcript({ + this.id, + this.guid, + this.subtitles = const [], + this.filtered = false, + this.lastUpdated, + }); + + Map toMap() { + return { + 'guid': guid, + 'subtitles': (subtitles).map((subtitle) => subtitle.toMap()).toList(growable: false), + 'lastUpdated': DateTime.now().millisecondsSinceEpoch, + }; + } + + static Transcript fromMap(int? key, Map transcript) { + var subtitles = []; + + if (transcript['subtitles'] != null) { + for (var subtitle in (transcript['subtitles'] as List)) { + if (subtitle is Map) { + subtitles.add(Subtitle.fromMap(subtitle)); + } + } + } + + return Transcript( + id: key, + guid: transcript['guid'] as String? ?? '', + subtitles: subtitles, + lastUpdated: transcript['lastUpdated'] == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(transcript['lastUpdated'] as int), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Transcript && + runtimeType == other.runtimeType && + guid == other.guid && + listEquals(subtitles, other.subtitles); + + @override + int get hashCode => guid.hashCode ^ subtitles.hashCode; + + bool get transcriptAvailable => (subtitles.isNotEmpty || filtered); +} + +/// Represents an individual line within a transcript. +class Subtitle { + final int index; + final Duration start; + Duration? end; + String? data; + String speaker; + + Subtitle({ + required this.index, + required this.start, + this.end, + this.data, + this.speaker = '', + }); + + Map toMap() { + return { + 'i': index, + 'start': start.inMilliseconds, + 'end': end!.inMilliseconds, + 'speaker': speaker, + 'data': data, + }; + } + + static Subtitle fromMap(Map subtitle) { + return Subtitle( + index: subtitle['i'] as int? ?? 0, + start: Duration(milliseconds: subtitle['start'] as int? ?? 0), + end: Duration(milliseconds: subtitle['end'] as int? ?? 0), + speaker: subtitle['speaker'] as String? ?? '', + data: subtitle['data'] as String? ?? '', + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Subtitle && + runtimeType == other.runtimeType && + index == other.index && + start == other.start && + end == other.end && + data == other.data && + speaker == other.speaker; + + @override + int get hashCode => index.hashCode ^ start.hashCode ^ end.hashCode ^ data.hashCode ^ speaker.hashCode; +} diff --git a/PinePods-0.8.2/mobile/lib/entities/user_stats.dart b/PinePods-0.8.2/mobile/lib/entities/user_stats.dart new file mode 100644 index 0000000..914abb9 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/entities/user_stats.dart @@ -0,0 +1,91 @@ +class UserStats { + final String userCreated; + final int podcastsPlayed; + final int timeListened; + final int podcastsAdded; + final int episodesSaved; + final int episodesDownloaded; + final String gpodderUrl; + final String podSyncType; + + UserStats({ + required this.userCreated, + required this.podcastsPlayed, + required this.timeListened, + required this.podcastsAdded, + required this.episodesSaved, + required this.episodesDownloaded, + required this.gpodderUrl, + required this.podSyncType, + }); + + factory UserStats.fromJson(Map json) { + return UserStats( + userCreated: json['UserCreated'] ?? '', + podcastsPlayed: json['PodcastsPlayed'] ?? 0, + timeListened: json['TimeListened'] ?? 0, + podcastsAdded: json['PodcastsAdded'] ?? 0, + episodesSaved: json['EpisodesSaved'] ?? 0, + episodesDownloaded: json['EpisodesDownloaded'] ?? 0, + gpodderUrl: json['GpodderUrl'] ?? '', + podSyncType: json['Pod_Sync_Type'] ?? '', + ); + } + + Map toJson() { + return { + 'UserCreated': userCreated, + 'PodcastsPlayed': podcastsPlayed, + 'TimeListened': timeListened, + 'PodcastsAdded': podcastsAdded, + 'EpisodesSaved': episodesSaved, + 'EpisodesDownloaded': episodesDownloaded, + 'GpodderUrl': gpodderUrl, + 'Pod_Sync_Type': podSyncType, + }; + } + + // Format time listened from minutes to human readable + String get formattedTimeListened { + if (timeListened <= 0) return '0 minutes'; + + final hours = timeListened ~/ 60; + final minutes = timeListened % 60; + + if (hours == 0) { + return '$minutes minute${minutes != 1 ? 's' : ''}'; + } else if (minutes == 0) { + return '$hours hour${hours != 1 ? 's' : ''}'; + } else { + return '$hours hour${hours != 1 ? 's' : ''} $minutes minute${minutes != 1 ? 's' : ''}'; + } + } + + // Format user created date + String get formattedUserCreated { + try { + final date = DateTime.parse(userCreated); + return '${date.day}/${date.month}/${date.year}'; + } catch (e) { + return userCreated; + } + } + + // Get sync status description + String get syncStatusDescription { + switch (podSyncType.toLowerCase()) { + case 'none': + return 'Not Syncing'; + case 'gpodder': + if (gpodderUrl == 'http://localhost:8042') { + return 'Internal gpodder'; + } else { + return 'External gpodder'; + } + case 'nextcloud': + return 'Nextcloud'; + default: + return 'Unknown sync type'; + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/l10n/L.dart b/PinePods-0.8.2/mobile/lib/l10n/L.dart new file mode 100644 index 0000000..2f97129 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/L.dart @@ -0,0 +1,1780 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; + +import 'messages_all.dart'; + +class L { + L(this.localeName, this.overrides); + + static Future load( + Locale locale, + Map> overrides, + ) { + final name = locale.countryCode?.isEmpty ?? true + ? locale.languageCode + : locale.toString(); + final localeName = Intl.canonicalizedLocale(name); + + return initializeMessages(localeName).then((_) { + return L(localeName, overrides); + }); + } + + static L? of(BuildContext context) { + return Localizations.of(context, L); + } + + final String localeName; + Map> overrides; + + /// Message definitions start here + String? message(String name) { + if (overrides == null || + overrides.isEmpty || + !overrides.containsKey(name)) { + return null; + } else { + return overrides[name]![localeName] ?? + 'Missing translation for $name and locale $localeName'; + } + } + + /// General + String get app_title { + return message('app_title') ?? + Intl.message( + 'Pinepods Podcast Client', + name: 'app_title', + desc: 'Full title for the application', + locale: localeName, + ); + } + + String get app_title_short { + return message('app_title_short') ?? + Intl.message( + 'Pinepods', + name: 'app_title_short', + desc: 'Title for the application', + locale: localeName, + ); + } + + String get library { + return message('library') ?? + Intl.message( + 'Library', + name: 'library', + desc: 'Library tab label', + locale: localeName, + ); + } + + String get discover { + return message('discover') ?? + Intl.message( + 'Discover', + name: 'discover', + desc: 'Discover tab label', + locale: localeName, + ); + } + + String get downloads { + return message('downloads') ?? + Intl.message( + 'Downloads', + name: 'downloads', + desc: 'Downloads tab label', + locale: localeName, + ); + } + + /// Podcasts + String get subscribe_button_label { + return message('subscribe_button_label') ?? + Intl.message( + 'Follow', + name: 'subscribe_button_label', + desc: 'Subscribe button label', + locale: localeName, + ); + } + + String get unsubscribe_button_label { + return message('unsubscribe_button_label') ?? + Intl.message( + 'Unfollow', + name: 'unsubscribe_button_label', + desc: 'Unsubscribe button label', + locale: localeName, + ); + } + + String get cancel_button_label { + return message('cancel_button_label') ?? + Intl.message( + 'Cancel', + name: 'cancel_button_label', + desc: 'Cancel button label', + locale: localeName, + ); + } + + String get ok_button_label { + return message('ok_button_label') ?? + Intl.message( + 'OK', + name: 'ok_button_label', + desc: 'OK button label', + locale: localeName, + ); + } + + String get subscribe_label { + return message('subscribe_label') ?? + Intl.message( + 'Follow', + name: 'subscribe_label', + desc: 'Subscribe label', + locale: localeName, + ); + } + + String get unsubscribe_label { + return message('unsubscribe_label') ?? + Intl.message( + 'Unfollow', + name: 'unsubscribe_label', + desc: 'Unsubscribe label', + locale: localeName, + ); + } + + String get unsubscribe_message { + return message('unsubscribe_message') ?? + Intl.message( + 'Unfollowing will delete all downloaded episodes of this podcast.', + name: 'unsubscribe_message', + desc: 'Displayed when the user unfollows a podcast.', + locale: localeName, + ); + } + + String get search_for_podcasts_hint { + return message('search_for_podcasts_hint') ?? + Intl.message( + 'Search for podcasts', + name: 'search_for_podcasts_hint', + desc: + 'Hint displayed on search bar when the user clicks the search icon.', + locale: localeName, + ); + } + + String get no_subscriptions_message { + return message('no_subscriptions_message') ?? + Intl.message( + 'Head to Settings to Connect a Pinepods Server if you haven\'t yet!', + name: 'no_subscriptions_message', + desc: + 'Displayed on the library tab when the user has no subscriptions', + locale: localeName, + ); + } + + String get delete_label { + return message('delete_label') ?? + Intl.message( + 'Delete', + name: 'delete_label', + desc: 'Delete label', + locale: localeName, + ); + } + + String get delete_button_label { + return message('delete_button_label') ?? + Intl.message( + 'Delete', + name: 'delete_button_label', + desc: 'Delete label', + locale: localeName, + ); + } + + String get mark_played_label { + return message('mark_played_label') ?? + Intl.message( + 'Mark Played', + name: 'mark_played_label', + desc: 'Mark as played', + locale: localeName, + ); + } + + String get mark_unplayed_label { + return message('mark_unplayed_label') ?? + Intl.message( + 'Mark Unplayed', + name: 'mark_unplayed_label', + desc: 'Mark as unplayed', + locale: localeName, + ); + } + + String get delete_episode_confirmation { + return message('delete_episode_confirmation') ?? + Intl.message( + 'Are you sure you wish to delete this episode?', + name: 'delete_episode_confirmation', + desc: + 'User is asked to confirm when they attempt to delete an episode', + locale: localeName, + ); + } + + String get delete_episode_title { + return message('delete_episode_title') ?? + Intl.message( + 'Delete Episode', + name: 'delete_episode_title', + desc: 'Delete label', + locale: localeName, + ); + } + + String get no_downloads_message { + return message('no_downloads_message') ?? + Intl.message( + 'You do not have any downloaded episodes', + name: 'no_downloads_message', + desc: + 'Displayed on the library tab when the user has no subscriptions', + locale: localeName, + ); + } + + String get no_search_results_message { + return message('no_search_results_message') ?? + Intl.message( + 'No podcasts found', + name: 'no_search_results_message', + desc: + 'Displayed on the library tab when the user has no subscriptions', + locale: localeName, + ); + } + + String get no_podcast_details_message { + return message('no_podcast_details_message') ?? + Intl.message( + 'Could not load podcast episodes. Please check your connection.', + name: 'no_podcast_details_message', + desc: + 'Displayed on the podcast details page when the details could not be loaded', + locale: localeName, + ); + } + + String get play_button_label { + return message('play_button_label') ?? + Intl.message( + 'Play episode', + name: 'play_button_label', + desc: 'Semantic label for the play button', + locale: localeName, + ); + } + + String get pause_button_label { + return message('pause_button_label') ?? + Intl.message( + 'Pause episode', + name: 'pause_button_label', + desc: 'Semantic label for the pause button', + locale: localeName, + ); + } + + String get download_episode_button_label { + return message('download_episode_button_label') ?? + Intl.message( + 'Download episode', + name: 'download_episode_button_label', + desc: 'Semantic label for the download episode button', + locale: localeName, + ); + } + + String get delete_episode_button_label { + return message('delete_episode_button_label') ?? + Intl.message( + 'Delete downloaded episode', + name: 'delete_episode_button_label', + desc: 'Semantic label for the delete episode', + locale: localeName, + ); + } + + String get close_button_label { + return message('close_button_label') ?? + Intl.message( + 'Close', + name: 'close_button_label', + desc: 'Close button label', + locale: localeName, + ); + } + + String get search_button_label { + return message('search_button_label') ?? + Intl.message( + 'Search', + name: 'search_button_label', + desc: 'Search button label', + locale: localeName, + ); + } + + String get clear_search_button_label { + return message('clear_search_button_label') ?? + Intl.message( + 'Clear search text', + name: 'clear_search_button_label', + desc: 'Search button label', + locale: localeName, + ); + } + + String get search_back_button_label { + return message('search_back_button_label') ?? + Intl.message( + 'Back', + name: 'search_back_button_label', + desc: 'Search button label', + locale: localeName, + ); + } + + String get minimise_player_window_button_label { + return message('minimise_player_window_button_label') ?? + Intl.message( + 'Minimise player window', + name: 'minimise_player_window_button_label', + desc: 'Search button label', + locale: localeName, + ); + } + + String get rewind_button_label { + return message('rewind_button_label') ?? + Intl.message( + 'Rewind episode 10 seconds', + name: 'rewind_button_label', + desc: 'Rewind button tooltip', + locale: localeName, + ); + } + + String get fast_forward_button_label { + return message('fast_forward_button_label') ?? + Intl.message( + 'Fast-forward episode 30 seconds', + name: 'fast_forward_button_label', + desc: 'Fast forward tooltip', + locale: localeName, + ); + } + + String get about_label { + return message('about_label') ?? + Intl.message( + 'About', + name: 'about_label', + desc: 'About menu item', + locale: localeName, + ); + } + + String get mark_episodes_played_label { + return message('mark_episodes_played_label') ?? + Intl.message( + 'Mark all episodes as played', + name: 'mark_episodes_played_label', + desc: 'Mark all episodes played menu item', + locale: localeName, + ); + } + + String get mark_episodes_not_played_label { + return message('mark_episodes_not_played_label') ?? + Intl.message( + 'Mark all episodes as not played', + name: 'mark_episodes_not_played_label', + desc: 'Mark all episodes not played menu item', + locale: localeName, + ); + } + + String get stop_download_confirmation { + return message('stop_download_confirmation') ?? + Intl.message( + 'Are you sure you wish to stop this download and delete the episode?', + name: 'stop_download_confirmation', + desc: + 'User is asked to confirm when they wish to stop the active download.', + locale: localeName, + ); + } + + String get stop_download_button_label { + return message('stop_download_button_label') ?? + Intl.message( + 'Stop', + name: 'stop_download_button_label', + desc: 'Stop label', + locale: localeName, + ); + } + + String get stop_download_title { + return message('stop_download_title') ?? + Intl.message( + 'Stop Download', + name: 'stop_download_title', + desc: 'Stop download label', + locale: localeName, + ); + } + + String get settings_mark_deleted_played_label { + return message('settings_mark_deleted_played_label') ?? + Intl.message( + 'Mark deleted episodes as played', + name: 'settings_mark_deleted_played_label', + desc: 'Mark deleted episodes as played setting', + locale: localeName, + ); + } + + String get settings_delete_played_label { + return message('settings_delete_played_label') ?? + Intl.message( + 'Delete downloaded episodes once played', + name: 'settings_delete_played_label', + desc: 'Delete downloaded episodes once played setting', + locale: localeName, + ); + } + + String get settings_download_sd_card_label { + return message('settings_download_sd_card_label') ?? + Intl.message( + 'Download episodes to SD card', + name: 'settings_download_sd_card_label', + desc: 'Download episodes to SD card setting', + locale: localeName, + ); + } + + String get settings_download_switch_card { + return message('settings_download_switch_card') ?? + Intl.message( + 'New downloads will be saved to the SD card. Existing downloads will remain on internal storage.', + name: 'settings_download_switch_card', + desc: 'Displayed when user switches from internal storage to SD card', + locale: localeName, + ); + } + + String get settings_download_switch_internal { + return message('settings_download_switch_internal') ?? + Intl.message( + 'New downloads will be saved to internal storage. Existing downloads will remain on the SD card.', + name: 'settings_download_switch_internal', + desc: + 'Displayed when user switches from internal SD card to internal storage', + locale: localeName, + ); + } + + String get settings_download_switch_label { + return message('settings_download_switch_label') ?? + Intl.message( + 'Change storage location', + name: 'settings_download_switch_label', + desc: 'Dialog label for storage switch', + locale: localeName, + ); + } + + String get cancel_option_label { + return message('cancel_option_label') ?? + Intl.message( + 'Cancel', + name: 'cancel_option_label', + desc: 'Cancel option label', + locale: localeName, + ); + } + + String get settings_theme_switch_label { + return message('settings_theme_switch_label') ?? + Intl.message( + 'Dark theme', + name: 'settings_theme_switch_label', + desc: 'Dark theme', + locale: localeName, + ); + } + + String get playback_speed_label { + return message('playback_speed_label') ?? + Intl.message( + 'Playback speed', + name: 'playback_speed_label', + desc: 'Set playback speed icon label', + locale: localeName, + ); + } + + String get show_notes_label { + return message('show_notes_label') ?? + Intl.message( + 'Show notes', + name: 'show_notes_label', + desc: 'Set show notes icon label', + locale: localeName, + ); + } + + String get search_provider_label { + return message('search_provider_label') ?? + Intl.message( + 'Search provider', + name: 'search_provider_label', + desc: 'Set search provider label', + locale: localeName, + ); + } + + String get settings_label { + return message('settings_label') ?? + Intl.message( + 'Settings', + name: 'settings_label', + desc: 'Settings label', + locale: localeName, + ); + } + + String get go_back_button_label { + return message('go_back_button_label') ?? + Intl.message( + 'Go Back', + name: 'go_back_button_label', + desc: 'Go-back button label', + locale: localeName, + ); + } + + String get continue_button_label { + return message('continue_button_label') ?? + Intl.message( + 'Continue', + name: 'continue_button_label', + desc: 'Continue button label', + locale: localeName, + ); + } + + String get consent_message { + return message('consent_message') ?? + Intl.message( + 'This funding link will take you to an external site where you will be able to directly support the show. Links are provided by the podcast authors and is not controlled by PinePods.', + name: 'consent_message', + desc: 'Display when first accessing external funding link', + locale: localeName, + ); + } + + String get episode_label { + return message('episode_label') ?? + Intl.message( + 'Episode', + name: 'episode_label', + desc: 'Tab label on now playing screen.', + locale: localeName, + ); + } + + String get chapters_label { + return message('chapters_label') ?? + Intl.message( + 'Chapters', + name: 'chapters_label', + desc: 'Tab label on now playing screen.', + locale: localeName, + ); + } + + String get notes_label { + return message('notes_label') ?? + Intl.message( + 'Notes', + name: 'notes_label', + desc: 'Tab label on now playing screen.', + locale: localeName, + ); + } + + String get podcast_funding_dialog_header { + return message('podcast_funding_dialog_header') ?? + Intl.message( + 'Podcast Funding', + name: 'podcast_funding_dialog_header', + desc: 'Header on podcast funding consent dialog', + locale: localeName, + ); + } + + String get settings_auto_open_now_playing { + return message('settings_auto_open_now_playing') ?? + Intl.message( + 'Full screen player mode on episode start', + name: 'settings_auto_open_now_playing', + desc: + 'Displayed when user switches to use full screen player automatically', + locale: localeName, + ); + } + + String get error_no_connection { + return message('error_no_connection') ?? + Intl.message( + 'Unable to play episode. Please check your connection and try again.', + name: 'error_no_connection', + desc: + 'Displayed when attempting to start streaming an episode with no data connection', + locale: localeName, + ); + } + + String get error_playback_fail { + return message('error_playback_fail') ?? + Intl.message( + 'An unexpected error occurred during playback. Please check your connection and try again.', + name: 'error_playback_fail', + desc: + 'Displayed when attempting to start streaming an episode with no data connection', + locale: localeName, + ); + } + + String get add_rss_feed_option { + return message('add_rss_feed_option') ?? + Intl.message( + 'Add RSS Feed', + name: 'add_rss_feed_option', + desc: 'Option label for adding manual RSS feed url', + locale: localeName, + ); + } + + String get settings_import_opml { + return message('settings_import_opml') ?? + Intl.message( + 'Import OPML', + name: 'settings_import_opml', + desc: 'Option label importing OPML file', + locale: localeName, + ); + } + + String get settings_export_opml { + return message('settings_export_opml') ?? + Intl.message( + 'Export OPML', + name: 'settings_export_opml', + desc: 'Option label exporting OPML file', + locale: localeName, + ); + } + + String get label_opml_importing { + return message('label_opml_importing') ?? + Intl.message( + 'Importing', + name: 'label_opml_importing', + desc: 'Label for importing OPML dialog', + locale: localeName, + ); + } + + String get settings_auto_update_episodes { + return message('settings_auto_update_episodes') ?? + Intl.message( + 'Auto update episodes', + name: 'settings_auto_update_episodes', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_never { + return message('settings_auto_update_episodes_never') ?? + Intl.message( + 'Never', + name: 'settings_auto_update_episodes_never', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_heading { + return message('settings_auto_update_episodes_heading') ?? + Intl.message( + 'Refresh episodes on details screen after', + name: 'settings_auto_update_episodes_heading', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_always { + return message('settings_auto_update_episodes_always') ?? + Intl.message( + 'Always', + name: 'settings_auto_update_episodes_always', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_10min { + return message('settings_auto_update_episodes_10min') ?? + Intl.message( + '10 minutes since last update', + name: 'settings_auto_update_episodes_10min', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_30min { + return message('settings_auto_update_episodes_30min') ?? + Intl.message( + '30 minutes since last update', + name: 'settings_auto_update_episodes_30min', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_1hour { + return message('settings_auto_update_episodes_1hour') ?? + Intl.message( + '1 hour since last update', + name: 'settings_auto_update_episodes_1hour', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_3hour { + return message('settings_auto_update_episodes_3hour') ?? + Intl.message( + '3 hours since last update', + name: 'settings_auto_update_episodes_3hour', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_6hour { + return message('settings_auto_update_episodes_6hour') ?? + Intl.message( + '6 hours since last update', + name: 'settings_auto_update_episodes_6hour', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_auto_update_episodes_12hour { + return message('settings_auto_update_episodes_12hour') ?? + Intl.message( + '12 hours since last update', + name: 'settings_auto_update_episodes_12hour', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get new_episodes_label { + return message('new_episodes_label') ?? + Intl.message( + 'New episodes are available', + name: 'new_episodes_label', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get new_episodes_view_now_label { + return message('new_episodes_view_now_label') ?? + Intl.message( + 'VIEW NOW', + name: 'new_episodes_view_now_label', + desc: 'Option label for auto updating of episodes', + locale: localeName, + ); + } + + String get settings_personalisation_divider_label { + return message('settings_personalisation_divider_label') ?? + Intl.message( + 'PERSONALISATION', + name: 'settings_personalisation_divider_label', + desc: 'Settings divider label for personalisation', + locale: localeName, + ); + } + + String get settings_episodes_divider_label { + return message('settings_episodes_divider_label') ?? + Intl.message( + 'EPISODES', + name: 'settings_episodes_divider_label', + desc: 'Settings divider label for episodes', + locale: localeName, + ); + } + + String get settings_playback_divider_label { + return message('settings_playback_divider_label') ?? + Intl.message( + 'PLAYBACK', + name: 'settings_playback_divider_label', + desc: 'Settings divider label for playback', + locale: localeName, + ); + } + + String get settings_data_divider_label { + return message('settings_data_divider_label') ?? + Intl.message( + 'DATA', + name: 'settings_data_divider_label', + desc: 'Settings divider label for data', + locale: localeName, + ); + } + + String get audio_effect_trim_silence_label { + return message('audio_effect_trim_silence_label') ?? + Intl.message( + 'Trim Silence', + name: 'audio_effect_trim_silence_label', + desc: 'Label for trim silence toggle', + locale: localeName, + ); + } + + String get audio_effect_volume_boost_label { + return message('audio_effect_volume_boost_label') ?? + Intl.message( + 'Volume Boost', + name: 'audio_effect_volume_boost_label', + desc: 'Label for volume boost toggle', + locale: localeName, + ); + } + + String get audio_settings_playback_speed_label { + return message('audio_settings_playback_speed_label') ?? + Intl.message( + 'Playback Speed', + name: 'audio_settings_playback_speed_label', + desc: 'Label for playback settings widget', + locale: localeName, + ); + } + + String get empty_queue_message { + return message('empty_queue_message') ?? + Intl.message( + 'Your queue is empty', + name: 'empty_queue_message', + desc: 'Displayed when there are no items left in the queue', + locale: localeName, + ); + } + + String get clear_queue_button_label { + return message('clear_queue_button_label') ?? + Intl.message( + 'CLEAR QUEUE', + name: 'clear_queue_button_label', + desc: 'Clear queue button label', + locale: localeName, + ); + } + + String get now_playing_queue_label { + return message('now_playing_queue_label') ?? + Intl.message( + 'Now Playing', + name: 'now_playing_queue_label', + desc: 'Now playing label on queue', + locale: localeName, + ); + } + + String get up_next_queue_label { + return message('up_next_queue_label') ?? + Intl.message( + 'Up Next', + name: 'up_next_queue_label', + desc: 'Up next label on queue', + locale: localeName, + ); + } + + String get more_label { + return message('more_label') ?? + Intl.message( + 'More', + name: 'more_label', + desc: 'More label', + locale: localeName, + ); + } + + String get queue_add_label { + return message('queue_add_label') ?? + Intl.message( + 'Add', + name: 'queue_add_label', + desc: 'Queue add label', + locale: localeName, + ); + } + + String get queue_remove_label { + return message('queue_remove_label') ?? + Intl.message( + 'Remove', + name: 'queue_remove_label', + desc: 'Queue remove label', + locale: localeName, + ); + } + + String get opml_import_button_label { + return message('opml_import_button_label') ?? + Intl.message( + 'Import', + name: 'opml_import_button_label', + desc: 'OPML Import button label', + locale: localeName, + ); + } + + String get opml_export_button_label { + return message('opml_export_button_label') ?? + Intl.message( + 'Export', + name: 'opml_export_button_label', + desc: 'OPML Export button label', + locale: localeName, + ); + } + + String get opml_import_export_label { + return message('opml_import_export_label') ?? + Intl.message( + 'OPML Import/Export', + name: 'opml_import_export_label', + desc: 'OPML Import/Export label', + locale: localeName, + ); + } + + String get queue_clear_label { + return message('queue_clear_label') ?? + Intl.message( + 'Are you sure you wish to clear the queue?', + name: 'queue_clear_label', + desc: 'Shown on dialog box when clearing queue', + locale: localeName, + ); + } + + String get queue_clear_button_label { + return message('queue_clear_button_label') ?? + Intl.message( + 'Clear', + name: 'queue_clear_button_label', + desc: 'Shown on dialog box when clearing queue', + locale: localeName, + ); + } + + String get queue_clear_label_title { + return message('queue_clear_label_title') ?? + Intl.message( + 'Clear Queue', + name: 'queue_clear_label_title', + desc: 'Shown on dialog box when clearing queue', + locale: localeName, + ); + } + + String get layout_label { + return message('layout_label') ?? + Intl.message( + 'Layout', + name: 'layout_label', + desc: 'Layout menu label', + locale: localeName, + ); + } + + String get discovery_categories_itunes { + return message('discovery_categories_itunes') ?? + Intl.message( + ',Arts,Business,Comedy,Education,Fiction,Government,Health & Fitness,History,Kids & Family,Leisure,Music,News,Religion & Spirituality,Science,Society & Culture,Sports,TV & Film,Technology,True Crime', + name: 'discovery_categories_itunes', + desc: 'Comma separated list of iTunes categories', + locale: localeName, + ); + } + + String get discovery_categories_pindex { + return message('discovery_categories_pindex') ?? + Intl.message( + ',After-Shows,Alternative,Animals,Animation,Arts,Astronomy,Automotive,Aviation,Baseball,Basketball,Beauty,Books,Buddhism,Business,Careers,Chemistry,Christianity,Climate,Comedy,Commentary,Courses,Crafts,Cricket,Cryptocurrency,Culture,Daily,Design,Documentary,Drama,Earth,Education,Entertainment,Entrepreneurship,Family,Fantasy,Fashion,Fiction,Film,Fitness,Food,Football,Games,Garden,Golf,Government,Health,Hinduism,History,Hobbies,Hockey,Home,HowTo,Improv,Interviews,Investing,Islam,Journals,Judaism,Kids,Language,Learning,Leisure,Life,Management,Manga,Marketing,Mathematics,Medicine,Mental,Music,Natural,Nature,News,NonProfit,Nutrition,Parenting,Performing,Personal,Pets,Philosophy,Physics,Places,Politics,Relationships,Religion,Reviews,Role-Playing,Rugby,Running,Science,Self-Improvement,Sexuality,Soccer,Social,Society,Spirituality,Sports,Stand-Up,Stories,Swimming,TV,Tabletop,Technology,Tennis,Travel,True Crime,Video-Games,Visual,Volleyball,Weather,Wilderness,Wrestling', + name: 'discovery_categories_pindex', + desc: 'Comma separated list of Podcast Index categories', + locale: localeName, + ); + } + + String get transcript_label { + return message('transcript_label') ?? + Intl.message( + 'Transcript', + name: 'transcript_label', + desc: 'Transcript label', + locale: localeName, + ); + } + + String get no_transcript_available_label { + return message('no_transcript_available_label') ?? + Intl.message( + 'A transcript is not available for this podcast', + name: 'no_transcript_available_label', + desc: 'Displayed in transcript view when no transcript is available', + locale: localeName, + ); + } + + String get search_transcript_label { + return message('search_transcript_label') ?? + Intl.message( + 'Search transcript', + name: 'search_transcript_label', + desc: 'Hint text for transcript search box', + locale: localeName, + ); + } + + String get auto_scroll_transcript_label { + return message('auto_scroll_transcript_label') ?? + Intl.message( + 'Follow transcript', + name: 'auto_scroll_transcript_label', + desc: 'Auto scroll switch label', + locale: localeName, + ); + } + + String get transcript_why_not_label { + return message('transcript_why_not_label') ?? + Intl.message( + 'Why not?', + name: 'transcript_why_not_label', + desc: 'Link to why no transcript is available', + locale: localeName, + ); + } + + String get transcript_why_not_url { + return message('transcript_why_not_url') ?? + Intl.message( + 'https://www.pinepods.online/docs/Features/Transcript', + name: 'transcript_why_not_url', + desc: 'Language specific link', + locale: localeName, + ); + } + + String get semantics_podcast_details_header { + return message('semantics_podcast_details_header') ?? + Intl.message( + 'Podcast details and episodes page', + name: 'semantics_podcast_details_header', + desc: 'Describes podcast details page', + locale: localeName, + ); + } + + String get semantics_layout_option_list { + return message('semantics_layout_option_list') ?? + Intl.message( + 'List layout', + name: 'semantics_layout_option_list', + desc: 'Describes list layout button', + locale: localeName, + ); + } + + String get semantics_layout_option_compact_grid { + return message('semantics_layout_option_compact_grid') ?? + Intl.message( + 'Compact grid layout', + name: 'semantics_layout_option_compact_grid', + desc: 'Describes compact grid layout button', + locale: localeName, + ); + } + + String get semantics_layout_option_grid { + return message('semantics_layout_option_grid') ?? + Intl.message( + 'Grid layout', + name: 'semantics_layout_option_grid', + desc: 'Describes grid layout button', + locale: localeName, + ); + } + + String get semantics_mini_player_header { + return message('semantics_mini_player_header') ?? + Intl.message( + 'Mini player. Swipe right to play/pause button. Activate to open main player window', + name: 'semantics_mini_player_header', + desc: 'Describes the mini player', + locale: localeName, + ); + } + + String get semantics_main_player_header { + return message('semantics_main_player_header') ?? + Intl.message( + 'Main player window', + name: 'semantics_main_player_header', + desc: 'Describes the main player', + locale: localeName, + ); + } + + String get semantics_play_pause_toggle { + return message('semantics_play_pause_toggle') ?? + Intl.message( + 'Play/pause toggle', + name: 'semantics_play_pause_toggle', + desc: 'Describes play/pause toggle button', + locale: localeName, + ); + } + + String get semantics_decrease_playback_speed { + return message('semantics_decrease_playback_speed') ?? + Intl.message( + 'Decrease playback speed', + name: 'semantics_decrease_playback_speed', + desc: 'Describes speed adjustment control', + locale: localeName, + ); + } + + String get semantics_increase_playback_speed { + return message('semantics_increase_playback_speed') ?? + Intl.message( + 'Increase playback speed', + name: 'semantics_increase_playback_speed', + desc: 'Describes speed adjustment control', + locale: localeName, + ); + } + + String get semantics_expand_podcast_description { + return message('semantics_expand_podcast_description') ?? + Intl.message( + 'Expand podcast description', + name: 'semantics_expand_podcast_description', + desc: 'Describes podcast collapse/expand button', + locale: localeName, + ); + } + + String get semantics_collapse_podcast_description { + return message('semantics_collapse_podcast_description') ?? + Intl.message( + 'Collapse podcast description', + name: 'semantics_collapse_podcast_description', + desc: 'Describes podcast collapse/expand button', + locale: localeName, + ); + } + + String get semantics_add_to_queue { + return message('semantics_add_to_queue') ?? + Intl.message( + 'Add episode to queue', + name: 'semantics_add_to_queue', + desc: 'Describes add to queue button', + locale: localeName, + ); + } + + String get semantics_remove_from_queue { + return message('semantics_remove_from_queue') ?? + Intl.message( + 'Remove episode from queue', + name: 'semantics_remove_from_queue', + desc: 'Describes add to queue button', + locale: localeName, + ); + } + + String get semantics_mark_episode_played { + return message('semantics_mark_episode_played') ?? + Intl.message( + 'Mark Episode as played', + name: 'semantics_mark_episode_played', + desc: 'Describes mark played button', + locale: localeName, + ); + } + + String get semantics_mark_episode_unplayed { + return message('semantics_mark_episode_unplayed') ?? + Intl.message( + 'Mark Episode as un-played', + name: 'semantics_mark_episode_unplayed', + desc: 'Describes mark unplayed button', + locale: localeName, + ); + } + + String get semantics_episode_tile_collapsed { + return message('semantics_episode_tile_collapsed') ?? + Intl.message( + 'Episode list item. Showing image, summary and main controls.', + name: 'semantics_episode_tile_collapsed', + desc: 'Describes episode tile options when collapsed', + locale: localeName, + ); + } + + String get semantics_episode_tile_expanded { + return message('semantics_episode_tile_expanded') ?? + Intl.message( + 'Episode list item. Showing description, main controls and additional controls.', + name: 'semantics_episode_tile_expanded', + desc: 'Describes episode tile options when expanded', + locale: localeName, + ); + } + + String get semantics_episode_tile_collapsed_hint { + return message('semantics_episode_tile_collapsed_hint') ?? + Intl.message( + 'expand and show more details and additional options', + name: 'semantics_episode_tile_collapsed_hint', + desc: 'Describes episode tile options when collapsed', + locale: localeName, + ); + } + + String get semantics_episode_tile_expanded_hint { + return message('semantics_episode_tile_expanded_hint') ?? + Intl.message( + 'collapse and show summary, download and play control', + name: 'semantics_episode_tile_expanded_hint', + desc: 'Describes episode tile options when expanded', + locale: localeName, + ); + } + + String get sleep_off_label { + return message('sleep_off_label') ?? + Intl.message( + 'Off', + name: 'sleep_off_label', + desc: 'Describes off sleep label', + locale: localeName, + ); + } + + String get sleep_episode_label { + return message('sleep_episode_label') ?? + Intl.message( + 'End of episode', + name: 'sleep_episode_label', + desc: 'Describes end of episode sleep label', + locale: localeName, + ); + } + + String sleep_minute_label(String minutes) { + return message('sleep_minute_label') ?? + Intl.message( + '$minutes minutes', + args: [minutes], + name: 'sleep_minute_label', + desc: 'Describes the number of minutes to sleep', + locale: localeName, + ); + } + + String get sleep_timer_label { + return message('sleep_timer_label') ?? + Intl.message( + 'Sleep Timer', + name: 'sleep_timer_label', + desc: 'Describes sleep timer label', + locale: localeName, + ); + } + + String get feedback_menu_item_label { + return message('feedback_menu_item_label') ?? + Intl.message( + 'Feedback', + name: 'feedback_menu_item_label', + desc: 'Feedback option in main menu', + locale: localeName, + ); + } + + String get podcast_options_overflow_menu_semantic_label { + return message('podcast_options_overflow_menu_semantic_label') ?? + Intl.message( + 'Options menu', + name: 'podcast_options_overflow_menu_semantic_label', + desc: 'Podcast details overflow menu', + locale: localeName, + ); + } + + String get semantic_announce_searching { + return message('semantic_announce_searching') ?? + Intl.message( + 'Searching, please wait.', + name: 'semantic_announce_searching', + desc: 'Spoken when search in progress.', + locale: localeName, + ); + } + + String get semantic_playing_options_expand_label { + return message('semantic_playing_options_expand_label') ?? + Intl.message( + 'Open playing options slider', + name: 'semantic_playing_options_expand_label', + desc: 'Placed on options handle when screen reader enabled.', + locale: localeName, + ); + } + + String get semantic_playing_options_collapse_label { + return message('semantic_playing_options_collapse_label') ?? + Intl.message( + 'Close playing options slider', + name: 'semantic_playing_options_collapse_label', + desc: 'Placed on options handle when screen reader enabled.', + locale: localeName, + ); + } + + String get semantic_podcast_artwork_label { + return message('semantic_podcast_artwork_label') ?? + Intl.message( + 'Podcast artwork', + name: 'semantic_podcast_artwork_label', + desc: 'Placed around podcast image on main player', + locale: localeName, + ); + } + + String get semantic_chapter_link_label { + return message('semantic_chapter_link_label') ?? + Intl.message( + 'Chapter web link', + name: 'semantic_chapter_link_label', + desc: 'Placed around chapter link', + locale: localeName, + ); + } + + String get semantic_current_chapter_label { + return message('semantic_current_chapter_label') ?? + Intl.message( + 'Current chapter', + name: 'semantic_current_chapter_label', + desc: 'Placed around chapter', + locale: localeName, + ); + } + + String get episode_filter_none_label { + return message('episode_filter_none_label') ?? + Intl.message( + 'None', + name: 'episode_filter_none_label', + desc: 'Episodes not filtered', + locale: localeName, + ); + } + + String get episode_filter_started_label { + return message('episode_filter_started_label') ?? + Intl.message( + 'Started', + name: 'episode_filter_started_label', + desc: 'Only show episodes that have been started', + locale: localeName, + ); + } + + String get episode_filter_played_label { + return message('episode_filter_played_label') ?? + Intl.message( + 'Played', + name: 'episode_filter_played_label', + desc: 'Only show episodes that have been played', + locale: localeName, + ); + } + + String get episode_filter_unplayed_label { + return message('episode_filter_unplayed_label') ?? + Intl.message( + 'Unplayed', + name: 'episode_filter_unplayed_label', + desc: 'Only show episodes that have not been played', + locale: localeName, + ); + } + + String get episode_filter_no_episodes_title_label { + return message('episode_filter_no_episodes_title_label') ?? + Intl.message( + 'No Episodes Found', + name: 'episode_filter_no_episodes_title_label', + desc: 'No Episodes title', + locale: localeName, + ); + } + + String get episode_filter_no_episodes_title_description { + return message('episode_filter_no_episodes_title_description') ?? + Intl.message( + 'No Episodes Found', + name: 'episode_filter_no_episodes_title_description', + desc: + 'This podcast has no episodes matching your search criteria and filter', + locale: localeName, + ); + } + + String get episode_filter_clear_filters_button_label { + return message('episode_filter_clear_filters_button_label') ?? + Intl.message( + 'Clear Filters', + name: 'episode_filter_clear_filters_button_label', + desc: 'Clear filters button', + locale: localeName, + ); + } + + String get episode_filter_semantic_label { + return message('episode_filter_semantic_label') ?? + Intl.message( + 'Episode filter', + name: 'episode_filter_semantic_label', + desc: 'Episode filter semantic label', + locale: localeName, + ); + } + + String get episode_sort_semantic_label { + return message('episode_sort_semantic_label') ?? + Intl.message( + 'Episode sort', + name: 'episode_sort_semantic_label', + desc: 'Episode sort semantic label', + locale: localeName, + ); + } + + String get episode_sort_none_label { + return message('episode_sort_none_label') ?? + Intl.message( + 'Default', + name: 'episode_sort_none_label', + desc: 'Episode default sort', + locale: localeName, + ); + } + + String get episode_sort_latest_first_label { + return message('episode_sort_latest_first_label') ?? + Intl.message( + 'Latest first', + name: 'episode_sort_latest_first_label', + desc: 'Episode latest first sort', + locale: localeName, + ); + } + + String get episode_sort_earliest_first_label { + return message('episode_sort_earliest_first_label') ?? + Intl.message( + 'Earliest first', + name: 'episode_sort_earliest_first_label', + desc: 'Episode earliest first sort', + locale: localeName, + ); + } + + String get episode_sort_alphabetical_ascending_label { + return message('episode_sort_alphabetical_ascending_label') ?? + Intl.message( + 'Alphabetical A-Z', + name: 'episode_sort_alphabetical_ascending_label', + desc: 'Episode alphabetical ascending', + locale: localeName, + ); + } + + String get episode_sort_alphabetical_descending_label { + return message('episode_sort_alphabetical_descending_label') ?? + Intl.message( + 'Alphabetical Z-A', + name: 'episode_sort_alphabetical_descending_label', + desc: 'Episode alphabetical descending', + locale: localeName, + ); + } + + String get open_show_website_label { + return message('open_show_website_label') ?? + Intl.message( + 'Open show website', + name: 'open_show_website_label', + desc: 'Open show website in browser', + locale: localeName, + ); + } + + String get refresh_feed_label { + return message('refresh_feed_label') ?? + Intl.message( + 'Refresh episodes', + name: 'refresh_feed_label', + desc: 'Menu item to refresh episodes', + locale: localeName, + ); + } + + String get scrim_layout_selector { + return message('scrim_layout_selector') ?? + Intl.message( + 'Dismiss layout selector', + name: 'scrim_layout_selector', + desc: + 'Replaces default scrim label for layout selector bottom sheet.', + locale: localeName, + ); + } + + String get now_playing_episode_position { + return message('now_playing_episode_position') ?? + Intl.message( + 'Episode position', + name: 'now_playing_episode_position', + desc: 'Episode position slider control label', + locale: localeName, + ); + } + + String get now_playing_episode_time_remaining { + return message('now_playing_episode_time_remaining') ?? + Intl.message( + 'Time remaining', + name: 'now_playing_episode_time_remaining', + desc: 'Episode time remaining slider control label', + locale: localeName, + ); + } + + String get resume_button_label { + return message('resume_button_label') ?? + Intl.message( + 'Resume episode', + name: 'resume_button_label', + desc: 'Semantic label for the resume button', + locale: localeName, + ); + } + + String get play_download_button_label { + return message('play_download_button_label') ?? + Intl.message( + 'Play downloaded episode', + name: 'play_download_button_label', + desc: 'Semantic label for the play downloaded episode button', + locale: localeName, + ); + } + + String get cancel_download_button_label { + return message('cancel_download_button_label') ?? + Intl.message( + 'Cancel download', + name: 'cancel_download_button_label', + desc: 'Semantic label for the play cancel download button', + locale: localeName, + ); + } + + String get episode_details_button_label { + return message('episode_details_button_label') ?? + Intl.message( + 'Show episode information', + name: 'episode_details_button_label', + desc: 'Semantic label for the show info button.', + locale: localeName, + ); + } + + String get scrim_sleep_timer_selector { + return message('scrim_sleep_timer_selector') ?? + Intl.message( + 'Dismiss sleep timer selector', + name: 'scrim_sleep_timer_selector', + desc: 'Replaces default scrim label for custom.', + locale: localeName, + ); + } + + String get scrim_speed_selector { + return message('scrim_speed_selector') ?? + Intl.message( + 'Dismiss playback speed selector', + name: 'scrim_speed_selector', + desc: 'Replaces default scrim label for custom.', + locale: localeName, + ); + } + + String get semantic_current_value_label { + return message('semantic_current_value_label') ?? + Intl.message( + 'Current value', + name: 'semantic_current_value_label', + desc: 'For current sleep setting', + locale: localeName, + ); + } + + String get scrim_episode_details_selector { + return message('scrim_episode_details_selector') ?? + Intl.message( + 'Dismiss episode details', + name: 'scrim_episode_details_selector', + desc: + 'Replaces default scrim label for episode details bottom sheet.', + locale: localeName, + ); + } + + String get scrim_episode_sort_selector { + return message('scrim_episode_sort_selector') ?? + Intl.message( + 'Dismiss episode sort', + name: 'scrim_episode_sort_selector', + desc: 'Replaces default scrim label for episode sort bottom sheet.', + locale: localeName, + ); + } + + String get scrim_episode_filter_selector { + return message('scrim_episode_filter_selector') ?? + Intl.message( + 'Dismiss episode filter', + name: 'scrim_episode_filter_selector', + desc: 'Replaces default scrim label for episode filter bottom sheet.', + locale: localeName, + ); + } + + String get search_episodes_label { + return message('search_episodes_label') ?? + Intl.message( + 'Search episodes', + name: 'search_episodes_label', + desc: 'Hint text for episode search box', + locale: localeName, + ); + } +} + +class PinepodsLocalisationsDelegate extends LocalizationsDelegate { + const PinepodsLocalisationsDelegate(); + + @override + bool isSupported(Locale locale) => + ['en', 'de', 'it'].contains(locale.languageCode); + + @override + Future load(Locale locale) => L.load(locale, const {}); + + @override + bool shouldReload(PinepodsLocalisationsDelegate old) => false; +} + +/// This class can be used by third-parties who wish to override or replace +/// some of the strings built into Pinepods. This class takes a map +/// of message labels which takes a map of localised string replacements. For +/// example, to update the app title you may passes messages containing: +/// app_title: { +/// 'en': 'my new app title', +/// 'de': 'Mein app-titel' +/// } +class EmbeddedLocalisationsDelegate extends LocalizationsDelegate { + final Map> messages; + + EmbeddedLocalisationsDelegate({@required this.messages = const {}}); + + @override + bool isSupported(Locale locale) => + ['en', 'de', 'it'].contains(locale.languageCode); + + @override + Future load(Locale locale) => L.load(locale, messages); + + @override + bool shouldReload(EmbeddedLocalisationsDelegate old) => false; +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/intl_de.arb b/PinePods-0.8.2/mobile/lib/l10n/intl_de.arb new file mode 100644 index 0000000..55192f4 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/intl_de.arb @@ -0,0 +1,1068 @@ +{ + "@@last_modified": "2020-02-20T10:40:43.008209", + "app_title": "PinePods Podcast Player", + "@app_title": { + "description": "Full title for the application", + "type": "text", + "placeholders": {} + }, + "app_title_short": "Pinepods", + "@app_title_short": { + "description": "Title for the application", + "type": "text", + "placeholders": {} + }, + "library": "Bibliothek", + "@library": { + "description": "Library tab label", + "type": "text", + "placeholders": {} + }, + "discover": "Entdecken", + "@discover": { + "description": "Discover tab label", + "type": "text", + "placeholders": {} + }, + "downloads": "Herunterladen", + "@downloads": { + "description": "Downloads tab label", + "type": "text", + "placeholders": {} + }, + "subscribe_button_label": "Folgen", + "@subscribe_button_label": { + "description": "Subscribe button label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_button_label": "Entfolgen", + "@unsubscribe_button_label": { + "description": "Unsubscribe button label", + "type": "text", + "placeholders": {} + }, + "cancel_button_label": "Stornieren", + "@cancel_button_label": { + "description": "Cancel button label", + "type": "text", + "placeholders": {} + }, + "ok_button_label": "OK", + "@ok_button_label": { + "description": "OK button label", + "type": "text", + "placeholders": {} + }, + "subscribe_label": "Folgen", + "@subscribe_label": { + "description": "Subscribe label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_label": "Nicht mehr folgen", + "@unsubscribe_label": { + "description": "Unsubscribe label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_message": "Wenn Sie nicht mehr folgen, werden alle heruntergeladenen Folgen dieses Podcasts gelöscht.", + "@unsubscribe_message": { + "description": "Displayed when the user unsubscribes from a podcast.", + "type": "text", + "placeholders": {} + }, + "search_for_podcasts_hint": "Suche nach Podcasts", + "@search_for_podcasts_hint": { + "description": "Hint displayed on search bar when the user clicks the search icon.", + "type": "text", + "placeholders": {} + }, + "no_subscriptions_message": "Tippen Sie unten auf die Schaltfläche „Entdecken“ oder verwenden Sie die Suchleiste oben, um Ihren ersten Podcast zu finden", + "@no_subscriptions_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "delete_label": "Löschen", + "@delete_label": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "delete_button_label": "Löschen", + "@delete_button_label": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "mark_played_label": "Markieren gespielt", + "@mark_played_label": { + "description": "Mark as played", + "type": "text", + "placeholders": {} + }, + "mark_unplayed_label": "Markieren nicht abgespielt", + "@mark_unplayed_label": { + "description": "Mark as unplayed", + "type": "text", + "placeholders": {} + }, + "delete_episode_confirmation": "Sind Sie sicher, dass Sie diese Episode löschen möchten?", + "@delete_episode_confirmation": { + "description": "User is asked to confirm when they attempt to delete an episode", + "type": "text", + "placeholders": {} + }, + "delete_episode_title": "Folge löschen", + "@delete_episode_title": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "no_downloads_message": "Sie haben keine Episoden heruntergeladen", + "@no_downloads_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "no_search_results_message": "Keine Podcasts gefunden", + "@no_search_results_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "no_podcast_details_message": "Podcast-Episoden konnten nicht geladen werden. Bitte überprüfen Sie Ihre Verbindung.", + "@no_podcast_details_message": { + "description": "Displayed on the podcast details page when the details could not be loaded", + "type": "text", + "placeholders": {} + }, + "play_button_label": "Folge abspielen", + "@play_button_label": { + "description": "Semantic label for the play button", + "type": "text", + "placeholders": {} + }, + "pause_button_label": "Folge pausieren", + "@pause_button_label": { + "description": "Semantic label for the pause button", + "type": "text", + "placeholders": {} + }, + "download_episode_button_label": "Folge herunterladen", + "@download_episode_button_label": { + "description": "Semantic label for the download episode button", + "type": "text", + "placeholders": {} + }, + "delete_episode_button_label": "Download -Episode löschen", + "@delete_episode_button_label": { + "description": "Semantic label for the delete episode", + "type": "text", + "placeholders": {} + }, + "close_button_label": "Schließen", + "@close_button_label": { + "description": "Close button label", + "type": "text", + "placeholders": {} + }, + "search_button_label": "Suche", + "@search_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "clear_search_button_label": "Suchtext löschen", + "@clear_search_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "search_back_button_label": "Zurück", + "@search_back_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "minimise_player_window_button_label": "Wiedergabebildschirm minimieren", + "@minimise_player_window_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "rewind_button_label": "10 Sekunden zurückspulen", + "@rewind_button_label": { + "description": "Rewind button tooltip", + "type": "text", + "placeholders": {} + }, + "fast_forward_button_label": "30 Sekunden schneller Vorlauf", + "@fast_forward_button_label": { + "description": "Fast forward tooltip", + "type": "text", + "placeholders": {} + }, + "about_label": "Über", + "@about_label": { + "description": "About menu item", + "type": "text", + "placeholders": {} + }, + "mark_episodes_played_label": "Markieren Sie alle Episoden als abgespielt", + "@mark_episodes_played_label": { + "description": "Mark all episodes played menu item", + "type": "text", + "placeholders": {} + }, + "mark_episodes_not_played_label": "Markieren Sie alle Folgen als nicht abgespielt", + "@mark_episodes_not_played_label": { + "description": "Mark all episodes not-played menu item", + "type": "text", + "placeholders": {} + }, + "stop_download_confirmation": "Möchten Sie diesen Download wirklich beenden und die Episode löschen?", + "@stop_download_confirmation": { + "description": "User is asked to confirm when they wish to stop the active download.", + "type": "text", + "placeholders": {} + }, + "stop_download_button_label": "Halt", + "@stop_download_button_label": { + "description": "Stop label", + "type": "text", + "placeholders": {} + }, + "stop_download_title": "Stop Download", + "@stop_download_title": { + "description": "Stop download label", + "type": "text", + "placeholders": {} + }, + "settings_mark_deleted_played_label": "Markieren Sie gelöschte Episoden als abgespielt", + "@settings_mark_deleted_played_label": { + "description": "Mark deleted episodes as played setting", + "type": "text", + "placeholders": {} + }, + "settings_delete_played_label": "Heruntergeladene Episoden nach dem Abspielen löschen", + "@settings_delete_played_label": { + "description": "Delete downloaded episodes once played setting", + "type": "text", + "placeholders": {} + }, + "settings_download_sd_card_label": "Episoden auf SD-Karte herunterladen", + "@settings_download_sd_card_label": { + "description": "Download episodes to SD card setting", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_card": "Neue Downloads werden auf der SD-Karte gespeichert. Bestehende Downloads bleiben im internen Speicher.", + "@settings_download_switch_card": { + "description": "Displayed when user switches from internal storage to SD card", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_internal": "Neue Downloads werden im internen Speicher gespeichert. Bestehende Downloads verbleiben auf der SD-Karte.", + "@settings_download_switch_internal": { + "description": "Displayed when user switches from internal SD card to internal storage", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_label": "Speicherort ändern", + "@settings_download_switch_label": { + "description": "Dialog label for storage switch", + "type": "text", + "placeholders": {} + }, + "cancel_option_label": "Stirbuereb", + "@cancel_option_label": { + "description": "Cancel option label", + "type": "text", + "placeholders": {} + }, + "settings_theme_switch_label": "Dark theme", + "@settings_theme_switch_label": { + "description": "Dunkles Thema", + "type": "text", + "placeholders": {} + }, + "playback_speed_label": "Stellen Sie die Wiedergabegeschwindigkeit ein", + "@playback_speed_label": { + "description": "Set playback speed icon label", + "type": "text", + "placeholders": {} + }, + "show_notes_label": "Notizen anzeigen", + "@show_notes_label": { + "description": "Set show notes icon label", + "type": "text", + "placeholders": {} + }, + "search_provider_label": "Suchmaschine", + "@search_provider_label": { + "description": "Set search provider label", + "type": "text", + "placeholders": {} + }, + "settings_label": "Einstellungen", + "@settings_label": { + "description": "Settings label", + "type": "text", + "placeholders": {} + }, + "go_back_button_label": "Geh Zurück", + "@go_back_button_label": { + "description": "Go-back button label", + "type": "text", + "placeholders": {} + }, + "continue_button_label": "Fortsetzen", + "@continue_button_label": { + "description": "Continue button label", + "type": "text", + "placeholders": {} + }, + "consent_message": "Über diesen Finanzierungslink gelangen Sie zu einer externen Website, auf der Sie die Show direkt unterstützen können. Links werden von den Podcast-Autoren bereitgestellt und nicht von PinePods kontrolliert.", + "@consent_message": { + "description": "Display when first accessing external funding link", + "type": "text", + "placeholders": {} + }, + "episode_label": "Episode", + "@episode_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "chapters_label": "Kapitel", + "@chapters_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "notes_label": "Notizen", + "@notes_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "podcast_funding_dialog_header": "Podcast-Finanzierung", + "@podcast_funding_dialog_header": { + "description": "Header on podcast funding consent dialog", + "type": "text", + "placeholders": {} + }, + "settings_auto_open_now_playing": "Vollbild-Player-Modus beim Episodenstart", + "@settings_auto_open_now_playing": { + "description": "Displayed when user switches to use full screen player automatically", + "type": "text", + "placeholders": {} + }, + "error_no_connection": "Episode kann nicht abgespielt werden. Überprüfen Sie bitte Ihre Verbindung und versuchen Sie es erneut.", + "@error_no_connection": { + "description": "Displayed when attempting to start streaming an episode with no data connection", + "type": "text", + "placeholders": {} + }, + "error_playback_fail": "Während der Wiedergabe ist ein unerwarteter Fehler aufgetreten. Überprüfen Sie bitte Ihre Verbindung und versuchen Sie es erneut.", + "@error_playback_fail": { + "description": "Displayed when attempting to start streaming an episode with no data connection", + "type": "text", + "placeholders": {} + }, + "add_rss_feed_option": "RSS-Feed hinzufügen", + "@add_rss_feed_option": { + "description": "Option label for adding manual RSS feed url", + "type": "text", + "placeholders": {} + }, + "settings_import_opml": "OPML importieren", + "@settings_import_opml": { + "description": "Option label importing OPML file", + "type": "text", + "placeholders": {} + }, + "settings_export_opml": "OPML exportieren", + "@settings_export_opml": { + "description": "Option label exporting OPML file", + "type": "text", + "placeholders": {} + }, + "label_opml_importing": "Importieren", + "@label_opml_importing": { + "description": "Label for importing OPML dialog", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_heading": "Folgen in der Detailansicht aktualisieren, nachdem", + "@settings_auto_update_episodes_heading": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes": "Folgen automatisch aktualisieren", + "@settings_auto_update_episodes": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_never": "Noch nie", + "@settings_auto_update_episodes_never": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_always": "Immer", + "@settings_auto_update_episodes_always": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_10min": "10 Minuten seit dem letzten Update", + "@settings_auto_update_episodes_10min": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_30min": "30 Minuten seit dem letzten Update", + "@settings_auto_update_episodes_30min": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_1hour": "1 Stunde seit dem letzten Update", + "@settings_auto_update_episodes_1hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_3hour": "3 Stunden seit dem letzten Update", + "@settings_auto_update_episodes_3hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_6hour": "6 Stunden seit dem letzten Update", + "@settings_auto_update_episodes_6hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_12hour": "12 Stunden seit dem letzten Update", + "@settings_auto_update_episodes_12hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "new_episodes_label": "Neue Folgen sind verfügbar", + "@new_episodes_label": { + "description": "Option label for new episodes snackbar", + "type": "text", + "placeholders": {} + }, + "new_episodes_view_now_label": "JETZT ANZEIGEN", + "@new_episodes_view_now_label": { + "description": "Option action label for new episodes snackbar", + "type": "text", + "placeholders": {} + }, + "settings_personalisation_divider_label": "PERSONALISIERUNG", + "@settings_personalisation_divider_label": { + "description": "Settings divider label for personalisation", + "type": "text", + "placeholders": {} + }, + "settings_episodes_divider_label": "EPISODEN", + "@settings_episodes_divider_label": { + "description": "Settings divider label for episodes", + "type": "text", + "placeholders": {} + }, + "settings_playback_divider_label": "WIEDERGABE", + "@settings_playback_divider_label": { + "description": "Settings divider label for playback", + "type": "text", + "placeholders": {} + }, + "settings_data_divider_label": "DATEN", + "@settings_data_divider_label": { + "description": "Settings divider label for data", + "type": "text", + "placeholders": {} + }, + "audio_effect_trim_silence_label": "Stille Trimmen", + "@audio_effect_trim_silence_label": { + "description": "Label for trim silence toggle", + "type": "text", + "placeholders": {} + }, + "audio_effect_volume_boost_label": "Lautstärke-Boost", + "@audio_effect_volume_boost_label": { + "description": "Label for volume boost toggle", + "type": "text", + "placeholders": {} + }, + "audio_settings_playback_speed_label": "Wiedergabe Schnelligkeit", + "@audio_settings_playback_speed_label": { + "description": "Label for playback settings widget", + "type": "text", + "placeholders": {} + }, + "empty_queue_message": "Ihre Warteschlange ist leer", + "@empty_queue_message": { + "description": "Displayed when there are no items left in the queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "clear_queue_button_label": "WARTESCHLANGE LÖSCHEN", + "@clear_queue_button_label": { + "description": "Clear queue button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "now_playing_queue_label": "Jetzt Spielen", + "@now_playing_queue_label": { + "description": "Now playing label on queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "up_next_queue_label": "Als nächstes", + "@up_next_queue_label": { + "description": "Up next label on queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "more_label": "Mehr", + "@more_label": { + "description": "More label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_add_label": "Addieren", + "@queue_add_label": { + "description": "Queue add label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_remove_label": "Entfernen", + "@queue_remove_label": { + "description": "Queue remove label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_import_button_label": "Importieren", + "@opml_import_button_label": { + "description": "OPML Import button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_export_button_label": "Export", + "@opml_export_button_label": { + "description": "OPML Export button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_import_export_label": "OPML Importieren/Export", + "@opml_import_export_label": { + "description": "OPML Import/Export label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_label": "Möchten Sie die Warteschlange wirklich löschen?", + "@queue_clear_label": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_button_label": "Klar", + "@queue_clear_button_label": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_label_title": "Warteschlange löschen", + "@queue_clear_label_title": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "layout_label": "Layout", + "@layout_label": { + "description": "Layout menu label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "discovery_categories_itunes": ",Künste,Geschäft,Komödie,Ausbildung,Fiktion,Regierung,Gesundheit & Fitness,Geschichte,Kinder & Familie,Freizeit,Musik,Die Nachrichten,Religion & Spiritualität,Wissenschaft,Gesellschaft & Kultur,Sport,Fernsehen & Film,Technologie,Echte Kriminalität", + "@discovery_categories_itunes": { + "description": "Comma separated list of iTunes categories", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "discovery_categories_pindex": ",After-Shows,Alternative,Tiere,Animation,Kunst,Astronomie,Automobil,Luftfahrt,Baseball,Basketball,Schönheit,Bücher,Buddhismus,Geschäft,Karriere,Chemie,Christentum,Klima,Komödie,Kommentar,Kurse,Kunsthandwerk,Kricket,Kryptowährung,Kultur,Täglich,Design,Dokumentarfilm,Theater,Erde,Ausbildung,Unterhaltung,Unternehmerschaft,Familie,Fantasie,Mode,Fiktion,Film,Fitness,Essen,Fußball,Spiele,Garten,Golf,Regierung,Gesundheit,Hinduismus,Geschichte,Hobbys,Eishockey,Heim,Wieman,Improvisieren,Vorstellungsgespräche,Investieren,Islam,Zeitschriften,Judentum,Kinder,Sprache,Lernen,Freizeit,Leben,Management,Manga,Marketing,Mathematik,Medizin,geistig,Musik,Natürlich,Natur,Nachricht,Gemeinnützig,Ernährung,Erziehung,Aufführung,Persönlich,Haustiere,Philosophie,Physik,Setzt,Politik,Beziehungen,Religion,Bewertungen,Rollenspiel,Rugby,Betrieb,Wissenschaft,Selbstverbesserung,Sexualität,Fußball,Sozial,Gesellschaft,Spiritualität,Sport,Aufstehen,Geschichten,Baden,FERNSEHER,Tischplatte,Technologie,Tennis,Reisen,EchteKriminalität,Videospiele,Visuell,Volleyball,Wetter,Wildnis,Ringen", + "@discovery_categories_pindex": { + "description": "Comma separated list of Podcast Index categories", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_label": "Transkript", + "@transcript_label": { + "description": "Transcript label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "no_transcript_available_label": "Für diesen Podcast ist kein Transkript verfügbar", + "@no_transcript_available_label": { + "description": "Displayed in transcript view when no transcript is available", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "search_transcript_label": "Transkript suchen", + "@search_transcript_label": { + "description": "Hint text for transcript search box", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "auto_scroll_transcript_label": "Follow the transcript", + "@auto_scroll_transcript_label": { + "description": "Auto scroll switch label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_why_not_label": "Warum nicht?", + "@transcript_why_not_label": { + "description": "Link to why no transcript is available", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_why_not_url": "https://www.pinepods.online/docs/Features/Transcript", + "@transcript_why_not_url": { + "description": "Language specific link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_podcast_details_header": "Podcast-Details und Episodenseite", + "@semantics_podcast_details_header": { + "description": "Describes podcast details page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_list": "Listenlayout", + "@semantics_layout_option_list": { + "description": "Describes list layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_compact_grid": "Kompaktes Rasterlayout", + "@semantics_layout_option_compact_grid": { + "description": "Describes compact grid layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_grid": "Gitterstruktur", + "@semantics_layout_option_grid": { + "description": "Describes grid layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mini_player_header": "Mini-Player. Wischen Sie nach rechts, um die Schaltfläche „Wiedergabe/Pause“ anzuzeigen. Aktivieren, um das Hauptfenster des Players zu öffnen", + "@semantics_mini_player_header": { + "description": "Describes the mini player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_main_player_header": "Hauptfenster des Players", + "@semantics_main_player_header": { + "description": "Describes the main player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_play_pause_toggle": "Umschalten zwischen Wiedergabe und Pause", + "@semantics_play_pause_toggle": { + "description": "Describes play/pause toggle button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_decrease_playback_speed": "Verringern Sie die Wiedergabegeschwindigkeit", + "@semantics_decrease_playback_speed": { + "description": "Describes speed adjustment control", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_increase_playback_speed": "Erhöhen Sie die Wiedergabegeschwindigkeit", + "@semantics_increase_playback_speed": { + "description": "Describes speed adjustment control", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_expand_podcast_description": "Erweitern Sie die Beschreibung der Podcast", + "@semantics_expand_podcast_description": { + "description": "Describes podcast collapse/expand button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_collapse_podcast_description": "Collapse Podcast Beschreibung", + "@semantics_collapse_podcast_description": { + "description": "Describes podcast collapse/expand button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_add_to_queue": "Fügen Sie die Episode zur Warteschlange hinzu", + "@semantics_add_to_queue": { + "description": "Describes add to queue button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_remove_from_queue": "Entfernen Sie die Episode aus der Warteschlange", + "@semantics_remove_from_queue": { + "description": "Describes add to queue button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mark_episode_played": "Mark Episode as played", + "@semantics_mark_episode_played": { + "description": "Describes mark played button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mark_episode_unplayed": "Mark Episode as un-played", + "@semantics_mark_episode_unplayed": { + "description": "Describes mark unplayed button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_collapsed": "Episodenlistenelement. Zeigt Bild, Zusammenfassung und Hauptsteuerelemente.", + "@semantics_episode_tile_collapsed": { + "description": "Describes episode tile options when collapsed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_expanded": "Episodenlistenelement. Beschreibung, Hauptsteuerelemente und zusätzliche Steuerelemente werden angezeigt.", + "@semantics_episode_tile_expanded": { + "description": "Describes episode tile options when expanded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_collapsed_hint": "erweitern und weitere Details und zusätzliche Optionen anzeigen", + "@semantics_episode_tile_collapsed_hint": { + "description": "Describes episode tile options when collapsed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_expanded_hint": "Reduzieren und Zusammenfassung anzeigen, Download- und Wiedergabesteuerung", + "@semantics_episode_tile_expanded_hint": { + "description": "Describes episode tile options when expanded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_off_label": "Aus", + "@sleep_off_label": { + "description": "Describes off sleep label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_episode_label": "Ende der Folge", + "@sleep_episode_label": { + "description": "Describes end of episode sleep label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_minute_label": "{minutes} Minuten", + "@sleep_minute_label": { + "description": "Describes the number of minutes to sleep", + "type": "text", + "placeholders_order": [ + "minutes" + ], + "placeholders": { + "minutes": {} + } + }, + "sleep_timer_label": "Sleep-Timer", + "@sleep_timer_label": { + "description": "Describes sleep timer label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "feedback_menu_item_label": "Rückmeldung", + "@feedback_menu_item_label": { + "description": "Feedback option in main menu", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "podcast_options_overflow_menu_semantic_label": "Optionsmenü", + "@podcast_options_overflow_menu_semantic_label": { + "description": "Podcast details overflow menu", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_announce_searching": "Suchen, bitte warten.", + "@semantic_announce_searching": { + "description": "Spoken when search in progress.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_playing_options_expand_label": "Öffnen Sie den Schieberegler für die Wiedergabeoptionen", + "@semantic_playing_options_expand_label": { + "description": "Placed on options handle when screen reader enabled.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_playing_options_collapse_label": "Schließen Sie den Schieberegler für die Wiedergabeoptionen", + "@semantic_playing_options_collapse_label": { + "description": "Placed on options handle when screen reader enabled.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_podcast_artwork_label": "Podcast-Artwork", + "@semantic_podcast_artwork_label": { + "description": "Placed around podcast image on main player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_chapter_link_label": "Weblink zum Kapitel", + "@semantic_chapter_link_label": { + "description": "Placed around chapter link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_current_chapter_label": "Aktuelles Kapitel", + "@semantic_current_chapter_label": { + "description": "Placed around chapter", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "episode_filter_none_label": "Keiner", + "@episode_filter_none_label": { + "description": "Episodes not filtered", + "type": "text", + "placeholders": {} + }, + "episode_filter_started_label": "Gestartet", + "@episode_filter_started_label": { + "description": "Only show episodes that have been started", + "type": "text", + "placeholders": {} + }, + "episode_filter_played_label": "Gespielt", + "@episode_filter_played_label": { + "description": "Only show episodes that have been played", + "type": "text", + "placeholders": {} + }, + "episode_filter_unplayed_label": "Nicht gespielt", + "@episode_filter_unplayed_label": { + "description": "Only show episodes that have not been played", + "type": "text", + "placeholders": {} + }, + "episode_filter_no_episodes_title_label": "Keine Episoden Gefunden", + "@episode_filter_no_episodes_title_label": { + "description": "No Episodes title", + "type": "text", + "placeholders": {} + }, + "episode_filter_no_episodes_title_description": "Dieser Podcast hat keine Episoden, die Ihren Suchkriterien und Filtern entsprechen", + "@episode_filter_no_episodes_title_description": { + "description": "No episodes found description", + "type": "text", + "placeholders": {} + }, + "episode_filter_clear_filters_button_label": "Filter zurücksetzen", + "@episode_filter_clear_filters_button_label": { + "description": "Clear filters button", + "type": "text", + "placeholders": {} + }, + "episode_filter_semantic_label": "Episoden filtern", + "@episode_filter_semantic_label": { + "description": "Episode filter semantic label", + "type": "text", + "placeholders": {} + }, + "episode_sort_semantic_label": "Episoden sortieren", + "@episode_sort_semantic_label": { + "description": "Episode sort semantic label", + "type": "text", + "placeholders": {} + }, + "episode_sort_none_label": "Standard", + "@episode_sort_none_label": { + "description": "Episode default sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_latest_first_label": "Das Neueste zuerst", + "@episode_sort_latest_first_label": { + "description": "Episode latest first sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_earliest_first_label": "Das Älteste zuerst", + "@episode_sort_earliest_first_label": { + "description": "Episode earliest first sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_alphabetical_ascending_label": "Alphabetisch von A bis Z", + "@episode_sort_alphabetical_ascending_label": { + "description": "Episode alphabetical ascending", + "type": "text", + "placeholders": {} + }, + "episode_sort_alphabetical_descending_label": "Alphabetisch von Z bis A", + "@episode_sort_alphabetical_descending_label": { + "description": "Episode alphabetical descending", + "type": "text", + "placeholders": {} + }, + "open_show_website_label": "Show-Website öffnen", + "@open_show_website_label": { + "description": "Open show website in browser", + "type": "text", + "placeholders": {} + }, + "refresh_feed_label": "Holen Sie sich neue Episoden", + "@refresh_feed_label": { + "description": "Menu item to refresh episodes", + "type": "text", + "placeholders": {} + }, + "scrim_layout_selector": "Layout-Auswahl schließen", + "@scrim_layout_selector": { + "description": "Replaces default scrim label for layout selector bottom sheet.", + "type": "text", + "placeholders": {} + }, + "now_playing_episode_position": "Episodenposition", + "@now_playing_episode_position": { + "description": "Episode position slider control label", + "type": "text", + "placeholders": {} + }, + "now_playing_episode_time_remaining": "Verbleibende Zeit", + "@now_playing_episode_time_remaining": { + "description": "Episode time remaining slider control label", + "type": "text", + "placeholders": {} + }, + "resume_button_label": "Folge fortsetzen", + "@resume_button_label": { + "description": "Semantic label for the resume button", + "type": "text", + "placeholders": {} + }, + "play_download_button_label": "Heruntergeladene Episode abspielen", + "@play_download_button_label": { + "description": "Semantic label for the play downloaded episode button", + "type": "text", + "placeholders": {} + }, + "cancel_download_button_label": "Download abbrechen", + "@cancel_download_button_label": { + "description": "Semantic label for the play cancel download button", + "type": "text", + "placeholders": {} + }, + "episode_details_button_label": "Episodeninformationen anzeigen", + "@episode_details_button_label": { + "description": "Semantic label for the show info button.", + "type": "text", + "placeholders": {} + }, + "scrim_sleep_timer_selector": "Auswahl des Sleep-Timers schließen", + "@scrim_sleep_timer_selector": { + "description": "Replaces default scrim label for custom.", + "type": "text", + "placeholders": {} + }, + "scrim_speed_selector": "Auswahl der Wiedergabegeschwindigkeit schließen", + "@scrim_speed_selector": { + "description": "Replaces default scrim label for custom.", + "type": "text", + "placeholders": {} + }, + "semantic_current_value_label": "Aktueller Wert", + "@semantic_current_value_label": { + "description": "For current sleep setting", + "type": "text", + "placeholders": {} + }, + "scrim_episode_details_selector": "Episodendetails schließen", + "@scrim_episode_details_selector": { + "description": "Replaces default scrim label for episode details bottom sheet.", + "type": "text", + "placeholders": {} + }, + "scrim_episode_sort_selector": "Episodensortierung schließen", + "@scrim_episode_sort_selector": { + "description": "Replaces default scrim label for episode sort bottom sheet.", + "type": "text", + "placeholders": {} + }, + "scrim_episode_filter_selector": "Episodenfilter schließen", + "@scrim_episode_filter_selector": { + "description": "Replaces default scrim label for episode filter bottom sheet.", + "type": "text", + "placeholders": {} + }, + "search_episodes_label": "Folgen suchen", + "@search_episodes_label": { + "description": "Hint text for episode search box", + "type": "text", + "placeholders": {} + } +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/intl_en.arb b/PinePods-0.8.2/mobile/lib/l10n/intl_en.arb new file mode 100644 index 0000000..3b2ba58 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/intl_en.arb @@ -0,0 +1,1069 @@ +{ + "@@last_modified": "2020-02-20T12:15:52.645497", + "@@locale": "en", + "app_title": "PinePods Podcast Player", + "@app_title": { + "description": "Full title for the application", + "type": "text", + "placeholders": {} + }, + "app_title_short": "Pinepods", + "@app_title_short": { + "description": "Title for the application", + "type": "text", + "placeholders": {} + }, + "library": "Library", + "@library": { + "description": "Library tab label", + "type": "text", + "placeholders": {} + }, + "discover": "Discover", + "@discover": { + "description": "Discover tab label", + "type": "text", + "placeholders": {} + }, + "downloads": "Downloads", + "@downloads": { + "description": "Downloads tab label", + "type": "text", + "placeholders": {} + }, + "subscribe_button_label": "Follow", + "@subscribe_button_label": { + "description": "Subscribe button label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_button_label": "Unfollow", + "@unsubscribe_button_label": { + "description": "Unsubscribe button label", + "type": "text", + "placeholders": {} + }, + "cancel_button_label": "Cancel", + "@cancel_button_label": { + "description": "Cancel button label", + "type": "text", + "placeholders": {} + }, + "ok_button_label": "OK", + "@ok_button_label": { + "description": "OK button label", + "type": "text", + "placeholders": {} + }, + "subscribe_label": "Follow", + "@subscribe_label": { + "description": "Subscribe label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_label": "Unfollow", + "@unsubscribe_label": { + "description": "Unsubscribe label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_message": "Unfollowing will delete all downloaded episodes of this podcast.", + "@unsubscribe_message": { + "description": "Displayed when the user unsubscribes from a podcast.", + "type": "text", + "placeholders": {} + }, + "search_for_podcasts_hint": "Search for new podcasts", + "@search_for_podcasts_hint": { + "description": "Hint displayed on search bar when the user clicks the search icon.", + "type": "text", + "placeholders": {} + }, + "no_subscriptions_message": "Head to Settings to Connect a Pinepods Server if you haven't yet!", + "@no_subscriptions_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "delete_label": "Delete", + "@delete_label": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "delete_button_label": "Delete", + "@delete_button_label": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "mark_played_label": "Mark Played", + "@mark_played_label": { + "description": "Mark as played", + "type": "text", + "placeholders": {} + }, + "mark_unplayed_label": "Mark Unplayed", + "@mark_unplayed_label": { + "description": "Mark as unplayed", + "type": "text", + "placeholders": {} + }, + "delete_episode_confirmation": "Are you sure you wish to delete this episode?", + "@delete_episode_confirmation": { + "description": "User is asked to confirm when they attempt to delete an episode", + "type": "text", + "placeholders": {} + }, + "delete_episode_title": "Delete Episode", + "@delete_episode_title": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "no_downloads_message": "You do not have any downloaded episodes", + "@no_downloads_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "no_search_results_message": "No podcasts found", + "@no_search_results_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "no_podcast_details_message": "Could not load podcast episodes. Please check your connection.", + "@no_podcast_details_message": { + "description": "Displayed on the podcast details page when the details could not be loaded", + "type": "text", + "placeholders": {} + }, + "play_button_label": "Play episode", + "@play_button_label": { + "description": "Semantic label for the play button", + "type": "text", + "placeholders": {} + }, + "pause_button_label": "Pause episode", + "@pause_button_label": { + "description": "Semantic label for the pause button", + "type": "text", + "placeholders": {} + }, + "download_episode_button_label": "Download episode", + "@download_episode_button_label": { + "description": "Semantic label for the download episode button", + "type": "text", + "placeholders": {} + }, + "delete_episode_button_label": "Delete downloaded episode", + "@delete_episode_button_label": { + "description": "Semantic label for the delete episode", + "type": "text", + "placeholders": {} + }, + "close_button_label": "Close", + "@close_button_label": { + "description": "Close button label", + "type": "text", + "placeholders": {} + }, + "search_button_label": "Search", + "@search_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "clear_search_button_label": "Clear search text", + "@clear_search_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "search_back_button_label": "Back", + "@search_back_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "minimise_player_window_button_label": "Minimise player window", + "@minimise_player_window_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "rewind_button_label": "Rewind episode 10 seconds", + "@rewind_button_label": { + "description": "Rewind button tooltip", + "type": "text", + "placeholders": {} + }, + "fast_forward_button_label": "Fast-forward episode 30 seconds", + "@fast_forward_button_label": { + "description": "Fast forward tooltip", + "type": "text", + "placeholders": {} + }, + "about_label": "About", + "@about_label": { + "description": "About menu item", + "type": "text", + "placeholders": {} + }, + "mark_episodes_played_label": "Mark all episodes as played", + "@mark_episodes_played_label": { + "description": "Mark all episodes played menu item", + "type": "text", + "placeholders": {} + }, + "mark_episodes_not_played_label": "Mark all episodes as not played", + "@mark_episodes_not_played_label": { + "description": "Mark all episodes not-played menu item", + "type": "text", + "placeholders": {} + }, + "stop_download_confirmation": "Are you sure you wish to stop this download and delete the episode?", + "@stop_download_confirmation": { + "description": "User is asked to confirm when they wish to stop the active download.", + "type": "text", + "placeholders": {} + }, + "stop_download_button_label": "Stop", + "@stop_download_button_label": { + "description": "Stop label", + "type": "text", + "placeholders": {} + }, + "stop_download_title": "Stop Download", + "@stop_download_title": { + "description": "Stop download label", + "type": "text", + "placeholders": {} + }, + "settings_mark_deleted_played_label": "Mark deleted episodes as played", + "@settings_mark_deleted_played_label": { + "description": "Mark deleted episodes as played setting", + "type": "text", + "placeholders": {} + }, + "settings_delete_played_label": "Delete downloaded episodes once played", + "@settings_delete_played_label": { + "description": "Delete downloaded episodes once played setting", + "type": "text", + "placeholders": {} + }, + "settings_download_sd_card_label": "Download episodes to SD card", + "@settings_download_sd_card_label": { + "description": "Download episodes to SD card setting", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_card": "New downloads will be saved to the SD card. Existing downloads will remain on internal storage.", + "@settings_download_switch_card": { + "description": "Displayed when user switches from internal storage to SD card", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_internal": "New downloads will be saved to internal storage. Existing downloads will remain on the SD card.", + "@settings_download_switch_internal": { + "description": "Displayed when user switches from internal SD card to internal storage", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_label": "Change storage location", + "@settings_download_switch_label": { + "description": "Dialog label for storage switch", + "type": "text", + "placeholders": {} + }, + "cancel_option_label": "Cancel", + "@cancel_option_label": { + "description": "Cancel option label", + "type": "text", + "placeholders": {} + }, + "settings_theme_switch_label": "Dark theme", + "@settings_theme_switch_label": { + "description": "Dark theme", + "type": "text", + "placeholders": {} + }, + "playback_speed_label": "Playback speed", + "@playback_speed_label": { + "description": "Set playback speed icon label", + "type": "text", + "placeholders": {} + }, + "show_notes_label": "Show notes", + "@show_notes_label": { + "description": "Set show notes icon label", + "type": "text", + "placeholders": {} + }, + "search_provider_label": "Search provider", + "@search_provider_label": { + "description": "Set search provider label", + "type": "text", + "placeholders": {} + }, + "settings_label": "Settings", + "@settings_label": { + "description": "Settings label", + "type": "text", + "placeholders": {} + }, + "go_back_button_label": "Go Back", + "@go_back_button_label": { + "description": "Go-back button label", + "type": "text", + "placeholders": {} + }, + "continue_button_label": "Continue", + "@continue_button_label": { + "description": "Continue button label", + "type": "text", + "placeholders": {} + }, + "consent_message": "This funding link will take you to an external site where you will be able to directly support the show. Links are provided by the podcast authors and is not controlled by PinePods.", + "@consent_message": { + "description": "Display when first accessing external funding link", + "type": "text", + "placeholders": {} + }, + "episode_label": "Episode", + "@episode_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "chapters_label": "Chapters", + "@chapters_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "notes_label": "Description", + "@notes_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "podcast_funding_dialog_header": "Podcast Funding", + "@podcast_funding_dialog_header": { + "description": "Header on podcast funding consent dialog", + "type": "text", + "placeholders": {} + }, + "settings_auto_open_now_playing": "Full screen player mode on episode start", + "@settings_auto_open_now_playing": { + "description": "Displayed when user switches to use full screen player automatically", + "type": "text", + "placeholders": {} + }, + "error_no_connection": "Unable to play episode. Please check your connection and try again.", + "@error_no_connection": { + "description": "Displayed when attempting to start streaming an episode with no data connection", + "type": "text", + "placeholders": {} + }, + "error_playback_fail": "An unexpected error occurred during playback. Please check your connection and try again.", + "@error_playback_fail": { + "description": "Displayed when attempting to start streaming an episode with no data connection", + "type": "text", + "placeholders": {} + }, + "add_rss_feed_option": "Add RSS Feed", + "@add_rss_feed_option": { + "description": "Option label for adding manual RSS feed url", + "type": "text", + "placeholders": {} + }, + "settings_import_opml": "Import OPML", + "@settings_import_opml": { + "description": "Option label importing OPML file", + "type": "text", + "placeholders": {} + }, + "settings_export_opml": "Export OPML", + "@settings_export_opml": { + "description": "Option label exporting OPML file", + "type": "text", + "placeholders": {} + }, + "label_opml_importing": "Importing", + "@label_opml_importing": { + "description": "Label for importing OPML dialog", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_heading": "Refresh episodes on details screen after", + "@settings_auto_update_episodes_heading": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes": "Auto update episodes", + "@settings_auto_update_episodes": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_never": "Never", + "@settings_auto_update_episodes_never": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_always": "Always", + "@settings_auto_update_episodes_always": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_10min": "10 minutes since last update", + "@settings_auto_update_episodes_10min": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_30min": "30 minutes since last update", + "@settings_auto_update_episodes_30min": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_1hour": "1 hour since last update", + "@settings_auto_update_episodes_1hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_3hour": "3 hours since last update", + "@settings_auto_update_episodes_3hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_6hour": "6 hours since last update", + "@settings_auto_update_episodes_6hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_12hour": "12 hours since last update", + "@settings_auto_update_episodes_12hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "new_episodes_label": "New episodes are available", + "@new_episodes_label": { + "description": "Option label for new episodes snackbar", + "type": "text", + "placeholders": {} + }, + "new_episodes_view_now_label": "VIEW NOW", + "@new_episodes_view_now_label": { + "description": "Option action label for new episodes snackbar", + "type": "text", + "placeholders": {} + }, + "settings_personalisation_divider_label": "Personalisation", + "@settings_personalisation_divider_label": { + "description": "Settings divider label for personalisation", + "type": "text", + "placeholders": {} + }, + "settings_episodes_divider_label": "EPISODES", + "@settings_episodes_divider_label": { + "description": "Settings divider label for episodes", + "type": "text", + "placeholders": {} + }, + "settings_playback_divider_label": "Playback", + "@settings_playback_divider_label": { + "description": "Settings divider label for playback", + "type": "text", + "placeholders": {} + }, + "settings_data_divider_label": "DATA", + "@settings_data_divider_label": { + "description": "Settings divider label for data", + "type": "text", + "placeholders": {} + }, + "audio_effect_trim_silence_label": "Trim Silence", + "@audio_effect_trim_silence_label": { + "description": "Label for trim silence toggle", + "type": "text", + "placeholders": {} + }, + "audio_effect_volume_boost_label": "Volume Boost", + "@audio_effect_volume_boost_label": { + "description": "Label for volume boost toggle", + "type": "text", + "placeholders": {} + }, + "audio_settings_playback_speed_label": "Playback Speed", + "@audio_settings_playback_speed_label": { + "description": "Label for playback settings widget", + "type": "text", + "placeholders": {} + }, + "empty_queue_message": "Your queue is empty", + "@empty_queue_message": { + "description": "Displayed when there are no items left in the queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "clear_queue_button_label": "CLEAR QUEUE", + "@clear_queue_button_label": { + "description": "Clear queue button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "now_playing_queue_label": "Now Playing", + "@now_playing_queue_label": { + "description": "Now playing label on queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "up_next_queue_label": "Up Next", + "@up_next_queue_label": { + "description": "Up next label on queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "more_label": "More", + "@more_label": { + "description": "More label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_add_label": "Add", + "@queue_add_label": { + "description": "Queue add label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_remove_label": "Remove", + "@queue_remove_label": { + "description": "Queue remove label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_import_button_label": "Import", + "@opml_import_button_label": { + "description": "OPML Import button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_export_button_label": "Export", + "@opml_export_button_label": { + "description": "OPML Export button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_import_export_label": "OPML Import/Export", + "@opml_import_export_label": { + "description": "OPML Import/Export label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_label": "Are you sure you wish to clear the queue?", + "@queue_clear_label": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_button_label": "Clear", + "@queue_clear_button_label": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_label_title": "Clear Queue", + "@queue_clear_label_title": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "layout_label": "Layout", + "@layout_label": { + "description": "Layout menu label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "discovery_categories_itunes": ",Arts,Business,Comedy,Education,Fiction,Government,Health & Fitness,History,Kids & Family,Leisure,Music,News,Religion & Spirituality,Science,Society & Culture,Sports,TV & Film,Technology,True Crime", + "@discovery_categories_itunes": { + "description": "Comma separated list of iTunes categories", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "discovery_categories_pindex": ",After-Shows,Alternative,Animals,Animation,Arts,Astronomy,Automotive,Aviation,Baseball,Basketball,Beauty,Books,Buddhism,Business,Careers,Chemistry,Christianity,Climate,Comedy,Commentary,Courses,Crafts,Cricket,Cryptocurrency,Culture,Daily,Design,Documentary,Drama,Earth,Education,Entertainment,Entrepreneurship,Family,Fantasy,Fashion,Fiction,Film,Fitness,Food,Football,Games,Garden,Golf,Government,Health,Hinduism,History,Hobbies,Hockey,Home,HowTo,Improv,Interviews,Investing,Islam,Journals,Judaism,Kids,Language,Learning,Leisure,Life,Management,Manga,Marketing,Mathematics,Medicine,Mental,Music,Natural,Nature,News,NonProfit,Nutrition,Parenting,Performing,Personal,Pets,Philosophy,Physics,Places,Politics,Relationships,Religion,Reviews,Role-Playing,Rugby,Running,Science,Self-Improvement,Sexuality,Soccer,Social,Society,Spirituality,Sports,Stand-Up,Stories,Swimming,TV,Tabletop,Technology,Tennis,Travel,True Crime,Video-Games,Visual,Volleyball,Weather,Wilderness,Wrestling", + "@discovery_categories_pindex": { + "description": "Comma separated list of Podcast Index categories", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_label": "Transcript", + "@transcript_label": { + "description": "Transcript label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "no_transcript_available_label": "A transcript is not available for this podcast", + "@no_transcript_available_label": { + "description": "Displayed in transcript view when no transcript is available", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "search_transcript_label": "Search transcript", + "@search_transcript_label": { + "description": "Hint text for transcript search box", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "auto_scroll_transcript_label": "Follow transcript", + "@auto_scroll_transcript_label": { + "description": "Auto scroll switch label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_why_not_label": "Why not?", + "@transcript_why_not_label": { + "description": "Link to why no transcript is available", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_why_not_url": "https://www.pinepods.online/docs/Features/Transcript", + "@transcript_why_not_url": { + "description": "Language specific link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_podcast_details_header": "Podcast details and episodes page", + "@semantics_podcast_details_header": { + "description": "Describes podcast details page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_list": "List layout", + "@semantics_layout_option_list": { + "description": "Describes list layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_compact_grid": "Compact grid layout", + "@semantics_layout_option_compact_grid": { + "description": "Describes compact grid layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_grid": "Grid layout", + "@semantics_layout_option_grid": { + "description": "Describes grid layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mini_player_header": "Mini player. Swipe right to play/pause button. Activate to open main player window", + "@semantics_mini_player_header": { + "description": "Describes the mini player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_main_player_header": "Main player window", + "@semantics_main_player_header": { + "description": "Describes the main player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_play_pause_toggle": "Play/pause toggle", + "@semantics_play_pause_toggle": { + "description": "Describes play/pause toggle button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_decrease_playback_speed": "Decrease playback speed", + "@semantics_decrease_playback_speed": { + "description": "Describes speed adjustment control", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_increase_playback_speed": "Increase playback speed", + "@semantics_increase_playback_speed": { + "description": "Describes speed adjustment control", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_expand_podcast_description": "Expand podcast description", + "@semantics_expand_podcast_description": { + "description": "Describes podcast collapse/expand button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_collapse_podcast_description": "Collapse podcast description", + "@semantics_collapse_podcast_description": { + "description": "Describes podcast collapse/expand button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_add_to_queue": "Add episode to queue", + "@semantics_add_to_queue": { + "description": "Describes add to queue button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_remove_from_queue": "Remove episode from queue", + "@semantics_remove_from_queue": { + "description": "Describes add to queue button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mark_episode_played": "Mark Episode as played", + "@semantics_mark_episode_played": { + "description": "Describes mark played button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mark_episode_unplayed": "Mark Episode as un-played", + "@semantics_mark_episode_unplayed": { + "description": "Describes mark unplayed button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_collapsed": "Episode list item. Showing image, summary and main controls.", + "@semantics_episode_tile_collapsed": { + "description": "Describes episode tile options when collapsed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_expanded": "Episode list item. Showing description, main controls and additional controls.", + "@semantics_episode_tile_expanded": { + "description": "Describes episode tile options when expanded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_collapsed_hint": "expand and show more details and additional options", + "@semantics_episode_tile_collapsed_hint": { + "description": "Describes episode tile options when collapsed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_expanded_hint": "collapse and show summary, download and play control", + "@semantics_episode_tile_expanded_hint": { + "description": "Describes episode tile options when expanded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_off_label": "Off", + "@sleep_off_label": { + "description": "Describes off sleep label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_episode_label": "End of episode", + "@sleep_episode_label": { + "description": "Describes end of episode sleep label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_minute_label": "{minutes} minutes", + "@sleep_minute_label": { + "description": "Describes the number of minutes to sleep", + "type": "text", + "placeholders_order": [ + "minutes" + ], + "placeholders": { + "minutes": {} + } + }, + "sleep_timer_label": "Sleep Timer", + "@sleep_timer_label": { + "description": "Describes sleep timer label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "feedback_menu_item_label": "Feedback", + "@feedback_menu_item_label": { + "description": "Feedback option in main menu", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "podcast_options_overflow_menu_semantic_label": "Options menu", + "@podcast_options_overflow_menu_semantic_label": { + "description": "Podcast details overflow menu", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_announce_searching": "Searching, please wait.", + "@semantic_announce_searching": { + "description": "Spoken when search in progress.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_playing_options_expand_label": "Open playing options slider", + "@semantic_playing_options_expand_label": { + "description": "Placed on options handle when screen reader enabled.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_playing_options_collapse_label": "Close playing options slider", + "@semantic_playing_options_collapse_label": { + "description": "Placed on options handle when screen reader enabled.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_podcast_artwork_label": "Podcast artwork", + "@semantic_podcast_artwork_label": { + "description": "Placed around podcast image on main player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_chapter_link_label": "Chapter web link", + "@semantic_chapter_link_label": { + "description": "Placed around chapter link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_current_chapter_label": "Current chapter", + "@semantic_current_chapter_label": { + "description": "Placed around chapter", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "episode_filter_none_label": "None", + "@episode_filter_none_label": { + "description": "Episodes not filtered", + "type": "text", + "placeholders": {} + }, + "episode_filter_started_label": "Started", + "@episode_filter_started_label": { + "description": "Only show episodes that have been started", + "type": "text", + "placeholders": {} + }, + "episode_filter_played_label": "Played", + "@episode_filter_played_label": { + "description": "Only show episodes that have been played", + "type": "text", + "placeholders": {} + }, + "episode_filter_unplayed_label": "Unplayed", + "@episode_filter_unplayed_label": { + "description": "Only show episodes that have not been played", + "type": "text", + "placeholders": {} + }, + "episode_filter_no_episodes_title_label": "No Episodes Found", + "@episode_filter_no_episodes_title_label": { + "description": "No Episodes title", + "type": "text", + "placeholders": {} + }, + "episode_filter_no_episodes_title_description": "This podcast has no episodes matching your search criteria and filter", + "@episode_filter_no_episodes_title_description": { + "description": "No episodes found description", + "type": "text", + "placeholders": {} + }, + "episode_filter_clear_filters_button_label": "Clear Filters", + "@episode_filter_clear_filters_button_label": { + "description": "Clear filters button", + "type": "text", + "placeholders": {} + }, + "episode_filter_semantic_label": "Filter episodes", + "@episode_filter_semantic_label": { + "description": "Episode filter semantic label", + "type": "text", + "placeholders": {} + }, + "episode_sort_semantic_label": "Sort episodes", + "@episode_sort_semantic_label": { + "description": "Episode sort semantic label", + "type": "text", + "placeholders": {} + }, + "episode_sort_none_label": "Default", + "@episode_sort_none_label": { + "description": "Episode default sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_latest_first_label": "Latest first", + "@episode_sort_latest_first_label": { + "description": "Episode latest first sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_earliest_first_label": "Earliest first", + "@episode_sort_earliest_first_label": { + "description": "Episode earliest first sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_alphabetical_ascending_label": "Alphabetical A-Z", + "@episode_sort_alphabetical_ascending_label": { + "description": "Episode alphabetical ascending", + "type": "text", + "placeholders": {} + }, + "episode_sort_alphabetical_descending_label": "Alphabetical Z-A", + "@episode_sort_alphabetical_descending_label": { + "description": "Episode alphabetical descending", + "type": "text", + "placeholders": {} + }, + "open_show_website_label": "Open show website", + "@open_show_website_label": { + "description": "Open show website in browser", + "type": "text", + "placeholders": {} + }, + "refresh_feed_label": "Refresh episodes", + "@refresh_feed_label": { + "description": "Menu item to refresh episodes", + "type": "text", + "placeholders": {} + }, + "scrim_layout_selector": "Dismiss layout selector", + "@scrim_layout_selector": { + "description": "Replaces default scrim label for layout selector bottom sheet.", + "type": "text", + "placeholders": {} + }, + "now_playing_episode_position": "Episode position", + "@now_playing_episode_position": { + "description": "Episode position slider control label", + "type": "text", + "placeholders": {} + }, + "now_playing_episode_time_remaining": "Time remaining", + "@now_playing_episode_time_remaining": { + "description": "Episode time remaining slider control label", + "type": "text", + "placeholders": {} + }, + "resume_button_label": "Resume episode", + "@resume_button_label": { + "description": "Semantic label for the resume button", + "type": "text", + "placeholders": {} + }, + "play_download_button_label": "Play downloaded episode", + "@play_download_button_label": { + "description": "Semantic label for the play downloaded episode button", + "type": "text", + "placeholders": {} + }, + "cancel_download_button_label": "Cancel download", + "@cancel_download_button_label": { + "description": "Semantic label for the play cancel download button", + "type": "text", + "placeholders": {} + }, + "episode_details_button_label": "Show episode information", + "@episode_details_button_label": { + "description": "Semantic label for the show info button.", + "type": "text", + "placeholders": {} + }, + "scrim_sleep_timer_selector": "Dismiss sleep timer selector", + "@scrim_sleep_timer_selector": { + "description": "Replaces default scrim label for custom.", + "type": "text", + "placeholders": {} + }, + "scrim_speed_selector": "Dismiss playback speed selector", + "@scrim_speed_selector": { + "description": "Replaces default scrim label for custom.", + "type": "text", + "placeholders": {} + }, + "semantic_current_value_label": "Current value", + "@semantic_current_value_label": { + "description": "For current sleep setting", + "type": "text", + "placeholders": {} + }, + "scrim_episode_details_selector": "Dismiss episode details", + "@scrim_episode_details_selector": { + "description": "Replaces default scrim label for episode details bottom sheet.", + "type": "text", + "placeholders": {} + }, + "scrim_episode_sort_selector": "Dismiss episode sort", + "@scrim_episode_sort_selector": { + "description": "Replaces default scrim label for episode sort bottom sheet.", + "type": "text", + "placeholders": {} + }, + "scrim_episode_filter_selector": "Dismiss episode filter", + "@scrim_episode_filter_selector": { + "description": "Replaces default scrim label for episode filter bottom sheet.", + "type": "text", + "placeholders": {} + }, + "search_episodes_label": "Search episodes", + "@search_episodes_label": { + "description": "Hint text for episode search box", + "type": "text", + "placeholders": {} + } +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/intl_it.arb b/PinePods-0.8.2/mobile/lib/l10n/intl_it.arb new file mode 100644 index 0000000..74cf7af --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/intl_it.arb @@ -0,0 +1,1068 @@ +{ + "@@last_modified": "2024-04-09T17:34:52.645497", + "app_title": "PinePods Podcast Player", + "@app_title": { + "description": "Full title for the application", + "type": "text", + "placeholders": {} + }, + "app_title_short": "Pinepods", + "@app_title_short": { + "description": "Title for the application", + "type": "text", + "placeholders": {} + }, + "library": "Libreria", + "@library": { + "description": "Library tab label", + "type": "text", + "placeholders": {} + }, + "discover": "Scopri", + "@discover": { + "description": "Discover tab label", + "type": "text", + "placeholders": {} + }, + "downloads": "Scaricati", + "@downloads": { + "description": "Downloads tab label", + "type": "text", + "placeholders": {} + }, + "subscribe_button_label": "Segui", + "@subscribe_button_label": { + "description": "Subscribe button label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_button_label": "Non Seguire", + "@unsubscribe_button_label": { + "description": "Unsubscribe button label", + "type": "text", + "placeholders": {} + }, + "cancel_button_label": "Annulla", + "@cancel_button_label": { + "description": "Cancel button label", + "type": "text", + "placeholders": {} + }, + "ok_button_label": "OK", + "@ok_button_label": { + "description": "OK button label", + "type": "text", + "placeholders": {} + }, + "subscribe_label": "Segui", + "@subscribe_label": { + "description": "Subscribe label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_label": "Smetti di seguire", + "@unsubscribe_label": { + "description": "Unsubscribe label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_message": "Smettendo di seguire questo podcast, tutti gli episodi scaricati verranno eliminati.", + "@unsubscribe_message": { + "description": "Displayed when the user unsubscribes from a podcast.", + "type": "text", + "placeholders": {} + }, + "search_for_podcasts_hint": "Ricerca dei podcasts", + "@search_for_podcasts_hint": { + "description": "Hint displayed on search bar when the user clicks the search icon.", + "type": "text", + "placeholders": {} + }, + "no_subscriptions_message": "Tappa il pulsante di ricerca sottostante o usa la barra di ricerca per trovare il tuo primo podcast", + "@no_subscriptions_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "delete_label": "Elimina", + "@delete_label": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "delete_button_label": "Elimina", + "@delete_button_label": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "mark_played_label": "Marca Riprodotto", + "@mark_played_label": { + "description": "Mark as played", + "type": "text", + "placeholders": {} + }, + "mark_unplayed_label": "Marca da Riprodurre", + "@mark_unplayed_label": { + "description": "Mark as unplayed", + "type": "text", + "placeholders": {} + }, + "delete_episode_confirmation": "Sicura/o di voler eliminare questo episodio?", + "@delete_episode_confirmation": { + "description": "User is asked to confirm when they attempt to delete an episode", + "type": "text", + "placeholders": {} + }, + "delete_episode_title": "Elimina Episodio", + "@delete_episode_title": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "no_downloads_message": "Non hai nessun episodio scaricato", + "@no_downloads_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "no_search_results_message": "Nessun podcast trovato", + "@no_search_results_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "no_podcast_details_message": "Non è possibile caricare gli episodi. Verifica la tua connessione, per favore.", + "@no_podcast_details_message": { + "description": "Displayed on the podcast details page when the details could not be loaded", + "type": "text", + "placeholders": {} + }, + "play_button_label": "Riproduci episodio", + "@play_button_label": { + "description": "Semantic label for the play button", + "type": "text", + "placeholders": {} + }, + "pause_button_label": "Sospendi episodio", + "@pause_button_label": { + "description": "Semantic label for the pause button", + "type": "text", + "placeholders": {} + }, + "download_episode_button_label": "Scarica episodio", + "@download_episode_button_label": { + "description": "Semantic label for the download episode button", + "type": "text", + "placeholders": {} + }, + "delete_episode_button_label": "Elimina episodio scaricato", + "@delete_episode_button_label": { + "description": "Semantic label for the delete episode", + "type": "text", + "placeholders": {} + }, + "close_button_label": "Chiudi", + "@close_button_label": { + "description": "Close button label", + "type": "text", + "placeholders": {} + }, + "search_button_label": "Cerca", + "@search_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "clear_search_button_label": "Pulisci il campo di ricerca", + "@clear_search_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "search_back_button_label": "Indietro", + "@search_back_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "minimise_player_window_button_label": "Minimizza la finestra del player", + "@minimise_player_window_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "rewind_button_label": "Riavvolgi di 10 secondi", + "@rewind_button_label": { + "description": "Rewind button tooltip", + "type": "text", + "placeholders": {} + }, + "fast_forward_button_label": "Manda avanti di 30 secondi", + "@fast_forward_button_label": { + "description": "Fast forward tooltip", + "type": "text", + "placeholders": {} + }, + "about_label": "Info", + "@about_label": { + "description": "About menu item", + "type": "text", + "placeholders": {} + }, + "mark_episodes_played_label": "Marca tutti gli episodi come riprodotti", + "@mark_episodes_played_label": { + "description": "Mark all episodes played menu item", + "type": "text", + "placeholders": {} + }, + "mark_episodes_not_played_label": "Marca tutti gli episodi come non riprodotti", + "@mark_episodes_not_played_label": { + "description": "Mark all episodes not-played menu item", + "type": "text", + "placeholders": {} + }, + "stop_download_confirmation": "Sicura/o di voler fermare il download ed eliminare l'episodio?", + "@stop_download_confirmation": { + "description": "User is asked to confirm when they wish to stop the active download.", + "type": "text", + "placeholders": {} + }, + "stop_download_button_label": "Stop", + "@stop_download_button_label": { + "description": "Stop label", + "type": "text", + "placeholders": {} + }, + "stop_download_title": "Stop Download", + "@stop_download_title": { + "description": "Stop download label", + "type": "text", + "placeholders": {} + }, + "settings_mark_deleted_played_label": "Marca gli episodi eliminati come riprodotti", + "@settings_mark_deleted_played_label": { + "description": "Mark deleted episodes as played setting", + "type": "text", + "placeholders": {} + }, + "settings_delete_played_label": "Elimina gli episodi scaricati una volta riprodotti", + "@settings_delete_played_label": { + "description": "Delete downloaded episodes once played setting", + "type": "text", + "placeholders": {} + }, + "settings_download_sd_card_label": "Scarica gli episodi nella card SD", + "@settings_download_sd_card_label": { + "description": "Download episodes to SD card setting", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_card": "I nuovi downloads saranno salvati nella card SD. I downloads esistenti rimarranno nello storage interno.", + "@settings_download_switch_card": { + "description": "Displayed when user switches from internal storage to SD card", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_internal": "I nuovi downloads saranno salvati nello storage interno. I downloads esistenti rimarranno nella card SD.", + "@settings_download_switch_internal": { + "description": "Displayed when user switches from internal SD card to internal storage", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_label": "Cambia la posizione per lo storage", + "@settings_download_switch_label": { + "description": "Dialog label for storage switch", + "type": "text", + "placeholders": {} + }, + "cancel_option_label": "Annulla", + "@cancel_option_label": { + "description": "Cancel option label", + "type": "text", + "placeholders": {} + }, + "settings_theme_switch_label": "Tema scuro", + "@settings_theme_switch_label": { + "description": "Dark theme", + "type": "text", + "placeholders": {} + }, + "playback_speed_label": "Velocità di riproduzione", + "@playback_speed_label": { + "description": "Set playback speed icon label", + "type": "text", + "placeholders": {} + }, + "show_notes_label": "Visualizza le note", + "@show_notes_label": { + "description": "Set show notes icon label", + "type": "text", + "placeholders": {} + }, + "search_provider_label": "Provider di ricerca", + "@search_provider_label": { + "description": "Set search provider label", + "type": "text", + "placeholders": {} + }, + "settings_label": "Impostazioni", + "@settings_label": { + "description": "Settings label", + "type": "text", + "placeholders": {} + }, + "go_back_button_label": "Torna indietro", + "@go_back_button_label": { + "description": "Go-back button label", + "type": "text", + "placeholders": {} + }, + "continue_button_label": "Continua", + "@continue_button_label": { + "description": "Continue button label", + "type": "text", + "placeholders": {} + }, + "consent_message": "Questo link per la ricerca fondi ti porterà a un sito esterno dove avrai la possibilità di supportare direttamente questo show. I link sono forniti dagli autori del podcast e non sono verificati da PinePods.", + "@consent_message": { + "description": "Display when first accessing external funding link", + "type": "text", + "placeholders": {} + }, + "episode_label": "Episodio", + "@episode_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "chapters_label": "Capitoli", + "@chapters_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "notes_label": "Note", + "@notes_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "podcast_funding_dialog_header": "Podcast Fondi", + "@podcast_funding_dialog_header": { + "description": "Header on podcast funding consent dialog", + "type": "text", + "placeholders": {} + }, + "settings_auto_open_now_playing": "Player a tutto schermo quando l'episodio inizia", + "@settings_auto_open_now_playing": { + "description": "Displayed when user switches to use full screen player automatically", + "type": "text", + "placeholders": {} + }, + "error_no_connection": "Impossibile riprodurre l'episodio. Per favore, verifica la tua connessione e prova di nuovo.", + "@error_no_connection": { + "description": "Displayed when attempting to start streaming an episode with no data connection", + "type": "text", + "placeholders": {} + }, + "error_playback_fail": "Sì è verificato un errore inatteso durante la riproduzione. Per favore, verifica la tua connessione e prova di nuovo.", + "@error_playback_fail": { + "description": "Displayed when attempting to start streaming an episode with no data connection", + "type": "text", + "placeholders": {} + }, + "add_rss_feed_option": "Aggiungi un Feed RSS", + "@add_rss_feed_option": { + "description": "Option label for adding manual RSS feed url", + "type": "text", + "placeholders": {} + }, + "settings_import_opml": "Importa OPML", + "@settings_import_opml": { + "description": "Option label importing OPML file", + "type": "text", + "placeholders": {} + }, + "settings_export_opml": "Esporta OPML", + "@settings_export_opml": { + "description": "Option label exporting OPML file", + "type": "text", + "placeholders": {} + }, + "label_opml_importing": "Importazione in corso", + "@label_opml_importing": { + "description": "Label for importing OPML dialog", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_heading": "Aggiorna gli episodi nella schermata successiva", + "@settings_auto_update_episodes_heading": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes": "Aggiorna automaticamente gli episodi", + "@settings_auto_update_episodes": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_never": "Mai", + "@settings_auto_update_episodes_never": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_always": "Sempre", + "@settings_auto_update_episodes_always": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_10min": "10 minuti dall'ultimo aggiornamento", + "@settings_auto_update_episodes_10min": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_30min": "30 minuti dall'ultimo aggiornamento", + "@settings_auto_update_episodes_30min": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_1hour": "1 ora dall'ultimo aggiornamento", + "@settings_auto_update_episodes_1hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_3hour": "3 ore dall'ultimo aggiornamento", + "@settings_auto_update_episodes_3hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_6hour": "6 ore dall'ultimo aggiornamento", + "@settings_auto_update_episodes_6hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_12hour": "12 ore dall'ultimo aggiornamento", + "@settings_auto_update_episodes_12hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "new_episodes_label": "Nuovi episodi sono disponibili", + "@new_episodes_label": { + "description": "Option label for new episodes snackbar", + "type": "text", + "placeholders": {} + }, + "new_episodes_view_now_label": "VEDI ORA", + "@new_episodes_view_now_label": { + "description": "Option action label for new episodes snackbar", + "type": "text", + "placeholders": {} + }, + "settings_personalisation_divider_label": "PERSONALIZZAZIONI", + "@settings_personalisation_divider_label": { + "description": "Settings divider label for personalisation", + "type": "text", + "placeholders": {} + }, + "settings_episodes_divider_label": "EPISODI", + "@settings_episodes_divider_label": { + "description": "Settings divider label for episodes", + "type": "text", + "placeholders": {} + }, + "settings_playback_divider_label": "RIPRODUZIONE", + "@settings_playback_divider_label": { + "description": "Settings divider label for playback", + "type": "text", + "placeholders": {} + }, + "settings_data_divider_label": "DATI", + "@settings_data_divider_label": { + "description": "Settings divider label for data", + "type": "text", + "placeholders": {} + }, + "audio_effect_trim_silence_label": "Rimuovi Silenzio", + "@audio_effect_trim_silence_label": { + "description": "Label for trim silence toggle", + "type": "text", + "placeholders": {} + }, + "audio_effect_volume_boost_label": "Incrementa Volume", + "@audio_effect_volume_boost_label": { + "description": "Label for volume boost toggle", + "type": "text", + "placeholders": {} + }, + "audio_settings_playback_speed_label": "Velocità Riproduzione", + "@audio_settings_playback_speed_label": { + "description": "Label for playback settings widget", + "type": "text", + "placeholders": {} + }, + "empty_queue_message": "La tua coda è vuota", + "@empty_queue_message": { + "description": "Displayed when there are no items left in the queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "clear_queue_button_label": "PULISCI CODA", + "@clear_queue_button_label": { + "description": "Clear queue button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "now_playing_queue_label": "In Riproduzione", + "@now_playing_queue_label": { + "description": "Now playing label on queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "up_next_queue_label": "Vai al Prossimo", + "@up_next_queue_label": { + "description": "Up next label on queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "more_label": "Di Più", + "@more_label": { + "description": "More label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_add_label": "Aggiungi", + "@queue_add_label": { + "description": "Queue add label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_remove_label": "Rimuovi", + "@queue_remove_label": { + "description": "Queue remove label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_import_button_label": "Importa", + "@opml_import_button_label": { + "description": "OPML Import button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_export_button_label": "Esporta", + "@opml_export_button_label": { + "description": "OPML Export button label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "opml_import_export_label": "OPML Importa/Esporta", + "@opml_import_export_label": { + "description": "OPML Import/Export label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_label": "Sicuro/a di voler ripulire la coda?", + "@queue_clear_label": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_button_label": "Svuota", + "@queue_clear_button_label": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "queue_clear_label_title": "Svuota la Coda", + "@queue_clear_label_title": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "layout_label": "Layout", + "@layout_label": { + "description": "Layout menu label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "discovery_categories_itunes": ",Arte,Business,Commedia,Educazione,Fiction,Governativi,Salute e Benessere,Storia,Bambini e Famiglia,Tempo Libero,Musica,Notizie,Religione e Spiritualità,Scienza,Società e Cultura,Sport,TV e Film,Tecnologia,True Crime", + "@discovery_categories_itunes": { + "description": "Comma separated list of iTunes categories", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "discovery_categories_pindex": ",Dopo-Spettacolo,Alternativi,Animali,Animazione,Arte,Astronomia,Automotive,Aviazione,Baseball,Pallacanestro,Bellezza,Libri,Buddismo,Business,Carriera,Chimica,Cristianità,Clima,Commedia,Commenti,Corsi,Artigianato,Cricket,Cryptocurrency,Cultura,Giornalieri,Design,Documentari,Dramma,Terra,Educazione,Intrattenimento,Imprenditoria,Famiglia,Fantasy,Fashion,Fiction,Film,Fitness,Cibo,Football,Giochi,Giardinaggio,Golf,Governativi,Salute,Induismo,Storia,Hobbies,Hockey,Casa,Come Fare,Improvvisazione,Interviste,Investimenti,Islam,Giornalismo,Giudaismo,Bambini,Lingue,Apprendimento,Tempo-Libero,Stili di Vita,Gestione,Manga,Marketing,Matematica,Medicina,Mentale,Musica,Naturale,Natura,Notizie,NonProfit,Nutrizione,Genitorialità,Esecuzione,Personale,Animali-Domestici,Filosofia,Fisica,Posti,Politica,Relazioni,Religione,Recensioni,Giochi-di-Ruolo,Rugby,Corsa,Scienza,Miglioramento-Personale,Sessualità,Calcio,Social,Società,Spiritualità,Sports,Stand-Up,Storie,Nuoto,TV,Tabletop,Tecnologia,Tennis,Viaggi,True Crime,Video-Giochi,Visivo,Pallavolo,Meteo,Natura-Selvaggia,Wrestling", + "@discovery_categories_pindex": { + "description": "Comma separated list of Podcast Index categories", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_label": "Trascrizioni", + "@transcript_label": { + "description": "Transcript label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "no_transcript_available_label": "Nessuna trascrizione disponibile per questo podcast", + "@no_transcript_available_label": { + "description": "Displayed in transcript view when no transcript is available", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "search_transcript_label": "Cerca trascrizione", + "@search_transcript_label": { + "description": "Hint text for transcript search box", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "auto_scroll_transcript_label": "Trascrizione sincronizzata", + "@auto_scroll_transcript_label": { + "description": "Auto scroll switch label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_why_not_label": "Perché no?", + "@transcript_why_not_label": { + "description": "Link to why no transcript is available", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "transcript_why_not_url": "https://www.pinepods.online/docs/Features/Transcript", + "@transcript_why_not_url": { + "description": "Language specific link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_podcast_details_header": "Podcast pagina dettagli ed episodi", + "@semantics_podcast_details_header": { + "description": "Describes podcast details page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_list": "Lista", + "@semantics_layout_option_list": { + "description": "Describes list layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_compact_grid": "Griglia compatta", + "@semantics_layout_option_compact_grid": { + "description": "Describes compact grid layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_layout_option_grid": "Griglia", + "@semantics_layout_option_grid": { + "description": "Describes grid layout button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mini_player_header": "Mini player. Swipe a destra per riprodurre/mettere in pausa. Attivare per aprire la finestra principale del player", + "@semantics_mini_player_header": { + "description": "Describes the mini player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_main_player_header": "Finestra principale del player", + "@semantics_main_player_header": { + "description": "Describes the main player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_play_pause_toggle": "Play/pause toggle", + "@semantics_play_pause_toggle": { + "description": "Describes play/pause toggle button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_decrease_playback_speed": "Rallenta la riproduzione", + "@semantics_decrease_playback_speed": { + "description": "Describes speed adjustment control", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_increase_playback_speed": "Incrementa la riproduzione", + "@semantics_increase_playback_speed": { + "description": "Describes speed adjustment control", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_expand_podcast_description": "Espandi la descrizione del podcast", + "@semantics_expand_podcast_description": { + "description": "Describes podcast collapse/expand button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_collapse_podcast_description": "Collassa la descrizione del podcast", + "@semantics_collapse_podcast_description": { + "description": "Describes podcast collapse/expand button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_add_to_queue": "Aggiungi episodio alla coda", + "@semantics_add_to_queue": { + "description": "Describes add to queue button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_remove_from_queue": "Rimuovi episodio dalla coda", + "@semantics_remove_from_queue": { + "description": "Describes add to queue button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mark_episode_played": "Marca Episodio come riprodotto", + "@semantics_mark_episode_played": { + "description": "Describes mark played button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_mark_episode_unplayed": "Marca Episodio come non-riprodotto", + "@semantics_mark_episode_unplayed": { + "description": "Describes mark unplayed button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_collapsed": "Voce dell'elenco degli episodi. Visualizza immagine, sommario e i controlli principali.", + "@semantics_episode_tile_collapsed": { + "description": "Describes episode tile options when collapsed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_expanded": "Voce dell'elenco degli episodi. Visualizza descrizione, controlli principali e controlli aggiuntivi.", + "@semantics_episode_tile_expanded": { + "description": "Describes episode tile options when expanded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_collapsed_hint": "espandi e visualizza più dettagli e opzioni aggiuntive", + "@semantics_episode_tile_collapsed_hint": { + "description": "Describes episode tile options when collapsed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantics_episode_tile_expanded_hint": "collassa e visualizza il sommario, download e controlli di riproduzione", + "@semantics_episode_tile_expanded_hint": { + "description": "Describes episode tile options when expanded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_off_label": "Off", + "@sleep_off_label": { + "description": "Describes off sleep label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_episode_label": "Fine dell'episodio", + "@sleep_episode_label": { + "description": "Describes end of episode sleep label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "sleep_minute_label": "{minutes} minuti", + "@sleep_minute_label": { + "description": "Describes the number of minutes to sleep", + "type": "text", + "placeholders_order": [ + "minutes" + ], + "placeholders": { + "minutes": {} + } + }, + "sleep_timer_label": "Timer di Riposo", + "@sleep_timer_label": { + "description": "Describes sleep timer label", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "feedback_menu_item_label": "Feedback", + "@feedback_menu_item_label": { + "description": "Feedback option in main menu", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "podcast_options_overflow_menu_semantic_label": "Menu opzioni", + "@podcast_options_overflow_menu_semantic_label": { + "description": "Podcast details overflow menu", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_announce_searching": "Ricerca in corso, attender prego.", + "@semantic_announce_searching": { + "description": "Spoken when search in progress.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_playing_options_expand_label": "Aprire il cursore delle opzioni di riproduzione", + "@semantic_playing_options_expand_label": { + "description": "Placed on options handle when screen reader enabled.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_playing_options_collapse_label": "Chiudere il cursore delle opzioni di riproduzione", + "@semantic_playing_options_collapse_label": { + "description": "Placed on options handle when screen reader enabled.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_podcast_artwork_label": "Podcast artwork", + "@semantic_podcast_artwork_label": { + "description": "Placed around podcast image on main player", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_chapter_link_label": "Web link al capitolo", + "@semantic_chapter_link_label": { + "description": "Placed around chapter link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "semantic_current_chapter_label": "Capitolo attuale", + "@semantic_current_chapter_label": { + "description": "Placed around chapter", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "episode_filter_none_label": "Nessuno", + "@episode_filter_none_label": { + "description": "Episodes not filtered", + "type": "text", + "placeholders": {} + }, + "episode_filter_started_label": "Avviato", + "@episode_filter_started_label": { + "description": "Only show episodes that have been started", + "type": "text", + "placeholders": {} + }, + "episode_filter_played_label": "Riprodotto", + "@episode_filter_played_label": { + "description": "Only show episodes that have been played", + "type": "text", + "placeholders": {} + }, + "episode_filter_unplayed_label": "Non riprodotto", + "@episode_filter_unplayed_label": { + "description": "Only show episodes that have not been played", + "type": "text", + "placeholders": {} + }, + "episode_filter_no_episodes_title_label": "Nessun episodio trovato", + "@episode_filter_no_episodes_title_label": { + "description": "No Episodes title", + "type": "text", + "placeholders": {} + }, + "episode_filter_no_episodes_title_description": "Questo podcast non ha episodi che corrispondono ai tuoi criteri di ricerca e filtro", + "@episode_filter_no_episodes_title_description": { + "description": "No episodes found description", + "type": "text", + "placeholders": {} + }, + "episode_filter_clear_filters_button_label": "Pulisci i Filtri", + "@episode_filter_clear_filters_button_label": { + "description": "Clear filters button", + "type": "text", + "placeholders": {} + }, + "episode_filter_semantic_label": "Filtra gli episodi", + "@episode_filter_semantic_label": { + "description": "Episode filter semantic label", + "type": "text", + "placeholders": {} + }, + "episode_sort_semantic_label": "Ordina gli episodi", + "@episode_sort_semantic_label": { + "description": "Episode sort semantic label", + "type": "text", + "placeholders": {} + }, + "episode_sort_none_label": "Default", + "@episode_sort_none_label": { + "description": "Episode default sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_latest_first_label": "Gli ultimi", + "@episode_sort_latest_first_label": { + "description": "Episode latest first sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_earliest_first_label": "I più vecchi", + "@episode_sort_earliest_first_label": { + "description": "Episode earliest first sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_alphabetical_ascending_label": "Ordine Alfabetico A-Z", + "@episode_sort_alphabetical_ascending_label": { + "description": "Episode alphabetical ascending", + "type": "text", + "placeholders": {} + }, + "episode_sort_alphabetical_descending_label": "Ordine Alfabetico Z-A", + "@episode_sort_alphabetical_descending_label": { + "description": "Episode alphabetical descending", + "type": "text", + "placeholders": {} + }, + "open_show_website_label": "Vai al sito web dello show", + "@open_show_website_label": { + "description": "Open show website in browser", + "type": "text", + "placeholders": {} + }, + "refresh_feed_label": "Recupera nuovi episodi", + "@refresh_feed_label": { + "description": "Menu item to refresh episodes", + "type": "text", + "placeholders": {} + }, + "scrim_layout_selector": "Chiudi il selettore del layout", + "@scrim_layout_selector": { + "description": "Replaces default scrim label for layout selector bottom sheet.", + "type": "text", + "placeholders": {} + }, + "now_playing_episode_position": "Posizione dell'episodio", + "@now_playing_episode_position": { + "description": "Episode position slider control label", + "type": "text", + "placeholders": {} + }, + "now_playing_episode_time_remaining": "Tempo rimanente", + "@now_playing_episode_time_remaining": { + "description": "Episode time remaining slider control label", + "type": "text", + "placeholders": {} + }, + "resume_button_label": "Riprendi episodio", + "@resume_button_label": { + "description": "Semantic label for the resume button", + "type": "text", + "placeholders": {} + }, + "play_download_button_label": "Riproduci l'episodio scaricato", + "@play_download_button_label": { + "description": "Semantic label for the play downloaded episode button", + "type": "text", + "placeholders": {} + }, + "cancel_download_button_label": "Annulla il download", + "@cancel_download_button_label": { + "description": "Semantic label for the play cancel download button", + "type": "text", + "placeholders": {} + }, + "episode_details_button_label": "Mostra le informazioni sull'episodio", + "@episode_details_button_label": { + "description": "Semantic label for the show info button.", + "type": "text", + "placeholders": {} + }, + "scrim_sleep_timer_selector": "Chiudere il selettore del timer di spegnimento", + "@scrim_sleep_timer_selector": { + "description": "Replaces default scrim label for custom.", + "type": "text", + "placeholders": {} + }, + "scrim_speed_selector": "Chiudere il selettore della velocità di riproduzione", + "@scrim_speed_selector": { + "description": "Replaces default scrim label for custom.", + "type": "text", + "placeholders": {} + }, + "semantic_current_value_label": "Impostazioni correnti", + "@semantic_current_value_label": { + "description": "For current sleep setting", + "type": "text", + "placeholders": {} + }, + "scrim_episode_details_selector": "Chiudi i dettagli dell'episodio", + "@scrim_episode_details_selector": { + "description": "Replaces default scrim label for episode details bottom sheet.", + "type": "text", + "placeholders": {} + }, + "scrim_episode_sort_selector": "Chiudi ordinamento degli episodi", + "@scrim_episode_sort_selector": { + "description": "Replaces default scrim label for episode sort bottom sheet.", + "type": "text", + "placeholders": {} + }, + "scrim_episode_filter_selector": "Chiudi il filtro degli episodi", + "@scrim_episode_filter_selector": { + "description": "Replaces default scrim label for episode filter bottom sheet.", + "type": "text", + "placeholders": {} + }, + "search_episodes_label": "Cerca episodi", + "@search_episodes_label": { + "description": "Hint text for episode search box", + "type": "text", + "placeholders": {} + } +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/intl_messages.arb b/PinePods-0.8.2/mobile/lib/l10n/intl_messages.arb new file mode 100644 index 0000000..564f2df --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/intl_messages.arb @@ -0,0 +1,1014 @@ +{ + "@@last_modified": "2024-11-20T13:57:44.880328", + "@@locale": "en", + "app_title": "Pinepods Podcast Client", + "@app_title": { + "description": "Full title for the application", + "type": "text", + "placeholders": {} + }, + "app_title_short": "Pinepods", + "@app_title_short": { + "description": "Title for the application", + "type": "text", + "placeholders": {} + }, + "library": "Library", + "@library": { + "description": "Library tab label", + "type": "text", + "placeholders": {} + }, + "discover": "Discover", + "@discover": { + "description": "Discover tab label", + "type": "text", + "placeholders": {} + }, + "downloads": "Downloads", + "@downloads": { + "description": "Downloads tab label", + "type": "text", + "placeholders": {} + }, + "subscribe_button_label": "Follow", + "@subscribe_button_label": { + "description": "Subscribe button label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_button_label": "Unfollow", + "@unsubscribe_button_label": { + "description": "Unsubscribe button label", + "type": "text", + "placeholders": {} + }, + "cancel_button_label": "Cancel", + "@cancel_button_label": { + "description": "Cancel button label", + "type": "text", + "placeholders": {} + }, + "ok_button_label": "OK", + "@ok_button_label": { + "description": "OK button label", + "type": "text", + "placeholders": {} + }, + "subscribe_label": "Follow", + "@subscribe_label": { + "description": "Subscribe label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_label": "Unfollow", + "@unsubscribe_label": { + "description": "Unsubscribe label", + "type": "text", + "placeholders": {} + }, + "unsubscribe_message": "Unfollowing will delete all downloaded episodes of this podcast.", + "@unsubscribe_message": { + "description": "Displayed when the user unfollows a podcast.", + "type": "text", + "placeholders": {} + }, + "search_for_podcasts_hint": "Search for podcasts", + "@search_for_podcasts_hint": { + "description": "Hint displayed on search bar when the user clicks the search icon.", + "type": "text", + "placeholders": {} + }, + "no_subscriptions_message": "Head to Settings to Connect a Pinepods Server if you haven't yet!", + "@no_subscriptions_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "delete_label": "Delete", + "@delete_label": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "delete_button_label": "Delete", + "@delete_button_label": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "mark_played_label": "Mark Played", + "@mark_played_label": { + "description": "Mark as played", + "type": "text", + "placeholders": {} + }, + "mark_unplayed_label": "Mark Unplayed", + "@mark_unplayed_label": { + "description": "Mark as unplayed", + "type": "text", + "placeholders": {} + }, + "delete_episode_confirmation": "Are you sure you wish to delete this episode?", + "@delete_episode_confirmation": { + "description": "User is asked to confirm when they attempt to delete an episode", + "type": "text", + "placeholders": {} + }, + "delete_episode_title": "Delete Episode", + "@delete_episode_title": { + "description": "Delete label", + "type": "text", + "placeholders": {} + }, + "no_downloads_message": "You do not have any downloaded episodes", + "@no_downloads_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "no_search_results_message": "No podcasts found", + "@no_search_results_message": { + "description": "Displayed on the library tab when the user has no subscriptions", + "type": "text", + "placeholders": {} + }, + "no_podcast_details_message": "Could not load podcast episodes. Please check your connection.", + "@no_podcast_details_message": { + "description": "Displayed on the podcast details page when the details could not be loaded", + "type": "text", + "placeholders": {} + }, + "play_button_label": "Play episode", + "@play_button_label": { + "description": "Semantic label for the play button", + "type": "text", + "placeholders": {} + }, + "pause_button_label": "Pause episode", + "@pause_button_label": { + "description": "Semantic label for the pause button", + "type": "text", + "placeholders": {} + }, + "download_episode_button_label": "Download episode", + "@download_episode_button_label": { + "description": "Semantic label for the download episode button", + "type": "text", + "placeholders": {} + }, + "delete_episode_button_label": "Delete downloaded episode", + "@delete_episode_button_label": { + "description": "Semantic label for the delete episode", + "type": "text", + "placeholders": {} + }, + "close_button_label": "Close", + "@close_button_label": { + "description": "Close button label", + "type": "text", + "placeholders": {} + }, + "search_button_label": "Search", + "@search_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "clear_search_button_label": "Clear search text", + "@clear_search_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "search_back_button_label": "Back", + "@search_back_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "minimise_player_window_button_label": "Minimise player window", + "@minimise_player_window_button_label": { + "description": "Search button label", + "type": "text", + "placeholders": {} + }, + "rewind_button_label": "Rewind episode 10 seconds", + "@rewind_button_label": { + "description": "Rewind button tooltip", + "type": "text", + "placeholders": {} + }, + "fast_forward_button_label": "Fast-forward episode 30 seconds", + "@fast_forward_button_label": { + "description": "Fast forward tooltip", + "type": "text", + "placeholders": {} + }, + "about_label": "About", + "@about_label": { + "description": "About menu item", + "type": "text", + "placeholders": {} + }, + "mark_episodes_played_label": "Mark all episodes as played", + "@mark_episodes_played_label": { + "description": "Mark all episodes played menu item", + "type": "text", + "placeholders": {} + }, + "mark_episodes_not_played_label": "Mark all episodes as not played", + "@mark_episodes_not_played_label": { + "description": "Mark all episodes not played menu item", + "type": "text", + "placeholders": {} + }, + "stop_download_confirmation": "Are you sure you wish to stop this download and delete the episode?", + "@stop_download_confirmation": { + "description": "User is asked to confirm when they wish to stop the active download.", + "type": "text", + "placeholders": {} + }, + "stop_download_button_label": "Stop", + "@stop_download_button_label": { + "description": "Stop label", + "type": "text", + "placeholders": {} + }, + "stop_download_title": "Stop Download", + "@stop_download_title": { + "description": "Stop download label", + "type": "text", + "placeholders": {} + }, + "settings_mark_deleted_played_label": "Mark deleted episodes as played", + "@settings_mark_deleted_played_label": { + "description": "Mark deleted episodes as played setting", + "type": "text", + "placeholders": {} + }, + "settings_delete_played_label": "Delete downloaded episodes once played", + "@settings_delete_played_label": { + "description": "Delete downloaded episodes once played setting", + "type": "text", + "placeholders": {} + }, + "settings_download_sd_card_label": "Download episodes to SD card", + "@settings_download_sd_card_label": { + "description": "Download episodes to SD card setting", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_card": "New downloads will be saved to the SD card. Existing downloads will remain on internal storage.", + "@settings_download_switch_card": { + "description": "Displayed when user switches from internal storage to SD card", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_internal": "New downloads will be saved to internal storage. Existing downloads will remain on the SD card.", + "@settings_download_switch_internal": { + "description": "Displayed when user switches from internal SD card to internal storage", + "type": "text", + "placeholders": {} + }, + "settings_download_switch_label": "Change storage location", + "@settings_download_switch_label": { + "description": "Dialog label for storage switch", + "type": "text", + "placeholders": {} + }, + "cancel_option_label": "Cancel", + "@cancel_option_label": { + "description": "Cancel option label", + "type": "text", + "placeholders": {} + }, + "settings_theme_switch_label": "Dark theme", + "@settings_theme_switch_label": { + "description": "Dark theme", + "type": "text", + "placeholders": {} + }, + "playback_speed_label": "Playback speed", + "@playback_speed_label": { + "description": "Set playback speed icon label", + "type": "text", + "placeholders": {} + }, + "show_notes_label": "Show notes", + "@show_notes_label": { + "description": "Set show notes icon label", + "type": "text", + "placeholders": {} + }, + "search_provider_label": "Search provider", + "@search_provider_label": { + "description": "Set search provider label", + "type": "text", + "placeholders": {} + }, + "settings_label": "Settings", + "@settings_label": { + "description": "Settings label", + "type": "text", + "placeholders": {} + }, + "go_back_button_label": "Go Back", + "@go_back_button_label": { + "description": "Go-back button label", + "type": "text", + "placeholders": {} + }, + "continue_button_label": "Continue", + "@continue_button_label": { + "description": "Continue button label", + "type": "text", + "placeholders": {} + }, + "consent_message": "This funding link will take you to an external site where you will be able to directly support the show. Links are provided by the podcast authors and is not controlled by Pinepods.", + "@consent_message": { + "description": "Display when first accessing external funding link", + "type": "text", + "placeholders": {} + }, + "episode_label": "Episode", + "@episode_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "chapters_label": "Chapters", + "@chapters_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "notes_label": "Description", + "@notes_label": { + "description": "Tab label on now playing screen.", + "type": "text", + "placeholders": {} + }, + "podcast_funding_dialog_header": "Podcast Funding", + "@podcast_funding_dialog_header": { + "description": "Header on podcast funding consent dialog", + "type": "text", + "placeholders": {} + }, + "settings_auto_open_now_playing": "Full screen player mode on episode start", + "@settings_auto_open_now_playing": { + "description": "Displayed when user switches to use full screen player automatically", + "type": "text", + "placeholders": {} + }, + "error_no_connection": "Unable to play episode. Please check your connection and try again.", + "@error_no_connection": { + "description": "Displayed when attempting to start streaming an episode with no data connection", + "type": "text", + "placeholders": {} + }, + "error_playback_fail": "An unexpected error occurred during playback. Please check your connection and try again.", + "@error_playback_fail": { + "description": "Displayed when attempting to start streaming an episode with no data connection", + "type": "text", + "placeholders": {} + }, + "add_rss_feed_option": "Add RSS Feed", + "@add_rss_feed_option": { + "description": "Option label for adding manual RSS feed url", + "type": "text", + "placeholders": {} + }, + "settings_import_opml": "Import OPML", + "@settings_import_opml": { + "description": "Option label importing OPML file", + "type": "text", + "placeholders": {} + }, + "settings_export_opml": "Export OPML", + "@settings_export_opml": { + "description": "Option label exporting OPML file", + "type": "text", + "placeholders": {} + }, + "label_opml_importing": "Importing", + "@label_opml_importing": { + "description": "Label for importing OPML dialog", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes": "Auto update episodes", + "@settings_auto_update_episodes": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_never": "Never", + "@settings_auto_update_episodes_never": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_heading": "Refresh episodes on details screen after", + "@settings_auto_update_episodes_heading": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_always": "Always", + "@settings_auto_update_episodes_always": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_10min": "10 minutes since last update", + "@settings_auto_update_episodes_10min": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_30min": "30 minutes since last update", + "@settings_auto_update_episodes_30min": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_1hour": "1 hour since last update", + "@settings_auto_update_episodes_1hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_3hour": "3 hours since last update", + "@settings_auto_update_episodes_3hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_6hour": "6 hours since last update", + "@settings_auto_update_episodes_6hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_auto_update_episodes_12hour": "12 hours since last update", + "@settings_auto_update_episodes_12hour": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "new_episodes_label": "New episodes are available", + "@new_episodes_label": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "new_episodes_view_now_label": "VIEW NOW", + "@new_episodes_view_now_label": { + "description": "Option label for auto updating of episodes", + "type": "text", + "placeholders": {} + }, + "settings_personalisation_divider_label": "Personalisation", + "@settings_personalisation_divider_label": { + "description": "Settings divider label for personalisation", + "type": "text", + "placeholders": {} + }, + "settings_episodes_divider_label": "EPISODES", + "@settings_episodes_divider_label": { + "description": "Settings divider label for episodes", + "type": "text", + "placeholders": {} + }, + "settings_playback_divider_label": "Playback", + "@settings_playback_divider_label": { + "description": "Settings divider label for playback", + "type": "text", + "placeholders": {} + }, + "settings_data_divider_label": "DATA", + "@settings_data_divider_label": { + "description": "Settings divider label for data", + "type": "text", + "placeholders": {} + }, + "audio_effect_trim_silence_label": "Trim Silence", + "@audio_effect_trim_silence_label": { + "description": "Label for trim silence toggle", + "type": "text", + "placeholders": {} + }, + "audio_effect_volume_boost_label": "Volume Boost", + "@audio_effect_volume_boost_label": { + "description": "Label for volume boost toggle", + "type": "text", + "placeholders": {} + }, + "audio_settings_playback_speed_label": "Playback Speed", + "@audio_settings_playback_speed_label": { + "description": "Label for playback settings widget", + "type": "text", + "placeholders": {} + }, + "empty_queue_message": "Your queue is empty", + "@empty_queue_message": { + "description": "Displayed when there are no items left in the queue", + "type": "text", + "placeholders": {} + }, + "clear_queue_button_label": "CLEAR QUEUE", + "@clear_queue_button_label": { + "description": "Clear queue button label", + "type": "text", + "placeholders": {} + }, + "now_playing_queue_label": "Now Playing", + "@now_playing_queue_label": { + "description": "Now playing label on queue", + "type": "text", + "placeholders": {} + }, + "up_next_queue_label": "Up Next", + "@up_next_queue_label": { + "description": "Up next label on queue", + "type": "text", + "placeholders": {} + }, + "more_label": "More", + "@more_label": { + "description": "More label", + "type": "text", + "placeholders": {} + }, + "queue_add_label": "Add", + "@queue_add_label": { + "description": "Queue add label", + "type": "text", + "placeholders": {} + }, + "queue_remove_label": "Remove", + "@queue_remove_label": { + "description": "Queue remove label", + "type": "text", + "placeholders": {} + }, + "opml_import_button_label": "Import", + "@opml_import_button_label": { + "description": "OPML Import button label", + "type": "text", + "placeholders": {} + }, + "opml_export_button_label": "Export", + "@opml_export_button_label": { + "description": "OPML Export button label", + "type": "text", + "placeholders": {} + }, + "opml_import_export_label": "OPML Import/Export", + "@opml_import_export_label": { + "description": "OPML Import/Export label", + "type": "text", + "placeholders": {} + }, + "queue_clear_label": "Are you sure you wish to clear the queue?", + "@queue_clear_label": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders": {} + }, + "queue_clear_button_label": "Clear", + "@queue_clear_button_label": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders": {} + }, + "queue_clear_label_title": "Clear Queue", + "@queue_clear_label_title": { + "description": "Shown on dialog box when clearing queue", + "type": "text", + "placeholders": {} + }, + "layout_label": "Layout", + "@layout_label": { + "description": "Layout menu label", + "type": "text", + "placeholders": {} + }, + "discovery_categories_itunes": ",Arts,Business,Comedy,Education,Fiction,Government,Health & Fitness,History,Kids & Family,Leisure,Music,News,Religion & Spirituality,Science,Society & Culture,Sports,TV & Film,Technology,True Crime", + "@discovery_categories_itunes": { + "description": "Comma separated list of iTunes categories", + "type": "text", + "placeholders": {} + }, + "discovery_categories_pindex": ",After-Shows,Alternative,Animals,Animation,Arts,Astronomy,Automotive,Aviation,Baseball,Basketball,Beauty,Books,Buddhism,Business,Careers,Chemistry,Christianity,Climate,Comedy,Commentary,Courses,Crafts,Cricket,Cryptocurrency,Culture,Daily,Design,Documentary,Drama,Earth,Education,Entertainment,Entrepreneurship,Family,Fantasy,Fashion,Fiction,Film,Fitness,Food,Football,Games,Garden,Golf,Government,Health,Hinduism,History,Hobbies,Hockey,Home,HowTo,Improv,Interviews,Investing,Islam,Journals,Judaism,Kids,Language,Learning,Leisure,Life,Management,Manga,Marketing,Mathematics,Medicine,Mental,Music,Natural,Nature,News,NonProfit,Nutrition,Parenting,Performing,Personal,Pets,Philosophy,Physics,Places,Politics,Relationships,Religion,Reviews,Role-Playing,Rugby,Running,Science,Self-Improvement,Sexuality,Soccer,Social,Society,Spirituality,Sports,Stand-Up,Stories,Swimming,TV,Tabletop,Technology,Tennis,Travel,True Crime,Video-Games,Visual,Volleyball,Weather,Wilderness,Wrestling", + "@discovery_categories_pindex": { + "description": "Comma separated list of Podcast Index categories", + "type": "text", + "placeholders": {} + }, + "transcript_label": "Transcript", + "@transcript_label": { + "description": "Transcript label", + "type": "text", + "placeholders": {} + }, + "no_transcript_available_label": "A transcript is not available for this podcast", + "@no_transcript_available_label": { + "description": "Displayed in transcript view when no transcript is available", + "type": "text", + "placeholders": {} + }, + "search_transcript_label": "Search transcript", + "@search_transcript_label": { + "description": "Hint text for transcript search box", + "type": "text", + "placeholders": {} + }, + "auto_scroll_transcript_label": "Follow transcript", + "@auto_scroll_transcript_label": { + "description": "Auto scroll switch label", + "type": "text", + "placeholders": {} + }, + "transcript_why_not_label": "Why not?", + "@transcript_why_not_label": { + "description": "Link to why no transcript is available", + "type": "text", + "placeholders": {} + }, + "transcript_why_not_url": "https://www.pinepods.online/docs/Features/Transcript", + "@transcript_why_not_url": { + "description": "Language specific link", + "type": "text", + "placeholders": {} + }, + "semantics_podcast_details_header": "Podcast details and episodes page", + "@semantics_podcast_details_header": { + "description": "Describes podcast details page", + "type": "text", + "placeholders": {} + }, + "semantics_layout_option_list": "List layout", + "@semantics_layout_option_list": { + "description": "Describes list layout button", + "type": "text", + "placeholders": {} + }, + "semantics_layout_option_compact_grid": "Compact grid layout", + "@semantics_layout_option_compact_grid": { + "description": "Describes compact grid layout button", + "type": "text", + "placeholders": {} + }, + "semantics_layout_option_grid": "Grid layout", + "@semantics_layout_option_grid": { + "description": "Describes grid layout button", + "type": "text", + "placeholders": {} + }, + "semantics_mini_player_header": "Mini player. Swipe right to play/pause button. Activate to open main player window", + "@semantics_mini_player_header": { + "description": "Describes the mini player", + "type": "text", + "placeholders": {} + }, + "semantics_main_player_header": "Main player window", + "@semantics_main_player_header": { + "description": "Describes the main player", + "type": "text", + "placeholders": {} + }, + "semantics_play_pause_toggle": "Play/pause toggle", + "@semantics_play_pause_toggle": { + "description": "Describes play/pause toggle button", + "type": "text", + "placeholders": {} + }, + "semantics_decrease_playback_speed": "Decrease playback speed", + "@semantics_decrease_playback_speed": { + "description": "Describes speed adjustment control", + "type": "text", + "placeholders": {} + }, + "semantics_increase_playback_speed": "Increase playback speed", + "@semantics_increase_playback_speed": { + "description": "Describes speed adjustment control", + "type": "text", + "placeholders": {} + }, + "semantics_expand_podcast_description": "Expand podcast description", + "@semantics_expand_podcast_description": { + "description": "Describes podcast collapse/expand button", + "type": "text", + "placeholders": {} + }, + "semantics_collapse_podcast_description": "Collapse podcast description", + "@semantics_collapse_podcast_description": { + "description": "Describes podcast collapse/expand button", + "type": "text", + "placeholders": {} + }, + "semantics_add_to_queue": "Add episode to queue", + "@semantics_add_to_queue": { + "description": "Describes add to queue button", + "type": "text", + "placeholders": {} + }, + "semantics_remove_from_queue": "Remove episode from queue", + "@semantics_remove_from_queue": { + "description": "Describes add to queue button", + "type": "text", + "placeholders": {} + }, + "semantics_mark_episode_played": "Mark Episode as played", + "@semantics_mark_episode_played": { + "description": "Describes mark played button", + "type": "text", + "placeholders": {} + }, + "semantics_mark_episode_unplayed": "Mark Episode as un-played", + "@semantics_mark_episode_unplayed": { + "description": "Describes mark unplayed button", + "type": "text", + "placeholders": {} + }, + "semantics_episode_tile_collapsed": "Episode list item. Showing image, summary and main controls.", + "@semantics_episode_tile_collapsed": { + "description": "Describes episode tile options when collapsed", + "type": "text", + "placeholders": {} + }, + "semantics_episode_tile_expanded": "Episode list item. Showing description, main controls and additional controls.", + "@semantics_episode_tile_expanded": { + "description": "Describes episode tile options when expanded", + "type": "text", + "placeholders": {} + }, + "semantics_episode_tile_collapsed_hint": "expand and show more details and additional options", + "@semantics_episode_tile_collapsed_hint": { + "description": "Describes episode tile options when collapsed", + "type": "text", + "placeholders": {} + }, + "semantics_episode_tile_expanded_hint": "collapse and show summary, download and play control", + "@semantics_episode_tile_expanded_hint": { + "description": "Describes episode tile options when expanded", + "type": "text", + "placeholders": {} + }, + "sleep_off_label": "Off", + "@sleep_off_label": { + "description": "Describes off sleep label", + "type": "text", + "placeholders": {} + }, + "sleep_episode_label": "End of episode", + "@sleep_episode_label": { + "description": "Describes end of episode sleep label", + "type": "text", + "placeholders": {} + }, + "sleep_minute_label": "{minutes} minutes", + "@sleep_minute_label": { + "description": "Describes the number of minutes to sleep", + "type": "text", + "placeholders": { + "minutes": {} + } + }, + "sleep_timer_label": "Sleep Timer", + "@sleep_timer_label": { + "description": "Describes sleep timer label", + "type": "text", + "placeholders": {} + }, + "feedback_menu_item_label": "Feedback", + "@feedback_menu_item_label": { + "description": "Feedback option in main menu", + "type": "text", + "placeholders": {} + }, + "podcast_options_overflow_menu_semantic_label": "Options menu", + "@podcast_options_overflow_menu_semantic_label": { + "description": "Podcast details overflow menu", + "type": "text", + "placeholders": {} + }, + "semantic_announce_searching": "Searching, please wait.", + "@semantic_announce_searching": { + "description": "Spoken when search in progress.", + "type": "text", + "placeholders": {} + }, + "semantic_playing_options_expand_label": "Open playing options slider", + "@semantic_playing_options_expand_label": { + "description": "Placed on options handle when screen reader enabled.", + "type": "text", + "placeholders": {} + }, + "semantic_playing_options_collapse_label": "Close playing options slider", + "@semantic_playing_options_collapse_label": { + "description": "Placed on options handle when screen reader enabled.", + "type": "text", + "placeholders": {} + }, + "semantic_podcast_artwork_label": "Podcast artwork", + "@semantic_podcast_artwork_label": { + "description": "Placed around podcast image on main player", + "type": "text", + "placeholders": {} + }, + "semantic_chapter_link_label": "Chapter web link", + "@semantic_chapter_link_label": { + "description": "Placed around chapter link", + "type": "text", + "placeholders": {} + }, + "semantic_current_chapter_label": "Current chapter", + "@semantic_current_chapter_label": { + "description": "Placed around chapter", + "type": "text", + "placeholders": {} + }, + "episode_filter_none_label": "None", + "@episode_filter_none_label": { + "description": "Episodes not filtered", + "type": "text", + "placeholders": {} + }, + "episode_filter_started_label": "Started", + "@episode_filter_started_label": { + "description": "Only show episodes that have been started", + "type": "text", + "placeholders": {} + }, + "episode_filter_played_label": "Played", + "@episode_filter_played_label": { + "description": "Only show episodes that have been played", + "type": "text", + "placeholders": {} + }, + "episode_filter_unplayed_label": "Unplayed", + "@episode_filter_unplayed_label": { + "description": "Only show episodes that have not been played", + "type": "text", + "placeholders": {} + }, + "episode_filter_no_episodes_title_label": "No Episodes Found", + "@episode_filter_no_episodes_title_label": { + "description": "No Episodes title", + "type": "text", + "placeholders": {} + }, + "episode_filter_no_episodes_title_description": "No Episodes Found", + "@episode_filter_no_episodes_title_description": { + "description": "This podcast has no episodes matching your search criteria and filter", + "type": "text", + "placeholders": {} + }, + "episode_filter_clear_filters_button_label": "Clear Filters", + "@episode_filter_clear_filters_button_label": { + "description": "Clear filters button", + "type": "text", + "placeholders": {} + }, + "episode_filter_semantic_label": "Episode filter", + "@episode_filter_semantic_label": { + "description": "Episode filter semantic label", + "type": "text", + "placeholders": {} + }, + "episode_sort_semantic_label": "Episode sort", + "@episode_sort_semantic_label": { + "description": "Episode sort semantic label", + "type": "text", + "placeholders": {} + }, + "episode_sort_none_label": "Default", + "@episode_sort_none_label": { + "description": "Episode default sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_latest_first_label": "Latest first", + "@episode_sort_latest_first_label": { + "description": "Episode latest first sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_earliest_first_label": "Earliest first", + "@episode_sort_earliest_first_label": { + "description": "Episode earliest first sort", + "type": "text", + "placeholders": {} + }, + "episode_sort_alphabetical_ascending_label": "Alphabetical A-Z", + "@episode_sort_alphabetical_ascending_label": { + "description": "Episode alphabetical ascending", + "type": "text", + "placeholders": {} + }, + "episode_sort_alphabetical_descending_label": "Alphabetical Z-A", + "@episode_sort_alphabetical_descending_label": { + "description": "Episode alphabetical descending", + "type": "text", + "placeholders": {} + }, + "open_show_website_label": "Open show website", + "@open_show_website_label": { + "description": "Open show website in browser", + "type": "text", + "placeholders": {} + }, + "refresh_feed_label": "Refresh episodes", + "@refresh_feed_label": { + "description": "Menu item to refresh episodes", + "type": "text", + "placeholders": {} + }, + "scrim_layout_selector": "Dismiss layout selector", + "@scrim_layout_selector": { + "description": "Replaces default scrim label for layout selector bottom sheet.", + "type": "text", + "placeholders": {} + }, + "now_playing_episode_position": "Episode position", + "@now_playing_episode_position": { + "description": "Episode position slider control label", + "type": "text", + "placeholders": {} + }, + "now_playing_episode_time_remaining": "Time remaining", + "@now_playing_episode_time_remaining": { + "description": "Episode time remaining slider control label", + "type": "text", + "placeholders": {} + }, + "resume_button_label": "Resume episode", + "@resume_button_label": { + "description": "Semantic label for the resume button", + "type": "text", + "placeholders": {} + }, + "play_download_button_label": "Play downloaded episode", + "@play_download_button_label": { + "description": "Semantic label for the play downloaded episode button", + "type": "text", + "placeholders": {} + }, + "cancel_download_button_label": "Cancel download", + "@cancel_download_button_label": { + "description": "Semantic label for the play cancel download button", + "type": "text", + "placeholders": {} + }, + "episode_details_button_label": "Show episode information", + "@episode_details_button_label": { + "description": "Semantic label for the show info button.", + "type": "text", + "placeholders": {} + }, + "scrim_sleep_timer_selector": "Dismiss sleep timer selector", + "@scrim_sleep_timer_selector": { + "description": "Replaces default scrim label for custom.", + "type": "text", + "placeholders": {} + }, + "scrim_speed_selector": "Dismiss playback speed selector", + "@scrim_speed_selector": { + "description": "Replaces default scrim label for custom.", + "type": "text", + "placeholders": {} + }, + "semantic_current_value_label": "Current value", + "@semantic_current_value_label": { + "description": "For current sleep setting", + "type": "text", + "placeholders": {} + }, + "scrim_episode_details_selector": "Dismiss episode details", + "@scrim_episode_details_selector": { + "description": "Replaces default scrim label for episode details bottom sheet.", + "type": "text", + "placeholders": {} + }, + "scrim_episode_sort_selector": "Dismiss episode sort", + "@scrim_episode_sort_selector": { + "description": "Replaces default scrim label for episode sort bottom sheet.", + "type": "text", + "placeholders": {} + }, + "scrim_episode_filter_selector": "Dismiss episode filter", + "@scrim_episode_filter_selector": { + "description": "Replaces default scrim label for episode filter bottom sheet.", + "type": "text", + "placeholders": {} + }, + "search_episodes_label": "Search episodes", + "@search_episodes_label": { + "description": "Hint text for episode search box", + "type": "text", + "placeholders": {} + } +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/messages_all.dart b/PinePods-0.8.2/mobile/lib/l10n/messages_all.dart new file mode 100644 index 0000000..48c387f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/messages_all.dart @@ -0,0 +1,7 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that looks up messages for specific locales by +// delegating to the appropriate library. +// @dart=2.12 +export 'messages_all_locales.dart' + show initializeMessages; + diff --git a/PinePods-0.8.2/mobile/lib/l10n/messages_all_locales.dart b/PinePods-0.8.2/mobile/lib/l10n/messages_all_locales.dart new file mode 100644 index 0000000..cc6ab33 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/messages_all_locales.dart @@ -0,0 +1,73 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that looks up messages for specific locales by +// delegating to the appropriate library. +// @dart=2.12 +// Ignore issues from commonly used lints in this file. +// ignore_for_file:implementation_imports, file_names +// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering +// ignore_for_file:argument_type_not_assignable, invalid_assignment +// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases +// ignore_for_file:comment_references + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; +import 'package:intl/src/intl_helpers.dart'; + +import 'messages_de.dart' as messages_de; +import 'messages_en.dart' as messages_en; +import 'messages_it.dart' as messages_it; +import 'messages_messages.dart' as messages_messages; + +typedef Future LibraryLoader(); +Map _deferredLibraries = { + 'de': () => Future.value(null), + 'en': () => Future.value(null), + 'it': () => Future.value(null), + 'messages': () => Future.value(null), +}; + +MessageLookupByLibrary? _findExact(String localeName) { + switch (localeName) { + case 'de': + return messages_de.messages; + case 'en': + return messages_en.messages; + case 'it': + return messages_it.messages; + case 'messages': + return messages_messages.messages; + default: + return null; + } +} + +/// User programs should call this before using [localeName] for messages. +Future initializeMessages(String? localeName) async { + var availableLocale = Intl.verifiedLocale( + localeName, + (locale) => _deferredLibraries[locale] != null, + onFailure: (_) => null); + if (availableLocale == null) { + return Future.value(false); + } + var lib = _deferredLibraries[availableLocale]; + await (lib == null ? Future.value(false) : lib()); + initializeInternalMessageLookup(() => CompositeMessageLookup()); + messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor); + return Future.value(true); +} + +bool _messagesExistFor(String locale) { + try { + return _findExact(locale) != null; + } catch (e) { + return false; + } +} + +MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) { + var actualLocale = Intl.verifiedLocale(locale, _messagesExistFor, + onFailure: (_) => null); + if (actualLocale == null) return null; + return _findExact(actualLocale); +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/messages_de.dart b/PinePods-0.8.2/mobile/lib/l10n/messages_de.dart new file mode 100644 index 0000000..dffa80e --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/messages_de.dart @@ -0,0 +1,364 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that provides messages for a de locale. All the +// messages from the main program should be duplicated here with the same +// function name. +// @dart=2.12 +// Ignore issues from commonly used lints in this file. +// ignore_for_file:unnecessary_brace_in_string_interps +// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering +// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases +// ignore_for_file:unused_import, file_names + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; + +final messages = MessageLookup(); + +typedef String? MessageIfAbsent(String? messageStr, List? args); + +class MessageLookup extends MessageLookupByLibrary { + @override + String get localeName => 'de'; + + static m0(minutes) => "${minutes} Minuten"; + + @override + final Map messages = + _notInlinedMessages(_notInlinedMessages); + + static Map _notInlinedMessages(_) => { + 'about_label': MessageLookupByLibrary.simpleMessage('Über'), + 'add_rss_feed_option': + MessageLookupByLibrary.simpleMessage('RSS-Feed hinzufügen'), + 'app_title': + MessageLookupByLibrary.simpleMessage('Pinepods Podcast Client'), + 'app_title_short': MessageLookupByLibrary.simpleMessage('Pinepods'), + 'audio_effect_trim_silence_label': + MessageLookupByLibrary.simpleMessage('Stille Trimmen'), + 'audio_effect_volume_boost_label': + MessageLookupByLibrary.simpleMessage('Lautstärke-Boost'), + 'audio_settings_playback_speed_label': + MessageLookupByLibrary.simpleMessage('Wiedergabe Schnelligkeit'), + 'auto_scroll_transcript_label': + MessageLookupByLibrary.simpleMessage('Follow the transcript'), + 'cancel_button_label': + MessageLookupByLibrary.simpleMessage('Stornieren'), + 'cancel_download_button_label': + MessageLookupByLibrary.simpleMessage('Download abbrechen'), + 'cancel_option_label': + MessageLookupByLibrary.simpleMessage('Stirbuereb'), + 'chapters_label': MessageLookupByLibrary.simpleMessage('Kapitel'), + 'clear_queue_button_label': + MessageLookupByLibrary.simpleMessage('WARTESCHLANGE LÖSCHEN'), + 'clear_search_button_label': + MessageLookupByLibrary.simpleMessage('Suchtext löschen'), + 'close_button_label': MessageLookupByLibrary.simpleMessage('Schließen'), + 'consent_message': MessageLookupByLibrary.simpleMessage( + 'Über diesen Finanzierungslink gelangen Sie zu einer externen Website, auf der Sie die Show direkt unterstützen können. Links werden von den Podcast-Autoren bereitgestellt und nicht von Pinepods kontrolliert.'), + 'continue_button_label': + MessageLookupByLibrary.simpleMessage('Fortsetzen'), + 'delete_button_label': MessageLookupByLibrary.simpleMessage('Löschen'), + 'delete_episode_button_label': + MessageLookupByLibrary.simpleMessage('Download -Episode löschen'), + 'delete_episode_confirmation': MessageLookupByLibrary.simpleMessage( + 'Sind Sie sicher, dass Sie diese Episode löschen möchten?'), + 'delete_episode_title': + MessageLookupByLibrary.simpleMessage('Folge löschen'), + 'delete_label': MessageLookupByLibrary.simpleMessage('Löschen'), + 'discover': MessageLookupByLibrary.simpleMessage('Entdecken'), + 'discovery_categories_itunes': MessageLookupByLibrary.simpleMessage( + ',Künste,Geschäft,Komödie,Ausbildung,Fiktion,Regierung,Gesundheit & Fitness,Geschichte,Kinder & Familie,Freizeit,Musik,Die Nachrichten,Religion & Spiritualität,Wissenschaft,Gesellschaft & Kultur,Sport,Fernsehen & Film,Technologie,Echte Kriminalität'), + 'discovery_categories_pindex': MessageLookupByLibrary.simpleMessage( + ',After-Shows,Alternative,Tiere,Animation,Kunst,Astronomie,Automobil,Luftfahrt,Baseball,Basketball,Schönheit,Bücher,Buddhismus,Geschäft,Karriere,Chemie,Christentum,Klima,Komödie,Kommentar,Kurse,Kunsthandwerk,Kricket,Kryptowährung,Kultur,Täglich,Design,Dokumentarfilm,Theater,Erde,Ausbildung,Unterhaltung,Unternehmerschaft,Familie,Fantasie,Mode,Fiktion,Film,Fitness,Essen,Fußball,Spiele,Garten,Golf,Regierung,Gesundheit,Hinduismus,Geschichte,Hobbys,Eishockey,Heim,Wieman,Improvisieren,Vorstellungsgespräche,Investieren,Islam,Zeitschriften,Judentum,Kinder,Sprache,Lernen,Freizeit,Leben,Management,Manga,Marketing,Mathematik,Medizin,geistig,Musik,Natürlich,Natur,Nachricht,Gemeinnützig,Ernährung,Erziehung,Aufführung,Persönlich,Haustiere,Philosophie,Physik,Setzt,Politik,Beziehungen,Religion,Bewertungen,Rollenspiel,Rugby,Betrieb,Wissenschaft,Selbstverbesserung,Sexualität,Fußball,Sozial,Gesellschaft,Spiritualität,Sport,Aufstehen,Geschichten,Baden,FERNSEHER,Tischplatte,Technologie,Tennis,Reisen,EchteKriminalität,Videospiele,Visuell,Volleyball,Wetter,Wildnis,Ringen'), + 'download_episode_button_label': + MessageLookupByLibrary.simpleMessage('Folge herunterladen'), + 'downloads': MessageLookupByLibrary.simpleMessage('Herunterladen'), + 'empty_queue_message': + MessageLookupByLibrary.simpleMessage('Ihre Warteschlange ist leer'), + 'episode_details_button_label': MessageLookupByLibrary.simpleMessage( + 'Episodeninformationen anzeigen'), + 'episode_filter_clear_filters_button_label': + MessageLookupByLibrary.simpleMessage('Filter zurücksetzen'), + 'episode_filter_no_episodes_title_description': + MessageLookupByLibrary.simpleMessage( + 'Dieser Podcast hat keine Episoden, die Ihren Suchkriterien und Filtern entsprechen'), + 'episode_filter_no_episodes_title_label': + MessageLookupByLibrary.simpleMessage('Keine Episoden Gefunden'), + 'episode_filter_none_label': + MessageLookupByLibrary.simpleMessage('Keiner'), + 'episode_filter_played_label': + MessageLookupByLibrary.simpleMessage('Gespielt'), + 'episode_filter_semantic_label': + MessageLookupByLibrary.simpleMessage('Episoden filtern'), + 'episode_filter_started_label': + MessageLookupByLibrary.simpleMessage('Gestartet'), + 'episode_filter_unplayed_label': + MessageLookupByLibrary.simpleMessage('Nicht gespielt'), + 'episode_label': MessageLookupByLibrary.simpleMessage('Episode'), + 'episode_sort_alphabetical_ascending_label': + MessageLookupByLibrary.simpleMessage('Alphabetisch von A bis Z'), + 'episode_sort_alphabetical_descending_label': + MessageLookupByLibrary.simpleMessage('Alphabetisch von Z bis A'), + 'episode_sort_earliest_first_label': + MessageLookupByLibrary.simpleMessage('Das Älteste zuerst'), + 'episode_sort_latest_first_label': + MessageLookupByLibrary.simpleMessage('Das Neueste zuerst'), + 'episode_sort_none_label': + MessageLookupByLibrary.simpleMessage('Standard'), + 'episode_sort_semantic_label': + MessageLookupByLibrary.simpleMessage('Episoden sortieren'), + 'error_no_connection': MessageLookupByLibrary.simpleMessage( + 'Episode kann nicht abgespielt werden. Überprüfen Sie bitte Ihre Verbindung und versuchen Sie es erneut.'), + 'error_playback_fail': MessageLookupByLibrary.simpleMessage( + 'Während der Wiedergabe ist ein unerwarteter Fehler aufgetreten. Überprüfen Sie bitte Ihre Verbindung und versuchen Sie es erneut.'), + 'fast_forward_button_label': MessageLookupByLibrary.simpleMessage( + '30 Sekunden schneller Vorlauf'), + 'feedback_menu_item_label': + MessageLookupByLibrary.simpleMessage('Rückmeldung'), + 'go_back_button_label': + MessageLookupByLibrary.simpleMessage('Geh Zurück'), + 'label_opml_importing': + MessageLookupByLibrary.simpleMessage('Importieren'), + 'layout_label': MessageLookupByLibrary.simpleMessage('Layout'), + 'library': MessageLookupByLibrary.simpleMessage('Bibliothek'), + 'mark_episodes_not_played_label': MessageLookupByLibrary.simpleMessage( + 'Markieren Sie alle Folgen als nicht abgespielt'), + 'mark_episodes_played_label': MessageLookupByLibrary.simpleMessage( + 'Markieren Sie alle Episoden als abgespielt'), + 'mark_played_label': + MessageLookupByLibrary.simpleMessage('Markieren gespielt'), + 'mark_unplayed_label': + MessageLookupByLibrary.simpleMessage('Markieren nicht abgespielt'), + 'minimise_player_window_button_label': + MessageLookupByLibrary.simpleMessage( + 'Wiedergabebildschirm minimieren'), + 'more_label': MessageLookupByLibrary.simpleMessage('Mehr'), + 'new_episodes_label': + MessageLookupByLibrary.simpleMessage('Neue Folgen sind verfügbar'), + 'new_episodes_view_now_label': + MessageLookupByLibrary.simpleMessage('JETZT ANZEIGEN'), + 'no_downloads_message': MessageLookupByLibrary.simpleMessage( + 'Sie haben keine Episoden heruntergeladen'), + 'no_podcast_details_message': MessageLookupByLibrary.simpleMessage( + 'Podcast-Episoden konnten nicht geladen werden. Bitte überprüfen Sie Ihre Verbindung.'), + 'no_search_results_message': + MessageLookupByLibrary.simpleMessage('Keine Podcasts gefunden'), + 'no_subscriptions_message': MessageLookupByLibrary.simpleMessage( + 'Tippen Sie unten auf die Schaltfläche „Entdecken“ oder verwenden Sie die Suchleiste oben, um Ihren ersten Podcast zu finden'), + 'no_transcript_available_label': MessageLookupByLibrary.simpleMessage( + 'Für diesen Podcast ist kein Transkript verfügbar'), + 'notes_label': MessageLookupByLibrary.simpleMessage('Notizen'), + 'now_playing_episode_position': + MessageLookupByLibrary.simpleMessage('Episodenposition'), + 'now_playing_episode_time_remaining': + MessageLookupByLibrary.simpleMessage('Verbleibende Zeit'), + 'now_playing_queue_label': + MessageLookupByLibrary.simpleMessage('Jetzt Spielen'), + 'ok_button_label': MessageLookupByLibrary.simpleMessage('OK'), + 'open_show_website_label': + MessageLookupByLibrary.simpleMessage('Show-Website öffnen'), + 'opml_export_button_label': + MessageLookupByLibrary.simpleMessage('Export'), + 'opml_import_button_label': + MessageLookupByLibrary.simpleMessage('Importieren'), + 'opml_import_export_label': + MessageLookupByLibrary.simpleMessage('OPML Importieren/Export'), + 'pause_button_label': + MessageLookupByLibrary.simpleMessage('Folge pausieren'), + 'play_button_label': + MessageLookupByLibrary.simpleMessage('Folge abspielen'), + 'play_download_button_label': MessageLookupByLibrary.simpleMessage( + 'Heruntergeladene Episode abspielen'), + 'playback_speed_label': MessageLookupByLibrary.simpleMessage( + 'Stellen Sie die Wiedergabegeschwindigkeit ein'), + 'podcast_funding_dialog_header': + MessageLookupByLibrary.simpleMessage('Podcast-Finanzierung'), + 'podcast_options_overflow_menu_semantic_label': + MessageLookupByLibrary.simpleMessage('Optionsmenü'), + 'queue_add_label': MessageLookupByLibrary.simpleMessage('Addieren'), + 'queue_clear_button_label': + MessageLookupByLibrary.simpleMessage('Klar'), + 'queue_clear_label': MessageLookupByLibrary.simpleMessage( + 'Möchten Sie die Warteschlange wirklich löschen?'), + 'queue_clear_label_title': + MessageLookupByLibrary.simpleMessage('Warteschlange löschen'), + 'queue_remove_label': MessageLookupByLibrary.simpleMessage('Entfernen'), + 'refresh_feed_label': MessageLookupByLibrary.simpleMessage( + 'Holen Sie sich neue Episoden'), + 'resume_button_label': + MessageLookupByLibrary.simpleMessage('Folge fortsetzen'), + 'rewind_button_label': + MessageLookupByLibrary.simpleMessage('10 Sekunden zurückspulen'), + 'scrim_episode_details_selector': + MessageLookupByLibrary.simpleMessage('Episodendetails schließen'), + 'scrim_episode_filter_selector': + MessageLookupByLibrary.simpleMessage('Episodenfilter schließen'), + 'scrim_episode_sort_selector': MessageLookupByLibrary.simpleMessage( + 'Episodensortierung schließen'), + 'scrim_layout_selector': + MessageLookupByLibrary.simpleMessage('Layout-Auswahl schließen'), + 'scrim_sleep_timer_selector': MessageLookupByLibrary.simpleMessage( + 'Auswahl des Sleep-Timers schließen'), + 'scrim_speed_selector': MessageLookupByLibrary.simpleMessage( + 'Auswahl der Wiedergabegeschwindigkeit schließen'), + 'search_back_button_label': + MessageLookupByLibrary.simpleMessage('Zurück'), + 'search_button_label': MessageLookupByLibrary.simpleMessage('Suche'), + 'search_episodes_label': + MessageLookupByLibrary.simpleMessage('Folgen suchen'), + 'search_for_podcasts_hint': + MessageLookupByLibrary.simpleMessage('Suche nach Podcasts'), + 'search_provider_label': + MessageLookupByLibrary.simpleMessage('Suchmaschine'), + 'search_transcript_label': + MessageLookupByLibrary.simpleMessage('Transkript suchen'), + 'semantic_announce_searching': + MessageLookupByLibrary.simpleMessage('Suchen, bitte warten.'), + 'semantic_chapter_link_label': + MessageLookupByLibrary.simpleMessage('Weblink zum Kapitel'), + 'semantic_current_chapter_label': + MessageLookupByLibrary.simpleMessage('Aktuelles Kapitel'), + 'semantic_current_value_label': + MessageLookupByLibrary.simpleMessage('Aktueller Wert'), + 'semantic_playing_options_collapse_label': + MessageLookupByLibrary.simpleMessage( + 'Schließen Sie den Schieberegler für die Wiedergabeoptionen'), + 'semantic_playing_options_expand_label': + MessageLookupByLibrary.simpleMessage( + 'Öffnen Sie den Schieberegler für die Wiedergabeoptionen'), + 'semantic_podcast_artwork_label': + MessageLookupByLibrary.simpleMessage('Podcast-Artwork'), + 'semantics_add_to_queue': MessageLookupByLibrary.simpleMessage( + 'Fügen Sie die Episode zur Warteschlange hinzu'), + 'semantics_collapse_podcast_description': + MessageLookupByLibrary.simpleMessage( + 'Collapse Podcast Beschreibung'), + 'semantics_decrease_playback_speed': + MessageLookupByLibrary.simpleMessage( + 'Verringern Sie die Wiedergabegeschwindigkeit'), + 'semantics_episode_tile_collapsed': MessageLookupByLibrary.simpleMessage( + 'Episodenlistenelement. Zeigt Bild, Zusammenfassung und Hauptsteuerelemente.'), + 'semantics_episode_tile_collapsed_hint': + MessageLookupByLibrary.simpleMessage( + 'erweitern und weitere Details und zusätzliche Optionen anzeigen'), + 'semantics_episode_tile_expanded': MessageLookupByLibrary.simpleMessage( + 'Episodenlistenelement. Beschreibung, Hauptsteuerelemente und zusätzliche Steuerelemente werden angezeigt.'), + 'semantics_episode_tile_expanded_hint': + MessageLookupByLibrary.simpleMessage( + 'Reduzieren und Zusammenfassung anzeigen, Download- und Wiedergabesteuerung'), + 'semantics_expand_podcast_description': + MessageLookupByLibrary.simpleMessage( + 'Erweitern Sie die Beschreibung der Podcast'), + 'semantics_increase_playback_speed': + MessageLookupByLibrary.simpleMessage( + 'Erhöhen Sie die Wiedergabegeschwindigkeit'), + 'semantics_layout_option_compact_grid': + MessageLookupByLibrary.simpleMessage('Kompaktes Rasterlayout'), + 'semantics_layout_option_grid': + MessageLookupByLibrary.simpleMessage('Gitterstruktur'), + 'semantics_layout_option_list': + MessageLookupByLibrary.simpleMessage('Listenlayout'), + 'semantics_main_player_header': + MessageLookupByLibrary.simpleMessage('Hauptfenster des Players'), + 'semantics_mark_episode_played': + MessageLookupByLibrary.simpleMessage('Mark Episode as played'), + 'semantics_mark_episode_unplayed': + MessageLookupByLibrary.simpleMessage('Mark Episode as un-played'), + 'semantics_mini_player_header': MessageLookupByLibrary.simpleMessage( + 'Mini-Player. Wischen Sie nach rechts, um die Schaltfläche „Wiedergabe/Pause“ anzuzeigen. Aktivieren, um das Hauptfenster des Players zu öffnen'), + 'semantics_play_pause_toggle': MessageLookupByLibrary.simpleMessage( + 'Umschalten zwischen Wiedergabe und Pause'), + 'semantics_podcast_details_header': + MessageLookupByLibrary.simpleMessage( + 'Podcast-Details und Episodenseite'), + 'semantics_remove_from_queue': MessageLookupByLibrary.simpleMessage( + 'Entfernen Sie die Episode aus der Warteschlange'), + 'settings_auto_open_now_playing': MessageLookupByLibrary.simpleMessage( + 'Vollbild-Player-Modus beim Episodenstart'), + 'settings_auto_update_episodes': MessageLookupByLibrary.simpleMessage( + 'Folgen automatisch aktualisieren'), + 'settings_auto_update_episodes_10min': + MessageLookupByLibrary.simpleMessage( + '10 Minuten seit dem letzten Update'), + 'settings_auto_update_episodes_12hour': + MessageLookupByLibrary.simpleMessage( + '12 Stunden seit dem letzten Update'), + 'settings_auto_update_episodes_1hour': + MessageLookupByLibrary.simpleMessage( + '1 Stunde seit dem letzten Update'), + 'settings_auto_update_episodes_30min': + MessageLookupByLibrary.simpleMessage( + '30 Minuten seit dem letzten Update'), + 'settings_auto_update_episodes_3hour': + MessageLookupByLibrary.simpleMessage( + '3 Stunden seit dem letzten Update'), + 'settings_auto_update_episodes_6hour': + MessageLookupByLibrary.simpleMessage( + '6 Stunden seit dem letzten Update'), + 'settings_auto_update_episodes_always': + MessageLookupByLibrary.simpleMessage('Immer'), + 'settings_auto_update_episodes_heading': + MessageLookupByLibrary.simpleMessage( + 'Folgen in der Detailansicht aktualisieren, nachdem'), + 'settings_auto_update_episodes_never': + MessageLookupByLibrary.simpleMessage('Noch nie'), + 'settings_data_divider_label': + MessageLookupByLibrary.simpleMessage('DATEN'), + 'settings_delete_played_label': MessageLookupByLibrary.simpleMessage( + 'Heruntergeladene Episoden nach dem Abspielen löschen'), + 'settings_download_sd_card_label': MessageLookupByLibrary.simpleMessage( + 'Episoden auf SD-Karte herunterladen'), + 'settings_download_switch_card': MessageLookupByLibrary.simpleMessage( + 'Neue Downloads werden auf der SD-Karte gespeichert. Bestehende Downloads bleiben im internen Speicher.'), + 'settings_download_switch_internal': MessageLookupByLibrary.simpleMessage( + 'Neue Downloads werden im internen Speicher gespeichert. Bestehende Downloads verbleiben auf der SD-Karte.'), + 'settings_download_switch_label': + MessageLookupByLibrary.simpleMessage('Speicherort ändern'), + 'settings_episodes_divider_label': + MessageLookupByLibrary.simpleMessage('EPISODEN'), + 'settings_export_opml': + MessageLookupByLibrary.simpleMessage('OPML exportieren'), + 'settings_import_opml': + MessageLookupByLibrary.simpleMessage('OPML importieren'), + 'settings_label': MessageLookupByLibrary.simpleMessage('Einstellungen'), + 'settings_mark_deleted_played_label': + MessageLookupByLibrary.simpleMessage( + 'Markieren Sie gelöschte Episoden als abgespielt'), + 'settings_personalisation_divider_label': + MessageLookupByLibrary.simpleMessage('PERSONALISIERUNG'), + 'settings_playback_divider_label': + MessageLookupByLibrary.simpleMessage('WIEDERGABE'), + 'settings_theme_switch_label': + MessageLookupByLibrary.simpleMessage('Dark theme'), + 'show_notes_label': + MessageLookupByLibrary.simpleMessage('Notizen anzeigen'), + 'sleep_episode_label': + MessageLookupByLibrary.simpleMessage('Ende der Folge'), + 'sleep_minute_label': m0, + 'sleep_off_label': MessageLookupByLibrary.simpleMessage('Aus'), + 'sleep_timer_label': + MessageLookupByLibrary.simpleMessage('Sleep-Timer'), + 'stop_download_button_label': + MessageLookupByLibrary.simpleMessage('Halt'), + 'stop_download_confirmation': MessageLookupByLibrary.simpleMessage( + 'Möchten Sie diesen Download wirklich beenden und die Episode löschen?'), + 'stop_download_title': + MessageLookupByLibrary.simpleMessage('Stop Download'), + 'subscribe_button_label': + MessageLookupByLibrary.simpleMessage('Folgen'), + 'subscribe_label': MessageLookupByLibrary.simpleMessage('Folgen'), + 'transcript_label': MessageLookupByLibrary.simpleMessage('Transkript'), + 'transcript_why_not_label': + MessageLookupByLibrary.simpleMessage('Warum nicht?'), + 'transcript_why_not_url': MessageLookupByLibrary.simpleMessage( + 'https://www.pinepods.online/docs/Features/Transcript'), + 'unsubscribe_button_label': + MessageLookupByLibrary.simpleMessage('Entfolgen'), + 'unsubscribe_label': + MessageLookupByLibrary.simpleMessage('Nicht mehr folgen'), + 'unsubscribe_message': MessageLookupByLibrary.simpleMessage( + 'Wenn Sie nicht mehr folgen, werden alle heruntergeladenen Folgen dieses Podcasts gelöscht.'), + 'up_next_queue_label': + MessageLookupByLibrary.simpleMessage('Als nächstes') + }; +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/messages_en.dart b/PinePods-0.8.2/mobile/lib/l10n/messages_en.dart new file mode 100644 index 0000000..41d3dbb --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/messages_en.dart @@ -0,0 +1,350 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that provides messages for a en locale. All the +// messages from the main program should be duplicated here with the same +// function name. +// @dart=2.12 +// Ignore issues from commonly used lints in this file. +// ignore_for_file:unnecessary_brace_in_string_interps +// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering +// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases +// ignore_for_file:unused_import, file_names + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; + +final messages = MessageLookup(); + +typedef String? MessageIfAbsent(String? messageStr, List? args); + +class MessageLookup extends MessageLookupByLibrary { + @override + String get localeName => 'en'; + + static m0(minutes) => "${minutes} minutes"; + + @override + final Map messages = + _notInlinedMessages(_notInlinedMessages); + + static Map _notInlinedMessages(_) => { + 'about_label': MessageLookupByLibrary.simpleMessage('About'), + 'add_rss_feed_option': + MessageLookupByLibrary.simpleMessage('Add RSS Feed'), + 'app_title': + MessageLookupByLibrary.simpleMessage('Pinepods Podcast Client'), + 'app_title_short': MessageLookupByLibrary.simpleMessage('Pinepods'), + 'audio_effect_trim_silence_label': + MessageLookupByLibrary.simpleMessage('Trim Silence'), + 'audio_effect_volume_boost_label': + MessageLookupByLibrary.simpleMessage('Volume Boost'), + 'audio_settings_playback_speed_label': + MessageLookupByLibrary.simpleMessage('Playback Speed'), + 'auto_scroll_transcript_label': + MessageLookupByLibrary.simpleMessage('Follow transcript'), + 'cancel_button_label': MessageLookupByLibrary.simpleMessage('Cancel'), + 'cancel_download_button_label': + MessageLookupByLibrary.simpleMessage('Cancel download'), + 'cancel_option_label': MessageLookupByLibrary.simpleMessage('Cancel'), + 'chapters_label': MessageLookupByLibrary.simpleMessage('Chapters'), + 'clear_queue_button_label': + MessageLookupByLibrary.simpleMessage('CLEAR QUEUE'), + 'clear_search_button_label': + MessageLookupByLibrary.simpleMessage('Clear search text'), + 'close_button_label': MessageLookupByLibrary.simpleMessage('Close'), + 'consent_message': MessageLookupByLibrary.simpleMessage( + 'This funding link will take you to an external site where you will be able to directly support the show. Links are provided by the podcast authors and is not controlled by Pinepods.'), + 'continue_button_label': + MessageLookupByLibrary.simpleMessage('Continue'), + 'delete_button_label': MessageLookupByLibrary.simpleMessage('Delete'), + 'delete_episode_button_label': + MessageLookupByLibrary.simpleMessage('Delete downloaded episode'), + 'delete_episode_confirmation': MessageLookupByLibrary.simpleMessage( + 'Are you sure you wish to delete this episode?'), + 'delete_episode_title': + MessageLookupByLibrary.simpleMessage('Delete Episode'), + 'delete_label': MessageLookupByLibrary.simpleMessage('Delete'), + 'discover': MessageLookupByLibrary.simpleMessage('Discover'), + 'discovery_categories_itunes': MessageLookupByLibrary.simpleMessage( + ',Arts,Business,Comedy,Education,Fiction,Government,Health & Fitness,History,Kids & Family,Leisure,Music,News,Religion & Spirituality,Science,Society & Culture,Sports,TV & Film,Technology,True Crime'), + 'discovery_categories_pindex': MessageLookupByLibrary.simpleMessage( + ',After-Shows,Alternative,Animals,Animation,Arts,Astronomy,Automotive,Aviation,Baseball,Basketball,Beauty,Books,Buddhism,Business,Careers,Chemistry,Christianity,Climate,Comedy,Commentary,Courses,Crafts,Cricket,Cryptocurrency,Culture,Daily,Design,Documentary,Drama,Earth,Education,Entertainment,Entrepreneurship,Family,Fantasy,Fashion,Fiction,Film,Fitness,Food,Football,Games,Garden,Golf,Government,Health,Hinduism,History,Hobbies,Hockey,Home,HowTo,Improv,Interviews,Investing,Islam,Journals,Judaism,Kids,Language,Learning,Leisure,Life,Management,Manga,Marketing,Mathematics,Medicine,Mental,Music,Natural,Nature,News,NonProfit,Nutrition,Parenting,Performing,Personal,Pets,Philosophy,Physics,Places,Politics,Relationships,Religion,Reviews,Role-Playing,Rugby,Running,Science,Self-Improvement,Sexuality,Soccer,Social,Society,Spirituality,Sports,Stand-Up,Stories,Swimming,TV,Tabletop,Technology,Tennis,Travel,True Crime,Video-Games,Visual,Volleyball,Weather,Wilderness,Wrestling'), + 'download_episode_button_label': + MessageLookupByLibrary.simpleMessage('Download episode'), + 'downloads': MessageLookupByLibrary.simpleMessage('Downloads'), + 'empty_queue_message': + MessageLookupByLibrary.simpleMessage('Your queue is empty'), + 'episode_details_button_label': + MessageLookupByLibrary.simpleMessage('Show episode information'), + 'episode_filter_clear_filters_button_label': + MessageLookupByLibrary.simpleMessage('Clear Filters'), + 'episode_filter_no_episodes_title_description': + MessageLookupByLibrary.simpleMessage( + 'This podcast has no episodes matching your search criteria and filter'), + 'episode_filter_no_episodes_title_label': + MessageLookupByLibrary.simpleMessage('No Episodes Found'), + 'episode_filter_none_label': + MessageLookupByLibrary.simpleMessage('None'), + 'episode_filter_played_label': + MessageLookupByLibrary.simpleMessage('Played'), + 'episode_filter_semantic_label': + MessageLookupByLibrary.simpleMessage('Filter episodes'), + 'episode_filter_started_label': + MessageLookupByLibrary.simpleMessage('Started'), + 'episode_filter_unplayed_label': + MessageLookupByLibrary.simpleMessage('Unplayed'), + 'episode_label': MessageLookupByLibrary.simpleMessage('Episode'), + 'episode_sort_alphabetical_ascending_label': + MessageLookupByLibrary.simpleMessage('Alphabetical A-Z'), + 'episode_sort_alphabetical_descending_label': + MessageLookupByLibrary.simpleMessage('Alphabetical Z-A'), + 'episode_sort_earliest_first_label': + MessageLookupByLibrary.simpleMessage('Earliest first'), + 'episode_sort_latest_first_label': + MessageLookupByLibrary.simpleMessage('Latest first'), + 'episode_sort_none_label': + MessageLookupByLibrary.simpleMessage('Default'), + 'episode_sort_semantic_label': + MessageLookupByLibrary.simpleMessage('Sort episodes'), + 'error_no_connection': MessageLookupByLibrary.simpleMessage( + 'Unable to play episode. Please check your connection and try again.'), + 'error_playback_fail': MessageLookupByLibrary.simpleMessage( + 'An unexpected error occurred during playback. Please check your connection and try again.'), + 'fast_forward_button_label': MessageLookupByLibrary.simpleMessage( + 'Fast-forward episode 30 seconds'), + 'feedback_menu_item_label': + MessageLookupByLibrary.simpleMessage('Feedback'), + 'go_back_button_label': MessageLookupByLibrary.simpleMessage('Go Back'), + 'label_opml_importing': + MessageLookupByLibrary.simpleMessage('Importing'), + 'layout_label': MessageLookupByLibrary.simpleMessage('Layout'), + 'library': MessageLookupByLibrary.simpleMessage('Library'), + 'mark_episodes_not_played_label': MessageLookupByLibrary.simpleMessage( + 'Mark all episodes as not played'), + 'mark_episodes_played_label': + MessageLookupByLibrary.simpleMessage('Mark all episodes as played'), + 'mark_played_label': + MessageLookupByLibrary.simpleMessage('Mark Played'), + 'mark_unplayed_label': + MessageLookupByLibrary.simpleMessage('Mark Unplayed'), + 'minimise_player_window_button_label': + MessageLookupByLibrary.simpleMessage('Minimise player window'), + 'more_label': MessageLookupByLibrary.simpleMessage('More'), + 'new_episodes_label': + MessageLookupByLibrary.simpleMessage('New episodes are available'), + 'new_episodes_view_now_label': + MessageLookupByLibrary.simpleMessage('VIEW NOW'), + 'no_downloads_message': MessageLookupByLibrary.simpleMessage( + 'You do not have any downloaded episodes'), + 'no_podcast_details_message': MessageLookupByLibrary.simpleMessage( + 'Could not load podcast episodes. Please check your connection.'), + 'no_search_results_message': + MessageLookupByLibrary.simpleMessage('No podcasts found'), + 'no_subscriptions_message': MessageLookupByLibrary.simpleMessage( + 'Head to Settings to Connect a Pinepods Server if you haven\'t yet!'), + 'no_transcript_available_label': MessageLookupByLibrary.simpleMessage( + 'A transcript is not available for this podcast'), + 'notes_label': MessageLookupByLibrary.simpleMessage('Description'), + 'now_playing_episode_position': + MessageLookupByLibrary.simpleMessage('Episode position'), + 'now_playing_episode_time_remaining': + MessageLookupByLibrary.simpleMessage('Time remaining'), + 'now_playing_queue_label': + MessageLookupByLibrary.simpleMessage('Now Playing'), + 'ok_button_label': MessageLookupByLibrary.simpleMessage('OK'), + 'open_show_website_label': + MessageLookupByLibrary.simpleMessage('Open show website'), + 'opml_export_button_label': + MessageLookupByLibrary.simpleMessage('Export'), + 'opml_import_button_label': + MessageLookupByLibrary.simpleMessage('Import'), + 'opml_import_export_label': + MessageLookupByLibrary.simpleMessage('OPML Import/Export'), + 'pause_button_label': + MessageLookupByLibrary.simpleMessage('Pause episode'), + 'play_button_label': + MessageLookupByLibrary.simpleMessage('Play episode'), + 'play_download_button_label': + MessageLookupByLibrary.simpleMessage('Play downloaded episode'), + 'playback_speed_label': + MessageLookupByLibrary.simpleMessage('Playback speed'), + 'podcast_funding_dialog_header': + MessageLookupByLibrary.simpleMessage('Podcast Funding'), + 'podcast_options_overflow_menu_semantic_label': + MessageLookupByLibrary.simpleMessage('Options menu'), + 'queue_add_label': MessageLookupByLibrary.simpleMessage('Add'), + 'queue_clear_button_label': + MessageLookupByLibrary.simpleMessage('Clear'), + 'queue_clear_label': MessageLookupByLibrary.simpleMessage( + 'Are you sure you wish to clear the queue?'), + 'queue_clear_label_title': + MessageLookupByLibrary.simpleMessage('Clear Queue'), + 'queue_remove_label': MessageLookupByLibrary.simpleMessage('Remove'), + 'refresh_feed_label': + MessageLookupByLibrary.simpleMessage('Refresh episodes'), + 'resume_button_label': + MessageLookupByLibrary.simpleMessage('Resume episode'), + 'rewind_button_label': + MessageLookupByLibrary.simpleMessage('Rewind episode 10 seconds'), + 'scrim_episode_details_selector': + MessageLookupByLibrary.simpleMessage('Dismiss episode details'), + 'scrim_episode_filter_selector': + MessageLookupByLibrary.simpleMessage('Dismiss episode filter'), + 'scrim_episode_sort_selector': + MessageLookupByLibrary.simpleMessage('Dismiss episode sort'), + 'scrim_layout_selector': + MessageLookupByLibrary.simpleMessage('Dismiss layout selector'), + 'scrim_sleep_timer_selector': MessageLookupByLibrary.simpleMessage( + 'Dismiss sleep timer selector'), + 'scrim_speed_selector': MessageLookupByLibrary.simpleMessage( + 'Dismiss playback speed selector'), + 'search_back_button_label': + MessageLookupByLibrary.simpleMessage('Back'), + 'search_button_label': MessageLookupByLibrary.simpleMessage('Search'), + 'search_episodes_label': + MessageLookupByLibrary.simpleMessage('Search episodes'), + 'search_for_podcasts_hint': + MessageLookupByLibrary.simpleMessage('Search for podcasts'), + 'search_provider_label': + MessageLookupByLibrary.simpleMessage('Search provider'), + 'search_transcript_label': + MessageLookupByLibrary.simpleMessage('Search transcript'), + 'semantic_announce_searching': + MessageLookupByLibrary.simpleMessage('Searching, please wait.'), + 'semantic_chapter_link_label': + MessageLookupByLibrary.simpleMessage('Chapter web link'), + 'semantic_current_chapter_label': + MessageLookupByLibrary.simpleMessage('Current chapter'), + 'semantic_current_value_label': + MessageLookupByLibrary.simpleMessage('Current value'), + 'semantic_playing_options_collapse_label': + MessageLookupByLibrary.simpleMessage( + 'Close playing options slider'), + 'semantic_playing_options_expand_label': + MessageLookupByLibrary.simpleMessage('Open playing options slider'), + 'semantic_podcast_artwork_label': + MessageLookupByLibrary.simpleMessage('Podcast artwork'), + 'semantics_add_to_queue': + MessageLookupByLibrary.simpleMessage('Add episode to queue'), + 'semantics_collapse_podcast_description': + MessageLookupByLibrary.simpleMessage( + 'Collapse podcast description'), + 'semantics_decrease_playback_speed': + MessageLookupByLibrary.simpleMessage('Decrease playback speed'), + 'semantics_episode_tile_collapsed': + MessageLookupByLibrary.simpleMessage( + 'Episode list item. Showing image, summary and main controls.'), + 'semantics_episode_tile_collapsed_hint': + MessageLookupByLibrary.simpleMessage( + 'expand and show more details and additional options'), + 'semantics_episode_tile_expanded': MessageLookupByLibrary.simpleMessage( + 'Episode list item. Showing description, main controls and additional controls.'), + 'semantics_episode_tile_expanded_hint': + MessageLookupByLibrary.simpleMessage( + 'collapse and show summary, download and play control'), + 'semantics_expand_podcast_description': + MessageLookupByLibrary.simpleMessage('Expand podcast description'), + 'semantics_increase_playback_speed': + MessageLookupByLibrary.simpleMessage('Increase playback speed'), + 'semantics_layout_option_compact_grid': + MessageLookupByLibrary.simpleMessage('Compact grid layout'), + 'semantics_layout_option_grid': + MessageLookupByLibrary.simpleMessage('Grid layout'), + 'semantics_layout_option_list': + MessageLookupByLibrary.simpleMessage('List layout'), + 'semantics_main_player_header': + MessageLookupByLibrary.simpleMessage('Main player window'), + 'semantics_mark_episode_played': + MessageLookupByLibrary.simpleMessage('Mark Episode as played'), + 'semantics_mark_episode_unplayed': + MessageLookupByLibrary.simpleMessage('Mark Episode as un-played'), + 'semantics_mini_player_header': MessageLookupByLibrary.simpleMessage( + 'Mini player. Swipe right to play/pause button. Activate to open main player window'), + 'semantics_play_pause_toggle': + MessageLookupByLibrary.simpleMessage('Play/pause toggle'), + 'semantics_podcast_details_header': + MessageLookupByLibrary.simpleMessage( + 'Podcast details and episodes page'), + 'semantics_remove_from_queue': + MessageLookupByLibrary.simpleMessage('Remove episode from queue'), + 'settings_auto_open_now_playing': MessageLookupByLibrary.simpleMessage( + 'Full screen player mode on episode start'), + 'settings_auto_update_episodes': + MessageLookupByLibrary.simpleMessage('Auto update episodes'), + 'settings_auto_update_episodes_10min': + MessageLookupByLibrary.simpleMessage( + '10 minutes since last update'), + 'settings_auto_update_episodes_12hour': + MessageLookupByLibrary.simpleMessage('12 hours since last update'), + 'settings_auto_update_episodes_1hour': + MessageLookupByLibrary.simpleMessage('1 hour since last update'), + 'settings_auto_update_episodes_30min': + MessageLookupByLibrary.simpleMessage( + '30 minutes since last update'), + 'settings_auto_update_episodes_3hour': + MessageLookupByLibrary.simpleMessage('3 hours since last update'), + 'settings_auto_update_episodes_6hour': + MessageLookupByLibrary.simpleMessage('6 hours since last update'), + 'settings_auto_update_episodes_always': + MessageLookupByLibrary.simpleMessage('Always'), + 'settings_auto_update_episodes_heading': + MessageLookupByLibrary.simpleMessage( + 'Refresh episodes on details screen after'), + 'settings_auto_update_episodes_never': + MessageLookupByLibrary.simpleMessage('Never'), + 'settings_data_divider_label': + MessageLookupByLibrary.simpleMessage('DATA'), + 'settings_delete_played_label': MessageLookupByLibrary.simpleMessage( + 'Delete downloaded episodes once played'), + 'settings_download_sd_card_label': MessageLookupByLibrary.simpleMessage( + 'Download episodes to SD card'), + 'settings_download_switch_card': MessageLookupByLibrary.simpleMessage( + 'New downloads will be saved to the SD card. Existing downloads will remain on internal storage.'), + 'settings_download_switch_internal': MessageLookupByLibrary.simpleMessage( + 'New downloads will be saved to internal storage. Existing downloads will remain on the SD card.'), + 'settings_download_switch_label': + MessageLookupByLibrary.simpleMessage('Change storage location'), + 'settings_episodes_divider_label': + MessageLookupByLibrary.simpleMessage('EPISODES'), + 'settings_export_opml': + MessageLookupByLibrary.simpleMessage('Export OPML'), + 'settings_import_opml': + MessageLookupByLibrary.simpleMessage('Import OPML'), + 'settings_label': MessageLookupByLibrary.simpleMessage('Settings'), + 'settings_mark_deleted_played_label': + MessageLookupByLibrary.simpleMessage( + 'Mark deleted episodes as played'), + 'settings_personalisation_divider_label': + MessageLookupByLibrary.simpleMessage('Personalisation'), + 'settings_playback_divider_label': + MessageLookupByLibrary.simpleMessage('Playback'), + 'settings_theme_switch_label': + MessageLookupByLibrary.simpleMessage('Dark theme'), + 'show_notes_label': MessageLookupByLibrary.simpleMessage('Show notes'), + 'sleep_episode_label': + MessageLookupByLibrary.simpleMessage('End of episode'), + 'sleep_minute_label': m0, + 'sleep_off_label': MessageLookupByLibrary.simpleMessage('Off'), + 'sleep_timer_label': + MessageLookupByLibrary.simpleMessage('Sleep Timer'), + 'stop_download_button_label': + MessageLookupByLibrary.simpleMessage('Stop'), + 'stop_download_confirmation': MessageLookupByLibrary.simpleMessage( + 'Are you sure you wish to stop this download and delete the episode?'), + 'stop_download_title': + MessageLookupByLibrary.simpleMessage('Stop Download'), + 'subscribe_button_label': + MessageLookupByLibrary.simpleMessage('Follow'), + 'subscribe_label': MessageLookupByLibrary.simpleMessage('Follow'), + 'transcript_label': MessageLookupByLibrary.simpleMessage('Transcript'), + 'transcript_why_not_label': + MessageLookupByLibrary.simpleMessage('Why not?'), + 'transcript_why_not_url': MessageLookupByLibrary.simpleMessage( + 'https://www.pinepods.online/docs/Features/Transcript'), + 'unsubscribe_button_label': + MessageLookupByLibrary.simpleMessage('Unfollow'), + 'unsubscribe_label': MessageLookupByLibrary.simpleMessage('Unfollow'), + 'unsubscribe_message': MessageLookupByLibrary.simpleMessage( + 'Unfollowing will delete all downloaded episodes of this podcast.'), + 'up_next_queue_label': MessageLookupByLibrary.simpleMessage('Up Next') + }; +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/messages_it.dart b/PinePods-0.8.2/mobile/lib/l10n/messages_it.dart new file mode 100644 index 0000000..209fc1b --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/messages_it.dart @@ -0,0 +1,359 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that provides messages for a it locale. All the +// messages from the main program should be duplicated here with the same +// function name. +// @dart=2.12 +// Ignore issues from commonly used lints in this file. +// ignore_for_file:unnecessary_brace_in_string_interps +// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering +// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases +// ignore_for_file:unused_import, file_names + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; + +final messages = MessageLookup(); + +typedef String? MessageIfAbsent(String? messageStr, List? args); + +class MessageLookup extends MessageLookupByLibrary { + @override + String get localeName => 'it'; + + static m0(minutes) => "${minutes} minuti"; + + @override + final Map messages = + _notInlinedMessages(_notInlinedMessages); + + static Map _notInlinedMessages(_) => { + 'about_label': MessageLookupByLibrary.simpleMessage('Info'), + 'add_rss_feed_option': + MessageLookupByLibrary.simpleMessage('Aggiungi un Feed RSS'), + 'app_title': MessageLookupByLibrary.simpleMessage('Pinepods'), + 'app_title_short': MessageLookupByLibrary.simpleMessage('Pinepods'), + 'audio_effect_trim_silence_label': + MessageLookupByLibrary.simpleMessage('Rimuovi Silenzio'), + 'audio_effect_volume_boost_label': + MessageLookupByLibrary.simpleMessage('Incrementa Volume'), + 'audio_settings_playback_speed_label': + MessageLookupByLibrary.simpleMessage('Velocità Riproduzione'), + 'auto_scroll_transcript_label': + MessageLookupByLibrary.simpleMessage('Trascrizione sincronizzata'), + 'cancel_button_label': MessageLookupByLibrary.simpleMessage('Annulla'), + 'cancel_download_button_label': + MessageLookupByLibrary.simpleMessage('Annulla il download'), + 'cancel_option_label': MessageLookupByLibrary.simpleMessage('Annulla'), + 'chapters_label': MessageLookupByLibrary.simpleMessage('Capitoli'), + 'clear_queue_button_label': + MessageLookupByLibrary.simpleMessage('PULISCI CODA'), + 'clear_search_button_label': + MessageLookupByLibrary.simpleMessage('Pulisci il campo di ricerca'), + 'close_button_label': MessageLookupByLibrary.simpleMessage('Chiudi'), + 'consent_message': MessageLookupByLibrary.simpleMessage( + 'Questo link per la ricerca fondi ti porterà a un sito esterno dove avrai la possibilità di supportare direttamente questo show. I link sono forniti dagli autori del podcast e non sono verificati da Pinepods.'), + 'continue_button_label': + MessageLookupByLibrary.simpleMessage('Continua'), + 'delete_button_label': MessageLookupByLibrary.simpleMessage('Elimina'), + 'delete_episode_button_label': + MessageLookupByLibrary.simpleMessage('Elimina episodio scaricato'), + 'delete_episode_confirmation': MessageLookupByLibrary.simpleMessage( + 'Sicura/o di voler eliminare questo episodio?'), + 'delete_episode_title': + MessageLookupByLibrary.simpleMessage('Elimina Episodio'), + 'delete_label': MessageLookupByLibrary.simpleMessage('Elimina'), + 'discover': MessageLookupByLibrary.simpleMessage('Scopri'), + 'discovery_categories_itunes': MessageLookupByLibrary.simpleMessage( + ',Arte,Business,Commedia,Educazione,Fiction,Governativi,Salute e Benessere,Storia,Bambini e Famiglia,Tempo Libero,Musica,Notizie,Religione e Spiritualità,Scienza,Società e Cultura,Sport,TV e Film,Tecnologia,True Crime'), + 'discovery_categories_pindex': MessageLookupByLibrary.simpleMessage( + ',Dopo-Spettacolo,Alternativi,Animali,Animazione,Arte,Astronomia,Automotive,Aviazione,Baseball,Pallacanestro,Bellezza,Libri,Buddismo,Business,Carriera,Chimica,Cristianità,Clima,Commedia,Commenti,Corsi,Artigianato,Cricket,Cryptocurrency,Cultura,Giornalieri,Design,Documentari,Dramma,Terra,Educazione,Intrattenimento,Imprenditoria,Famiglia,Fantasy,Fashion,Fiction,Film,Fitness,Cibo,Football,Giochi,Giardinaggio,Golf,Governativi,Salute,Induismo,Storia,Hobbies,Hockey,Casa,Come Fare,Improvvisazione,Interviste,Investimenti,Islam,Giornalismo,Giudaismo,Bambini,Lingue,Apprendimento,Tempo-Libero,Stili di Vita,Gestione,Manga,Marketing,Matematica,Medicina,Mentale,Musica,Naturale,Natura,Notizie,NonProfit,Nutrizione,Genitorialità,Esecuzione,Personale,Animali-Domestici,Filosofia,Fisica,Posti,Politica,Relazioni,Religione,Recensioni,Giochi-di-Ruolo,Rugby,Corsa,Scienza,Miglioramento-Personale,Sessualità,Calcio,Social,Società,Spiritualità,Sports,Stand-Up,Storie,Nuoto,TV,Tabletop,Tecnologia,Tennis,Viaggi,True Crime,Video-Giochi,Visivo,Pallavolo,Meteo,Natura-Selvaggia,Wrestling'), + 'download_episode_button_label': + MessageLookupByLibrary.simpleMessage('Scarica episodio'), + 'downloads': MessageLookupByLibrary.simpleMessage('Scaricati'), + 'empty_queue_message': + MessageLookupByLibrary.simpleMessage('La tua coda è vuota'), + 'episode_details_button_label': MessageLookupByLibrary.simpleMessage( + 'Mostra le informazioni sull\'episodio'), + 'episode_filter_clear_filters_button_label': + MessageLookupByLibrary.simpleMessage('Pulisci i Filtri'), + 'episode_filter_no_episodes_title_description': + MessageLookupByLibrary.simpleMessage( + 'Questo podcast non ha episodi che corrispondono ai tuoi criteri di ricerca e filtro'), + 'episode_filter_no_episodes_title_label': + MessageLookupByLibrary.simpleMessage('Nessun episodio trovato'), + 'episode_filter_none_label': + MessageLookupByLibrary.simpleMessage('Nessuno'), + 'episode_filter_played_label': + MessageLookupByLibrary.simpleMessage('Riprodotto'), + 'episode_filter_semantic_label': + MessageLookupByLibrary.simpleMessage('Filtra gli episodi'), + 'episode_filter_started_label': + MessageLookupByLibrary.simpleMessage('Avviato'), + 'episode_filter_unplayed_label': + MessageLookupByLibrary.simpleMessage('Non riprodotto'), + 'episode_label': MessageLookupByLibrary.simpleMessage('Episodio'), + 'episode_sort_alphabetical_ascending_label': + MessageLookupByLibrary.simpleMessage('Ordine Alfabetico A-Z'), + 'episode_sort_alphabetical_descending_label': + MessageLookupByLibrary.simpleMessage('Ordine Alfabetico Z-A'), + 'episode_sort_earliest_first_label': + MessageLookupByLibrary.simpleMessage('I più vecchi'), + 'episode_sort_latest_first_label': + MessageLookupByLibrary.simpleMessage('Gli ultimi'), + 'episode_sort_none_label': + MessageLookupByLibrary.simpleMessage('Default'), + 'episode_sort_semantic_label': + MessageLookupByLibrary.simpleMessage('Ordina gli episodi'), + 'error_no_connection': MessageLookupByLibrary.simpleMessage( + 'Impossibile riprodurre l\'episodio. Per favore, verifica la tua connessione e prova di nuovo.'), + 'error_playback_fail': MessageLookupByLibrary.simpleMessage( + 'Sì è verificato un errore inatteso durante la riproduzione. Per favore, verifica la tua connessione e prova di nuovo.'), + 'fast_forward_button_label': + MessageLookupByLibrary.simpleMessage('Manda avanti di 30 secondi'), + 'feedback_menu_item_label': + MessageLookupByLibrary.simpleMessage('Feedback'), + 'go_back_button_label': + MessageLookupByLibrary.simpleMessage('Torna indietro'), + 'label_opml_importing': + MessageLookupByLibrary.simpleMessage('Importazione in corso'), + 'layout_label': MessageLookupByLibrary.simpleMessage('Layout'), + 'library': MessageLookupByLibrary.simpleMessage('Libreria'), + 'mark_episodes_not_played_label': MessageLookupByLibrary.simpleMessage( + 'Marca tutti gli episodi come non riprodotti'), + 'mark_episodes_played_label': MessageLookupByLibrary.simpleMessage( + 'Marca tutti gli episodi come riprodotti'), + 'mark_played_label': + MessageLookupByLibrary.simpleMessage('Marca Riprodotto'), + 'mark_unplayed_label': + MessageLookupByLibrary.simpleMessage('Marca da Riprodurre'), + 'minimise_player_window_button_label': + MessageLookupByLibrary.simpleMessage( + 'Minimizza la finestra del player'), + 'more_label': MessageLookupByLibrary.simpleMessage('Di Più'), + 'new_episodes_label': MessageLookupByLibrary.simpleMessage( + 'Nuovi episodi sono disponibili'), + 'new_episodes_view_now_label': + MessageLookupByLibrary.simpleMessage('VEDI ORA'), + 'no_downloads_message': MessageLookupByLibrary.simpleMessage( + 'Non hai nessun episodio scaricato'), + 'no_podcast_details_message': MessageLookupByLibrary.simpleMessage( + 'Non è possibile caricare gli episodi. Verifica la tua connessione, per favore.'), + 'no_search_results_message': + MessageLookupByLibrary.simpleMessage('Nessun podcast trovato'), + 'no_subscriptions_message': MessageLookupByLibrary.simpleMessage( + 'Tappa il pulsante di ricerca sottostante o usa la barra di ricerca per trovare il tuo primo podcast'), + 'no_transcript_available_label': MessageLookupByLibrary.simpleMessage( + 'Nessuna trascrizione disponibile per questo podcast'), + 'notes_label': MessageLookupByLibrary.simpleMessage('Note'), + 'now_playing_episode_position': + MessageLookupByLibrary.simpleMessage('Posizione dell\'episodio'), + 'now_playing_episode_time_remaining': + MessageLookupByLibrary.simpleMessage('Tempo rimanente'), + 'now_playing_queue_label': + MessageLookupByLibrary.simpleMessage('In Riproduzione'), + 'ok_button_label': MessageLookupByLibrary.simpleMessage('OK'), + 'open_show_website_label': + MessageLookupByLibrary.simpleMessage('Vai al sito web dello show'), + 'opml_export_button_label': + MessageLookupByLibrary.simpleMessage('Esporta'), + 'opml_import_button_label': + MessageLookupByLibrary.simpleMessage('Importa'), + 'opml_import_export_label': + MessageLookupByLibrary.simpleMessage('OPML Importa/Esporta'), + 'pause_button_label': + MessageLookupByLibrary.simpleMessage('Sospendi episodio'), + 'play_button_label': + MessageLookupByLibrary.simpleMessage('Riproduci episodio'), + 'play_download_button_label': MessageLookupByLibrary.simpleMessage( + 'Riproduci l\'episodio scaricato'), + 'playback_speed_label': + MessageLookupByLibrary.simpleMessage('Velocità di riproduzione'), + 'podcast_funding_dialog_header': + MessageLookupByLibrary.simpleMessage('Podcast Fondi'), + 'podcast_options_overflow_menu_semantic_label': + MessageLookupByLibrary.simpleMessage('Menu opzioni'), + 'queue_add_label': MessageLookupByLibrary.simpleMessage('Aggiungi'), + 'queue_clear_button_label': + MessageLookupByLibrary.simpleMessage('Svuota'), + 'queue_clear_label': MessageLookupByLibrary.simpleMessage( + 'Sicuro/a di voler ripulire la coda?'), + 'queue_clear_label_title': + MessageLookupByLibrary.simpleMessage('Svuota la Coda'), + 'queue_remove_label': MessageLookupByLibrary.simpleMessage('Rimuovi'), + 'refresh_feed_label': + MessageLookupByLibrary.simpleMessage('Recupera nuovi episodi'), + 'resume_button_label': + MessageLookupByLibrary.simpleMessage('Riprendi episodio'), + 'rewind_button_label': + MessageLookupByLibrary.simpleMessage('Riavvolgi di 10 secondi'), + 'scrim_episode_details_selector': MessageLookupByLibrary.simpleMessage( + 'Chiudi i dettagli dell\'episodio'), + 'scrim_episode_filter_selector': MessageLookupByLibrary.simpleMessage( + 'Chiudi il filtro degli episodi'), + 'scrim_episode_sort_selector': MessageLookupByLibrary.simpleMessage( + 'Chiudi ordinamento degli episodi'), + 'scrim_layout_selector': MessageLookupByLibrary.simpleMessage( + 'Chiudi il selettore del layout'), + 'scrim_sleep_timer_selector': MessageLookupByLibrary.simpleMessage( + 'Chiudere il selettore del timer di spegnimento'), + 'scrim_speed_selector': MessageLookupByLibrary.simpleMessage( + 'Chiudere il selettore della velocità di riproduzione'), + 'search_back_button_label': + MessageLookupByLibrary.simpleMessage('Indietro'), + 'search_button_label': MessageLookupByLibrary.simpleMessage('Cerca'), + 'search_episodes_label': + MessageLookupByLibrary.simpleMessage('Cerca episodi'), + 'search_for_podcasts_hint': + MessageLookupByLibrary.simpleMessage('Ricerca dei podcasts'), + 'search_provider_label': + MessageLookupByLibrary.simpleMessage('Provider di ricerca'), + 'search_transcript_label': + MessageLookupByLibrary.simpleMessage('Cerca trascrizione'), + 'semantic_announce_searching': MessageLookupByLibrary.simpleMessage( + 'Ricerca in corso, attender prego.'), + 'semantic_chapter_link_label': + MessageLookupByLibrary.simpleMessage('Web link al capitolo'), + 'semantic_current_chapter_label': + MessageLookupByLibrary.simpleMessage('Capitolo attuale'), + 'semantic_current_value_label': + MessageLookupByLibrary.simpleMessage('Impostazioni correnti'), + 'semantic_playing_options_collapse_label': + MessageLookupByLibrary.simpleMessage( + 'Chiudere il cursore delle opzioni di riproduzione'), + 'semantic_playing_options_expand_label': + MessageLookupByLibrary.simpleMessage( + 'Aprire il cursore delle opzioni di riproduzione'), + 'semantic_podcast_artwork_label': + MessageLookupByLibrary.simpleMessage('Podcast artwork'), + 'semantics_add_to_queue': + MessageLookupByLibrary.simpleMessage('Aggiungi episodio alla coda'), + 'semantics_collapse_podcast_description': + MessageLookupByLibrary.simpleMessage( + 'Collassa la descrizione del podcast'), + 'semantics_decrease_playback_speed': + MessageLookupByLibrary.simpleMessage('Rallenta la riproduzione'), + 'semantics_episode_tile_collapsed': MessageLookupByLibrary.simpleMessage( + 'Voce dell\'elenco degli episodi. Visualizza immagine, sommario e i controlli principali.'), + 'semantics_episode_tile_collapsed_hint': + MessageLookupByLibrary.simpleMessage( + 'espandi e visualizza più dettagli e opzioni aggiuntive'), + 'semantics_episode_tile_expanded': MessageLookupByLibrary.simpleMessage( + 'Voce dell\'elenco degli episodi. Visualizza descrizione, controlli principali e controlli aggiuntivi.'), + 'semantics_episode_tile_expanded_hint': + MessageLookupByLibrary.simpleMessage( + 'collassa e visualizza il sommario, download e controlli di riproduzione'), + 'semantics_expand_podcast_description': + MessageLookupByLibrary.simpleMessage( + 'Espandi la descrizione del podcast'), + 'semantics_increase_playback_speed': + MessageLookupByLibrary.simpleMessage('Incrementa la riproduzione'), + 'semantics_layout_option_compact_grid': + MessageLookupByLibrary.simpleMessage('Griglia compatta'), + 'semantics_layout_option_grid': + MessageLookupByLibrary.simpleMessage('Griglia'), + 'semantics_layout_option_list': + MessageLookupByLibrary.simpleMessage('Lista'), + 'semantics_main_player_header': MessageLookupByLibrary.simpleMessage( + 'Finestra principale del player'), + 'semantics_mark_episode_played': MessageLookupByLibrary.simpleMessage( + 'Marca Episodio come riprodotto'), + 'semantics_mark_episode_unplayed': MessageLookupByLibrary.simpleMessage( + 'Marca Episodio come non-riprodotto'), + 'semantics_mini_player_header': MessageLookupByLibrary.simpleMessage( + 'Mini player. Swipe a destra per riprodurre/mettere in pausa. Attivare per aprire la finestra principale del player'), + 'semantics_play_pause_toggle': + MessageLookupByLibrary.simpleMessage('Play/pause toggle'), + 'semantics_podcast_details_header': + MessageLookupByLibrary.simpleMessage( + 'Podcast pagina dettagli ed episodi'), + 'semantics_remove_from_queue': + MessageLookupByLibrary.simpleMessage('Rimuovi episodio dalla coda'), + 'settings_auto_open_now_playing': MessageLookupByLibrary.simpleMessage( + 'Player a tutto schermo quando l\'episodio inizia'), + 'settings_auto_update_episodes': MessageLookupByLibrary.simpleMessage( + 'Aggiorna automaticamente gli episodi'), + 'settings_auto_update_episodes_10min': + MessageLookupByLibrary.simpleMessage( + '10 minuti dall\'ultimo aggiornamento'), + 'settings_auto_update_episodes_12hour': + MessageLookupByLibrary.simpleMessage( + '12 ore dall\'ultimo aggiornamento'), + 'settings_auto_update_episodes_1hour': + MessageLookupByLibrary.simpleMessage( + '1 ora dall\'ultimo aggiornamento'), + 'settings_auto_update_episodes_30min': + MessageLookupByLibrary.simpleMessage( + '30 minuti dall\'ultimo aggiornamento'), + 'settings_auto_update_episodes_3hour': + MessageLookupByLibrary.simpleMessage( + '3 ore dall\'ultimo aggiornamento'), + 'settings_auto_update_episodes_6hour': + MessageLookupByLibrary.simpleMessage( + '6 ore dall\'ultimo aggiornamento'), + 'settings_auto_update_episodes_always': + MessageLookupByLibrary.simpleMessage('Sempre'), + 'settings_auto_update_episodes_heading': + MessageLookupByLibrary.simpleMessage( + 'Aggiorna gli episodi nella schermata successiva'), + 'settings_auto_update_episodes_never': + MessageLookupByLibrary.simpleMessage('Mai'), + 'settings_data_divider_label': + MessageLookupByLibrary.simpleMessage('DATI'), + 'settings_delete_played_label': MessageLookupByLibrary.simpleMessage( + 'Elimina gli episodi scaricati una volta riprodotti'), + 'settings_download_sd_card_label': MessageLookupByLibrary.simpleMessage( + 'Scarica gli episodi nella card SD'), + 'settings_download_switch_card': MessageLookupByLibrary.simpleMessage( + 'I nuovi downloads saranno salvati nella card SD. I downloads esistenti rimarranno nello storage interno.'), + 'settings_download_switch_internal': MessageLookupByLibrary.simpleMessage( + 'I nuovi downloads saranno salvati nello storage interno. I downloads esistenti rimarranno nella card SD.'), + 'settings_download_switch_label': MessageLookupByLibrary.simpleMessage( + 'Cambia la posizione per lo storage'), + 'settings_episodes_divider_label': + MessageLookupByLibrary.simpleMessage('EPISODI'), + 'settings_export_opml': + MessageLookupByLibrary.simpleMessage('Esporta OPML'), + 'settings_import_opml': + MessageLookupByLibrary.simpleMessage('Importa OPML'), + 'settings_label': MessageLookupByLibrary.simpleMessage('Impostazioni'), + 'settings_mark_deleted_played_label': + MessageLookupByLibrary.simpleMessage( + 'Marca gli episodi eliminati come riprodotti'), + 'settings_personalisation_divider_label': + MessageLookupByLibrary.simpleMessage('PERSONALIZZAZIONI'), + 'settings_playback_divider_label': + MessageLookupByLibrary.simpleMessage('RIPRODUZIONE'), + 'settings_theme_switch_label': + MessageLookupByLibrary.simpleMessage('Tema scuro'), + 'show_notes_label': + MessageLookupByLibrary.simpleMessage('Visualizza le note'), + 'sleep_episode_label': + MessageLookupByLibrary.simpleMessage('Fine dell\'episodio'), + 'sleep_minute_label': m0, + 'sleep_off_label': MessageLookupByLibrary.simpleMessage('Off'), + 'sleep_timer_label': + MessageLookupByLibrary.simpleMessage('Timer di Riposo'), + 'stop_download_button_label': + MessageLookupByLibrary.simpleMessage('Stop'), + 'stop_download_confirmation': MessageLookupByLibrary.simpleMessage( + 'Sicura/o di voler fermare il download ed eliminare l\'episodio?'), + 'stop_download_title': + MessageLookupByLibrary.simpleMessage('Stop Download'), + 'subscribe_button_label': MessageLookupByLibrary.simpleMessage('Segui'), + 'subscribe_label': MessageLookupByLibrary.simpleMessage('Segui'), + 'transcript_label': + MessageLookupByLibrary.simpleMessage('Trascrizioni'), + 'transcript_why_not_label': + MessageLookupByLibrary.simpleMessage('Perché no?'), + 'transcript_why_not_url': MessageLookupByLibrary.simpleMessage( + 'https://www.pinepods.online/docs/Features/Transcript'), + 'unsubscribe_button_label': + MessageLookupByLibrary.simpleMessage('Non Seguire'), + 'unsubscribe_label': + MessageLookupByLibrary.simpleMessage('Smetti di seguire'), + 'unsubscribe_message': MessageLookupByLibrary.simpleMessage( + 'Smettendo di seguire questo podcast, tutti gli episodi scaricati verranno eliminati.'), + 'up_next_queue_label': + MessageLookupByLibrary.simpleMessage('Vai al Prossimo') + }; +} diff --git a/PinePods-0.8.2/mobile/lib/l10n/messages_messages.dart b/PinePods-0.8.2/mobile/lib/l10n/messages_messages.dart new file mode 100644 index 0000000..66a4fed --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/l10n/messages_messages.dart @@ -0,0 +1,349 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that provides messages for a messages locale. All the +// messages from the main program should be duplicated here with the same +// function name. +// @dart=2.12 +// Ignore issues from commonly used lints in this file. +// ignore_for_file:unnecessary_brace_in_string_interps +// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering +// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases +// ignore_for_file:unused_import, file_names + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; + +final messages = MessageLookup(); + +typedef String? MessageIfAbsent(String? messageStr, List? args); + +class MessageLookup extends MessageLookupByLibrary { + @override + String get localeName => 'messages'; + + static m0(minutes) => "${minutes} minutes"; + + @override + final Map messages = + _notInlinedMessages(_notInlinedMessages); + + static Map _notInlinedMessages(_) => { + 'about_label': MessageLookupByLibrary.simpleMessage('About'), + 'add_rss_feed_option': + MessageLookupByLibrary.simpleMessage('Add RSS Feed'), + 'app_title': + MessageLookupByLibrary.simpleMessage('Pinepods Podcast Client'), + 'app_title_short': MessageLookupByLibrary.simpleMessage('Pinepods'), + 'audio_effect_trim_silence_label': + MessageLookupByLibrary.simpleMessage('Trim Silence'), + 'audio_effect_volume_boost_label': + MessageLookupByLibrary.simpleMessage('Volume Boost'), + 'audio_settings_playback_speed_label': + MessageLookupByLibrary.simpleMessage('Playback Speed'), + 'auto_scroll_transcript_label': + MessageLookupByLibrary.simpleMessage('Follow transcript'), + 'cancel_button_label': MessageLookupByLibrary.simpleMessage('Cancel'), + 'cancel_download_button_label': + MessageLookupByLibrary.simpleMessage('Cancel download'), + 'cancel_option_label': MessageLookupByLibrary.simpleMessage('Cancel'), + 'chapters_label': MessageLookupByLibrary.simpleMessage('Chapters'), + 'clear_queue_button_label': + MessageLookupByLibrary.simpleMessage('CLEAR QUEUE'), + 'clear_search_button_label': + MessageLookupByLibrary.simpleMessage('Clear search text'), + 'close_button_label': MessageLookupByLibrary.simpleMessage('Close'), + 'consent_message': MessageLookupByLibrary.simpleMessage( + 'This funding link will take you to an external site where you will be able to directly support the show. Links are provided by the podcast authors and is not controlled by Pinepods.'), + 'continue_button_label': + MessageLookupByLibrary.simpleMessage('Continue'), + 'delete_button_label': MessageLookupByLibrary.simpleMessage('Delete'), + 'delete_episode_button_label': + MessageLookupByLibrary.simpleMessage('Delete downloaded episode'), + 'delete_episode_confirmation': MessageLookupByLibrary.simpleMessage( + 'Are you sure you wish to delete this episode?'), + 'delete_episode_title': + MessageLookupByLibrary.simpleMessage('Delete Episode'), + 'delete_label': MessageLookupByLibrary.simpleMessage('Delete'), + 'discover': MessageLookupByLibrary.simpleMessage('Discover'), + 'discovery_categories_itunes': MessageLookupByLibrary.simpleMessage( + ',Arts,Business,Comedy,Education,Fiction,Government,Health & Fitness,History,Kids & Family,Leisure,Music,News,Religion & Spirituality,Science,Society & Culture,Sports,TV & Film,Technology,True Crime'), + 'discovery_categories_pindex': MessageLookupByLibrary.simpleMessage( + ',After-Shows,Alternative,Animals,Animation,Arts,Astronomy,Automotive,Aviation,Baseball,Basketball,Beauty,Books,Buddhism,Business,Careers,Chemistry,Christianity,Climate,Comedy,Commentary,Courses,Crafts,Cricket,Cryptocurrency,Culture,Daily,Design,Documentary,Drama,Earth,Education,Entertainment,Entrepreneurship,Family,Fantasy,Fashion,Fiction,Film,Fitness,Food,Football,Games,Garden,Golf,Government,Health,Hinduism,History,Hobbies,Hockey,Home,HowTo,Improv,Interviews,Investing,Islam,Journals,Judaism,Kids,Language,Learning,Leisure,Life,Management,Manga,Marketing,Mathematics,Medicine,Mental,Music,Natural,Nature,News,NonProfit,Nutrition,Parenting,Performing,Personal,Pets,Philosophy,Physics,Places,Politics,Relationships,Religion,Reviews,Role-Playing,Rugby,Running,Science,Self-Improvement,Sexuality,Soccer,Social,Society,Spirituality,Sports,Stand-Up,Stories,Swimming,TV,Tabletop,Technology,Tennis,Travel,True Crime,Video-Games,Visual,Volleyball,Weather,Wilderness,Wrestling'), + 'download_episode_button_label': + MessageLookupByLibrary.simpleMessage('Download episode'), + 'downloads': MessageLookupByLibrary.simpleMessage('Downloads'), + 'empty_queue_message': + MessageLookupByLibrary.simpleMessage('Your queue is empty'), + 'episode_details_button_label': + MessageLookupByLibrary.simpleMessage('Show episode information'), + 'episode_filter_clear_filters_button_label': + MessageLookupByLibrary.simpleMessage('Clear Filters'), + 'episode_filter_no_episodes_title_description': + MessageLookupByLibrary.simpleMessage('No Episodes Found'), + 'episode_filter_no_episodes_title_label': + MessageLookupByLibrary.simpleMessage('No Episodes Found'), + 'episode_filter_none_label': + MessageLookupByLibrary.simpleMessage('None'), + 'episode_filter_played_label': + MessageLookupByLibrary.simpleMessage('Played'), + 'episode_filter_semantic_label': + MessageLookupByLibrary.simpleMessage('Episode filter'), + 'episode_filter_started_label': + MessageLookupByLibrary.simpleMessage('Started'), + 'episode_filter_unplayed_label': + MessageLookupByLibrary.simpleMessage('Unplayed'), + 'episode_label': MessageLookupByLibrary.simpleMessage('Episode'), + 'episode_sort_alphabetical_ascending_label': + MessageLookupByLibrary.simpleMessage('Alphabetical A-Z'), + 'episode_sort_alphabetical_descending_label': + MessageLookupByLibrary.simpleMessage('Alphabetical Z-A'), + 'episode_sort_earliest_first_label': + MessageLookupByLibrary.simpleMessage('Earliest first'), + 'episode_sort_latest_first_label': + MessageLookupByLibrary.simpleMessage('Latest first'), + 'episode_sort_none_label': + MessageLookupByLibrary.simpleMessage('Default'), + 'episode_sort_semantic_label': + MessageLookupByLibrary.simpleMessage('Episode sort'), + 'error_no_connection': MessageLookupByLibrary.simpleMessage( + 'Unable to play episode. Please check your connection and try again.'), + 'error_playback_fail': MessageLookupByLibrary.simpleMessage( + 'An unexpected error occurred during playback. Please check your connection and try again.'), + 'fast_forward_button_label': MessageLookupByLibrary.simpleMessage( + 'Fast-forward episode 30 seconds'), + 'feedback_menu_item_label': + MessageLookupByLibrary.simpleMessage('Feedback'), + 'go_back_button_label': MessageLookupByLibrary.simpleMessage('Go Back'), + 'label_opml_importing': + MessageLookupByLibrary.simpleMessage('Importing'), + 'layout_label': MessageLookupByLibrary.simpleMessage('Layout'), + 'library': MessageLookupByLibrary.simpleMessage('Library'), + 'mark_episodes_not_played_label': MessageLookupByLibrary.simpleMessage( + 'Mark all episodes as not played'), + 'mark_episodes_played_label': + MessageLookupByLibrary.simpleMessage('Mark all episodes as played'), + 'mark_played_label': + MessageLookupByLibrary.simpleMessage('Mark Played'), + 'mark_unplayed_label': + MessageLookupByLibrary.simpleMessage('Mark Unplayed'), + 'minimise_player_window_button_label': + MessageLookupByLibrary.simpleMessage('Minimise player window'), + 'more_label': MessageLookupByLibrary.simpleMessage('More'), + 'new_episodes_label': + MessageLookupByLibrary.simpleMessage('New episodes are available'), + 'new_episodes_view_now_label': + MessageLookupByLibrary.simpleMessage('VIEW NOW'), + 'no_downloads_message': MessageLookupByLibrary.simpleMessage( + 'You do not have any downloaded episodes'), + 'no_podcast_details_message': MessageLookupByLibrary.simpleMessage( + 'Could not load podcast episodes. Please check your connection.'), + 'no_search_results_message': + MessageLookupByLibrary.simpleMessage('No podcasts found'), + 'no_subscriptions_message': MessageLookupByLibrary.simpleMessage( + 'Head to Settings to Connect a Pinepods Server if you haven\'t yet!'), + 'no_transcript_available_label': MessageLookupByLibrary.simpleMessage( + 'A transcript is not available for this podcast'), + 'notes_label': MessageLookupByLibrary.simpleMessage('Description'), + 'now_playing_episode_position': + MessageLookupByLibrary.simpleMessage('Episode position'), + 'now_playing_episode_time_remaining': + MessageLookupByLibrary.simpleMessage('Time remaining'), + 'now_playing_queue_label': + MessageLookupByLibrary.simpleMessage('Now Playing'), + 'ok_button_label': MessageLookupByLibrary.simpleMessage('OK'), + 'open_show_website_label': + MessageLookupByLibrary.simpleMessage('Open show website'), + 'opml_export_button_label': + MessageLookupByLibrary.simpleMessage('Export'), + 'opml_import_button_label': + MessageLookupByLibrary.simpleMessage('Import'), + 'opml_import_export_label': + MessageLookupByLibrary.simpleMessage('OPML Import/Export'), + 'pause_button_label': + MessageLookupByLibrary.simpleMessage('Pause episode'), + 'play_button_label': + MessageLookupByLibrary.simpleMessage('Play episode'), + 'play_download_button_label': + MessageLookupByLibrary.simpleMessage('Play downloaded episode'), + 'playback_speed_label': + MessageLookupByLibrary.simpleMessage('Playback speed'), + 'podcast_funding_dialog_header': + MessageLookupByLibrary.simpleMessage('Podcast Funding'), + 'podcast_options_overflow_menu_semantic_label': + MessageLookupByLibrary.simpleMessage('Options menu'), + 'queue_add_label': MessageLookupByLibrary.simpleMessage('Add'), + 'queue_clear_button_label': + MessageLookupByLibrary.simpleMessage('Clear'), + 'queue_clear_label': MessageLookupByLibrary.simpleMessage( + 'Are you sure you wish to clear the queue?'), + 'queue_clear_label_title': + MessageLookupByLibrary.simpleMessage('Clear Queue'), + 'queue_remove_label': MessageLookupByLibrary.simpleMessage('Remove'), + 'refresh_feed_label': + MessageLookupByLibrary.simpleMessage('Refresh episodes'), + 'resume_button_label': + MessageLookupByLibrary.simpleMessage('Resume episode'), + 'rewind_button_label': + MessageLookupByLibrary.simpleMessage('Rewind episode 10 seconds'), + 'scrim_episode_details_selector': + MessageLookupByLibrary.simpleMessage('Dismiss episode details'), + 'scrim_episode_filter_selector': + MessageLookupByLibrary.simpleMessage('Dismiss episode filter'), + 'scrim_episode_sort_selector': + MessageLookupByLibrary.simpleMessage('Dismiss episode sort'), + 'scrim_layout_selector': + MessageLookupByLibrary.simpleMessage('Dismiss layout selector'), + 'scrim_sleep_timer_selector': MessageLookupByLibrary.simpleMessage( + 'Dismiss sleep timer selector'), + 'scrim_speed_selector': MessageLookupByLibrary.simpleMessage( + 'Dismiss playback speed selector'), + 'search_back_button_label': + MessageLookupByLibrary.simpleMessage('Back'), + 'search_button_label': MessageLookupByLibrary.simpleMessage('Search'), + 'search_episodes_label': + MessageLookupByLibrary.simpleMessage('Search episodes'), + 'search_for_podcasts_hint': + MessageLookupByLibrary.simpleMessage('Search for podcasts'), + 'search_provider_label': + MessageLookupByLibrary.simpleMessage('Search provider'), + 'search_transcript_label': + MessageLookupByLibrary.simpleMessage('Search transcript'), + 'semantic_announce_searching': + MessageLookupByLibrary.simpleMessage('Searching, please wait.'), + 'semantic_chapter_link_label': + MessageLookupByLibrary.simpleMessage('Chapter web link'), + 'semantic_current_chapter_label': + MessageLookupByLibrary.simpleMessage('Current chapter'), + 'semantic_current_value_label': + MessageLookupByLibrary.simpleMessage('Current value'), + 'semantic_playing_options_collapse_label': + MessageLookupByLibrary.simpleMessage( + 'Close playing options slider'), + 'semantic_playing_options_expand_label': + MessageLookupByLibrary.simpleMessage('Open playing options slider'), + 'semantic_podcast_artwork_label': + MessageLookupByLibrary.simpleMessage('Podcast artwork'), + 'semantics_add_to_queue': + MessageLookupByLibrary.simpleMessage('Add episode to queue'), + 'semantics_collapse_podcast_description': + MessageLookupByLibrary.simpleMessage( + 'Collapse podcast description'), + 'semantics_decrease_playback_speed': + MessageLookupByLibrary.simpleMessage('Decrease playback speed'), + 'semantics_episode_tile_collapsed': + MessageLookupByLibrary.simpleMessage( + 'Episode list item. Showing image, summary and main controls.'), + 'semantics_episode_tile_collapsed_hint': + MessageLookupByLibrary.simpleMessage( + 'expand and show more details and additional options'), + 'semantics_episode_tile_expanded': MessageLookupByLibrary.simpleMessage( + 'Episode list item. Showing description, main controls and additional controls.'), + 'semantics_episode_tile_expanded_hint': + MessageLookupByLibrary.simpleMessage( + 'collapse and show summary, download and play control'), + 'semantics_expand_podcast_description': + MessageLookupByLibrary.simpleMessage('Expand podcast description'), + 'semantics_increase_playback_speed': + MessageLookupByLibrary.simpleMessage('Increase playback speed'), + 'semantics_layout_option_compact_grid': + MessageLookupByLibrary.simpleMessage('Compact grid layout'), + 'semantics_layout_option_grid': + MessageLookupByLibrary.simpleMessage('Grid layout'), + 'semantics_layout_option_list': + MessageLookupByLibrary.simpleMessage('List layout'), + 'semantics_main_player_header': + MessageLookupByLibrary.simpleMessage('Main player window'), + 'semantics_mark_episode_played': + MessageLookupByLibrary.simpleMessage('Mark Episode as played'), + 'semantics_mark_episode_unplayed': + MessageLookupByLibrary.simpleMessage('Mark Episode as un-played'), + 'semantics_mini_player_header': MessageLookupByLibrary.simpleMessage( + 'Mini player. Swipe right to play/pause button. Activate to open main player window'), + 'semantics_play_pause_toggle': + MessageLookupByLibrary.simpleMessage('Play/pause toggle'), + 'semantics_podcast_details_header': + MessageLookupByLibrary.simpleMessage( + 'Podcast details and episodes page'), + 'semantics_remove_from_queue': + MessageLookupByLibrary.simpleMessage('Remove episode from queue'), + 'settings_auto_open_now_playing': MessageLookupByLibrary.simpleMessage( + 'Full screen player mode on episode start'), + 'settings_auto_update_episodes': + MessageLookupByLibrary.simpleMessage('Auto update episodes'), + 'settings_auto_update_episodes_10min': + MessageLookupByLibrary.simpleMessage( + '10 minutes since last update'), + 'settings_auto_update_episodes_12hour': + MessageLookupByLibrary.simpleMessage('12 hours since last update'), + 'settings_auto_update_episodes_1hour': + MessageLookupByLibrary.simpleMessage('1 hour since last update'), + 'settings_auto_update_episodes_30min': + MessageLookupByLibrary.simpleMessage( + '30 minutes since last update'), + 'settings_auto_update_episodes_3hour': + MessageLookupByLibrary.simpleMessage('3 hours since last update'), + 'settings_auto_update_episodes_6hour': + MessageLookupByLibrary.simpleMessage('6 hours since last update'), + 'settings_auto_update_episodes_always': + MessageLookupByLibrary.simpleMessage('Always'), + 'settings_auto_update_episodes_heading': + MessageLookupByLibrary.simpleMessage( + 'Refresh episodes on details screen after'), + 'settings_auto_update_episodes_never': + MessageLookupByLibrary.simpleMessage('Never'), + 'settings_data_divider_label': + MessageLookupByLibrary.simpleMessage('DATA'), + 'settings_delete_played_label': MessageLookupByLibrary.simpleMessage( + 'Delete downloaded episodes once played'), + 'settings_download_sd_card_label': MessageLookupByLibrary.simpleMessage( + 'Download episodes to SD card'), + 'settings_download_switch_card': MessageLookupByLibrary.simpleMessage( + 'New downloads will be saved to the SD card. Existing downloads will remain on internal storage.'), + 'settings_download_switch_internal': MessageLookupByLibrary.simpleMessage( + 'New downloads will be saved to internal storage. Existing downloads will remain on the SD card.'), + 'settings_download_switch_label': + MessageLookupByLibrary.simpleMessage('Change storage location'), + 'settings_episodes_divider_label': + MessageLookupByLibrary.simpleMessage('EPISODES'), + 'settings_export_opml': + MessageLookupByLibrary.simpleMessage('Export OPML'), + 'settings_import_opml': + MessageLookupByLibrary.simpleMessage('Import OPML'), + 'settings_label': MessageLookupByLibrary.simpleMessage('Settings'), + 'settings_mark_deleted_played_label': + MessageLookupByLibrary.simpleMessage( + 'Mark deleted episodes as played'), + 'settings_personalisation_divider_label': + MessageLookupByLibrary.simpleMessage('Personalisation'), + 'settings_playback_divider_label': + MessageLookupByLibrary.simpleMessage('Playback'), + 'settings_theme_switch_label': + MessageLookupByLibrary.simpleMessage('Dark theme'), + 'show_notes_label': MessageLookupByLibrary.simpleMessage('Show notes'), + 'sleep_episode_label': + MessageLookupByLibrary.simpleMessage('End of episode'), + 'sleep_minute_label': m0, + 'sleep_off_label': MessageLookupByLibrary.simpleMessage('Off'), + 'sleep_timer_label': + MessageLookupByLibrary.simpleMessage('Sleep Timer'), + 'stop_download_button_label': + MessageLookupByLibrary.simpleMessage('Stop'), + 'stop_download_confirmation': MessageLookupByLibrary.simpleMessage( + 'Are you sure you wish to stop this download and delete the episode?'), + 'stop_download_title': + MessageLookupByLibrary.simpleMessage('Stop Download'), + 'subscribe_button_label': + MessageLookupByLibrary.simpleMessage('Follow'), + 'subscribe_label': MessageLookupByLibrary.simpleMessage('Follow'), + 'transcript_label': MessageLookupByLibrary.simpleMessage('Transcript'), + 'transcript_why_not_label': + MessageLookupByLibrary.simpleMessage('Why not?'), + 'transcript_why_not_url': MessageLookupByLibrary.simpleMessage( + 'https://www.pinepods.online/docs/Features/Transcript'), + 'unsubscribe_button_label': + MessageLookupByLibrary.simpleMessage('Unfollow'), + 'unsubscribe_label': MessageLookupByLibrary.simpleMessage('Unfollow'), + 'unsubscribe_message': MessageLookupByLibrary.simpleMessage( + 'Unfollowing will delete all downloaded episodes of this podcast.'), + 'up_next_queue_label': MessageLookupByLibrary.simpleMessage('Up Next') + }; +} diff --git a/PinePods-0.8.2/mobile/lib/main.dart b/PinePods-0.8.2/mobile/lib/main.dart new file mode 100644 index 0000000..93f15a5 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/main.dart @@ -0,0 +1,99 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:pinepods_mobile/services/settings/mobile_settings_service.dart'; +import 'package:pinepods_mobile/services/logging/app_logger.dart'; +import 'package:pinepods_mobile/ui/pinepods_podcast_app.dart'; +import 'package:pinepods_mobile/ui/widgets/restart_widget.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; + +// ignore_for_file: avoid_print +void main() async { + List certificateAuthorityBytes = []; + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(statusBarColor: Colors.transparent)); + + // Initialize app logger + final appLogger = AppLogger(); + await appLogger.initialize(); + + Logger.root.level = Level.FINE; + + Logger.root.onRecord.listen((record) { + print('${record.level.name}: - ${record.time}: ${record.loggerName}: ${record.message}'); + + // Also log to our app logger + LogLevel appLogLevel; + switch (record.level.name) { + case 'SEVERE': + appLogLevel = LogLevel.critical; + break; + case 'WARNING': + appLogLevel = LogLevel.warning; + break; + case 'INFO': + appLogLevel = LogLevel.info; + break; + case 'FINE': + case 'FINER': + case 'FINEST': + appLogLevel = LogLevel.debug; + break; + default: + appLogLevel = LogLevel.info; + break; + } + + appLogger.log(appLogLevel, record.loggerName, record.message); + }); + + var mobileSettingsService = (await MobileSettingsService.instance())!; + certificateAuthorityBytes = await setupCertificateAuthority(); + + + runApp(RestartWidget( + child: PinepodsPodcastApp( + mobileSettingsService: mobileSettingsService, + certificateAuthorityBytes: certificateAuthorityBytes, + ), + )); +} + +/// When certificate authorities certificates expire, older devices may not be able to handle +/// the re-issued certificate resulting in SSL errors being thrown. This routine is called to +/// manually install the newer certificates on older devices so they continue to work. +Future> setupCertificateAuthority() async { + List ca = []; + var loadedCerts = false; + + if (Platform.isAndroid) { + DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + var major = androidInfo.version.release.split('.'); + + if ((int.tryParse(major[0]) ?? 100.0) < 8.0) { + ByteData data = await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem'); + ca.addAll(data.buffer.asUint8List()); + loadedCerts = true; + } + + if ((int.tryParse(major[0]) ?? 100.0) < 10.0) { + ByteData data = await PlatformAssetBundle().load('assets/ca/globalsign-gcc-r6-alphassl-ca-2023.pem'); + ca.addAll(data.buffer.asUint8List()); + loadedCerts = true; + } + + if (loadedCerts) { + SecurityContext.defaultContext.setTrustedCertificatesBytes(ca); + } + } + + return ca; +} + diff --git a/PinePods-0.8.2/mobile/lib/navigation/navigation_route_observer.dart b/PinePods-0.8.2/mobile/lib/navigation/navigation_route_observer.dart new file mode 100644 index 0000000..622f38f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/navigation/navigation_route_observer.dart @@ -0,0 +1,48 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +/// This class will observe the current route. +/// +/// This gives us an easy way to tell what screen we are on from elsewhere within +/// the application. This is useful, for example, when responding to external links +/// and determining if we need to display the podcast details or just update the +/// current screen. +class NavigationRouteObserver extends NavigatorObserver { + final List?> _routeStack = ?>[]; + + static final NavigationRouteObserver _instance = NavigationRouteObserver._internal(); + + NavigationRouteObserver._internal(); + + factory NavigationRouteObserver() { + return _instance; + } + + @override + void didPop(Route route, Route? previousRoute) { + _routeStack.removeLast(); + } + + @override + void didPush(Route route, Route? previousRoute) { + _routeStack.add(route); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _routeStack.remove(route); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + int oldRouteIndex = _routeStack.indexOf(oldRoute); + + _routeStack.replaceRange(oldRouteIndex, oldRouteIndex + 1, [newRoute]); + } + + Route? get top => _routeStack.last; +} diff --git a/PinePods-0.8.2/mobile/lib/repository/repository.dart b/PinePods-0.8.2/mobile/lib/repository/repository.dart new file mode 100644 index 0000000..b5ec5eb --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/repository/repository.dart @@ -0,0 +1,70 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:pinepods_mobile/state/episode_state.dart'; + +/// An abstract class that represent the actions supported by the chosen +/// database or storage implementation. +abstract class Repository { + /// General + Future close(); + + /// Podcasts + Future findPodcastById(num id); + + Future findPodcastByGuid(String guid); + + Future savePodcast(Podcast podcast, {bool withEpisodes = true}); + + Future deletePodcast(Podcast podcast); + + Future> subscriptions(); + + /// Episodes + Future> findAllEpisodes(); + + Future findEpisodeById(int id); + + Future findEpisodeByGuid(String guid); + + Future> findEpisodesByPodcastGuid( + String pguid, { + PodcastEpisodeFilter filter = PodcastEpisodeFilter.none, + PodcastEpisodeSort sort = PodcastEpisodeSort.none, + }); + + Future findEpisodeByTaskId(String taskId); + + Future saveEpisode(Episode episode, [bool updateIfSame = false]); + + Future> saveEpisodes(List episodes, [bool updateIfSame = false]); + + Future deleteEpisode(Episode episode); + + Future deleteEpisodes(List episodes); + + Future> findDownloadsByPodcastGuid(String pguid); + + Future> findDownloads(); + + Future findTranscriptById(int id); + + Future saveTranscript(Transcript transcript); + + Future deleteTranscriptById(int id); + + Future deleteTranscriptsById(List id); + + /// Queue + Future saveQueue(List episodes); + + Future> loadQueue(); + + /// Event listeners + Stream? podcastListener; + Stream? episodeListener; +} diff --git a/PinePods-0.8.2/mobile/lib/repository/sembast/sembast_database_service.dart b/PinePods-0.8.2/mobile/lib/repository/sembast/sembast_database_service.dart new file mode 100644 index 0000000..d70d74e --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/repository/sembast/sembast_database_service.dart @@ -0,0 +1,52 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast/sembast_io.dart'; + +typedef DatabaseUpgrade = Future Function(Database, int, int); + +/// Provides a database instance to other services and handles the opening +/// of the Sembast DB. +class DatabaseService { + Completer? _databaseCompleter; + String databaseName; + int? version = 1; + DatabaseUpgrade? upgraderCallback; + + DatabaseService( + this.databaseName, { + this.version, + this.upgraderCallback, + }); + + Future get database async { + if (_databaseCompleter == null) { + _databaseCompleter = Completer(); + await _openDatabase(); + } + + return _databaseCompleter!.future; + } + + Future _openDatabase() async { + final appDocumentDir = await getApplicationDocumentsDirectory(); + final dbPath = join(appDocumentDir.path, databaseName); + final database = await databaseFactoryIo.openDatabase( + dbPath, + version: version, + onVersionChanged: (db, oldVersion, newVersion) async { + if (upgraderCallback != null) { + await upgraderCallback!(db, oldVersion, newVersion); + } + }, + ); + + _databaseCompleter!.complete(database); + } +} diff --git a/PinePods-0.8.2/mobile/lib/repository/sembast/sembast_repository.dart b/PinePods-0.8.2/mobile/lib/repository/sembast/sembast_repository.dart new file mode 100644 index 0000000..0defaba --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/repository/sembast/sembast_repository.dart @@ -0,0 +1,681 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/core/extensions.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/queue.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:pinepods_mobile/repository/repository.dart'; +import 'package:pinepods_mobile/repository/sembast/sembast_database_service.dart'; +import 'package:pinepods_mobile/state/episode_state.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:sembast/sembast.dart'; + +/// An implementation of [Repository] that is backed by +/// [Sembast](https://github.com/tekartik/sembast.dart/tree/master/sembast) +class SembastRepository extends Repository { + final log = Logger('SembastRepository'); + + final _podcastSubject = BehaviorSubject(); + final _episodeSubject = BehaviorSubject(); + + final _podcastStore = intMapStoreFactory.store('podcast'); + final _episodeStore = intMapStoreFactory.store('episode'); + final _queueStore = intMapStoreFactory.store('queue'); + final _transcriptStore = intMapStoreFactory.store('transcript'); + + final _queueGuids = []; + + late DatabaseService _databaseService; + + Future get _db async => _databaseService.database; + + SembastRepository({ + bool cleanup = true, + String databaseName = 'pinepods.db', + }) { + _databaseService = DatabaseService(databaseName, version: 2, upgraderCallback: dbUpgrader); + + if (cleanup) { + _cleanupEpisodes().then((value) { + log.fine('Orphan episodes cleanup complete'); + }); + } + } + + /// Saves the [Podcast] instance and associated [Episode]s. Podcasts are + /// only stored when we subscribe to them, so at the point we store a + /// new podcast we store the current [DateTime] to mark the + /// subscription date. + @override + Future savePodcast(Podcast podcast, {bool withEpisodes = true}) async { + log.fine('Saving podcast (${podcast.id ?? -1}) ${podcast.url}'); + + final finder = podcast.id == null + ? Finder(filter: Filter.equals('guid', podcast.guid)) + : Finder(filter: Filter.byKey(podcast.id)); + final RecordSnapshot>? snapshot = + await _podcastStore.findFirst(await _db, finder: finder); + + podcast.lastUpdated = DateTime.now(); + + if (snapshot == null) { + podcast.subscribedDate = DateTime.now(); + podcast.id = await _podcastStore.add(await _db, podcast.toMap()); + } else { + await _podcastStore.update(await _db, podcast.toMap(), finder: finder); + } + + if (withEpisodes) { + await _saveEpisodes(podcast.episodes); + } + + _podcastSubject.add(podcast); + + return podcast; + } + + @override + Future> subscriptions() async { + // Custom sort order to ignore title case. + final titleSortOrder = SortOrder.custom('title', (title1, title2) { + return title1.toLowerCase().compareTo(title2.toLowerCase()); + }); + + final finder = Finder(sortOrders: [ + titleSortOrder, + ]); + + final List>> subscriptionSnapshot = await _podcastStore.find( + await _db, + finder: finder, + ); + + final subs = subscriptionSnapshot.map((snapshot) { + final subscription = Podcast.fromMap(snapshot.key, snapshot.value); + + return subscription; + }).toList(); + + return subs; + } + + @override + Future deletePodcast(Podcast podcast) async { + final db = await _db; + + await db.transaction((txn) async { + final podcastFinder = Finder(filter: Filter.byKey(podcast.id)); + final episodeFinder = Finder(filter: Filter.equals('pguid', podcast.guid)); + + await _podcastStore.delete( + txn, + finder: podcastFinder, + ); + + await _episodeStore.delete( + txn, + finder: episodeFinder, + ); + }); + } + + @override + Future findPodcastById(num id) async { + final finder = Finder(filter: Filter.byKey(id)); + + final RecordSnapshot>? snapshot = + await _podcastStore.findFirst(await _db, finder: finder); + + if (snapshot != null) { + var p = Podcast.fromMap(snapshot.key, snapshot.value); + + // Now attach all episodes for this podcast + p.episodes = await findEpisodesByPodcastGuid( + p.guid, + filter: p.filter, + sort: p.sort, + ); + + return p; + } + + return null; + } + + @override + Future findPodcastByGuid(String guid) async { + final finder = Finder(filter: Filter.equals('guid', guid)); + + final RecordSnapshot>? snapshot = + await _podcastStore.findFirst(await _db, finder: finder); + + if (snapshot != null) { + var p = Podcast.fromMap(snapshot.key, snapshot.value); + + // Now attach all episodes for this podcast + p.episodes = await findEpisodesByPodcastGuid( + p.guid, + filter: p.filter, + sort: p.sort, + ); + + return p; + } + + return null; + } + + @override + Future> findAllEpisodes() async { + final finder = Finder( + sortOrders: [SortOrder('publicationDate', false)], + ); + + final List>> recordSnapshots = + await _episodeStore.find(await _db, finder: finder); + + final results = recordSnapshots.map((snapshot) { + final episode = Episode.fromMap(snapshot.key, snapshot.value); + + return episode; + }).toList(); + + return results; + } + + @override + Future findEpisodeById(int? id) async { + final finder = Finder(filter: Filter.byKey(id)); + final RecordSnapshot> snapshot = + (await _episodeStore.findFirst(await _db, finder: finder))!; + + return await _loadEpisodeSnapshot(snapshot.key, snapshot.value); + } + + @override + Future findEpisodeByGuid(String guid) async { + final finder = Finder(filter: Filter.equals('guid', guid)); + + final RecordSnapshot>? snapshot = + await _episodeStore.findFirst(await _db, finder: finder); + + if (snapshot == null) { + return null; + } + + return await _loadEpisodeSnapshot(snapshot.key, snapshot.value); + } + + // TODO: Remove nullable on pguid as this does not make sense. + @override + Future> findEpisodesByPodcastGuid( + String? pguid, { + PodcastEpisodeFilter filter = PodcastEpisodeFilter.none, + PodcastEpisodeSort sort = PodcastEpisodeSort.none, + }) async { + var episodeFilter = Filter.equals('pguid', pguid); + var sortOrder = SortOrder('publicationDate', false); + + // If we have an additional episode filter and/or sort, apply it. + episodeFilter = _applyEpisodeFilter(filter, episodeFilter, pguid); + sortOrder = _applyEpisodeSort(sort, sortOrder); + + final finder = Finder( + filter: episodeFilter, + sortOrders: [sortOrder], + ); + + final List>> recordSnapshots = + await _episodeStore.find(await _db, finder: finder); + + final results = recordSnapshots.map((snapshot) async { + return await _loadEpisodeSnapshot(snapshot.key, snapshot.value); + }).toList(); + + final episodeList = Future.wait(results); + + return episodeList; + } + + @override + Future> findDownloadsByPodcastGuid(String pguid) async { + final finder = Finder( + filter: Filter.and([ + Filter.equals('pguid', pguid), + Filter.equals('downloadPercentage', '100'), + ]), + sortOrders: [SortOrder('publicationDate', false)], + ); + + final List>> recordSnapshots = + await _episodeStore.find(await _db, finder: finder); + + final results = recordSnapshots.map((snapshot) { + final episode = Episode.fromMap(snapshot.key, snapshot.value); + + return episode; + }).toList(); + + return results; + } + + @override + Future> findDownloads() async { + final finder = + Finder(filter: Filter.equals('downloadPercentage', '100'), sortOrders: [SortOrder('publicationDate', false)]); + + final List>> recordSnapshots = + await _episodeStore.find(await _db, finder: finder); + + final results = recordSnapshots.map((snapshot) { + final episode = Episode.fromMap(snapshot.key, snapshot.value); + + return episode; + }).toList(); + + return results; + } + + @override + Future deleteEpisode(Episode episode) async { + final finder = Finder(filter: Filter.byKey(episode.id)); + + final RecordSnapshot>? snapshot = + await _episodeStore.findFirst(await _db, finder: finder); + + if (snapshot == null) { + // Oops! + } else { + await _episodeStore.delete(await _db, finder: finder); + _episodeSubject.add(EpisodeDeleteState(episode)); + } + } + + @override + Future deleteEpisodes(List episodes) async { + var d = await _db; + + if (episodes.isNotEmpty) { + for (var chunk in episodes.chunk(100)) { + await d.transaction((txn) async { + var futures = >[]; + + for (var episode in chunk) { + final finder = Finder(filter: Filter.byKey(episode.id)); + + futures.add(_episodeStore.delete(txn, finder: finder)); + } + + if (futures.isNotEmpty) { + await Future.wait(futures); + } + }); + } + } + } + + @override + Future saveEpisode(Episode episode, [bool updateIfSame = false]) async { + var e = await _saveEpisode(episode, updateIfSame); + + _episodeSubject.add(EpisodeUpdateState(e)); + + return e; + } + + @override + Future> saveEpisodes(List episodes, [bool updateIfSame = false]) async { + final updatedEpisodes = []; + + for (var es in episodes) { + var e = await _saveEpisode(es, updateIfSame); + + updatedEpisodes.add(e); + + _episodeSubject.add(EpisodeUpdateState(e)); + } + + return updatedEpisodes; + } + + @override + Future> loadQueue() async { + var episodes = []; + + final RecordSnapshot>? snapshot = await _queueStore.record(1).getSnapshot(await _db); + + if (snapshot != null) { + var queue = Queue.fromMap(snapshot.key, snapshot.value); + + var episodeFinder = Finder(filter: Filter.inList('guid', queue.guids)); + + final List>> recordSnapshots = + await _episodeStore.find(await _db, finder: episodeFinder); + + episodes = recordSnapshots.map((snapshot) { + final episode = Episode.fromMap(snapshot.key, snapshot.value); + + return episode; + }).toList(); + } + + return episodes; + } + + @override + Future saveQueue(List episodes) async { + /// Check to see if we have any ad-hoc episodes and save them first + for (var e in episodes) { + if (e.pguid == null || e.pguid!.isEmpty) { + _saveEpisode(e, false); + } + } + + var guids = episodes.map((e) => e.guid).toList(); + + /// Only bother saving if the queue has changed + if (!listEquals(guids, _queueGuids)) { + final queue = Queue(guids: guids); + + await _queueStore.record(1).put(await _db, queue.toMap()); + + _queueGuids.clear(); + _queueGuids.addAll(guids); + } + } + + @override + Future findTranscriptById(int? id) async { + final finder = Finder(filter: Filter.byKey(id)); + final RecordSnapshot>? snapshot = + await _transcriptStore.findFirst(await _db, finder: finder); + + return snapshot == null ? null : Transcript.fromMap(snapshot.key, snapshot.value); + } + + @override + Future deleteTranscriptById(int id) async { + final finder = Finder(filter: Filter.byKey(id)); + + final RecordSnapshot>? snapshot = + await _transcriptStore.findFirst(await _db, finder: finder); + + if (snapshot == null) { + // Oops! + } else { + await _transcriptStore.delete(await _db, finder: finder); + } + } + + @override + Future deleteTranscriptsById(List id) async { + var d = await _db; + + if (id.isNotEmpty) { + for (var chunk in id.chunk(100)) { + await d.transaction((txn) async { + var futures = >[]; + + for (var id in chunk) { + final finder = Finder(filter: Filter.byKey(id)); + + futures.add(_transcriptStore.delete(txn, finder: finder)); + } + + if (futures.isNotEmpty) { + await Future.wait(futures); + } + }); + } + } + } + + @override + Future saveTranscript(Transcript transcript) async { + final finder = Finder(filter: Filter.byKey(transcript.id)); + + final RecordSnapshot>? snapshot = + await _transcriptStore.findFirst(await _db, finder: finder); + + transcript.lastUpdated = DateTime.now(); + + if (snapshot == null) { + transcript.id = await _transcriptStore.add(await _db, transcript.toMap()); + } else { + await _transcriptStore.update(await _db, transcript.toMap(), finder: finder); + } + + return transcript; + } + + Future _cleanupEpisodes() async { + final threshold = DateTime.now().subtract(const Duration(days: 60)).millisecondsSinceEpoch; + + /// Find all streamed episodes over the threshold. + final filter = Filter.and([ + Filter.equals('downloadState', 0), + Filter.lessThan('lastUpdated', threshold), + ]); + + final orphaned = []; + final pguids = []; + final List>> episodes = + await _episodeStore.find(await _db, finder: Finder(filter: filter)); + + // First, find all podcasts + for (var podcast in await _podcastStore.find(await _db)) { + pguids.add(podcast.value['guid'] as String?); + } + + for (var episode in episodes) { + final pguid = episode.value['pguid'] as String?; + final podcast = pguids.contains(pguid); + + if (!podcast) { + orphaned.add(Episode.fromMap(episode.key, episode.value)); + } + } + + await deleteEpisodes(orphaned); + } + + SortOrder _applyEpisodeSort(PodcastEpisodeSort sort, SortOrder sortOrder) { + switch (sort) { + case PodcastEpisodeSort.none: + case PodcastEpisodeSort.latestFirst: + sortOrder = SortOrder('publicationDate', false); + break; + case PodcastEpisodeSort.earliestFirst: + sortOrder = SortOrder('publicationDate', true); + break; + case PodcastEpisodeSort.alphabeticalDescending: + sortOrder = SortOrder.custom('title', (title1, title2) { + return title2.toLowerCase().compareTo(title1.toLowerCase()); + }); + break; + case PodcastEpisodeSort.alphabeticalAscending: + sortOrder = SortOrder.custom('title', (title1, title2) { + return title1.toLowerCase().compareTo(title2.toLowerCase()); + }); + break; + } + return sortOrder; + } + + Filter _applyEpisodeFilter(PodcastEpisodeFilter filter, Filter episodeFilter, String? pguid) { + // If we have an additional episode filter, apply it. + switch (filter) { + case PodcastEpisodeFilter.none: + episodeFilter = Filter.equals('pguid', pguid); + break; + case PodcastEpisodeFilter.started: + episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.notEquals('position', '0')]); + break; + case PodcastEpisodeFilter.played: + episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.equals('played', 'true')]); + break; + case PodcastEpisodeFilter.notPlayed: + episodeFilter = Filter.and([Filter.equals('pguid', pguid), Filter.equals('played', 'false')]); + break; + } + return episodeFilter; + } + + /// Saves a list of episodes to the repository. To improve performance we + /// split the episodes into chunks of 100 and save any that have been updated + /// in that chunk in a single transaction. + Future _saveEpisodes(List? episodes) async { + var d = await _db; + var dateStamp = DateTime.now(); + + if (episodes != null && episodes.isNotEmpty) { + for (var chunk in episodes.chunk(100)) { + await d.transaction((txn) async { + var futures = >[]; + + for (var episode in chunk) { + episode!.lastUpdated = dateStamp; + + if (episode.id == null) { + futures.add(_episodeStore.add(txn, episode.toMap()).then((id) => episode.id = id)); + } else { + final finder = Finder(filter: Filter.byKey(episode.id)); + + var existingEpisode = await findEpisodeById(episode.id); + + if (existingEpisode == null || existingEpisode != episode) { + futures.add(_episodeStore.update(txn, episode.toMap(), finder: finder)); + } + } + } + + if (futures.isNotEmpty) { + await Future.wait(futures); + } + }); + } + } + } + + Future _saveEpisode(Episode episode, bool updateIfSame) async { + final finder = Finder(filter: Filter.byKey(episode.id)); + + final RecordSnapshot>? snapshot = + await _episodeStore.findFirst(await _db, finder: finder); + + if (snapshot == null) { + episode.lastUpdated = DateTime.now(); + episode.id = await _episodeStore.add(await _db, episode.toMap()); + } else { + var e = Episode.fromMap(episode.id, snapshot.value); + episode.lastUpdated = DateTime.now(); + + if (updateIfSame || episode != e) { + await _episodeStore.update(await _db, episode.toMap(), finder: finder); + } + } + + return episode; + } + + @override + Future findEpisodeByTaskId(String taskId) async { + final finder = Finder(filter: Filter.equals('downloadTaskId', taskId)); + final RecordSnapshot>? snapshot = + await _episodeStore.findFirst(await _db, finder: finder); + + if (snapshot != null) { + return await _loadEpisodeSnapshot(snapshot.key, snapshot.value); + } else { + return null; + } + } + + Future _loadEpisodeSnapshot(int key, Map snapshot) async { + var episode = Episode.fromMap(key, snapshot); + + if (episode.transcriptId! > 0) { + episode.transcript = await findTranscriptById(episode.transcriptId); + } + + return episode; + } + + @override + Future close() async { + final d = await _db; + + await d.close(); + } + + Future dbUpgrader(Database db, int oldVersion, int newVersion) async { + if (oldVersion == 1) { + await _upgradeV2(db); + } + } + + /// In v1 we allowed http requests, where as now we force to https. As we currently use the + /// URL as the GUID we need to upgrade any followed podcasts that have a http base to https. + /// We use the passed [Database] rather than _db to prevent deadlocking, hence the direct + /// update to data within this routine rather than using the existing find/update methods. + Future _upgradeV2(Database db) async { + List>> data = await _podcastStore.find(db); + final podcasts = data.map((e) => Podcast.fromMap(e.key, e.value)).toList(); + + log.info('Upgrading Sembast store to V2'); + + for (var podcast in podcasts) { + if (podcast.guid!.startsWith('http:')) { + final idFinder = Finder(filter: Filter.byKey(podcast.id)); + final guid = podcast.guid!.replaceFirst('http:', 'https:'); + final episodeFinder = Finder( + filter: Filter.equals('pguid', podcast.guid), + ); + + log.fine('Upgrading GUID ${podcast.guid} - to $guid'); + + var upgradedPodcast = Podcast( + id: podcast.id, + guid: guid, + url: podcast.url, + link: podcast.link, + title: podcast.title, + description: podcast.description, + imageUrl: podcast.imageUrl, + thumbImageUrl: podcast.thumbImageUrl, + copyright: podcast.copyright, + funding: podcast.funding, + persons: podcast.persons, + lastUpdated: DateTime.now(), + ); + + final List>> episodeData = + await _episodeStore.find(db, finder: episodeFinder); + final episodes = episodeData.map((e) => Episode.fromMap(e.key, e.value)).toList(); + + // Now upgrade episodes + for (var e in episodes) { + e.pguid = guid; + log.fine('Updating episode guid for ${e.title} from ${e.pguid} to $guid'); + + final epf = Finder(filter: Filter.byKey(e.id)); + await _episodeStore.update(db, e.toMap(), finder: epf); + } + + upgradedPodcast.episodes = episodes; + await _podcastStore.update(db, upgradedPodcast.toMap(), finder: idFinder); + } + } + } + + @override + Stream get episodeListener => _episodeSubject.stream; + + @override + Stream get podcastListener => _podcastSubject.stream; +} diff --git a/PinePods-0.8.2/mobile/lib/services/audio/audio_player_service.dart b/PinePods-0.8.2/mobile/lib/services/audio/audio_player_service.dart new file mode 100644 index 0000000..9d0551b --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/audio/audio_player_service.dart @@ -0,0 +1,112 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/sleep.dart'; +import 'package:pinepods_mobile/state/queue_event_state.dart'; +import 'package:pinepods_mobile/state/transcript_state_event.dart'; +import 'package:rxdart/rxdart.dart'; + +enum AudioState { + none, + buffering, + starting, + playing, + pausing, + stopped, + error, +} + +class PositionState { + Duration position; + late Duration length; + late int percentage; + Episode? episode; + final bool buffering; + + PositionState({ + required this.position, + required this.length, + required this.percentage, + this.episode, + this.buffering = false, + }); + + PositionState.emptyState() + : position = const Duration(seconds: 0), + length = const Duration(seconds: 0), + percentage = 0, + buffering = false; +} + +/// This class defines the audio playback options supported by Pinepods. +/// +/// The implementing classes will then handle the specifics for the platform we are running on. +abstract class AudioPlayerService { + /// Play a new episode, optionally resume at last save point. + Future playEpisode({required Episode episode, bool resume = true}); + + /// Resume playing of current episode + Future play(); + + /// Stop playing of current episode. Set update to false to stop + /// playback without saving any episode or positional updates. + Future stop(); + + /// Pause the current episode. + Future pause(); + + /// Rewind the current episode by pre-set number of seconds. + Future rewind(); + + /// Fast forward the current episode by pre-set number of seconds. + Future fastForward(); + + /// Seek to the specified position within the current episode. + Future seek({required int position}); + + /// Call when the app is resumed to re-establish the audio service. + Future resume(); + + /// Add an episode to the playback queue + Future addUpNextEpisode(Episode episode); + + /// Remove an episode from the playback queue if it exists + Future removeUpNextEpisode(Episode episode); + + /// Remove an episode from the playback queue if it exists + Future moveUpNextEpisode(Episode episode, int oldIndex, int newIndex); + + /// Empty the up next queue + Future clearUpNext(); + + /// Call when the app is about to be suspended. + Future suspend(); + + /// Call to set the playback speed. + Future setPlaybackSpeed(double speed); + + /// Call to toggle trim silence. + Future trimSilence(bool trim); + + /// Call to toggle trim silence. + Future volumeBoost(bool boost); + + Future searchTranscript(String search); + + Future clearTranscript(); + + void sleep(Sleep sleep); + + Episode? nowPlaying; + + /// Event listeners + Stream? playingState; + ValueStream? playPosition; + ValueStream? episodeEvent; + Stream? transcriptEvent; + Stream? playbackError; + Stream? queueState; + Stream? sleepStream; +} diff --git a/PinePods-0.8.2/mobile/lib/services/audio/default_audio_player_service.dart b/PinePods-0.8.2/mobile/lib/services/audio/default_audio_player_service.dart new file mode 100644 index 0000000..70abce8 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/audio/default_audio_player_service.dart @@ -0,0 +1,1397 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:pinepods_mobile/core/environment.dart'; +import 'package:pinepods_mobile/core/utils.dart'; +import 'package:pinepods_mobile/entities/chapter.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/persistable.dart'; +import 'package:pinepods_mobile/entities/sleep.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:pinepods_mobile/repository/repository.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/services/settings/settings_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/state/episode_state.dart'; +import 'package:pinepods_mobile/state/persistent_state.dart'; +import 'package:pinepods_mobile/state/queue_event_state.dart'; +import 'package:pinepods_mobile/state/transcript_state_event.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:collection/collection.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; + +/// This is the default implementation of [AudioPlayerService]. +/// +/// This implementation uses the [audio_service](https://pub.dev/packages/audio_service) +/// package to run the audio layer as a service to allow background play, and playback +/// is handled by the [just_audio](https://pub.dev/packages/just_audio) package. +class DefaultAudioPlayerService extends AudioPlayerService { + final zeroDuration = const Duration(seconds: 0); + final log = Logger('DefaultAudioPlayerService'); + final Repository repository; + final SettingsService settingsService; + final PodcastService podcastService; + PinepodsAudioService? _pinepodsAudioService; + + late AudioHandler _audioHandler; + var _initialised = false; + var _cold = false; + var _playbackSpeed = 1.0; + var _trimSilence = false; + + /// Track episode start time for calculating listen duration + DateTime? _episodeStartTime; + + + /// Timer for local position saves (every 5 seconds) + Timer? _localPositionTimer; + var _volumeBoost = false; + var _queue = []; + var _sleep = Sleep(type: SleepType.none); + + /// The currently playing episode + Episode? _currentEpisode; + + /// The currently 'processed' transcript; + Transcript? _currentTranscript; + + /// Subscription to the position ticker. + StreamSubscription? _positionSubscription; + + /// Subscription to the sleep ticker. + StreamSubscription? _sleepSubscription; + + /// Stream showing our current playing state. + final BehaviorSubject _playingState = BehaviorSubject.seeded(AudioState.none); + + /// Ticks whilst playing. Updates our current position within an episode. + final _durationTicker = Stream.periodic( + const Duration(milliseconds: 500), + (count) => count, + ).asBroadcastStream(); + + /// Ticks twice every second if a time-based sleep has been started. + final _sleepTicker = Stream.periodic( + const Duration(milliseconds: 500), + (count) => count, + ).asBroadcastStream(); + + /// Stream for the current position of the playing track. + final _playPosition = BehaviorSubject(); + + /// Stream the current playing episode + final _episodeEvent = BehaviorSubject(sync: true); + + /// Stream transcript events such as search filters and updates. + final _transcriptEvent = BehaviorSubject(sync: true); + + /// Stream for the last audio error as an integer code. + final _playbackError = PublishSubject(); + + final _queueState = BehaviorSubject(); + + final _sleepState = BehaviorSubject(); + + DefaultAudioPlayerService({ + required this.repository, + required this.settingsService, + required this.podcastService, + }) { + AudioService.init( + builder: () => _DefaultAudioPlayerHandler( + repository: repository, + settings: settingsService, + podcastService: podcastService, + ), + config: const AudioServiceConfig( + androidResumeOnClick: true, + androidNotificationChannelName: 'Pinepods Podcast Client', + androidNotificationIcon: 'drawable/ic_stat_name', + androidNotificationOngoing: false, + androidStopForegroundOnPause: true, + rewindInterval: Duration(seconds: 10), + fastForwardInterval: Duration(seconds: 30), + ), + ).then((value) { + _audioHandler = value; + _initialised = true; + _handleAudioServiceTransitions(); + _loadQueue(); + }); + } + + /// Set the PinepodsAudioService reference for listen duration tracking + void setPinepodsAudioService(PinepodsAudioService? service) { + _pinepodsAudioService = service; + log.info('PinepodsAudioService reference set for enhanced sync capabilities'); + } + + /// Save episode position locally (every 3 seconds for more frequent updates) + void _startLocalPositionSaver() { + _localPositionTimer?.cancel(); + _localPositionTimer = Timer.periodic(const Duration(seconds: 3), (_) { + _saveLocalPosition(); + }); + } + + /// Stop local position saver + void _stopLocalPositionSaver() { + _localPositionTimer?.cancel(); + _localPositionTimer = null; + } + + /// Save position locally with higher frequency + Future _saveLocalPosition() async { + if (_currentEpisode != null) { + final position = _audioHandler.playbackState.value.position.inMilliseconds; + + // Update position directly instead of creating new instance to preserve chapter data + _currentEpisode!.position = position; + + await repository.saveEpisode(_currentEpisode!); + log.fine('Saved local position: ${position}ms for episode ${_currentEpisode!.title}'); + } + } + + /// Get the best position (furthest of local vs server) + Future _getBestEpisodePosition(Episode episode) async { + // Get local position + final localPosition = episode.position; + + // Get server position if we have PinePods service and episode is from PinePods + int serverPosition = 0; + if (_pinepodsAudioService != null && episode.guid.startsWith('pinepods_')) { + try { + // Extract episode ID from GUID (format: 'pinepods_123') + final episodeIdStr = episode.guid.replaceFirst('pinepods_', '').split('_').first; + final episodeId = int.tryParse(episodeIdStr); + + if (episodeId != null) { + final serverPos = await _pinepodsAudioService!.getServerPositionForEpisode( + episodeId, + settingsService.pinepodsUserId ?? 0, + episode.pguid?.contains('youtube') ?? false, + ); + + if (serverPos != null) { + serverPosition = (serverPos * 1000).round(); // Convert to milliseconds + } + } + } catch (e) { + log.warning('Failed to get server position: $e'); + } + } + + // Return the furthest position + final bestPosition = localPosition > serverPosition ? localPosition : serverPosition; + return bestPosition; + } + + /// Calculate and record listen duration + Future _recordListenDuration() async { + if (_episodeStartTime == null || _pinepodsAudioService == null) return; + + final now = DateTime.now(); + final sessionDuration = now.difference(_episodeStartTime!); + + // Only record meaningful listen time (at least 5 seconds) + if (sessionDuration.inSeconds >= 5) { + await _pinepodsAudioService!.recordListenDuration(sessionDuration.inSeconds.toDouble()); + log.info('Recorded listen duration: ${sessionDuration.inSeconds}s'); + } + } + + @override + Future pause() async { + // Pause immediately - don't wait for server sync + await _audioHandler.pause(); + + // Stop local position saver while paused + _stopLocalPositionSaver(); + + log.info('Episode paused - starting background sync'); + + // Do server sync in background without blocking pause + _performBackgroundSync(); + } + + /// Perform server sync in background without blocking user actions + void _performBackgroundSync() async { + try { + // Record listen duration + await _recordListenDuration(); + log.info('Listen duration recorded successfully'); + + // Sync position to PinePods server + if (_pinepodsAudioService != null) { + await _pinepodsAudioService!.onPause(); + log.info('Position synced to server successfully'); + } + } catch (e) { + log.warning('Background sync failed (but pause still worked): $e'); + // Pause still succeeded even if sync failed - user experience is not affected + } + } + + @override + Future play() { + if (_cold) { + _cold = false; + return playEpisode(episode: _currentEpisode!, resume: true); + } else { + // Restart tracking when resuming + _episodeStartTime = DateTime.now(); + _startLocalPositionSaver(); + log.info('Resumed episode tracking at ${_episodeStartTime}'); + + return _audioHandler.play(); + } + } + + /// Called by the client (UI), or when we move to a different episode within the queue, to play an episode. + /// + /// If we have a downloaded copy of the requested episode we will use that; otherwise we will stream the + /// episode directly. + @override + Future playEpisode({required Episode episode, bool? resume}) async { + if (episode.guid != '' && _initialised) { + var uri = (await _generateEpisodeUri(episode))!; + + log.info('Playing episode ${episode.id} - ${episode.title} from position ${episode.position}'); + log.fine(' - $uri'); + + _playingState.add(AudioState.buffering); + _playbackSpeed = settingsService.playbackSpeed; + _trimSilence = settingsService.trimSilence; + _volumeBoost = settingsService.volumeBoost; + + // If we are currently playing a track - save the position of the current + // track before switching to the next. + var currentState = _audioHandler.playbackState.value.processingState; + + log.fine( + 'Current playback state is $currentState. Speed = $_playbackSpeed. Trim = $_trimSilence. Volume Boost = $_volumeBoost}'); + + if (currentState == AudioProcessingState.ready) { + await _saveCurrentEpisodePosition(); + } else if (currentState == AudioProcessingState.loading) { + _audioHandler.stop(); + } + + // If we have a queue, we are currently playing and the user has elected to play something new, + // place the current episode at the top of the queue before moving on. + if (_currentEpisode != null && _currentEpisode!.guid != episode.guid && _queue.isNotEmpty) { + _queue.insert(0, _currentEpisode!); + } + + // If we are attempting to play an episode that is also in the queue, remove it from the queue. + _queue.removeWhere((e) => episode.guid == e.guid); + + // Current episode is saved. Now we re-point the current episode to the new one passed in. + _currentEpisode = episode; + _currentEpisode!.played = false; + + // Get the best position (furthest of local vs server) + final bestPosition = await _getBestEpisodePosition(_currentEpisode!); + if (bestPosition > _currentEpisode!.position) { + // Update position directly instead of creating new instance to preserve chapter data + _currentEpisode!.position = bestPosition; + log.info('Updated episode position to best available: ${bestPosition}ms'); + } + + await repository.saveEpisode(_currentEpisode!); + + /// Update the state of the queue. + _updateQueueState(); + _updateEpisodeState(); + + /// And the position of our current episode. + _broadcastEpisodePosition(_currentEpisode!); + + try { + // Load ancillary items + _loadEpisodeAncillaryItems(); + + await _audioHandler.playMediaItem(_episodeToMediaItem(_currentEpisode!, uri)); + + // Track episode start time for listen duration calculation + _episodeStartTime = DateTime.now(); + + // Start local position saving (every 3 seconds for better accuracy) + _startLocalPositionSaver(); + + log.info('Started episode tracking at ${_episodeStartTime}'); + + _currentEpisode!.duration = _audioHandler.mediaItem.value?.duration?.inSeconds ?? 0; + + await repository.saveEpisode(_currentEpisode!); + } catch (e) { + log.fine('Error during playback'); + log.fine(e.toString()); + + _playingState.add(AudioState.error); + _playingState.add(AudioState.stopped); + + await _audioHandler.stop(); + } + } else { + log.fine('ERROR: Attempting to play an empty episode'); + } + } + + @override + Future rewind() => _audioHandler.rewind(); + + @override + Future fastForward() => _audioHandler.fastForward(); + + @override + Future seek({required int position}) async { + var currentMediaItem = _audioHandler.mediaItem.value; + var duration = currentMediaItem?.duration ?? const Duration(seconds: 1); + var p = Duration(seconds: position); + var complete = p.inSeconds > 0 ? (duration.inSeconds / p.inSeconds) * 100 : 0; + + // Pause the ticker whilst we seek to prevent jumpy UI. + _positionSubscription?.pause(); + + _updateChapter(p.inSeconds, duration.inSeconds); + + _playPosition.add(PositionState( + position: p, + length: duration, + percentage: complete.toInt(), + episode: _currentEpisode, + buffering: true, + )); + + await _audioHandler.seek(Duration(seconds: position)); + + _positionSubscription?.resume(); + } + + @override + Future setPlaybackSpeed(double speed) => _audioHandler.setSpeed(speed); + + @override + Future addUpNextEpisode(Episode episode) async { + log.fine('addUpNextEpisode Adding ${episode.title} - ${episode.guid}'); + + if (episode.guid != _currentEpisode?.guid) { + _queue.add(episode); + _updateQueueState(); + } + } + + @override + Future removeUpNextEpisode(Episode episode) async { + var removed = false; + log.fine('removeUpNextEpisode Removing ${episode.title} - ${episode.guid}'); + + var i = _queue.indexWhere((element) => element.guid == episode.guid); + + if (i >= 0) { + removed = true; + _queue.removeAt(i); + _updateQueueState(); + } + + return removed; + } + + @override + Future moveUpNextEpisode(Episode episode, int oldIndex, int newIndex) async { + var moved = false; + log.fine('moveUpNextEpisode Moving ${episode.title} - ${episode.guid} from $oldIndex to $newIndex'); + + var oldEpisode = _queue.removeAt(oldIndex); + + _queue.insert(newIndex, oldEpisode); + _updateQueueState(); + + return moved; + } + + @override + Future clearUpNext() async { + _queue.clear(); + _updateQueueState(); + } + + @override + Future stop() async { + // Record listen duration before stopping + await _recordListenDuration(); + + // Sync position to PinePods server immediately + if (_pinepodsAudioService != null) { + await _pinepodsAudioService!.onStop(); + } + + // Stop local position saver + _stopLocalPositionSaver(); + + _currentEpisode = null; + await _audioHandler.stop(); + + log.info('Episode stopped - listen duration recorded and synced to server'); + } + + @override + void sleep(Sleep sleep) { + switch (sleep.type) { + case SleepType.none: + case SleepType.episode: + _stopSleepTicker(); + break; + case SleepType.time: + _startSleepTicker(); + break; + } + + _sleep = sleep; + _sleepState.sink.add(_sleep); + } + + void updateCurrentPosition(Episode? e) { + if (e != null) { + var duration = Duration(seconds: e.duration); + var complete = duration.inSeconds > 0 ? (e.position / duration.inSeconds) * 100 : 0; + + _playPosition.add(PositionState( + position: Duration(milliseconds: e.position), + length: duration, + percentage: complete.toInt(), + episode: e, + buffering: false, + )); + } + } + + @override + Future suspend() async { + _stopPositionTicker(); + _persistState(); + } + + @override + Future resume() async { + /// If _episode is null, we must have stopped whilst still active or we were killed. + if (_currentEpisode == null) { + if (_initialised && _audioHandler.mediaItem.value != null) { + if (_audioHandler.playbackState.value.processingState != AudioProcessingState.idle) { + final extras = _audioHandler.mediaItem.value?.extras; + + if (extras != null && extras['eid'] != null) { + _currentEpisode = await repository.findEpisodeByGuid(extras['eid'] as String); + } + } + } else { + // Let's see if we have a persisted state + var ps = await PersistentState.fetchState(); + + if (ps.state == LastState.paused) { + _currentEpisode = await repository.findEpisodeById(ps.episodeId); + _currentEpisode!.position = ps.position; + _playingState.add(AudioState.pausing); + + updateCurrentPosition(_currentEpisode); + + _cold = true; + } + } + } else { + final playbackState = _audioHandler.playbackState.value; + final basicState = playbackState.processingState; + + // If we have no state we'll have to assume we stopped whilst suspended. + if (basicState == AudioProcessingState.idle) { + /// We will have to assume we have stopped. + _playingState.add(AudioState.stopped); + } else if (basicState == AudioProcessingState.ready) { + _startPositionTicker(); + } + } + + await PersistentState.clearState(); + + _episodeEvent.sink.add(_currentEpisode); + + return Future.value(_currentEpisode); + } + + void _updateEpisodeState() { + _episodeEvent.sink.add(_currentEpisode); + } + + void _updateTranscriptState({TranscriptState? state}) { + if (state == null) { + if (_currentTranscript != null) { + _transcriptEvent.sink.add(TranscriptUpdateState(transcript: _currentTranscript!)); + } + } else { + _transcriptEvent.sink.add(state); + } + } + + void _updateQueueState() { + _queueState.add(QueueListState(playing: _currentEpisode, queue: _queue)); + } + + Future _generateEpisodeUri(Episode episode) async { + var uri = episode.contentUrl; + + if (episode.downloadState == DownloadState.downloaded) { + if (await hasStoragePermission()) { + uri = await resolvePath(episode); + + episode.streaming = false; + } else { + throw Exception('Insufficient storage permissions'); + } + } + + return uri; + } + + Future _persistState() async { + var currentPosition = _audioHandler.playbackState.value.position.inMilliseconds; + + /// We only need to persist if we are paused. + if (_playingState.value == AudioState.pausing) { + await PersistentState.persistState(Persistable( + pguid: '', + episodeId: _currentEpisode!.id!, + position: currentPosition, + state: LastState.paused, + )); + } + } + + @override + Future trimSilence(bool trim) { + return _audioHandler.customAction('trim', { + 'value': trim, + }); + } + + @override + Future volumeBoost(bool boost) { + return _audioHandler.customAction('boost', { + 'value': boost, + }); + } + + @override + Future searchTranscript(String search) async { + search = search.trim(); + + final subtitles = _currentEpisode!.transcript!.subtitles.where((subtitle) { + return subtitle.data!.toLowerCase().contains(search.toLowerCase()); + }).toList(); + + _currentTranscript = Transcript( + id: _currentEpisode!.transcript!.id, + guid: _currentEpisode!.transcript!.guid, + filtered: true, + subtitles: subtitles, + ); + + _updateTranscriptState(); + } + + @override + Future clearTranscript() async { + _currentTranscript = _currentEpisode!.transcript; + _currentTranscript!.filtered = false; + + _updateTranscriptState(); + } + + MediaItem _episodeToMediaItem(Episode episode, String uri) { + return MediaItem( + id: uri, + title: episode.title ?? 'Unknown Title', + artist: episode.author ?? 'Unknown Title', + artUri: Uri.parse(episode.imageUrl!), + duration: Duration(seconds: episode.duration), + extras: { + 'position': episode.position, + 'downloaded': episode.downloaded, + 'speed': _playbackSpeed, + 'trim': _trimSilence, + 'boost': _volumeBoost, + 'eid': episode.guid, + }, + ); + } + + void _handleAudioServiceTransitions() { + _audioHandler.playbackState.distinct((previousState, currentState) { + return previousState.playing == currentState.playing && + previousState.processingState == currentState.processingState; + }).listen((PlaybackState state) async { + switch (state.processingState) { + case AudioProcessingState.idle: + _playingState.add(AudioState.none); + _stopPositionTicker(); + break; + case AudioProcessingState.loading: + _playingState.add(AudioState.buffering); + break; + case AudioProcessingState.buffering: + _playingState.add(AudioState.buffering); + break; + case AudioProcessingState.ready: + if (state.playing) { + _startPositionTicker(); + _playingState.add(AudioState.playing); + } else { + _stopPositionTicker(); + _playingState.add(AudioState.pausing); + } + break; + case AudioProcessingState.completed: + await _completed(); + break; + case AudioProcessingState.error: + _playingState.add(AudioState.error); + break; + } + }); + } + + Future _loadQueue() async { + _queue = await podcastService.loadQueue(); + } + + Future _completed() async { + await _saveCurrentEpisodePosition(complete: true); + + // Record listen duration for completed episode + await _recordListenDuration(); + + // Stop local position saver + _stopLocalPositionSaver(); + + log.fine('We have completed episode ${_currentEpisode?.title}'); + + /// If we have sleep at end of episode enabled and we have more items in the + /// queue, we do not want to potentially delete the episode when we reach + /// the end. When the user continues playback, we'll complete fully and + /// can delete the episode. + final sleepy = _sleep.type == SleepType.episode && _queue.isNotEmpty; + + if ( + settingsService.deleteDownloadedPlayedEpisodes && + _currentEpisode?.downloadState == DownloadState.downloaded && !sleepy + ) { + await podcastService.deleteDownload(_currentEpisode!); + } + + _stopPositionTicker(); + + // Check if sleep at end of episode is enabled first + if (_sleep.type == SleepType.episode) { + log.fine('Sleeping at end of episode'); + await _audioHandler.customAction('sleep'); + _playingState.add(AudioState.pausing); + _stopSleepTicker(); + return; + } + + // Try to get next episode from PinePods server queue + try { + final nextEpisode = await _getNextQueuedEpisode(); + if (nextEpisode != null) { + log.fine('Playing next episode from server queue: ${nextEpisode.title}'); + _currentEpisode = null; + await playEpisode(episode: nextEpisode); + _updateQueueState(); + return; + } + } catch (e) { + log.warning('Failed to get next episode from server queue: $e'); + } + + // Fallback to local queue if server queue fails or is empty + if (_queue.isEmpty) { + log.fine('No episodes in local or server queue, stopping playback'); + _queue = []; + _currentEpisode = null; + _playingState.add(AudioState.stopped); + await _audioHandler.customAction('queueend'); + } else { + log.fine('Playing next episode from local queue'); + _currentEpisode = null; + var ep = _queue.removeAt(0); + await playEpisode(episode: ep); + _updateQueueState(); + } + } + + /// Get the next episode from PinePods server queue and remove it from the queue + Future _getNextQueuedEpisode() async { + // Check if we have PinePods credentials + final server = settingsService.pinepodsServer; + final apiKey = settingsService.pinepodsApiKey; + final userId = settingsService.pinepodsUserId; + + if (server == null || apiKey == null || userId == null) { + log.fine('No PinePods credentials available, skipping server queue'); + return null; + } + + try { + // Initialize PinePods service + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials(server, apiKey); + + // Get current queue from server + final queuedEpisodes = await pinepodsService.getQueuedEpisodes(userId); + + if (queuedEpisodes.isEmpty) { + log.fine('Server queue is empty'); + return null; + } + + // Get the first episode from the queue + final nextPinepodsEpisode = queuedEpisodes.first; + + // Remove this episode from the server queue + await pinepodsService.removeQueuedEpisode( + nextPinepodsEpisode.episodeId, + userId, + nextPinepodsEpisode.isYoutube, + ); + + // Convert PinepodsEpisode to Episode for playback + final episode = _convertPinepodsEpisodeToEpisode(nextPinepodsEpisode, pinepodsService, userId); + + log.fine('Retrieved next episode from server queue: ${episode.title}'); + return episode; + + } catch (e) { + log.warning('Error getting next episode from server queue: $e'); + rethrow; + } + } + + /// Convert PinepodsEpisode to Episode for audio playback + Episode _convertPinepodsEpisodeToEpisode(PinepodsEpisode pinepodsEpisode, PinepodsService pinepodsService, int userId) { + // Determine the content URL + String contentUrl; + if (pinepodsEpisode.downloaded) { + // Use stream URL for downloaded episodes + contentUrl = pinepodsService.getStreamUrl( + pinepodsEpisode.episodeId, + userId, + isYoutube: pinepodsEpisode.isYoutube, + isLocal: true, + ); + } else if (pinepodsEpisode.isYoutube) { + // Use stream URL for YouTube episodes + contentUrl = pinepodsService.getStreamUrl( + pinepodsEpisode.episodeId, + userId, + isYoutube: true, + isLocal: false, + ); + } else { + // Use original URL for external episodes + contentUrl = pinepodsEpisode.episodeUrl; + } + + return Episode( + guid: 'pinepods_${pinepodsEpisode.episodeId}', + pguid: 'pinepods_${pinepodsEpisode.podcastId}', + podcast: pinepodsEpisode.podcastName, + title: pinepodsEpisode.episodeTitle, + description: pinepodsEpisode.episodeDescription, + link: pinepodsEpisode.episodeUrl, + publicationDate: DateTime.tryParse(pinepodsEpisode.episodePubDate) ?? DateTime.now(), + author: '', + duration: (pinepodsEpisode.episodeDuration * 1000).round(), // Convert to milliseconds + contentUrl: contentUrl, + position: pinepodsEpisode.completed ? 0 : ((pinepodsEpisode.listenDuration ?? 0) * 1000).round(), // Convert to milliseconds, reset to 0 for completed episodes + imageUrl: pinepodsEpisode.episodeArtwork, + played: pinepodsEpisode.completed, + chapters: [], // Basic conversion without chapters + chaptersUrl: null, + persons: [], // Basic conversion without persons + transcriptUrls: [], // Basic conversion without transcripts + ); + } + + /// This method is called when audio_service sends a [AudioProcessingState.loading] event. + void _loadEpisodeAncillaryItems() async { + if (_currentEpisode == null) { + log.fine('_onLoadEpisode: _episode is null - cannot load!'); + return; + } + + _updateEpisodeState(); + + // Chapters + if (_currentEpisode!.hasChapters && _currentEpisode!.streaming) { + // Only load chapters if they don't already exist (e.g., from PinePods podcast 2.0 data) + if (_currentEpisode!.chapters.isEmpty) { + _currentEpisode!.chaptersLoading = true; + _currentEpisode!.chapters = []; + + _updateEpisodeState(); + + await _onUpdatePosition(); + + log.fine('Loading chapters from ${_currentEpisode!.chaptersUrl}'); + + if (_currentEpisode!.chaptersUrl != null) { + _currentEpisode!.chapters = await podcastService.loadChaptersByUrl(url: _currentEpisode!.chaptersUrl!); + _currentEpisode!.chaptersLoading = false; + } + + _updateEpisodeState(); + + log.fine('We have ${_currentEpisode!.chapters.length} chapters'); + } else { + log.fine('Episode already has ${_currentEpisode!.chapters.length} chapters, skipping load'); + } + + _currentEpisode = await repository.saveEpisode(_currentEpisode!); + } + + if (_currentEpisode!.hasTranscripts) { + Transcript? transcript; + + if (_currentEpisode!.streaming) { + var sub = _currentEpisode!.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.json); + + sub ??= _currentEpisode!.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.subrip); + + sub ??= _currentEpisode!.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.html); + + if (sub != null) { + _updateTranscriptState(state: TranscriptLoadingState()); + + log.fine('Loading transcript from ${sub.url}'); + + transcript = await podcastService.loadTranscriptByUrl(transcriptUrl: sub); + + log.fine('We have ${transcript.subtitles.length} transcript lines'); + } + } else { + transcript = await repository.findTranscriptById(_currentEpisode!.transcriptId!); + } + + if (transcript != null) { + _currentEpisode!.transcript = transcript; + _currentTranscript = transcript; + _updateTranscriptState(); + } + } else { + _updateTranscriptState(state: TranscriptUnavailableState()); + } + + /// Update the state of the current episode & transcript. + _updateEpisodeState(); + + await _onUpdatePosition(); + } + + void _broadcastEpisodePosition(Episode? e) { + if (e != null) { + var duration = Duration(seconds: e.duration); + var complete = duration.inSeconds > 0 ? (e.position / duration.inSeconds) * 100 : 0; + + _playPosition.add(PositionState( + position: Duration(milliseconds: e.position), + length: duration, + percentage: complete.toInt(), + episode: e, + buffering: false, + )); + } + } + + /// Saves the current play position to persistent storage. This enables a + /// podcast to continue playing where it left off if played at a later + /// time. + Future _saveCurrentEpisodePosition({bool complete = false}) async { + if (_currentEpisode != null) { + // The episode may have been updated elsewhere - re-fetch it. + var currentPosition = _audioHandler.playbackState.value.position.inMilliseconds; + + // Preserve chapter data and current chapter before re-fetching + final originalChapters = _currentEpisode!.chapters; + final originalCurrentChapter = _currentEpisode!.currentChapter; + final originalChaptersLoading = _currentEpisode!.chaptersLoading; + + _currentEpisode = await repository.findEpisodeByGuid(_currentEpisode!.guid); + + // Restore chapter data after re-fetching + _currentEpisode!.chapters = originalChapters; + _currentEpisode!.currentChapter = originalCurrentChapter; + _currentEpisode!.chaptersLoading = originalChaptersLoading; + + log.fine( + '_saveCurrentEpisodePosition(): Current position is $currentPosition - stored position is ${_currentEpisode!.position} complete is $complete'); + + if (currentPosition != _currentEpisode!.position) { + _currentEpisode!.position = complete ? 0 : currentPosition; + _currentEpisode!.played = complete; + + _currentEpisode = await repository.saveEpisode(_currentEpisode!); + + // Restore chapter data again after saving + _currentEpisode!.chapters = originalChapters; + _currentEpisode!.currentChapter = originalCurrentChapter; + _currentEpisode!.chaptersLoading = originalChaptersLoading; + } + } else { + log.fine(' - Cannot save position as episode is null'); + } + } + + /// Called when play starts. Each time we receive an event in the stream + /// we check the current position of the episode from the audio service + /// and then push that information out via the [_playPosition] stream + /// to inform our listeners. + void _startPositionTicker() async { + if (_positionSubscription == null) { + _positionSubscription = _durationTicker.listen((int period) async { + await _onUpdatePosition(); + }); + } else if (_positionSubscription!.isPaused) { + _positionSubscription!.resume(); + } + } + + void _stopPositionTicker() async { + if (_positionSubscription != null) { + await _positionSubscription!.cancel(); + _positionSubscription = null; + } + } + + /// We only want to start the sleep timer ticker when the user has requested a sleep. + void _startSleepTicker() async { + _sleepSubscription ??= _sleepTicker.listen((int period) async { + if (_sleep.type == SleepType.time && DateTime.now().isAfter(_sleep.endTime)) { + await pause(); + _sleep = Sleep(type: SleepType.none); + _sleepState.sink.add(_sleep); + _sleepSubscription?.cancel(); + _sleepSubscription = null; + } else { + _sleepState.sink.add(_sleep); + } + }); + } + + /// Once we have stopped sleeping we call this method to tidy up the ticker subscription. + void _stopSleepTicker() async { + _sleep = Sleep(type: SleepType.none); + _sleepState.sink.add(_sleep); + + if (_sleepSubscription != null) { + await _sleepSubscription!.cancel(); + _sleepSubscription = null; + } + } + + Future _onUpdatePosition() async { + var playbackState = _audioHandler.playbackState.value; + + var currentMediaItem = _audioHandler.mediaItem.value; + var duration = currentMediaItem?.duration ?? const Duration(seconds: 1); + var position = playbackState.position; + var complete = duration.inSeconds > 0 ? (position.inSeconds / duration.inSeconds) * 100 : 0; + var buffering = playbackState.processingState == AudioProcessingState.buffering; + + _updateChapter(position.inSeconds, duration.inSeconds); + + _playPosition.add(PositionState( + position: position, + length: duration, + percentage: complete.toInt(), + episode: _currentEpisode, + buffering: buffering, + )); + } + + /// Calculate our current chapter based on playback position, and if it's different to + /// the currently stored chapter - update. + void _updateChapter(int seconds, int duration) { + if (_currentEpisode == null) { + log.fine('Warning. Attempting to update chapter information on a null _episode'); + } else if (_currentEpisode!.hasChapters && _currentEpisode!.chaptersAreLoaded) { + final chapters = _currentEpisode!.chapters.where((element) => element.toc).toList(growable: false); + + for (var chapterPtr = 0; chapterPtr < chapters.length; chapterPtr++) { + final startTime = chapters[chapterPtr].startTime; + final endTime = chapterPtr == (chapters.length - 1) ? duration : chapters[chapterPtr + 1].startTime; + + if (seconds >= startTime && seconds < endTime) { + if (chapters[chapterPtr] != _currentEpisode!.currentChapter) { + _currentEpisode!.currentChapter = chapters[chapterPtr]; + // Force a new episode state by creating a copy to ensure UI updates + _episodeEvent.sink.add(_currentEpisode!); + // Also update the now playing stream to force UI refresh + _updateEpisodeState(); + break; + } + } + } + } + } + + @override + Episode? get nowPlaying => _currentEpisode; + + /// Get the current playing state + @override + Stream get playingState => _playingState.stream; + + Stream? get episodeListener => repository.episodeListener; + + @override + ValueStream get playPosition => _playPosition.stream; + + @override + ValueStream get episodeEvent => _episodeEvent.stream; + + @override + Stream get transcriptEvent => _transcriptEvent.stream; + + @override + Stream get playbackError => _playbackError.stream; + + @override + Stream get queueState => _queueState.stream; + + @override + Stream get sleepStream => _sleepState.stream; +} + +/// This is the default audio handler used by the [DefaultAudioPlayerService] service. +/// This handles the interaction between the service (via the audio service package) and +/// the underlying player. +class _DefaultAudioPlayerHandler extends BaseAudioHandler with SeekHandler { + final log = Logger('DefaultAudioPlayerHandler'); + final Repository repository; + final SettingsService settings; + final PodcastService podcastService; + + static const rewindMillis = 10001; + static const fastForwardMillis = 30000; + static const audioGain = 0.8; + bool _trimSilence = false; + + late AndroidLoudnessEnhancer _androidLoudnessEnhancer; + AudioPipeline? _audioPipeline; + late AudioPlayer _player; + MediaItem? _currentItem; + + static const MediaControl rewindControl = MediaControl( + androidIcon: 'drawable/ic_action_rewind_10', + label: 'Rewind', + action: MediaAction.rewind, + ); + + static const MediaControl fastforwardControl = MediaControl( + androidIcon: 'drawable/ic_action_fastforward_30', + label: 'Fastforward', + action: MediaAction.fastForward, + ); + + _DefaultAudioPlayerHandler({ + required this.repository, + required this.settings, + required this.podcastService, + }) { + _initPlayer(); + } + + void _initPlayer() { + if (Platform.isAndroid) { + _androidLoudnessEnhancer = AndroidLoudnessEnhancer(); + _androidLoudnessEnhancer.setEnabled(true); + _audioPipeline = AudioPipeline(androidAudioEffects: [_androidLoudnessEnhancer]); + _player = AudioPlayer( + audioPipeline: _audioPipeline, + userAgent: Environment.userAgent(), + ); + } else { + _player = AudioPlayer( + userAgent: Environment.userAgent(), + useProxyForRequestHeaders: false, + audioLoadConfiguration: const AudioLoadConfiguration( + androidLoadControl: AndroidLoadControl( + backBufferDuration: Duration(seconds: 45), + ), + darwinLoadControl: DarwinLoadControl(), + )); + } + + /// List to events from the player itself, transform the player event to an audio service one + /// and hand it off to the playback state stream to inform our client(s). + _player.playbackEventStream.map((event) => _transformEvent(event)).listen((data) { + if (playbackState.isClosed) { + log.warning('WARN: Playback state is already closed.'); + } else { + playbackState.add(data); + } + }).onError((error) { + log.fine('Playback error received'); + log.fine(error.toString()); + + _player.stop(); + }); + } + + @override + Future playMediaItem(MediaItem mediaItem) async { + _currentItem = mediaItem; + + var downloaded = mediaItem.extras!['downloaded'] as bool? ?? true; + var startPosition = mediaItem.extras!['position'] as int? ?? 0; + var playbackSpeed = mediaItem.extras!['speed'] as double? ?? 0.0; + var start = startPosition > 0 ? Duration(milliseconds: startPosition) : Duration.zero; + var boost = mediaItem.extras!['boost'] as bool? ?? true; + // Commented out until just audio position bug is fixed + // var trim = mediaItem.extras['trim'] as bool ?? true; + + log.fine('loading new track ${mediaItem.id} - from position ${start.inSeconds} (${start.inMilliseconds})'); + + var source = downloaded + ? AudioSource.uri( + Uri.parse("file://${mediaItem.id}"), + tag: mediaItem.id, + ) + : AudioSource.uri(Uri.parse(mediaItem.id), tag: mediaItem.id); + + try { + var duration = await _player.setAudioSource(source, initialPosition: start); + + /// As duration returned from the player library can be different from the duration in the feed - usually + /// because of DAI - if we have a duration from the player, use that. + if (duration != null) { + _currentItem = _currentItem!.copyWith(duration: duration); + } + + if (_player.processingState != ProcessingState.idle) { + try { + if (_player.speed != playbackSpeed) { + await _player.setSpeed(playbackSpeed); + } + + if (Platform.isAndroid) { + if (_player.skipSilenceEnabled != _trimSilence) { + await _player.setSkipSilenceEnabled(_trimSilence); + } + + volumeBoost(boost); + } + + _player.play(); + } catch (e) { + log.fine('State error ${e.toString()}'); + } + } + } on PlayerException catch (e) { + log.fine('PlayerException'); + log.fine(' - Error code ${e.code}'); + log.fine(' - ${e.message}'); + await stop(); + log.fine(e); + } on PlayerInterruptedException catch (e) { + log.fine('PlayerInterruptedException'); + await stop(); + log.fine(e); + } catch (e) { + log.fine('General playback exception'); + await stop(); + log.fine(e); + } + + super.mediaItem.add(_currentItem); + } + + @override + Future play() async { + await _player.play(); + } + + @override + Future pause() async { + log.fine('pause() triggered - saving position'); + await _savePosition(); + await _player.pause(); + log.info('Audio handler pause completed - position saved'); + } + + @override + Future stop() async { + log.fine('stop() triggered - saving position'); + + await _player.stop(); + await _savePosition(); + + await super.stop(); + log.info('Audio handler stop completed - position saved'); + } + + @override + Future fastForward() async { + var forwardPosition = _player.position.inMilliseconds; + + await _player.seek(Duration(milliseconds: forwardPosition + fastForwardMillis)); + } + + @override + Future skipToNext() => fastForward(); + + @override + Future skipToPrevious() => rewind(); + + @override + Future seek(Duration position) async { + return _player.seek(position); + } + + @override + Future rewind() async { + var rewindPosition = _player.position.inMilliseconds; + + if (rewindPosition > 0) { + rewindPosition -= rewindMillis; + + if (rewindPosition < 0) { + rewindPosition = 0; + } + + await _player.seek(Duration(milliseconds: rewindPosition)); + } + } + + @override + Future customAction(String name, [Map? extras]) async { + switch (name) { + case 'trim': + var t = extras!['value'] as bool; + return trimSilence(t); + case 'boost': + var t = extras!['value'] as bool?; + return volumeBoost(t); + case 'queueend': + log.fine('Received custom action: queue end'); + await _player.stop(); + await super.stop(); + break; + case 'sleep': + log.fine('Received custom action: sleep end of episode'); + // We need to wind back a several milliseconds to stop just_audio + // from sending more complete events on iOS when we pause. + var position = _player.position.inMilliseconds - 200; + + if (position < 0) { + position = 0; + } + + await _player.seek(Duration(milliseconds: position)); + await _player.pause(); + break; + } + } + + @override + Future setSpeed(double speed) => _player.setSpeed(speed); + + Future trimSilence(bool trim) async { + _trimSilence = trim; + await _player.setSkipSilenceEnabled(trim); + } + + Future volumeBoost(bool? boost) async { + /// For now, we know we only have one effect so we can cheat + var e = _audioPipeline!.androidAudioEffects[0]; + + if (e is AndroidLoudnessEnhancer) { + e.setTargetGain(boost! ? audioGain : 0.0); + } + } + + PlaybackState _transformEvent(PlaybackEvent event) { + log.fine('_transformEvent Sending state ${_player.processingState}'); + + // To enable skip next and previous for headphones on iOS we need the + // add the skipToNext & skipToPrevious controls; however, on Android + // we don't need to specify them and doing so adds the next and previous + // buttons to the notification shade which we do not want. + final systemActions = Platform.isIOS + ? const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + MediaAction.skipToNext, + MediaAction.skipToPrevious, + } + : const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }; + + return PlaybackState( + controls: [ + rewindControl, + if (_player.playing) MediaControl.pause else MediaControl.play, + fastforwardControl, + ], + systemActions: systemActions, + androidCompactActionIndices: const [0, 1, 2], + processingState: { + ProcessingState.idle: _player.playing ? AudioProcessingState.ready : AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[_player.processingState]!, + playing: _player.playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + queueIndex: event.currentIndex, + ); + } + + Future _savePosition() async { + if (_currentItem != null) { + // The episode may have been updated elsewhere - re-fetch it. + var currentPosition = playbackState.value.position.inMilliseconds; + var storedEpisode = (await repository.findEpisodeByGuid(_currentItem!.extras!['eid'] as String))!; + + log.fine( + '_savePosition(): Current position is $currentPosition - stored position is ${storedEpisode.position} on episode ${storedEpisode.title}'); + + if (currentPosition != storedEpisode.position) { + storedEpisode.position = currentPosition; + + await repository.saveEpisode(storedEpisode); + } + } else { + log.fine(' - Cannot save position as episode is null'); + } + } +} diff --git a/PinePods-0.8.2/mobile/lib/services/auth_notifier.dart b/PinePods-0.8.2/mobile/lib/services/auth_notifier.dart new file mode 100644 index 0000000..6ee7de0 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/auth_notifier.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +// Global authentication notifier for cross-context communication +class AuthNotifier { + static VoidCallback? _globalLoginSuccessCallback; + + static void setGlobalLoginSuccessCallback(VoidCallback? callback) { + _globalLoginSuccessCallback = callback; + } + + static void notifyLoginSuccess() { + _globalLoginSuccessCallback?.call(); + } + + static void clearGlobalLoginSuccessCallback() { + _globalLoginSuccessCallback = null; + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/services/download/download_manager.dart b/PinePods-0.8.2/mobile/lib/services/download/download_manager.dart new file mode 100644 index 0000000..e02ab59 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/download/download_manager.dart @@ -0,0 +1,27 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/entities/downloadable.dart'; + +class DownloadProgress { + final String id; + final int percentage; + final DownloadState status; + + DownloadProgress( + this.id, + this.percentage, + this.status, + ); +} + +abstract class DownloadManager { + Future enqueueTask(String url, String downloadPath, String fileName); + + Stream get downloadProgress; + + void dispose(); +} diff --git a/PinePods-0.8.2/mobile/lib/services/download/download_service.dart b/PinePods-0.8.2/mobile/lib/services/download/download_service.dart new file mode 100644 index 0000000..de989a0 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/download/download_service.dart @@ -0,0 +1,13 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/episode.dart'; + +abstract class DownloadService { + Future downloadEpisode(Episode episode); + + Future findEpisodeByTaskId(String taskId); + + void dispose(); +} diff --git a/PinePods-0.8.2/mobile/lib/services/download/mobile_download_manager.dart b/PinePods-0.8.2/mobile/lib/services/download/mobile_download_manager.dart new file mode 100644 index 0000000..e14a099 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/download/mobile_download_manager.dart @@ -0,0 +1,119 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:pinepods_mobile/core/environment.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/services/download/download_manager.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:logging/logging.dart'; + +/// A [DownloadManager] for handling downloading of podcasts on a mobile device. +@pragma('vm:entry-point') +class MobileDownloaderManager implements DownloadManager { + static const portName = 'downloader_send_port'; + final log = Logger('MobileDownloaderManager'); + final ReceivePort _port = ReceivePort(); + final downloadController = StreamController(); + var _lastUpdateTime = 0; + + @override + Stream get downloadProgress => downloadController.stream; + + MobileDownloaderManager() { + _init(); + } + + Future _init() async { + log.fine('Initialising download manager'); + + await FlutterDownloader.initialize(); + IsolateNameServer.removePortNameMapping(portName); + + IsolateNameServer.registerPortWithName(_port.sendPort, portName); + + var tasks = await FlutterDownloader.loadTasks(); + + // Update the status of any tasks that may have been updated whilst + // Pinepods was close or in the background. + if (tasks != null && tasks.isNotEmpty) { + for (var t in tasks) { + _updateDownloadState(id: t.taskId, progress: t.progress, status: t.status); + + /// If we are not queued or running we can safely clean up this event + if (t.status != DownloadTaskStatus.enqueued && t.status != DownloadTaskStatus.running) { + FlutterDownloader.remove(taskId: t.taskId, shouldDeleteContent: false); + } + } + } + + _port.listen((dynamic data) { + final id = (data as List)[0] as String; + final status = DownloadTaskStatus.fromInt(data[1] as int); + final progress = data[2] as int; + + _updateDownloadState(id: id, progress: progress, status: status); + }); + + FlutterDownloader.registerCallback(downloadCallback); + } + + @override + Future enqueueTask(String url, String downloadPath, String fileName) async { + return await FlutterDownloader.enqueue( + url: url, + savedDir: downloadPath, + fileName: fileName, + showNotification: true, + openFileFromNotification: false, + headers: { + 'User-Agent': Environment.userAgent(), + }, + ); + } + + @override + void dispose() { + IsolateNameServer.removePortNameMapping(portName); + downloadController.close(); + } + + void _updateDownloadState({required String id, required int progress, required DownloadTaskStatus status}) { + var state = DownloadState.none; + var updateTime = DateTime.now().millisecondsSinceEpoch; + + if (status == DownloadTaskStatus.enqueued) { + state = DownloadState.queued; + } else if (status == DownloadTaskStatus.canceled) { + state = DownloadState.cancelled; + } else if (status == DownloadTaskStatus.complete) { + state = DownloadState.downloaded; + } else if (status == DownloadTaskStatus.running) { + state = DownloadState.downloading; + } else if (status == DownloadTaskStatus.failed) { + state = DownloadState.failed; + } else if (status == DownloadTaskStatus.paused) { + state = DownloadState.paused; + } + + /// If we are running, we want to limit notifications to 1 per second. Otherwise, + /// small downloads can cause a flood of events. Any other status we always want + /// to push through. + if (status != DownloadTaskStatus.running || + progress == 0 || + progress == 100 || + updateTime > _lastUpdateTime + 1000) { + downloadController.add(DownloadProgress(id, progress, state)); + _lastUpdateTime = updateTime; + } + } + + @pragma('vm:entry-point') + static void downloadCallback(String id, int status, int progress) { + IsolateNameServer.lookupPortByName('downloader_send_port')?.send([id, status, progress]); + } +} diff --git a/PinePods-0.8.2/mobile/lib/services/download/mobile_download_service.dart b/PinePods-0.8.2/mobile/lib/services/download/mobile_download_service.dart new file mode 100644 index 0000000..70d0c1c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/download/mobile_download_service.dart @@ -0,0 +1,180 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:pinepods_mobile/core/utils.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:pinepods_mobile/repository/repository.dart'; +import 'package:pinepods_mobile/services/download/download_manager.dart'; +import 'package:pinepods_mobile/services/download/download_service.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:logging/logging.dart'; +import 'package:mp3_info/mp3_info.dart'; +import 'package:rxdart/rxdart.dart'; + +/// An implementation of a [DownloadService] that handles downloading +/// of episodes on mobile. +class MobileDownloadService extends DownloadService { + static BehaviorSubject downloadProgress = BehaviorSubject(); + + final log = Logger('MobileDownloadService'); + final Repository repository; + final DownloadManager downloadManager; + final PodcastService podcastService; + + MobileDownloadService({required this.repository, required this.downloadManager, required this.podcastService}) { + downloadManager.downloadProgress.pipe(downloadProgress); + downloadProgress.listen((progress) { + _updateDownloadProgress(progress); + }); + } + + @override + void dispose() { + downloadManager.dispose(); + } + + @override + Future downloadEpisode(Episode episode) async { + try { + final season = episode.season > 0 ? episode.season.toString() : ''; + final epno = episode.episode > 0 ? episode.episode.toString() : ''; + var dirty = false; + + if (await hasStoragePermission()) { + // If this episode contains chapter, fetch them first. + if (episode.hasChapters && episode.chaptersUrl != null) { + var chapters = await podcastService.loadChaptersByUrl(url: episode.chaptersUrl!); + + episode.chapters = chapters; + + dirty = true; + } + + // Next, if the episode supports transcripts download that next + if (episode.hasTranscripts) { + var sub = episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.json); + + sub ??= episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.subrip); + + sub ??= episode.transcriptUrls.firstWhereOrNull((element) => element.type == TranscriptFormat.html); + + if (sub != null) { + var transcript = await podcastService.loadTranscriptByUrl(transcriptUrl: sub); + + transcript = await podcastService.saveTranscript(transcript); + + episode.transcript = transcript; + episode.transcriptId = transcript.id; + + dirty = true; + } + } + + if (dirty) { + await podcastService.saveEpisode(episode); + } + + final episodePath = await resolveDirectory(episode: episode); + final downloadPath = await resolveDirectory(episode: episode, full: true); + var uri = Uri.parse(episode.contentUrl!); + + // Ensure the download directory exists + await createDownloadDirectory(episode); + + // Filename should be last segment of URI. + var filename = safeFile(uri.pathSegments.lastWhereOrNull((e) => e.toLowerCase().endsWith('.mp3'))); + + filename ??= safeFile(uri.pathSegments.lastWhereOrNull((e) => e.toLowerCase().endsWith('.m4a'))); + + if (filename == null) { + //TODO: Handle unsupported format. + } else { + // The last segment could also be a full URL. Take a second pass. + if (filename.contains('/')) { + try { + uri = Uri.parse(filename); + filename = uri.pathSegments.last; + } on FormatException { + // It wasn't a URL... + } + } + + // Some podcasts use the same file name for each episode. If we have a + // season and/or episode number provided by iTunes we can use that. We + // will also append the filename with the publication date if available. + var pubDate = ''; + + if (episode.publicationDate != null) { + pubDate = '${episode.publicationDate!.millisecondsSinceEpoch ~/ 1000}-'; + } + + filename = '$season$epno$pubDate$filename'; + + log.fine('Download episode (${episode.title}) $filename to $downloadPath/$filename'); + + /// If we get a redirect to an http endpoint the download will fail. Let's fully resolve + /// the URL before calling download and ensure it is https. + var url = await resolveUrl(episode.contentUrl!, forceHttps: true); + + final taskId = await downloadManager.enqueueTask(url, downloadPath, filename); + + // Update the episode with download data + episode.filepath = episodePath; + episode.filename = filename; + episode.downloadTaskId = taskId; + episode.downloadState = DownloadState.downloading; + episode.downloadPercentage = 0; + + await repository.saveEpisode(episode); + + return true; + } + } + + return false; + } catch (e, stack) { + log.warning('Episode download failed (${episode.title})', e, stack); + return false; + } + } + + @override + Future findEpisodeByTaskId(String taskId) { + return repository.findEpisodeByTaskId(taskId); + } + + Future _updateDownloadProgress(DownloadProgress progress) async { + var episode = await repository.findEpisodeByTaskId(progress.id); + + if (episode != null) { + // We might be called during the cleanup routine during startup. + // Do not bother updating if nothing has changed. + if (episode.downloadPercentage != progress.percentage || episode.downloadState != progress.status) { + episode.downloadPercentage = progress.percentage; + episode.downloadState = progress.status; + + if (progress.percentage == 100) { + if (await hasStoragePermission()) { + final filename = await resolvePath(episode); + + // If we do not have a duration for this file - let's calculate it + if (episode.duration == 0) { + var mp3Info = MP3Processor.fromFile(File(filename)); + + episode.duration = mp3Info.duration.inSeconds; + } + } + } + + await repository.saveEpisode(episode); + } + } + } +} diff --git a/PinePods-0.8.2/mobile/lib/services/error_handling_service.dart b/PinePods-0.8.2/mobile/lib/services/error_handling_service.dart new file mode 100644 index 0000000..377de92 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/error_handling_service.dart @@ -0,0 +1,202 @@ +// lib/services/error_handling_service.dart +import 'dart:io'; +import 'package:http/http.dart' as http; + +/// Service for handling and categorizing errors, especially server connection issues +class ErrorHandlingService { + /// Checks if an error indicates a server connection issue + static bool isServerConnectionError(dynamic error) { + if (error == null) return false; + + final errorString = error.toString().toLowerCase(); + + // Network-related errors + if (error is SocketException) return true; + if (error is HttpException) return true; + if (error is http.ClientException) return true; + + // Check for common connection error patterns + final connectionErrorPatterns = [ + 'connection refused', + 'connection timeout', + 'connection failed', + 'network is unreachable', + 'no route to host', + 'connection reset', + 'connection aborted', + 'host is unreachable', + 'server unavailable', + 'service unavailable', + 'bad gateway', + 'gateway timeout', + 'connection timed out', + 'failed host lookup', + 'no address associated with hostname', + 'network unreachable', + 'operation timed out', + 'handshake failure', + 'certificate verify failed', + 'ssl handshake failed', + 'unable to connect', + 'server closed the connection', + 'connection closed', + 'broken pipe', + 'no internet connection', + 'offline', + 'dns lookup failed', + 'name resolution failed', + ]; + + return connectionErrorPatterns.any((pattern) => errorString.contains(pattern)); + } + + /// Checks if an error indicates authentication/authorization issues + static bool isAuthenticationError(dynamic error) { + if (error == null) return false; + + final errorString = error.toString().toLowerCase(); + + final authErrorPatterns = [ + 'unauthorized', + 'authentication failed', + 'invalid credentials', + 'access denied', + 'forbidden', + 'token expired', + 'invalid token', + 'login required', + '401', + '403', + ]; + + return authErrorPatterns.any((pattern) => errorString.contains(pattern)); + } + + /// Checks if an error indicates server-side issues (5xx errors) + static bool isServerError(dynamic error) { + if (error == null) return false; + + final errorString = error.toString().toLowerCase(); + + final serverErrorPatterns = [ + 'internal server error', + 'server error', + 'service unavailable', + 'bad gateway', + 'gateway timeout', + '500', + '502', + '503', + '504', + '505', + ]; + + return serverErrorPatterns.any((pattern) => errorString.contains(pattern)); + } + + /// Gets a user-friendly error message based on the error type + static String getUserFriendlyErrorMessage(dynamic error) { + if (error == null) return 'An unknown error occurred'; + + if (isServerConnectionError(error)) { + return 'Unable to connect to the PinePods server. Please check your internet connection and server settings.'; + } + + if (isAuthenticationError(error)) { + return 'Authentication failed. Please check your login credentials.'; + } + + if (isServerError(error)) { + return 'The PinePods server is experiencing issues. Please try again later.'; + } + + // Return the original error message for other types of errors + return error.toString(); + } + + /// Gets an appropriate title for the error + static String getErrorTitle(dynamic error) { + if (error == null) return 'Error'; + + if (isServerConnectionError(error)) { + return 'Server Unavailable'; + } + + if (isAuthenticationError(error)) { + return 'Authentication Error'; + } + + if (isServerError(error)) { + return 'Server Error'; + } + + return 'Error'; + } + + /// Gets troubleshooting suggestions based on the error type + static List getTroubleshootingSteps(dynamic error) { + if (error == null) return ['Please try again later']; + + if (isServerConnectionError(error)) { + return [ + 'Check your internet connection', + 'Verify server URL in settings', + 'Ensure the PinePods server is running', + 'Check if the server port is accessible', + 'Contact your administrator if the issue persists', + ]; + } + + if (isAuthenticationError(error)) { + return [ + 'Check your username and password', + 'Ensure your account is still active', + 'Try logging out and logging back in', + 'Contact your administrator for help', + ]; + } + + if (isServerError(error)) { + return [ + 'Wait a few minutes and try again', + 'Check if the server is overloaded', + 'Contact your administrator', + 'Check server logs for more details', + ]; + } + + return [ + 'Try refreshing the page', + 'Restart the app if the issue persists', + 'Contact support for assistance', + ]; + } + + /// Wraps an async function call with error handling + static Future handleApiCall( + Future Function() apiCall, { + String? context, + }) async { + try { + return await apiCall(); + } catch (error) { + // Log the error with context if provided + if (context != null) { + print('API Error in $context: $error'); + } + + // Re-throw the error to be handled by the UI layer + rethrow; + } + } +} + +/// Extension to make error checking easier +extension ErrorTypeExtension on dynamic { + bool get isServerConnectionError => ErrorHandlingService.isServerConnectionError(this); + bool get isAuthenticationError => ErrorHandlingService.isAuthenticationError(this); + bool get isServerError => ErrorHandlingService.isServerError(this); + String get userFriendlyMessage => ErrorHandlingService.getUserFriendlyErrorMessage(this); + String get errorTitle => ErrorHandlingService.getErrorTitle(this); + List get troubleshootingSteps => ErrorHandlingService.getTroubleshootingSteps(this); +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/services/global_services.dart b/PinePods-0.8.2/mobile/lib/services/global_services.dart new file mode 100644 index 0000000..7e54238 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/global_services.dart @@ -0,0 +1,35 @@ +// lib/services/global_services.dart +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; + +/// Global service access point for the app +class GlobalServices { + static PinepodsAudioService? _pinepodsAudioService; + static PinepodsService? _pinepodsService; + + /// Set the global services (called from PinepodsPodcastApp) + static void initialize({ + required PinepodsAudioService pinepodsAudioService, + required PinepodsService pinepodsService, + }) { + _pinepodsAudioService = pinepodsAudioService; + _pinepodsService = pinepodsService; + } + + /// Update global service credentials (called when user logs in or settings change) + static void setCredentials(String server, String apiKey) { + _pinepodsService?.setCredentials(server, apiKey); + } + + /// Get the global PinepodsAudioService instance + static PinepodsAudioService? get pinepodsAudioService => _pinepodsAudioService; + + /// Get the global PinepodsService instance + static PinepodsService? get pinepodsService => _pinepodsService; + + /// Clear services (for testing or cleanup) + static void clear() { + _pinepodsAudioService = null; + _pinepodsService = null; + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/services/logging/app_logger.dart b/PinePods-0.8.2/mobile/lib/services/logging/app_logger.dart new file mode 100644 index 0000000..bcad244 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/logging/app_logger.dart @@ -0,0 +1,488 @@ +// lib/services/logging/app_logger.dart +import 'dart:io'; +import 'dart:collection'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path_helper; +import 'package:intl/intl.dart'; + +enum LogLevel { + debug, + info, + warning, + error, + critical, +} + +class LogEntry { + final DateTime timestamp; + final LogLevel level; + final String tag; + final String message; + final String? stackTrace; + + LogEntry({ + required this.timestamp, + required this.level, + required this.tag, + required this.message, + this.stackTrace, + }); + + String get levelString { + switch (level) { + case LogLevel.debug: + return 'DEBUG'; + case LogLevel.info: + return 'INFO'; + case LogLevel.warning: + return 'WARN'; + case LogLevel.error: + return 'ERROR'; + case LogLevel.critical: + return 'CRITICAL'; + } + } + + String get formattedMessage { + final timeStr = timestamp.toString().substring(0, 19); // Remove milliseconds for readability + var result = '[$timeStr] [$levelString] [$tag] $message'; + if (stackTrace != null && stackTrace!.isNotEmpty) { + result += '\nStackTrace: $stackTrace'; + } + return result; + } +} + +class DeviceInfo { + final String platform; + final String osVersion; + final String model; + final String manufacturer; + final String appVersion; + final String buildNumber; + + DeviceInfo({ + required this.platform, + required this.osVersion, + required this.model, + required this.manufacturer, + required this.appVersion, + required this.buildNumber, + }); + + String get formattedInfo { + return ''' +Device Information: +- Platform: $platform +- OS Version: $osVersion +- Model: $model +- Manufacturer: $manufacturer +- App Version: $appVersion +- Build Number: $buildNumber +'''; + } +} + +class AppLogger { + static final AppLogger _instance = AppLogger._internal(); + factory AppLogger() => _instance; + AppLogger._internal(); + + static const int maxLogEntries = 1000; // Keep last 1000 log entries in memory + static const int maxSessionFiles = 5; // Keep last 5 session log files + static const String crashLogFileName = 'pinepods_last_crash.txt'; + + final Queue _logs = Queue(); + DeviceInfo? _deviceInfo; + File? _currentSessionFile; + File? _crashLogFile; + Directory? _logsDirectory; + String? _sessionId; + bool _isInitialized = false; + + // Initialize the logger and collect device info + Future initialize() async { + if (_isInitialized) return; + + await _collectDeviceInfo(); + await _initializeLogFiles(); + await _setupCrashHandler(); + await _loadPreviousCrash(); + + _isInitialized = true; + + // Log initialization + log(LogLevel.info, 'AppLogger', 'Logger initialized successfully'); + } + + Future _collectDeviceInfo() async { + try { + final deviceInfoPlugin = DeviceInfoPlugin(); + final packageInfo = await PackageInfo.fromPlatform(); + + if (Platform.isAndroid) { + final androidInfo = await deviceInfoPlugin.androidInfo; + _deviceInfo = DeviceInfo( + platform: 'Android', + osVersion: 'Android ${androidInfo.version.release} (API ${androidInfo.version.sdkInt})', + model: '${androidInfo.manufacturer} ${androidInfo.model}', + manufacturer: androidInfo.manufacturer, + appVersion: packageInfo.version, + buildNumber: packageInfo.buildNumber, + ); + } else if (Platform.isIOS) { + final iosInfo = await deviceInfoPlugin.iosInfo; + _deviceInfo = DeviceInfo( + platform: 'iOS', + osVersion: '${iosInfo.systemName} ${iosInfo.systemVersion}', + model: iosInfo.model, + manufacturer: 'Apple', + appVersion: packageInfo.version, + buildNumber: packageInfo.buildNumber, + ); + } else { + _deviceInfo = DeviceInfo( + platform: Platform.operatingSystem, + osVersion: Platform.operatingSystemVersion, + model: 'Unknown', + manufacturer: 'Unknown', + appVersion: packageInfo.version, + buildNumber: packageInfo.buildNumber, + ); + } + } catch (e) { + // If device info collection fails, create a basic info object + try { + final packageInfo = await PackageInfo.fromPlatform(); + _deviceInfo = DeviceInfo( + platform: Platform.operatingSystem, + osVersion: Platform.operatingSystemVersion, + model: 'Unknown', + manufacturer: 'Unknown', + appVersion: packageInfo.version, + buildNumber: packageInfo.buildNumber, + ); + } catch (e2) { + _deviceInfo = DeviceInfo( + platform: 'Unknown', + osVersion: 'Unknown', + model: 'Unknown', + manufacturer: 'Unknown', + appVersion: 'Unknown', + buildNumber: 'Unknown', + ); + } + } + } + + void log(LogLevel level, String tag, String message, [String? stackTrace]) { + final entry = LogEntry( + timestamp: DateTime.now(), + level: level, + tag: tag, + message: message, + stackTrace: stackTrace, + ); + + _logs.add(entry); + + // Keep only the last maxLogEntries in memory + while (_logs.length > maxLogEntries) { + _logs.removeFirst(); + } + + // Write to current session file asynchronously (don't await to avoid blocking) + _writeToSessionFile(entry); + + // Also print to console in debug mode + if (kDebugMode) { + print(entry.formattedMessage); + } + } + + // Convenience methods for different log levels + void debug(String tag, String message) => log(LogLevel.debug, tag, message); + void info(String tag, String message) => log(LogLevel.info, tag, message); + void warning(String tag, String message) => log(LogLevel.warning, tag, message); + void error(String tag, String message, [String? stackTrace]) => log(LogLevel.error, tag, message, stackTrace); + void critical(String tag, String message, [String? stackTrace]) => log(LogLevel.critical, tag, message, stackTrace); + + // Log an exception with automatic stack trace + void logException(String tag, String message, dynamic exception, [StackTrace? stackTrace]) { + final stackTraceStr = stackTrace?.toString() ?? exception.toString(); + error(tag, '$message: $exception', stackTraceStr); + } + + // Get all logs + List get logs => _logs.toList(); + + // Get logs filtered by level + List getLogsByLevel(LogLevel level) { + return _logs.where((log) => log.level == level).toList(); + } + + // Get logs from a specific time period + List getLogsInTimeRange(DateTime start, DateTime end) { + return _logs.where((log) => + log.timestamp.isAfter(start) && log.timestamp.isBefore(end) + ).toList(); + } + + // Get formatted log string for copying + String getFormattedLogs() { + final buffer = StringBuffer(); + + // Add device info + if (_deviceInfo != null) { + buffer.writeln(_deviceInfo!.formattedInfo); + } + + // Add separator + buffer.writeln('=' * 50); + buffer.writeln('Application Logs:'); + buffer.writeln('=' * 50); + + // Add all logs + for (final log in _logs) { + buffer.writeln(log.formattedMessage); + } + + // Add footer + buffer.writeln(); + buffer.writeln('=' * 50); + buffer.writeln('End of logs - Total entries: ${_logs.length}'); + buffer.writeln('Bug reports: https://github.com/madeofpendletonwool/pinepods/issues'); + + return buffer.toString(); + } + + // Clear all logs + void clearLogs() { + _logs.clear(); + log(LogLevel.info, 'AppLogger', 'Logs cleared by user'); + } + + // Initialize log files and directory structure + Future _initializeLogFiles() async { + try { + final appDocDir = await getApplicationDocumentsDirectory(); + _logsDirectory = Directory(path_helper.join(appDocDir.path, 'logs')); + + // Create logs directory if it doesn't exist + if (!await _logsDirectory!.exists()) { + await _logsDirectory!.create(recursive: true); + } + + // Clean up old session files (keep only last 5) + await _cleanupOldSessionFiles(); + + // Create new session file + _sessionId = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + _currentSessionFile = File(path_helper.join(_logsDirectory!.path, 'session_$_sessionId.log')); + await _currentSessionFile!.create(); + + // Initialize crash log file + _crashLogFile = File(path_helper.join(_logsDirectory!.path, crashLogFileName)); + if (!await _crashLogFile!.exists()) { + await _crashLogFile!.create(); + } + + log(LogLevel.info, 'AppLogger', 'Session log files initialized at ${_logsDirectory!.path}'); + } catch (e) { + if (kDebugMode) { + print('Failed to initialize log files: $e'); + } + } + } + + // Clean up old session files, keeping only the most recent ones + Future _cleanupOldSessionFiles() async { + try { + final files = await _logsDirectory!.list().toList(); + final sessionFiles = files + .whereType() + .where((f) => path_helper.basename(f.path).startsWith('session_')) + .toList(); + + // Sort by last modified date (newest first) + sessionFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync())); + + // Delete files beyond the limit + if (sessionFiles.length > maxSessionFiles) { + for (int i = maxSessionFiles; i < sessionFiles.length; i++) { + await sessionFiles[i].delete(); + } + } + } catch (e) { + if (kDebugMode) { + print('Failed to cleanup old session files: $e'); + } + } + } + + // Write log entry to current session file + Future _writeToSessionFile(LogEntry entry) async { + if (_currentSessionFile == null) return; + + try { + await _currentSessionFile!.writeAsString( + '${entry.formattedMessage}\n', + mode: FileMode.append, + ); + } catch (e) { + // Silently fail to avoid logging loops + if (kDebugMode) { + print('Failed to write log to session file: $e'); + } + } + } + + // Setup crash handler + Future _setupCrashHandler() async { + FlutterError.onError = (FlutterErrorDetails details) { + _logCrash('Flutter Error', details.exception.toString(), details.stack); + // Still call the default error handler + FlutterError.presentError(details); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + _logCrash('Platform Error', error.toString(), stack); + return true; // Mark as handled + }; + } + + // Log crash to persistent storage + Future _logCrash(String type, String error, StackTrace? stackTrace) async { + try { + final crashInfo = { + 'timestamp': DateTime.now().toIso8601String(), + 'sessionId': _sessionId, + 'type': type, + 'error': error, + 'stackTrace': stackTrace?.toString(), + 'deviceInfo': _deviceInfo?.formattedInfo, + 'recentLogs': _logs.length > 20 ? _logs.skip(_logs.length - 20).map((e) => e.formattedMessage).toList() : _logs.map((e) => e.formattedMessage).toList(), // Only last 20 entries + }; + + if (_crashLogFile != null) { + await _crashLogFile!.writeAsString(jsonEncode(crashInfo)); + } + + // Also log through normal logging + critical('CrashHandler', '$type: $error', stackTrace?.toString()); + } catch (e) { + if (kDebugMode) { + print('Failed to log crash: $e'); + } + } + } + + // Load and log previous crash if exists + Future _loadPreviousCrash() async { + if (_crashLogFile == null || !await _crashLogFile!.exists()) return; + + try { + final crashData = await _crashLogFile!.readAsString(); + if (crashData.isNotEmpty) { + final crash = jsonDecode(crashData); + warning('PreviousCrash', 'Previous crash detected: ${crash['type']} at ${crash['timestamp']}'); + warning('PreviousCrash', 'Session: ${crash['sessionId'] ?? 'unknown'}'); + warning('PreviousCrash', 'Error: ${crash['error']}'); + if (crash['stackTrace'] != null) { + warning('PreviousCrash', 'Stack trace available in crash log file'); + } + } + } catch (e) { + warning('AppLogger', 'Failed to load previous crash info: $e'); + } + } + + // Get list of available session files + Future> getSessionFiles() async { + if (_logsDirectory == null) return []; + + try { + final files = await _logsDirectory!.list().toList(); + final sessionFiles = files + .whereType() + .where((f) => path_helper.basename(f.path).startsWith('session_')) + .toList(); + + // Sort by last modified date (newest first) + sessionFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync())); + return sessionFiles; + } catch (e) { + return []; + } + } + + // Get current session file path + String? get currentSessionPath => _currentSessionFile?.path; + + // Get crash log file path + String? get crashLogPath => _crashLogFile?.path; + + // Get logs directory path + String? get logsDirectoryPath => _logsDirectory?.path; + + // Check if previous crash exists + Future hasPreviousCrash() async { + if (_crashLogFile == null) return false; + try { + final exists = await _crashLogFile!.exists(); + if (!exists) return false; + final content = await _crashLogFile!.readAsString(); + return content.isNotEmpty; + } catch (e) { + return false; + } + } + + // Clear crash log + Future clearCrashLog() async { + if (_crashLogFile != null && await _crashLogFile!.exists()) { + await _crashLogFile!.writeAsString(''); + } + } + + // Get formatted logs with session info + String getFormattedLogsWithSessionInfo() { + final buffer = StringBuffer(); + + // Add session info + buffer.writeln('Session ID: $_sessionId'); + buffer.writeln('Session started: ${DateTime.now().toString()}'); + + // Add device info + if (_deviceInfo != null) { + buffer.writeln(_deviceInfo!.formattedInfo); + } + + // Add separator + buffer.writeln('=' * 50); + buffer.writeln('Application Logs (Current Session):'); + buffer.writeln('=' * 50); + + // Add all logs + for (final log in _logs) { + buffer.writeln(log.formattedMessage); + } + + // Add footer + buffer.writeln(); + buffer.writeln('=' * 50); + buffer.writeln('End of logs - Total entries: ${_logs.length}'); + buffer.writeln('Session file: ${_currentSessionFile?.path}'); + buffer.writeln('Bug reports: https://github.com/madeofpendletonwool/pinepods/issues'); + + return buffer.toString(); + } + + // Get device info + DeviceInfo? get deviceInfo => _deviceInfo; +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/services/pinepods/login_service.dart b/PinePods-0.8.2/mobile/lib/services/pinepods/login_service.dart new file mode 100644 index 0000000..121ea1e --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/pinepods/login_service.dart @@ -0,0 +1,532 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class PinepodsLoginService { + static const String userAgent = 'PinePods Mobile/1.0'; + + /// Verify if the server is a valid PinePods instance + static Future verifyPinepodsInstance(String serverUrl) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/pinepods_check'); + + final response = await http.get( + url, + headers: {'User-Agent': userAgent}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['pinepods_instance'] == true; + } + return false; + } catch (e) { + return false; + } + } + + /// Initial login - returns either API key or MFA session info + static Future initialLogin(String serverUrl, String username, String password) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final credentials = base64Encode(utf8.encode('$username:$password')); + final authHeader = 'Basic $credentials'; + final url = Uri.parse('$normalizedUrl/api/data/get_key'); + + final response = await http.get( + url, + headers: { + 'Authorization': authHeader, + 'User-Agent': userAgent, + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + + // Check if MFA is required + if (data['status'] == 'mfa_required' && data['mfa_required'] == true) { + return InitialLoginResponse.mfaRequired( + serverUrl: normalizedUrl, + username: username, + userId: data['user_id'], + mfaSessionToken: data['mfa_session_token'], + ); + } + + // Normal flow - no MFA required + final apiKey = data['retrieved_key']; + if (apiKey != null) { + return InitialLoginResponse.success(apiKey: apiKey); + } + } + + return InitialLoginResponse.failure('Authentication failed'); + } catch (e) { + return InitialLoginResponse.failure('Error: ${e.toString()}'); + } + } + + /// Legacy method for backwards compatibility + @deprecated + static Future getApiKey(String serverUrl, String username, String password) async { + final result = await initialLogin(serverUrl, username, password); + return result.isSuccess ? result.apiKey : null; + } + + /// Verify API key is valid + static Future verifyApiKey(String serverUrl, String apiKey) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/data/verify_key'); + + final response = await http.get( + url, + headers: { + 'Api-Key': apiKey, + 'User-Agent': userAgent, + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['status'] == 'success'; + } + return false; + } catch (e) { + return false; + } + } + + /// Get user ID + static Future getUserId(String serverUrl, String apiKey) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/data/get_user'); + + final response = await http.get( + url, + headers: { + 'Api-Key': apiKey, + 'User-Agent': userAgent, + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['status'] == 'success' && data['retrieved_id'] != null) { + return data['retrieved_id'] as int; + } + } + return null; + } catch (e) { + return null; + } + } + + /// Get user details + static Future getUserDetails(String serverUrl, String apiKey, int userId) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/data/user_details_id/$userId'); + + final response = await http.get( + url, + headers: { + 'Api-Key': apiKey, + 'User-Agent': userAgent, + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return UserDetails.fromJson(data); + } + return null; + } catch (e) { + return null; + } + } + + /// Get API configuration + static Future getApiConfig(String serverUrl, String apiKey) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/data/config'); + + final response = await http.get( + url, + headers: { + 'Api-Key': apiKey, + 'User-Agent': userAgent, + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return ApiConfig.fromJson(data); + } + return null; + } catch (e) { + return null; + } + } + + /// Check if MFA is enabled for user + static Future checkMfaEnabled(String serverUrl, String apiKey, int userId) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/data/check_mfa_enabled/$userId'); + + final response = await http.get( + url, + headers: { + 'Api-Key': apiKey, + 'User-Agent': userAgent, + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['mfa_enabled'] == true; + } + return false; + } catch (e) { + return false; + } + } + + /// Verify MFA code and get API key during login (secure flow) + static Future verifyMfaAndGetKey(String serverUrl, String mfaSessionToken, String mfaCode) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/data/verify_mfa_and_get_key'); + + final requestBody = jsonEncode({ + 'mfa_session_token': mfaSessionToken, + 'mfa_code': mfaCode, + }); + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }, + body: requestBody, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['verified'] == true && data['status'] == 'success') { + return data['retrieved_key']; + } + } + return null; + } catch (e) { + return null; + } + } + + /// Legacy MFA verification (for post-login MFA checks) + @deprecated + static Future verifyMfa(String serverUrl, String apiKey, int userId, String mfaCode) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/data/verify_mfa'); + + final requestBody = jsonEncode({ + 'user_id': userId, + 'mfa_code': mfaCode, + }); + + final response = await http.post( + url, + headers: { + 'Api-Key': apiKey, + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }, + body: requestBody, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['verified'] == true; + } + return false; + } catch (e) { + return false; + } + } + + /// Complete login flow (new secure MFA implementation) + static Future login(String serverUrl, String username, String password) async { + try { + // Step 1: Verify server + final isPinepods = await verifyPinepodsInstance(serverUrl); + if (!isPinepods) { + return LoginResult.failure('Not a valid PinePods server'); + } + + // Step 2: Initial login - get API key or MFA session + final initialResult = await initialLogin(serverUrl, username, password); + + if (!initialResult.isSuccess) { + return LoginResult.failure(initialResult.errorMessage ?? 'Login failed'); + } + + if (initialResult.requiresMfa) { + // MFA required - return MFA prompt state + return LoginResult.mfaRequired( + serverUrl: initialResult.serverUrl!, + username: username, + userId: initialResult.userId!, + mfaSessionToken: initialResult.mfaSessionToken!, + ); + } + + // No MFA required - complete login with API key + return await _completeLoginWithApiKey( + serverUrl, + username, + initialResult.apiKey!, + ); + } catch (e) { + return LoginResult.failure('Error: ${e.toString()}'); + } + } + + /// Complete MFA login flow + static Future completeMfaLogin({ + required String serverUrl, + required String username, + required String mfaSessionToken, + required String mfaCode, + }) async { + try { + // Verify MFA and get API key + final apiKey = await verifyMfaAndGetKey(serverUrl, mfaSessionToken, mfaCode); + if (apiKey == null) { + return LoginResult.failure('Invalid MFA code'); + } + + // Complete login with verified API key + return await _completeLoginWithApiKey(serverUrl, username, apiKey); + } catch (e) { + return LoginResult.failure('Error: ${e.toString()}'); + } + } + + /// Complete login flow with API key (common logic) + static Future _completeLoginWithApiKey(String serverUrl, String username, String apiKey) async { + // Step 1: Verify API key + final isValidKey = await verifyApiKey(serverUrl, apiKey); + if (!isValidKey) { + return LoginResult.failure('API key verification failed'); + } + + // Step 2: Get user ID + final userId = await getUserId(serverUrl, apiKey); + if (userId == null) { + return LoginResult.failure('Failed to get user ID'); + } + + // Step 3: Get user details + final userDetails = await getUserDetails(serverUrl, apiKey, userId); + if (userDetails == null) { + return LoginResult.failure('Failed to get user details'); + } + + // Step 4: Get API configuration + final apiConfig = await getApiConfig(serverUrl, apiKey); + if (apiConfig == null) { + return LoginResult.failure('Failed to get server configuration'); + } + + return LoginResult.success( + serverUrl: serverUrl, + apiKey: apiKey, + userId: userId, + userDetails: userDetails, + apiConfig: apiConfig, + ); + } +} + +class InitialLoginResponse { + final bool isSuccess; + final bool requiresMfa; + final String? errorMessage; + final String? apiKey; + final String? serverUrl; + final String? username; + final int? userId; + final String? mfaSessionToken; + + InitialLoginResponse._({ + required this.isSuccess, + required this.requiresMfa, + this.errorMessage, + this.apiKey, + this.serverUrl, + this.username, + this.userId, + this.mfaSessionToken, + }); + + factory InitialLoginResponse.success({required String apiKey}) { + return InitialLoginResponse._( + isSuccess: true, + requiresMfa: false, + apiKey: apiKey, + ); + } + + factory InitialLoginResponse.mfaRequired({ + required String serverUrl, + required String username, + required int userId, + required String mfaSessionToken, + }) { + return InitialLoginResponse._( + isSuccess: true, + requiresMfa: true, + serverUrl: serverUrl, + username: username, + userId: userId, + mfaSessionToken: mfaSessionToken, + ); + } + + factory InitialLoginResponse.failure(String errorMessage) { + return InitialLoginResponse._( + isSuccess: false, + requiresMfa: false, + errorMessage: errorMessage, + ); + } +} + +class LoginResult { + final bool isSuccess; + final bool requiresMfa; + final String? errorMessage; + final String? serverUrl; + final String? apiKey; + final String? username; + final int? userId; + final String? mfaSessionToken; + final UserDetails? userDetails; + final ApiConfig? apiConfig; + + LoginResult._({ + required this.isSuccess, + required this.requiresMfa, + this.errorMessage, + this.serverUrl, + this.apiKey, + this.username, + this.userId, + this.mfaSessionToken, + this.userDetails, + this.apiConfig, + }); + + factory LoginResult.success({ + required String serverUrl, + required String apiKey, + required int userId, + required UserDetails userDetails, + required ApiConfig apiConfig, + }) { + return LoginResult._( + isSuccess: true, + requiresMfa: false, + serverUrl: serverUrl, + apiKey: apiKey, + userId: userId, + userDetails: userDetails, + apiConfig: apiConfig, + ); + } + + factory LoginResult.failure(String errorMessage) { + return LoginResult._( + isSuccess: false, + requiresMfa: false, + errorMessage: errorMessage, + ); + } + + factory LoginResult.mfaRequired({ + required String serverUrl, + required String username, + required int userId, + required String mfaSessionToken, + }) { + return LoginResult._( + isSuccess: false, + requiresMfa: true, + serverUrl: serverUrl, + username: username, + userId: userId, + mfaSessionToken: mfaSessionToken, + ); + } +} + +class UserDetails { + final int userId; + final String? fullname; + final String? username; + final String? email; + + UserDetails({ + required this.userId, + this.fullname, + this.username, + this.email, + }); + + factory UserDetails.fromJson(Map json) { + return UserDetails( + userId: json['UserID'], + fullname: json['Fullname'], + username: json['Username'], + email: json['Email'], + ); + } +} + +class ApiConfig { + final String? apiUrl; + final String? proxyUrl; + final String? proxyHost; + final String? proxyPort; + final String? proxyProtocol; + final String? reverseProxy; + final String? peopleUrl; + + ApiConfig({ + this.apiUrl, + this.proxyUrl, + this.proxyHost, + this.proxyPort, + this.proxyProtocol, + this.reverseProxy, + this.peopleUrl, + }); + + factory ApiConfig.fromJson(Map json) { + return ApiConfig( + apiUrl: json['api_url'], + proxyUrl: json['proxy_url'], + proxyHost: json['proxy_host'], + proxyPort: json['proxy_port'], + proxyProtocol: json['proxy_protocol'], + reverseProxy: json['reverse_proxy'], + peopleUrl: json['people_url'], + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/services/pinepods/oidc_service.dart b/PinePods-0.8.2/mobile/lib/services/pinepods/oidc_service.dart new file mode 100644 index 0000000..b2bd5fc --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/pinepods/oidc_service.dart @@ -0,0 +1,405 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +class OidcService { + static const String userAgent = 'PinePods Mobile/1.0'; + static const String callbackUrlScheme = 'pinepods'; + static const String callbackPath = '/auth/callback'; + + /// Get available OIDC providers from server + static Future> getPublicProviders(String serverUrl) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/data/public_oidc_providers'); + + final response = await http.get( + url, + headers: {'User-Agent': userAgent}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final providers = (data['providers'] as List) + .map((provider) => OidcProvider.fromJson(provider)) + .toList(); + return providers; + } + return []; + } catch (e) { + return []; + } + } + + /// Generate PKCE code verifier and challenge for secure OIDC flow + static OidcPkce generatePkce() { + final codeVerifier = _generateCodeVerifier(); + final codeChallenge = _generateCodeChallenge(codeVerifier); + + return OidcPkce( + codeVerifier: codeVerifier, + codeChallenge: codeChallenge, + ); + } + + /// Generate random state parameter + static String generateState() { + final random = Random.secure(); + final bytes = List.generate(32, (i) => random.nextInt(256)); + return base64UrlEncode(bytes).replaceAll('=', ''); + } + + /// Store OIDC state on server (matches web implementation) + static Future storeOidcState({ + required String serverUrl, + required String state, + required String clientId, + String? originUrl, + String? codeVerifier, + }) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/auth/store_state'); + + final requestBody = jsonEncode({ + 'state': state, + 'client_id': clientId, + 'origin_url': originUrl, + 'code_verifier': codeVerifier, + }); + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }, + body: requestBody, + ); + + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + /// Build authorization URL and return it for in-app browser use + static Future buildOidcLoginUrl({ + required OidcProvider provider, + required String serverUrl, + required String state, + OidcPkce? pkce, + }) async { + try { + // Store state on server first - use web origin for in-app browser + final stateStored = await storeOidcState( + serverUrl: serverUrl, + state: state, + clientId: provider.clientId, + originUrl: '$serverUrl/oauth/callback', // Use web callback for in-app browser + codeVerifier: pkce?.codeVerifier, // Include PKCE code verifier + ); + + if (!stateStored) { + return null; + } + + // Build authorization URL + final authUri = Uri.parse(provider.authorizationUrl); + final queryParams = { + 'client_id': provider.clientId, + 'response_type': 'code', + 'scope': provider.scope, + 'redirect_uri': '$serverUrl/api/auth/callback', + 'state': state, + }; + + // Add PKCE parameters if provided + if (pkce != null) { + queryParams['code_challenge'] = pkce.codeChallenge; + queryParams['code_challenge_method'] = 'S256'; + } + + final authUrl = authUri.replace(queryParameters: queryParams); + + return authUrl.toString(); + + } catch (e) { + return null; + } + } + + /// Extract API key from callback URL (for in-app browser) + static String? extractApiKeyFromUrl(String url) { + try { + final uri = Uri.parse(url); + + // Check if this is our callback URL with API key + if (uri.path.contains('/oauth/callback')) { + return uri.queryParameters['api_key']; + } + + return null; + } catch (e) { + return null; + } + } + + /// Handle OIDC callback and extract authentication result + static OidcCallbackResult parseCallback(String callbackUrl) { + try { + final uri = Uri.parse(callbackUrl); + final queryParams = uri.queryParameters; + + // Check for error + if (queryParams.containsKey('error')) { + return OidcCallbackResult.error( + error: queryParams['error'] ?? 'Unknown error', + errorDescription: queryParams['error_description'], + ); + } + + // Check if we have an API key directly (PinePods backend provides this) + final apiKey = queryParams['api_key']; + if (apiKey != null && apiKey.isNotEmpty) { + return OidcCallbackResult.success( + apiKey: apiKey, + state: queryParams['state'], + ); + } + + // Fallback: Extract traditional OAuth code and state + final code = queryParams['code']; + final state = queryParams['state']; + + if (code != null && state != null) { + return OidcCallbackResult.success( + code: code, + state: state, + ); + } + + return OidcCallbackResult.error( + error: 'missing_parameters', + errorDescription: 'Neither API key nor authorization code found in callback', + ); + } catch (e) { + return OidcCallbackResult.error( + error: 'parse_error', + errorDescription: e.toString(), + ); + } + } + + /// Complete OIDC authentication by verifying with server + static Future completeAuthentication({ + required String serverUrl, + required String code, + required String state, + OidcPkce? pkce, + }) async { + try { + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/auth/oidc_complete'); + + final requestBody = { + 'code': code, + 'state': state, + }; + + // Add PKCE verifier if provided + if (pkce != null) { + requestBody['code_verifier'] = pkce.codeVerifier; + } + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }, + body: jsonEncode(requestBody), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return OidcAuthResult.success( + apiKey: data['api_key'], + userId: data['user_id'], + serverUrl: normalizedUrl, + ); + } else { + final errorData = jsonDecode(response.body); + return OidcAuthResult.failure( + errorData['error'] ?? 'Authentication failed', + ); + } + } catch (e) { + return OidcAuthResult.failure('Network error: ${e.toString()}'); + } + } + + /// Generate secure random code verifier + static String _generateCodeVerifier() { + final random = Random.secure(); + // Generate 32 random bytes (256 bits) which will create a ~43 character base64url string + final bytes = List.generate(32, (i) => random.nextInt(256)); + // Use base64url encoding (- and _ instead of + and /) and remove padding + return base64UrlEncode(bytes).replaceAll('=', ''); + } + + /// Generate code challenge from verifier using SHA256 + static String _generateCodeChallenge(String codeVerifier) { + final bytes = utf8.encode(codeVerifier); + final digest = sha256.convert(bytes); + return base64UrlEncode(digest.bytes) + .replaceAll('=', '') + .replaceAll('+', '-') + .replaceAll('/', '_'); + } +} + +/// OIDC Provider model +class OidcProvider { + final int providerId; + final String providerName; + final String clientId; + final String authorizationUrl; + final String scope; + final String? buttonColor; + final String? buttonText; + final String? buttonTextColor; + final String? iconSvg; + + OidcProvider({ + required this.providerId, + required this.providerName, + required this.clientId, + required this.authorizationUrl, + required this.scope, + this.buttonColor, + this.buttonText, + this.buttonTextColor, + this.iconSvg, + }); + + factory OidcProvider.fromJson(Map json) { + return OidcProvider( + providerId: json['provider_id'], + providerName: json['provider_name'], + clientId: json['client_id'], + authorizationUrl: json['authorization_url'], + scope: json['scope'], + buttonColor: json['button_color'], + buttonText: json['button_text'], + buttonTextColor: json['button_text_color'], + iconSvg: json['icon_svg'], + ); + } + + /// Get display text for the provider button + String get displayText => buttonText ?? 'Login with $providerName'; + + /// Get button color or default + String get buttonColorHex => buttonColor ?? '#007bff'; + + /// Get button text color or default + String get buttonTextColorHex => buttonTextColor ?? '#ffffff'; +} + +/// PKCE (Proof Key for Code Exchange) parameters +class OidcPkce { + final String codeVerifier; + final String codeChallenge; + + OidcPkce({ + required this.codeVerifier, + required this.codeChallenge, + }); +} + +/// OIDC callback parsing result +class OidcCallbackResult { + final bool isSuccess; + final String? code; + final String? state; + final String? apiKey; + final String? error; + final String? errorDescription; + + OidcCallbackResult._({ + required this.isSuccess, + this.code, + this.state, + this.apiKey, + this.error, + this.errorDescription, + }); + + factory OidcCallbackResult.success({ + String? code, + String? state, + String? apiKey, + }) { + return OidcCallbackResult._( + isSuccess: true, + code: code, + state: state, + apiKey: apiKey, + ); + } + + factory OidcCallbackResult.error({ + required String error, + String? errorDescription, + }) { + return OidcCallbackResult._( + isSuccess: false, + error: error, + errorDescription: errorDescription, + ); + } + + bool get hasApiKey => apiKey != null && apiKey!.isNotEmpty; + bool get hasCode => code != null && code!.isNotEmpty; +} + +/// OIDC authentication completion result +class OidcAuthResult { + final bool isSuccess; + final String? apiKey; + final int? userId; + final String? serverUrl; + final String? errorMessage; + + OidcAuthResult._({ + required this.isSuccess, + this.apiKey, + this.userId, + this.serverUrl, + this.errorMessage, + }); + + factory OidcAuthResult.success({ + required String apiKey, + required int userId, + required String serverUrl, + }) { + return OidcAuthResult._( + isSuccess: true, + apiKey: apiKey, + userId: userId, + serverUrl: serverUrl, + ); + } + + factory OidcAuthResult.failure(String errorMessage) { + return OidcAuthResult._( + isSuccess: false, + errorMessage: errorMessage, + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_audio_service.dart b/PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_audio_service.dart new file mode 100644 index 0000000..f3f5690 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_audio_service.dart @@ -0,0 +1,504 @@ +// lib/services/pinepods/pinepods_audio_service.dart + +import 'dart:async'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/chapter.dart'; +import 'package:pinepods_mobile/entities/person.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:logging/logging.dart'; + +class PinepodsAudioService { + final log = Logger('PinepodsAudioService'); + final AudioPlayerService _audioPlayerService; + final PinepodsService _pinepodsService; + final SettingsBloc _settingsBloc; + + Timer? _episodeUpdateTimer; + Timer? _userStatsTimer; + int? _currentEpisodeId; + int? _currentUserId; + bool _isYoutube = false; + double _lastRecordedPosition = 0; + + /// Callbacks for pause/stop events + Function()? _onPauseCallback; + Function()? _onStopCallback; + + PinepodsAudioService( + this._audioPlayerService, + this._pinepodsService, + this._settingsBloc, { + Function()? onPauseCallback, + Function()? onStopCallback, + }) : _onPauseCallback = onPauseCallback, + _onStopCallback = onStopCallback; + + /// Play a PinePods episode with full server integration + Future playPinepodsEpisode({ + required PinepodsEpisode pinepodsEpisode, + bool resume = true, + }) async { + try { + final settings = _settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + log.warning('No user ID found - cannot play episode with server tracking'); + return; + } + + _currentUserId = userId; + _isYoutube = pinepodsEpisode.isYoutube; + + log.info('Starting PinePods episode playback: ${pinepodsEpisode.episodeTitle}'); + + // Use the episode ID that's already available from the PinepodsEpisode + final episodeId = pinepodsEpisode.episodeId; + + if (episodeId == 0) { + log.warning('Episode ID is 0 - cannot track playback'); + return; + } + + _currentEpisodeId = episodeId; + + // Get podcast ID for settings + final podcastId = await _pinepodsService.getPodcastIdFromEpisode( + episodeId, + userId, + pinepodsEpisode.isYoutube, + ); + + // Get playback settings (speed, skip times) + final playDetails = await _pinepodsService.getPlayEpisodeDetails( + userId, + podcastId, + pinepodsEpisode.isYoutube, + ); + + // Fetch podcast 2.0 data including chapters + final podcast2Data = await _pinepodsService.fetchPodcasting2Data(episodeId, userId); + + // Convert PinepodsEpisode to Episode for the audio player + final episode = _convertToEpisode(pinepodsEpisode, playDetails, podcast2Data); + + // Set playback speed + await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed); + + // Start playing with the existing audio service + await _audioPlayerService.playEpisode(episode: episode, resume: resume); + + // Handle skip intro if enabled and episode just started + if (playDetails.startSkip > 0 && !resume) { + await Future.delayed(const Duration(milliseconds: 500)); // Wait for player to initialize + await _audioPlayerService.seek(position: playDetails.startSkip); + } + + // Add to history + log.info('Adding episode $episodeId to history for user $userId'); + final initialPosition = resume ? (pinepodsEpisode.listenDuration ?? 0).toDouble() : 0.0; + await _pinepodsService.recordListenDuration( + episodeId, + userId, + initialPosition, // Send seconds like web app does + pinepodsEpisode.isYoutube, + ); + + // Queue episode for tracking + log.info('Queueing episode $episodeId for user $userId'); + await _pinepodsService.queueEpisode( + episodeId, + userId, + pinepodsEpisode.isYoutube, + ); + + // Increment played count + log.info('Incrementing played count for user $userId'); + await _pinepodsService.incrementPlayed(userId); + + // Start periodic updates + _startPeriodicUpdates(); + + log.info('PinePods episode playback started successfully'); + } catch (e) { + log.severe('Error playing PinePods episode: $e'); + rethrow; + } + } + + /// Start periodic updates to server + void _startPeriodicUpdates() { + _stopPeriodicUpdates(); // Clean up any existing timers + + log.info('Starting periodic updates - episode position every 15s, user stats every 60s'); + + // Episode position updates every 15 seconds (more frequent for reliability) + _episodeUpdateTimer = Timer.periodic( + const Duration(seconds: 15), + (_) => _safeUpdateEpisodePosition(), + ); + + // User listen time updates every 60 seconds + _userStatsTimer = Timer.periodic( + const Duration(seconds: 60), + (_) => _safeUpdateUserListenTime(), + ); + } + + /// Safely update episode position without affecting playback + void _safeUpdateEpisodePosition() async { + try { + await _updateEpisodePosition(); + } catch (e) { + log.warning('Periodic sync completely failed but playback continues: $e'); + // Completely isolate any network failures from affecting playback + } + } + + /// Update episode position on server + Future _updateEpisodePosition() async { + // Updating episode position + if (_currentEpisodeId == null || _currentUserId == null) { + log.warning('Skipping scheduled sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)'); + return; + } + + try { + final positionState = _audioPlayerService.playPosition?.value; + if (positionState == null) return; + + final currentPosition = positionState.position.inSeconds.toDouble(); + + // Only update if position has changed by more than 2 seconds (more responsive) + if ((currentPosition - _lastRecordedPosition).abs() > 2) { + // Convert seconds to minutes for the API + final currentPositionMinutes = currentPosition / 60.0; + // Position changed, syncing to server + + await _pinepodsService.recordListenDuration( + _currentEpisodeId!, + _currentUserId!, + currentPosition, // Send seconds like web app does + _isYoutube, + ); + + _lastRecordedPosition = currentPosition; + // Sync completed successfully + } + } catch (e) { + log.warning('Failed to update episode position: $e'); + } + } + + /// Safely update user listen time without affecting playback + void _safeUpdateUserListenTime() async { + try { + await _updateUserListenTime(); + } catch (e) { + log.warning('User stats sync completely failed but playback continues: $e'); + // Completely isolate any network failures from affecting playback + } + } + + /// Update user listen time statistics + Future _updateUserListenTime() async { + if (_currentUserId == null) return; + + try { + await _pinepodsService.incrementListenTime(_currentUserId!); + // User listen time updated + } catch (e) { + log.warning('Failed to update user listen time: $e'); + } + } + + /// Sync current position to server immediately (for pause/stop events) + Future syncCurrentPositionToServer() async { + // Syncing current position to server + + if (_currentEpisodeId == null || _currentUserId == null) { + log.warning('Cannot sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)'); + return; + } + + try { + final positionState = _audioPlayerService.playPosition?.value; + if (positionState == null) { + log.warning('Cannot sync - positionState is null'); + return; + } + + final currentPosition = positionState.position.inSeconds.toDouble(); + + log.info('Syncing position to server: ${currentPosition}s for episode $_currentEpisodeId'); + + await _pinepodsService.recordListenDuration( + _currentEpisodeId!, + _currentUserId!, + currentPosition, // Send seconds like web app does + _isYoutube, + ); + + _lastRecordedPosition = currentPosition; + log.info('Successfully synced position to server: ${currentPosition}s'); + } catch (e) { + log.warning('Failed to sync position to server: $e'); + log.warning('Stack trace: ${StackTrace.current}'); + } + } + + /// Get server position for current episode + Future getServerPosition() async { + if (_currentEpisodeId == null || _currentUserId == null) return null; + + try { + final episodeMetadata = await _pinepodsService.getEpisodeMetadata( + _currentEpisodeId!, + _currentUserId!, + isYoutube: _isYoutube, + ); + + return episodeMetadata?.listenDuration?.toDouble(); + } catch (e) { + log.warning('Failed to get server position: $e'); + return null; + } + } + + /// Get server position for any episode + Future getServerPositionForEpisode(int episodeId, int userId, bool isYoutube) async { + try { + final episodeMetadata = await _pinepodsService.getEpisodeMetadata( + episodeId, + userId, + isYoutube: isYoutube, + ); + + return episodeMetadata?.listenDuration?.toDouble(); + } catch (e) { + log.warning('Failed to get server position for episode $episodeId: $e'); + return null; + } + } + + /// Record listen duration when episode ends or is stopped + Future recordListenDuration(double listenDuration) async { + if (_currentEpisodeId == null || _currentUserId == null) return; + + try { + await _pinepodsService.recordListenDuration( + _currentEpisodeId!, + _currentUserId!, + listenDuration, + _isYoutube, + ); + log.info('Recorded listen duration: ${listenDuration}s'); + } catch (e) { + log.warning('Failed to record listen duration: $e'); + } + } + + /// Handle pause event - sync position to server + Future onPause() async { + try { + await syncCurrentPositionToServer(); + log.info('Pause event handled - position synced to server'); + } catch (e) { + log.warning('Pause sync failed but pause succeeded: $e'); + } + _onPauseCallback?.call(); + } + + /// Handle stop event - sync position to server + Future onStop() async { + try { + await syncCurrentPositionToServer(); + log.info('Stop event handled - position synced to server'); + } catch (e) { + log.warning('Stop sync failed but stop succeeded: $e'); + } + _onStopCallback?.call(); + } + + /// Stop periodic updates + void _stopPeriodicUpdates() { + _episodeUpdateTimer?.cancel(); + _userStatsTimer?.cancel(); + _episodeUpdateTimer = null; + _userStatsTimer = null; + } + + /// Convert PinepodsEpisode to Episode for the audio player + Episode _convertToEpisode(PinepodsEpisode pinepodsEpisode, PlayEpisodeDetails playDetails, Map? podcast2Data) { + // Determine the content URL + String contentUrl; + if (pinepodsEpisode.downloaded && _currentEpisodeId != null && _currentUserId != null) { + // Use stream URL for local episodes + contentUrl = _pinepodsService.getStreamUrl( + _currentEpisodeId!, + _currentUserId!, + isYoutube: pinepodsEpisode.isYoutube, + isLocal: true, + ); + } else if (pinepodsEpisode.isYoutube && _currentEpisodeId != null && _currentUserId != null) { + // Use stream URL for YouTube episodes + contentUrl = _pinepodsService.getStreamUrl( + _currentEpisodeId!, + _currentUserId!, + isYoutube: true, + isLocal: false, + ); + } else { + // Use original URL for external episodes + contentUrl = pinepodsEpisode.episodeUrl; + } + + // Process podcast 2.0 data + List chapters = []; + List persons = []; + List transcriptUrls = []; + String? chaptersUrl; + + if (podcast2Data != null) { + // Extract chapters data + final chaptersData = podcast2Data['chapters'] as List?; + if (chaptersData != null) { + try { + chapters = chaptersData.map((chapterData) { + return Chapter( + title: chapterData['title'] ?? '', + startTime: _parseDouble(chapterData['startTime'] ?? chapterData['start_time'] ?? 0) ?? 0.0, + endTime: _parseDouble(chapterData['endTime'] ?? chapterData['end_time']), + imageUrl: chapterData['img'] ?? chapterData['image'], + url: chapterData['url'], + toc: chapterData['toc'] ?? true, + ); + }).toList(); + + log.info('Loaded ${chapters.length} chapters from podcast 2.0 data'); + } catch (e) { + log.warning('Error parsing chapters from podcast 2.0 data: $e'); + } + } + + // Extract chapters URL if available + chaptersUrl = podcast2Data['chapters_url']; + + // Extract persons data + final personsData = podcast2Data['people'] as List?; + if (personsData != null) { + try { + persons = personsData.map((personData) { + return Person( + name: personData['name'] ?? '', + role: personData['role'] ?? '', + group: personData['group'] ?? '', + image: personData['img'], + link: personData['href'], + ); + }).toList(); + + log.info('Loaded ${persons.length} persons from podcast 2.0 data'); + } catch (e) { + log.warning('Error parsing persons from podcast 2.0 data: $e'); + } + } + + // Extract transcript data + final transcriptsData = podcast2Data['transcripts'] as List?; + if (transcriptsData != null) { + try { + transcriptUrls = transcriptsData.map((transcriptData) { + TranscriptFormat format = TranscriptFormat.unsupported; + + // Determine format from URL, mime_type, or type field + final url = transcriptData['url'] ?? ''; + final mimeType = transcriptData['mime_type'] ?? ''; + final type = transcriptData['type'] ?? ''; + + // Processing transcript + + if (url.toLowerCase().contains('.json') || + mimeType.toLowerCase().contains('json') || + type.toLowerCase().contains('json')) { + format = TranscriptFormat.json; + // Detected JSON transcript + } else if (url.toLowerCase().contains('.srt') || + mimeType.toLowerCase().contains('srt') || + type.toLowerCase().contains('srt') || + type.toLowerCase().contains('subrip') || + url.toLowerCase().contains('subrip')) { + format = TranscriptFormat.subrip; + // Detected SubRip transcript + } else if (url.toLowerCase().contains('transcript') || + mimeType.toLowerCase().contains('html') || + type.toLowerCase().contains('html')) { + format = TranscriptFormat.html; + // Detected HTML transcript + } else { + log.warning('Transcript format not recognized: mimeType=$mimeType, type=$type'); + } + + return TranscriptUrl( + url: url, + type: format, + language: transcriptData['language'] ?? transcriptData['lang'] ?? 'en', + rel: transcriptData['rel'], + ); + }).toList(); + + log.info('Loaded ${transcriptUrls.length} transcript URLs from podcast 2.0 data'); + } catch (e) { + log.warning('Error parsing transcripts from podcast 2.0 data: $e'); + } + } + } + + return Episode( + guid: pinepodsEpisode.episodeUrl, + podcast: pinepodsEpisode.podcastName, + title: pinepodsEpisode.episodeTitle, + description: pinepodsEpisode.episodeDescription, + link: pinepodsEpisode.episodeUrl, + publicationDate: DateTime.tryParse(pinepodsEpisode.episodePubDate) ?? DateTime.now(), + author: '', + duration: (pinepodsEpisode.episodeDuration * 1000).round(), // Convert to milliseconds + contentUrl: contentUrl, + position: pinepodsEpisode.completed ? 0 : ((pinepodsEpisode.listenDuration ?? 0) * 1000).round(), // Convert to milliseconds, reset to 0 for completed episodes + imageUrl: pinepodsEpisode.episodeArtwork, + played: pinepodsEpisode.completed, + chapters: chapters, + chaptersUrl: chaptersUrl, + persons: persons, + transcriptUrls: transcriptUrls, + ); + } + + /// Helper method to safely parse double values + double? _parseDouble(dynamic value) { + if (value == null) return null; + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is String) { + try { + return double.parse(value); + } catch (e) { + log.warning('Failed to parse double from string: $value'); + return null; + } + } + return null; + } + + /// Clean up resources + void dispose() { + _stopPeriodicUpdates(); + _currentEpisodeId = null; + _currentUserId = null; + } +} + diff --git a/PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_service.dart b/PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_service.dart new file mode 100644 index 0000000..e5862bb --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_service.dart @@ -0,0 +1,2170 @@ +// Create this file at lib/services/pinepods/pinepods_service.dart + +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/entities/user_stats.dart'; +import 'package:pinepods_mobile/entities/home_data.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; + +class PinepodsService { + String? _server; + String? _apiKey; + + // Method to initialize with existing credentials + void initializeWithCredentials(String server, String apiKey) { + _server = server; + _apiKey = apiKey; + } + + String get apiKey => _apiKey ?? ''; + + Future verifyPinepodsInstance(String serverUrl) async { + // Normalize the URL by removing trailing slashes + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + final url = Uri.parse('$normalizedUrl/api/pinepods_check'); + + try { + final response = await http.get(url); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['pinepods_instance'] == true; + } + return false; + } catch (e) { + print('Error verifying PinePods instance: $e'); + return false; + } + } + + Future login(String serverUrl, String username, String password) async { + // Normalize the URL by removing trailing slashes + final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); + _server = normalizedUrl; + + // Create Basic Auth header + final credentials = base64Encode(utf8.encode('$username:$password')); + final authHeader = 'Basic $credentials'; + + final url = Uri.parse('$normalizedUrl/api/data/get_key'); + + try { + final response = await http.get( + url, + headers: {'Authorization': authHeader}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _apiKey = data['retrieved_key']; + + // Verify the API key + return await verifyApiKey(); + } + return false; + } catch (e) { + print('Login error: $e'); + return false; + } + } + + Future verifyApiKey() async { + if (_server == null || _apiKey == null) { + return false; + } + + final url = Uri.parse('$_server/api/data/verify_key'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['status'] == 'success'; + } + return false; + } catch (e) { + print('Error verifying API key: $e'); + return false; + } + } + + // Add method to fetch podcasts from PinePods + Future>> fetchPodcasts() async { + if (_server == null || _apiKey == null) { + return []; + } + + // This endpoint would need to be implemented in your PinePods backend + final url = Uri.parse('$_server/api/data/podcasts'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as List; + return data.cast>(); + } + return []; + } catch (e) { + print('Error fetching podcasts: $e'); + return []; + } + } + + // Get user's subscribed podcasts using return_pods endpoint + Future> getUserPodcasts(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/return_pods/$userId'); + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + // User podcasts API response received + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final List podsData = data['pods'] ?? []; + + List podcasts = []; + for (var podData in podsData) { + // Use episode count from server response + final episodeCount = podData['episodecount'] ?? 0; + + // Create placeholder episodes to represent the count + final placeholderEpisodes = List.generate( + episodeCount, + (index) => Episode( + guid: 'placeholder_$index', + podcast: podData['podcastname'] ?? '', + title: 'Episode ${index + 1}', + ), + ); + + podcasts.add( + Podcast( + id: podData['podcastid'], + title: podData['podcastname'] ?? '', + description: podData['description'] ?? '', + imageUrl: podData['artworkurl'] ?? '', + thumbImageUrl: podData['artworkurl'] ?? '', + url: podData['feedurl'] ?? '', + link: podData['websiteurl'] ?? '', + copyright: podData['author'] ?? '', + guid: podData['feedurl'] ?? '', + episodes: placeholderEpisodes, + ), + ); + } + + return podcasts; + } else { + throw Exception('Failed to get user podcasts: ${response.statusCode}'); + } + } catch (e) { + print('Error getting user podcasts: $e'); + rethrow; + } + } + + // Get recent episodes (last 30 days) + Future> getRecentEpisodes(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated - server or API key missing'); + } + + final url = Uri.parse('$_server/api/data/return_episodes/$userId'); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final responseText = response.body; + final data = jsonDecode(responseText); + + // Handle the response structure from the web implementation + if (data is Map && data['episodes'] != null) { + final episodesList = data['episodes'] as List; + return episodesList + .map((episode) => PinepodsEpisode.fromJson(episode)) + .toList(); + } else if (data is List) { + // Handle direct list response + return data + .map((episode) => PinepodsEpisode.fromJson(episode)) + .toList(); + } else { + return []; + } + } else { + throw Exception( + 'Failed to fetch recent episodes: ${response.statusCode} ${response.reasonPhrase}', + ); + } + } catch (e) { + print('Error fetching recent episodes: $e'); + throw Exception('Error fetching recent episodes: $e'); + } + } + + // Set credentials (used when user logs in) + void setCredentials(String server, String apiKey) { + _server = server.trim().replaceAll(RegExp(r'/$'), ''); + _apiKey = apiKey; + } + + // Check if user is authenticated + bool get isAuthenticated => _server != null && _apiKey != null; + + // Get server URL + String? get server => _server; + + // Check if episode exists in database + Future checkEpisodeInDb( + int userId, + String episodeTitle, + String episodeUrl, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/check_episode_in_db'); + + try { + final requestBody = jsonEncode({ + 'user_id': userId, + 'episode_title': episodeTitle, + 'episode_url': episodeUrl, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['exists'] == true; + } + return false; + } catch (e) { + print('Error checking episode in DB: $e'); + return false; + } + } + + // Get episode ID from title and URL + Future getEpisodeId( + int userId, + String episodeTitle, + String episodeUrl, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse( + '$_server/api/data/get_episode_id_ep_name?user_id=$userId&episode_url=${Uri.encodeComponent(episodeUrl)}&episode_title=${Uri.encodeComponent(episodeTitle)}&is_youtube=$isYoutube', + ); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + // Parse the response as a plain integer + final episodeId = int.tryParse(response.body.trim()) ?? 0; + return episodeId; + } + return 0; + } catch (e) { + print('Error getting episode ID: $e'); + return 0; + } + } + + // Add episode to history + Future addHistory( + int episodeId, + double episodePos, + int userId, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/record_podcast_history'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'episode_pos': episodePos, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + // History API response received + return response.statusCode == 200; + } catch (e) { + print('Error adding history: $e'); + return false; + } + } + + // Queue episode + Future queueEpisode(int episodeId, int userId, bool isYoutube) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/queue_pod'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + // Queue API response received + return response.statusCode == 200; + } catch (e) { + print('Error queueing episode: $e'); + return false; + } + } + + // Increment played count + Future incrementPlayed(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/increment_played/$userId'); + print('Making API call to: $url'); + + try { + final response = await http.put(url, headers: {'Api-Key': _apiKey!}); + + print( + 'Increment played response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error incrementing played: $e'); + return false; + } + } + + // Get podcast ID from episode + Future getPodcastIdFromEpisode( + int episodeId, + int userId, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse( + '$_server/api/data/get_podcast_id_from_ep/$episodeId', + ); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['podcast_id'] ?? 0; + } + return 0; + } catch (e) { + print('Error getting podcast ID: $e'); + return 0; + } + } + + // Get play episode details (playback speed, skip times) + Future getPlayEpisodeDetails( + int userId, + int podcastId, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_play_episode_details'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'user_id': userId, + 'podcast_id': podcastId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + print( + 'Play episode details response: ${response.statusCode} - ${response.body}', + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return PlayEpisodeDetails( + playbackSpeed: (data['playback_speed'] as num?)?.toDouble() ?? 1.0, + startSkip: data['start_skip'] ?? 0, + endSkip: data['end_skip'] ?? 0, + ); + } + return PlayEpisodeDetails(playbackSpeed: 1.0, startSkip: 0, endSkip: 0); + } catch (e) { + print('Error getting play episode details: $e'); + return PlayEpisodeDetails(playbackSpeed: 1.0, startSkip: 0, endSkip: 0); + } + } + + // Record listen duration for episode + Future recordListenDuration( + int episodeId, + int userId, + double listenDuration, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/record_listen_duration'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'listen_duration': listenDuration, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + print( + 'Record listen duration response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error recording listen duration: $e'); + return false; + } + } + + // Increment listen time for user stats + Future incrementListenTime(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/increment_listen_time/$userId'); + print('Making API call to: $url'); + + try { + final response = await http.put(url, headers: {'Api-Key': _apiKey!}); + + print( + 'Increment listen time response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error incrementing listen time: $e'); + return false; + } + } + + // Save episode + Future saveEpisode(int episodeId, int userId, bool isYoutube) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/save_episode'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + // Save episode API response received + return response.statusCode == 200; + } catch (e) { + print('Error saving episode: $e'); + return false; + } + } + + // Remove saved episode + Future removeSavedEpisode( + int episodeId, + int userId, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/remove_saved_episode'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + print( + 'Remove saved episode response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error removing saved episode: $e'); + return false; + } + } + + // Download episode to server + Future downloadEpisode( + int episodeId, + int userId, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/download_podcast'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + print( + 'Download episode response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error downloading episode: $e'); + return false; + } + } + + // Delete downloaded episode from server + Future deleteEpisode(int episodeId, int userId, bool isYoutube) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/delete_episode'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + print( + 'Delete episode response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error deleting episode: $e'); + return false; + } + } + + // Mark episode as completed + Future markEpisodeCompleted( + int episodeId, + int userId, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/mark_episode_completed'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + print( + 'Mark completed response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error marking episode completed: $e'); + return false; + } + } + + // Mark episode as uncompleted + Future markEpisodeUncompleted( + int episodeId, + int userId, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/mark_episode_uncompleted'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + print( + 'Mark uncompleted response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error marking episode uncompleted: $e'); + return false; + } + } + + // Remove episode from queue + Future removeQueuedEpisode( + int episodeId, + int userId, + bool isYoutube, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/remove_queued_pod'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + print( + 'Remove queued response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error removing queued episode: $e'); + return false; + } + } + + // Get user history + Future> getUserHistory(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/user_history/$userId'); + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + // User history API response received + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final episodesList = data['data'] as List? ?? []; + + return episodesList.map((episodeData) { + return PinepodsEpisode( + podcastName: episodeData['podcastname'] ?? '', + episodeTitle: episodeData['episodetitle'] ?? '', + episodePubDate: episodeData['episodepubdate'] ?? '', + episodeDescription: episodeData['episodedescription'] ?? '', + episodeArtwork: episodeData['episodeartwork'] ?? '', + episodeUrl: episodeData['episodeurl'] ?? '', + episodeDuration: episodeData['episodeduration'] ?? 0, + listenDuration: episodeData['listenduration'] ?? 0, + episodeId: episodeData['episodeid'] ?? 0, + completed: episodeData['completed'] ?? false, + saved: episodeData['saved'] ?? false, + queued: episodeData['queued'] ?? false, + downloaded: episodeData['downloaded'] ?? false, + isYoutube: episodeData['is_youtube'] ?? false, + ); + }).toList(); + } else { + throw Exception('Failed to load user history: ${response.statusCode}'); + } + } catch (e) { + print('Error getting user history: $e'); + rethrow; + } + } + + // Get queued episodes + Future> getQueuedEpisodes(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse( + '$_server/api/data/get_queued_episodes?user_id=$userId', + ); + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + print( + 'Queued episodes response: ${response.statusCode} - ${response.body}', + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final episodesList = data['data'] as List? ?? []; + + return episodesList.map((episodeData) { + return PinepodsEpisode( + podcastName: episodeData['podcastname'] ?? '', + episodeTitle: episodeData['episodetitle'] ?? '', + episodePubDate: episodeData['episodepubdate'] ?? '', + episodeDescription: episodeData['episodedescription'] ?? '', + episodeArtwork: episodeData['episodeartwork'] ?? '', + episodeUrl: episodeData['episodeurl'] ?? '', + episodeDuration: episodeData['episodeduration'] ?? 0, + listenDuration: episodeData['listenduration'] ?? 0, + episodeId: episodeData['episodeid'] ?? 0, + completed: episodeData['completed'] ?? false, + saved: episodeData['saved'] ?? false, + queued: + episodeData['queued'] ?? + true, // Should always be true for queued episodes + downloaded: episodeData['downloaded'] ?? false, + isYoutube: episodeData['is_youtube'] ?? false, + ); + }).toList(); + } else { + throw Exception( + 'Failed to load queued episodes: ${response.statusCode}', + ); + } + } catch (e) { + print('Error getting queued episodes: $e'); + rethrow; + } + } + + // Get saved episodes + Future> getSavedEpisodes(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/saved_episode_list/$userId'); + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + // Saved episodes API response received + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final episodesList = data['saved_episodes'] as List? ?? []; + + return episodesList.map((episodeData) { + return PinepodsEpisode( + podcastName: episodeData['podcastname'] ?? '', + episodeTitle: episodeData['episodetitle'] ?? '', + episodePubDate: episodeData['episodepubdate'] ?? '', + episodeDescription: episodeData['episodedescription'] ?? '', + episodeArtwork: episodeData['episodeartwork'] ?? '', + episodeUrl: episodeData['episodeurl'] ?? '', + episodeDuration: episodeData['episodeduration'] ?? 0, + listenDuration: episodeData['listenduration'] ?? 0, + episodeId: episodeData['episodeid'] ?? 0, + completed: episodeData['completed'] ?? false, + saved: + episodeData['saved'] ?? + true, // Should always be true for saved episodes + queued: episodeData['queued'] ?? false, + downloaded: episodeData['downloaded'] ?? false, + isYoutube: episodeData['is_youtube'] ?? false, + ); + }).toList(); + } else { + throw Exception( + 'Failed to load saved episodes: ${response.statusCode}', + ); + } + } catch (e) { + print('Error getting saved episodes: $e'); + rethrow; + } + } + + // Get episode metadata + Future getEpisodeMetadata( + int episodeId, + int userId, { + bool isYoutube = false, + bool personEpisode = false, + }) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_episode_metadata'); + + try { + final requestBody = jsonEncode({ + 'episode_id': episodeId, + 'user_id': userId, + 'person_episode': personEpisode, + 'is_youtube': isYoutube, + }); + + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final episodeData = data['episode']; + + return PinepodsEpisode( + podcastName: episodeData['podcastname'] ?? '', + episodeTitle: episodeData['episodetitle'] ?? '', + episodePubDate: episodeData['episodepubdate'] ?? '', + episodeDescription: episodeData['episodedescription'] ?? '', + episodeArtwork: episodeData['episodeartwork'] ?? '', + episodeUrl: episodeData['episodeurl'] ?? '', + episodeDuration: episodeData['episodeduration'] ?? 0, + listenDuration: episodeData['listenduration'] ?? 0, + episodeId: episodeData['episodeid'] ?? episodeId, + completed: episodeData['completed'] ?? false, + saved: episodeData['is_saved'] ?? false, + queued: episodeData['is_queued'] ?? false, + downloaded: episodeData['is_downloaded'] ?? false, + isYoutube: episodeData['is_youtube'] ?? isYoutube, + podcastId: episodeData['podcastid'], + ); + } + return null; + } catch (e) { + print('Error getting episode metadata: $e'); + return null; + } + } + + // Get downloaded episodes from server + Future> getServerDownloads(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse( + '$_server/api/data/download_episode_list?user_id=$userId', + ); + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final episodesList = + data['downloaded_episodes'] as List? ?? []; + + return episodesList.map((episodeData) { + return PinepodsEpisode( + podcastName: episodeData['podcastname'] ?? '', + episodeTitle: episodeData['episodetitle'] ?? '', + episodePubDate: episodeData['episodepubdate'] ?? '', + episodeDescription: episodeData['episodedescription'] ?? '', + episodeArtwork: episodeData['episodeartwork'] ?? '', + episodeUrl: episodeData['episodeurl'] ?? '', + episodeDuration: episodeData['episodeduration'] ?? 0, + listenDuration: episodeData['listenduration'] ?? 0, + episodeId: episodeData['episodeid'] ?? 0, + completed: episodeData['completed'] ?? false, + saved: episodeData['saved'] ?? false, + queued: episodeData['queued'] ?? false, + downloaded: + episodeData['downloaded'] ?? + true, // Should always be true for downloaded episodes + isYoutube: episodeData['is_youtube'] ?? false, + ); + }).toList(); + } else { + throw Exception( + 'Failed to load server downloads: ${response.statusCode}', + ); + } + } catch (e) { + print('Error getting server downloads: $e'); + rethrow; + } + } + + // Get stream URL for episode + String getStreamUrl( + int episodeId, + int userId, { + bool isYoutube = false, + bool isLocal = false, + }) { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + if (isYoutube) { + return '$_server/api/data/stream/$episodeId?api_key=$_apiKey&user_id=$userId&type=youtube'; + } else if (isLocal) { + return '$_server/api/data/stream/$episodeId?api_key=$_apiKey&user_id=$userId'; + } else { + // For external episodes, return the original URL + return ''; + } + } + + // Search for podcasts using PinePods search API + Future searchPodcasts( + String query, + SearchProvider provider, + ) async { + const searchApiUrl = 'https://search.pinepods.online'; + final url = Uri.parse( + '$searchApiUrl/api/search?query=${Uri.encodeComponent(query)}&index=${provider.value}', + ); + + try { + print('Making search request to: $url'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + // Search API response received + return PinepodsSearchResult.fromJson(data); + } else { + throw Exception('Failed to search podcasts: ${response.statusCode}'); + } + } catch (e) { + print('Error searching podcasts: $e'); + rethrow; + } + } + + // Check if a podcast is already added to the server + Future checkPodcastExists( + String podcastTitle, + String podcastUrl, + int userId, + ) async { + if (_server == null || _apiKey == null) { + return false; + } + + final url = Uri.parse('$_server/api/data/check_podcast').replace( + queryParameters: { + 'user_id': userId.toString(), + 'podcast_name': podcastTitle, + 'podcast_url': podcastUrl, + }, + ); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['exists'] == true; + } + return false; + } catch (e) { + print('Error checking podcast exists: $e'); + return false; + } + } + + // Add a podcast to the server + Future addPodcast(UnifiedPinepodsPodcast podcast, int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/add_podcast'); + final body = { + 'podcast_values': { + 'pod_title': podcast.title, + 'pod_artwork': podcast.artwork, + 'pod_author': podcast.author, + 'categories': podcast.categories ?? {}, + 'pod_description': podcast.description, + 'pod_episode_count': podcast.episodeCount, + 'pod_feed_url': podcast.url, + 'pod_website': podcast.link, + 'pod_explicit': podcast.explicit, + 'user_id': userId, + }, + 'podcast_index_id': podcast.indexId, + }; + + try { + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['success'] == true; + } + return false; + } catch (e) { + print('Error adding podcast: $e'); + rethrow; + } + } + + // Remove a podcast from the server + Future removePodcast( + String podcastTitle, + String podcastUrl, + int userId, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/remove_podcast'); + final body = { + 'podcast_name': podcastTitle, + 'podcast_url': podcastUrl, + 'user_id': userId, + }; + + try { + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['success'] == true; + } + return false; + } catch (e) { + print('Error removing podcast: $e'); + rethrow; + } + } + + // Get podcast details dynamically (whether added or not) + Future getPodcastDetailsDynamic({ + required int userId, + required String podcastTitle, + required String podcastUrl, + required int podcastIndexId, + required bool added, + bool displayOnly = false, + }) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_podcast_details_dynamic') + .replace( + queryParameters: { + 'user_id': userId.toString(), + 'podcast_title': podcastTitle, + 'podcast_url': podcastUrl, + 'podcast_index_id': podcastIndexId.toString(), + 'added': added.toString(), + 'display_only': displayOnly.toString(), + }, + ); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + // Podcast details API response received + return PodcastDetailsData.fromJson(data); + } else { + throw Exception( + 'Failed to get podcast details: ${response.statusCode}', + ); + } + } catch (e) { + print('Error getting podcast details: $e'); + rethrow; + } + } + + // Get podcast details by podcast ID (for subscribed podcasts) + Future?> getPodcastDetailsById( + int podcastId, + int userId, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_podcast_details').replace( + queryParameters: { + 'podcast_id': podcastId.toString(), + 'user_id': userId.toString(), + }, + ); + + try { + print('Getting podcast details by ID from: $url'); + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + // Podcast details by ID API response received + return data['details']; + } else { + throw Exception( + 'Failed to get podcast details: ${response.statusCode}', + ); + } + } catch (e) { + print('Error getting podcast details by ID: $e'); + rethrow; + } + } + + // Get podcast ID by feed URL and title + Future getPodcastId( + int userId, + String podcastFeed, + String podcastTitle, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_podcast_id').replace( + queryParameters: { + 'user_id': userId.toString(), + 'podcast_feed': podcastFeed, + 'podcast_title': podcastTitle, + }, + ); + + try { + print('Getting podcast ID from: $url'); + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + // Podcast ID API response received + final podcastId = data['podcast_id']; + if (podcastId is int) { + return podcastId; + } + return null; + } else { + throw Exception('Failed to get podcast ID: ${response.statusCode}'); + } + } catch (e) { + print('Error getting podcast ID: $e'); + return null; + } + } + + // Get episodes for an added podcast + Future> getPodcastEpisodes( + int userId, + int podcastId, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/podcast_episodes').replace( + queryParameters: { + 'user_id': userId.toString(), + 'podcast_id': podcastId.toString(), + }, + ); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final episodes = data['episodes'] as List; + return episodes.map((episodeData) { + // Add default values only for fields not provided by this endpoint + final episodeWithDefaults = Map.from(episodeData); + + // Only add defaults if these fields are not present in the API response + episodeWithDefaults['saved'] ??= false; + episodeWithDefaults['queued'] ??= false; + episodeWithDefaults['downloaded'] ??= false; + episodeWithDefaults['is_youtube'] ??= false; + + return PinepodsEpisode.fromJson(episodeWithDefaults); + }).toList(); + } else { + throw Exception( + 'Failed to get podcast episodes: ${response.statusCode}', + ); + } + } catch (e) { + print('Error getting podcast episodes: $e'); + rethrow; + } + } + + // Get user statistics + Future getUserStats(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse( + '$_server/api/data/get_stats', + ).replace(queryParameters: {'user_id': userId.toString()}); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return UserStats.fromJson(data); + } else { + throw Exception('Failed to get user stats: ${response.statusCode}'); + } + } catch (e) { + print('Error getting user stats: $e'); + rethrow; + } + } + + // Get PinePods version + Future getPinepodsVersion() async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_pinepods_version'); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['data'] ?? 'Unknown'; + } else { + throw Exception( + 'Failed to get PinePods version: ${response.statusCode}', + ); + } + } catch (e) { + print('Error getting PinePods version: $e'); + return 'Unknown'; + } + } + + // Get user details by user ID + Future?> getUserDetails(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/user_details_id/$userId'); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data; + } else { + throw Exception('Failed to get user details: ${response.statusCode}'); + } + } catch (e) { + print('Error getting user details: $e'); + return null; + } + } + + // Get user ID from API key + Future getUserIdFromApiKey() async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/id_from_api_key'); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final userId = int.tryParse(response.body.trim()); + return userId; + } else { + throw Exception('Failed to get user ID: ${response.statusCode}'); + } + } catch (e) { + print('Error getting user ID: $e'); + return null; + } + } + + // Get home overview data + Future getHomeOverview(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/home_overview?user_id=$userId'); + print('Making API call to: $url'); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return HomeOverview.fromJson(data); + } else { + throw Exception('Failed to load home overview: ${response.statusCode}'); + } + } catch (e) { + print('Error getting home overview: $e'); + rethrow; + } + } + + // Get playlists + Future getPlaylists(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_playlists?user_id=$userId'); + print('Making API call to: $url'); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + // Playlists API response received + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return PlaylistResponse.fromJson(data); + } else { + throw Exception('Failed to load playlists: ${response.statusCode}'); + } + } catch (e) { + print('Error getting playlists: $e'); + rethrow; + } + } + + // Get user theme + Future getUserTheme(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_theme/$userId'); + print('Making API call to: $url'); + + try { + final response = await http.get( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + ); + + // Theme API response received + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['theme'] as String?; + } else { + throw Exception('Failed to get user theme: ${response.statusCode}'); + } + } catch (e) { + print('Error getting user theme: $e'); + return null; + } + } + + // Set user theme + Future setUserTheme(int userId, String theme) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/user/set_theme'); + print('Making API call to: $url'); + + try { + final requestBody = jsonEncode({'user_id': userId, 'new_theme': theme}); + + final response = await http.put( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: requestBody, + ); + + // Set theme API response received + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['message'] != null; + } else { + throw Exception('Failed to set user theme: ${response.statusCode}'); + } + } catch (e) { + print('Error setting user theme: $e'); + return false; + } + } + + // Get user playlists + Future> getUserPlaylists(int userId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/get_playlists?user_id=$userId'); + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + print( + 'Get playlists response: ${response.statusCode} - ${response.body}', + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final List playlistsData = data['playlists'] ?? []; + + List playlists = []; + for (var playlistData in playlistsData) { + playlists.add(PlaylistData.fromJson(playlistData)); + } + + return playlists; + } else { + throw Exception('Failed to get playlists: ${response.statusCode}'); + } + } catch (e) { + print('Error getting playlists: $e'); + rethrow; + } + } + + // Create playlist + Future createPlaylist(CreatePlaylistRequest request) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/create_playlist'); + print('Making API call to: $url'); + + try { + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: jsonEncode(request.toJson()), + ); + + print( + 'Create playlist response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error creating playlist: $e'); + return false; + } + } + + // Delete playlist + Future deletePlaylist(int userId, int playlistId) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/delete_playlist'); + print('Making API call to: $url'); + + try { + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: jsonEncode({'user_id': userId, 'playlist_id': playlistId}), + ); + + print( + 'Delete playlist response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error deleting playlist: $e'); + return false; + } + } + + // Get playlist episodes + Future getPlaylistEpisodes( + int userId, + int playlistId, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse( + '$_server/api/data/get_playlist_episodes?user_id=$userId&playlist_id=$playlistId', + ); + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + print( + 'Get playlist episodes response: ${response.statusCode} - ${response.body}', + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return PlaylistEpisodesResponse.fromJson(data); + } else { + throw Exception( + 'Failed to get playlist episodes: ${response.statusCode}', + ); + } + } catch (e) { + print('Error getting playlist episodes: $e'); + rethrow; + } + } + + // Reorder queue episodes + Future reorderQueue(int userId, List episodeIds) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/reorder_queue?user_id=$userId'); + print('Making API call to: $url'); + + try { + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: jsonEncode({'episode_ids': episodeIds}), + ); + + print( + 'Reorder queue response: ${response.statusCode} - ${response.body}', + ); + return response.statusCode == 200; + } catch (e) { + print('Error reordering queue: $e'); + return false; + } + } + + // Search episodes in user's subscriptions + Future> searchEpisodes( + int userId, + String searchTerm, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/search_data'); + print('Making API call to: $url'); + + try { + final response = await http.post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: jsonEncode({'search_term': searchTerm, 'user_id': userId}), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final List episodesData = data['data'] ?? []; + + List episodes = []; + for (var episodeData in episodesData) { + episodes.add(SearchEpisodeResult.fromJson(episodeData)); + } + + return episodes; + } else { + throw Exception('Failed to search episodes: ${response.statusCode}'); + } + } catch (e) { + print('Error searching episodes: $e'); + rethrow; + } + } + + // Fetch podcast 2.0 data for a specific episode + Future?> fetchPodcasting2Data( + int episodeId, + int userId, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/fetch_podcasting_2_data').replace( + queryParameters: { + 'episode_id': episodeId.toString(), + 'user_id': userId.toString(), + }, + ); + + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + print( + 'Podcast 2.0 data response: ${response.statusCode} - ${response.body}', + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data; + } else { + print('Failed to fetch podcast 2.0 data: ${response.statusCode}'); + return null; + } + } catch (e) { + print('Error fetching podcast 2.0 data: $e'); + return null; + } + } + + // Fetch podcast 2.0 data for a specific podcast + Future?> fetchPodcasting2PodData( + int podcastId, + int userId, + ) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/fetch_podcasting_2_pod_data') + .replace( + queryParameters: { + 'podcast_id': podcastId.toString(), + 'user_id': userId.toString(), + }, + ); + + print('Making API call to: $url'); + + try { + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); + + print( + 'Podcast 2.0 pod data response: ${response.statusCode} - ${response.body}', + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data; + } else { + print('Failed to fetch podcast 2.0 pod data: ${response.statusCode}'); + return null; + } + } catch (e) { + print('Error fetching podcast 2.0 pod data: $e'); + return null; + } + } +} + +class PodcastDetailsData { + final int podcastId; + final String podcastName; + final String feedUrl; + final String description; + final String author; + final String artworkUrl; + final bool explicit; + final int episodeCount; + final Map? categories; + final String websiteUrl; + final int podcastIndexId; + final bool isYoutube; + + PodcastDetailsData({ + required this.podcastId, + required this.podcastName, + required this.feedUrl, + required this.description, + required this.author, + required this.artworkUrl, + required this.explicit, + required this.episodeCount, + this.categories, + required this.websiteUrl, + required this.podcastIndexId, + required this.isYoutube, + }); + + factory PodcastDetailsData.fromJson(Map json) { + return PodcastDetailsData( + podcastId: json['podcastid'] ?? 0, + podcastName: json['podcastname'] ?? '', + feedUrl: json['feedurl'] ?? '', + description: json['description'] ?? '', + author: json['author'] ?? '', + artworkUrl: json['artworkurl'] ?? '', + explicit: json['explicit'] ?? false, + episodeCount: json['episodecount'] ?? 0, + categories: json['categories'] != null + ? Map.from(json['categories'] as Map) + : null, + websiteUrl: json['websiteurl'] ?? '', + podcastIndexId: json['podcastindexid'] ?? 0, + isYoutube: json['is_youtube'] ?? false, + ); + } +} + +class PlayEpisodeDetails { + final double playbackSpeed; + final int startSkip; + final int endSkip; + + PlayEpisodeDetails({ + required this.playbackSpeed, + required this.startSkip, + required this.endSkip, + }); +} + +// Playlist Data Classes +class PlaylistData { + final int playlistId; + final int userId; + final String name; + final String? description; + final bool isSystemPlaylist; + final List? podcastIds; + final bool includeUnplayed; + final bool includePartiallyPlayed; + final bool includePlayed; + final int? minDuration; + final int? maxDuration; + final String sortOrder; + final bool groupByPodcast; + final int? maxEpisodes; + final String lastUpdated; + final String created; + final int? episodeCount; + final String? iconName; + + PlaylistData({ + required this.playlistId, + required this.userId, + required this.name, + this.description, + required this.isSystemPlaylist, + this.podcastIds, + required this.includeUnplayed, + required this.includePartiallyPlayed, + required this.includePlayed, + this.minDuration, + this.maxDuration, + required this.sortOrder, + required this.groupByPodcast, + this.maxEpisodes, + required this.lastUpdated, + required this.created, + this.episodeCount, + this.iconName, + }); + + factory PlaylistData.fromJson(Map json) { + return PlaylistData( + playlistId: json['playlist_id'] ?? 0, + userId: json['user_id'] ?? 0, + name: json['name'] ?? '', + description: json['description'], + isSystemPlaylist: json['is_system_playlist'] ?? false, + podcastIds: json['podcast_ids'] != null + ? List.from(json['podcast_ids']) + : null, + includeUnplayed: json['include_unplayed'] ?? true, + includePartiallyPlayed: json['include_partially_played'] ?? true, + includePlayed: json['include_played'] ?? false, + minDuration: json['min_duration'], + maxDuration: json['max_duration'], + sortOrder: json['sort_order'] ?? 'date_desc', + groupByPodcast: json['group_by_podcast'] ?? false, + maxEpisodes: json['max_episodes'], + lastUpdated: json['last_updated'] ?? '', + created: json['created'] ?? '', + episodeCount: json['episode_count'], + iconName: json['icon_name'], + ); + } +} + +class CreatePlaylistRequest { + final int userId; + final String name; + final String? description; + final List? podcastIds; + final bool includeUnplayed; + final bool includePartiallyPlayed; + final bool includePlayed; + final int? minDuration; + final int? maxDuration; + final String sortOrder; + final bool groupByPodcast; + final int? maxEpisodes; + final String iconName; + final double? playProgressMin; + final double? playProgressMax; + final int? timeFilterHours; + + CreatePlaylistRequest({ + required this.userId, + required this.name, + this.description, + this.podcastIds, + required this.includeUnplayed, + required this.includePartiallyPlayed, + required this.includePlayed, + this.minDuration, + this.maxDuration, + required this.sortOrder, + required this.groupByPodcast, + this.maxEpisodes, + required this.iconName, + this.playProgressMin, + this.playProgressMax, + this.timeFilterHours, + }); + + Map toJson() { + return { + 'user_id': userId, + 'name': name, + 'description': description, + 'podcast_ids': podcastIds, + 'include_unplayed': includeUnplayed, + 'include_partially_played': includePartiallyPlayed, + 'include_played': includePlayed, + 'min_duration': minDuration, + 'max_duration': maxDuration, + 'sort_order': sortOrder, + 'group_by_podcast': groupByPodcast, + 'max_episodes': maxEpisodes, + 'icon_name': iconName, + 'play_progress_min': playProgressMin, + 'play_progress_max': playProgressMax, + 'time_filter_hours': timeFilterHours, + }; + } +} + +class PlaylistEpisodesResponse { + final List episodes; + final PlaylistInfo playlistInfo; + + PlaylistEpisodesResponse({ + required this.episodes, + required this.playlistInfo, + }); + + factory PlaylistEpisodesResponse.fromJson(Map json) { + return PlaylistEpisodesResponse( + episodes: (json['episodes'] as List? ?? []) + .map((e) => PinepodsEpisode.fromJson(e)) + .toList(), + playlistInfo: PlaylistInfo.fromJson(json['playlist_info'] ?? {}), + ); + } +} + +class PlaylistInfo { + final String name; + final String? description; + final int? episodeCount; + final String? iconName; + + PlaylistInfo({ + required this.name, + this.description, + this.episodeCount, + this.iconName, + }); + + factory PlaylistInfo.fromJson(Map json) { + return PlaylistInfo( + name: json['name'] ?? '', + description: json['description'], + episodeCount: json['episode_count'], + iconName: json['icon_name'], + ); + } +} + +class SearchEpisodeResult { + final int podcastId; + final String podcastName; + final String artworkUrl; + final String author; + final String categories; + final String description; + final int? episodeCount; + final String feedUrl; + final String websiteUrl; + final bool explicit; + final int userId; + final int episodeId; + final String episodeTitle; + final String episodeDescription; + final String episodePubDate; + final String episodeArtwork; + final String episodeUrl; + final int episodeDuration; + final bool completed; + final bool saved; + final bool queued; + final bool downloaded; + final bool isYoutube; + final int? listenDuration; + + SearchEpisodeResult({ + required this.podcastId, + required this.podcastName, + required this.artworkUrl, + required this.author, + required this.categories, + required this.description, + this.episodeCount, + required this.feedUrl, + required this.websiteUrl, + required this.explicit, + required this.userId, + required this.episodeId, + required this.episodeTitle, + required this.episodeDescription, + required this.episodePubDate, + required this.episodeArtwork, + required this.episodeUrl, + required this.episodeDuration, + required this.completed, + required this.saved, + required this.queued, + required this.downloaded, + required this.isYoutube, + this.listenDuration, + }); + + factory SearchEpisodeResult.fromJson(Map json) { + return SearchEpisodeResult( + podcastId: json['podcastid'] ?? 0, + podcastName: json['podcastname'] ?? '', + artworkUrl: json['artworkurl'] ?? '', + author: json['author'] ?? '', + categories: _parseCategories(json['categories']), + description: json['description'] ?? '', + episodeCount: json['episodecount'], + feedUrl: json['feedurl'] ?? '', + websiteUrl: json['websiteurl'] ?? '', + explicit: (json['explicit'] ?? 0) == 1, + userId: json['userid'] ?? 0, + episodeId: json['episodeid'] ?? 0, + episodeTitle: json['episodetitle'] ?? '', + episodeDescription: json['episodedescription'] ?? '', + episodePubDate: json['episodepubdate'] ?? '', + episodeArtwork: json['episodeartwork'] ?? '', + episodeUrl: json['episodeurl'] ?? '', + episodeDuration: json['episodeduration'] ?? 0, + completed: json['completed'] ?? false, + saved: json['saved'] ?? false, + queued: json['queued'] ?? false, + downloaded: json['downloaded'] ?? false, + isYoutube: json['is_youtube'] ?? false, + listenDuration: json['listenduration'], + ); + } + + // Convert to PinepodsEpisode for compatibility with existing widgets + PinepodsEpisode toPinepodsEpisode() { + return PinepodsEpisode( + podcastName: podcastName, + episodeTitle: episodeTitle, + episodePubDate: episodePubDate, + episodeDescription: episodeDescription, + episodeArtwork: episodeArtwork.isNotEmpty ? episodeArtwork : artworkUrl, + episodeUrl: episodeUrl, + episodeDuration: episodeDuration, + listenDuration: listenDuration, + episodeId: episodeId, + completed: completed, + saved: saved, + queued: queued, + downloaded: downloaded, + isYoutube: isYoutube, + ); + } + + /// Parse categories from either string or Map format + static String _parseCategories(dynamic categories) { + if (categories == null) return ''; + + if (categories is String) { + // Old format - return as is + return categories; + } else if (categories is Map) { + // New format - convert map values to comma-separated string + if (categories.isEmpty) return ''; + return categories.values.join(', '); + } + + return ''; + } +} diff --git a/PinePods-0.8.2/mobile/lib/services/podcast/mobile_podcast_service.dart b/PinePods-0.8.2/mobile/lib/services/podcast/mobile_podcast_service.dart new file mode 100644 index 0000000..7ef6929 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/podcast/mobile_podcast_service.dart @@ -0,0 +1,808 @@ +// Copyright 2019 Ben Hills. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:collection'; +import 'dart:io'; + +import 'package:pinepods_mobile/api/podcast/podcast_api.dart'; +import 'package:pinepods_mobile/core/utils.dart'; +import 'package:pinepods_mobile/entities/chapter.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/funding.dart'; +import 'package:pinepods_mobile/entities/person.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:pinepods_mobile/l10n/messages_all.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/state/episode_state.dart'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart'; +import 'package:podcast_search/podcast_search.dart' as podcast_search; + +class MobilePodcastService extends PodcastService { + final descriptionRegExp1 = RegExp(r'(


|


|


|


)'); + final descriptionRegExp2 = RegExp(r'(


|


)'); + final log = Logger('MobilePodcastService'); + final _cache = _PodcastCache(maxItems: 10, expiration: const Duration(minutes: 30)); + var _categories = []; + var _intlCategories = []; + var _intlCategoriesSorted = []; + + MobilePodcastService({ + required super.api, + required super.repository, + required super.settingsService, + }) { + _init(); + } + + Future _init() async { + final List systemLocales = PlatformDispatcher.instance.locales; + + var currentLocale = Platform.localeName; + // Attempt to get current locale + var supportedLocale = await initializeMessages(Platform.localeName); + + // If we do not support the default, try all supported locales + if (!supportedLocale) { + for (var l in systemLocales) { + supportedLocale = await initializeMessages('${l.languageCode}_${l.countryCode}'); + if (supportedLocale) { + currentLocale = '${l.languageCode}_${l.countryCode}'; + break; + } + } + + if (!supportedLocale) { + // We give up! Default to English + currentLocale = 'en'; + supportedLocale = await initializeMessages(currentLocale); + } + } + + _setupGenres(currentLocale); + + /// Listen for user changes in search provider. If changed, reload the genre list + settingsService.settingsListener.where((event) => event == 'search').listen((event) { + _setupGenres(currentLocale); + }); + } + + void _setupGenres(String locale) { + var categoryList = ''; + + /// Fetch the correct categories for the current local and selected provider. + if (settingsService.searchProvider == 'itunes') { + _categories = PodcastService.itunesGenres; + categoryList = Intl.message('discovery_categories_itunes', locale: locale); + } else { + _categories = PodcastService.podcastIndexGenres; + categoryList = Intl.message('discovery_categories_pindex', locale: locale); + } + + _intlCategories = categoryList.split(','); + _intlCategoriesSorted = categoryList.split(','); + _intlCategoriesSorted.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + } + + @override + Future search({ + required String term, + String? country, + String? attribute, + int? limit, + String? language, + int version = 0, + bool explicit = false, + }) { + return api.search( + term, + country: country, + attribute: attribute, + limit: limit, + language: language, + explicit: explicit, + searchProvider: settingsService.searchProvider, + ); + } + + @override + Future charts({ + int size = 20, + String? genre, + String? countryCode = '', + String? languageCode = '', + }) { + var providerGenre = _decodeGenre(genre); + + return api.charts( + size: size, + searchProvider: settingsService.searchProvider, + genre: providerGenre, + countryCode: countryCode, + languageCode: languageCode, + ); + } + + @override + List genres() { + return _intlCategoriesSorted; + } + + /// Loads the specified [Podcast]. If the Podcast instance has an ID we'll fetch + /// it from storage. If not, we'll check the cache to see if we have seen it + /// recently and return that if available. If not, we'll make a call to load + /// it from the network. + /// TODO: The complexity of this method is now too high - needs to be refactored. + @override + Future loadPodcast({ + required Podcast podcast, + bool highlightNewEpisodes = false, + bool refresh = false, + }) async { + log.fine('loadPodcast. ID ${podcast.id} (refresh $refresh)'); + + if (podcast.id == null || refresh) { + podcast_search.Podcast? loadedPodcast; + var imageUrl = podcast.imageUrl; + var thumbImageUrl = podcast.thumbImageUrl; + var sourceUrl = podcast.url; + + if (!refresh) { + log.fine('Not a refresh so try to fetch from cache'); + loadedPodcast = _cache.item(podcast.url); + } + + // If we didn't get a cache hit load the podcast feed. + if (loadedPodcast == null) { + var tries = 2; + var url = podcast.url; + + while (tries-- > 0) { + try { + log.fine('Loading podcast from feed $url'); + loadedPodcast = await _loadPodcastFeed(url: url); + tries = 0; + } catch (e) { + if (tries > 0) { + //TODO: Needs improving to only fall back if original URL was http and we forced it up to https. + if (e is podcast_search.PodcastCertificateException && url.startsWith('https')) { + log.fine('Certificate error whilst fetching podcast. Fallback to http and try again'); + + url = url.replaceFirst('https', 'http'); + } + } else { + rethrow; + } + } + } + + _cache.store(loadedPodcast!); + } + + final title = _format(loadedPodcast.title); + final description = _format(loadedPodcast.description); + final copyright = _format(loadedPodcast.copyright); + final funding = []; + final persons = []; + final existingEpisodes = await repository.findEpisodesByPodcastGuid(sourceUrl); + + // If imageUrl is null we have not loaded the podcast as a result of a search. + if (imageUrl == null || imageUrl.isEmpty || refresh) { + imageUrl = loadedPodcast.image; + thumbImageUrl = loadedPodcast.image; + } + + for (var f in loadedPodcast.funding) { + if (f.url != null) { + funding.add(Funding(url: f.url!, value: f.value ?? '')); + } + } + + for (var p in loadedPodcast.persons) { + persons.add(Person( + name: p.name, + role: p.role ?? '', + group: p.group ?? '', + image: p.image, + link: p.link, + )); + } + + Podcast pc = Podcast( + guid: sourceUrl, + url: sourceUrl, + link: loadedPodcast.link, + title: title, + description: description, + imageUrl: imageUrl, + thumbImageUrl: thumbImageUrl, + copyright: copyright, + funding: funding, + persons: persons, + episodes: [], + ); + + /// We could be following this podcast already. Let's check. + var follow = await repository.findPodcastByGuid(sourceUrl); + + if (follow != null) { + // We are, so swap in the stored ID so we update the saved version later. + pc.id = follow.id; + + // And preserve any filter & sort applied + pc.filter = follow.filter; + pc.sort = follow.sort; + } + + // Usually, episodes are order by reverse publication date - but not always. + // Enforce that ordering. To prevent unnecessary sorting, we'll sample the + // first two episodes to see what order they are in. + if (loadedPodcast.episodes.length > 1) { + if (loadedPodcast.episodes[0].publicationDate!.millisecondsSinceEpoch < + loadedPodcast.episodes[1].publicationDate!.millisecondsSinceEpoch) { + loadedPodcast.episodes.sort((e1, e2) => e2.publicationDate!.compareTo(e1.publicationDate!)); + } + } + + // Loop through all episodes in the feed and check to see if we already have that episode + // stored. If we don't, it's a new episode so add it; if we do update our copy in case it's changed. + for (final episode in loadedPodcast.episodes) { + final existingEpisode = existingEpisodes.firstWhereOrNull((ep) => ep!.guid == episode.guid); + final author = episode.author?.replaceAll('\n', '').trim() ?? ''; + final title = _format(episode.title); + final description = _format(episode.description); + final content = episode.content; + + final episodeImage = episode.imageUrl == null || episode.imageUrl!.isEmpty ? pc.imageUrl : episode.imageUrl; + final episodeThumbImage = + episode.imageUrl == null || episode.imageUrl!.isEmpty ? pc.thumbImageUrl : episode.imageUrl; + final duration = episode.duration?.inSeconds ?? 0; + final transcriptUrls = []; + final episodePersons = []; + + for (var t in episode.transcripts) { + late TranscriptFormat type; + + switch (t.type) { + case podcast_search.TranscriptFormat.subrip: + type = TranscriptFormat.subrip; + break; + case podcast_search.TranscriptFormat.json: + type = TranscriptFormat.json; + break; + case podcast_search.TranscriptFormat.vtt: + type = TranscriptFormat.subrip; // Map VTT to subrip for now + break; + case podcast_search.TranscriptFormat.unsupported: + type = TranscriptFormat.unsupported; + break; + } + + transcriptUrls.add(TranscriptUrl(url: t.url, type: type)); + } + + if (episode.persons.isNotEmpty) { + for (var p in episode.persons) { + episodePersons.add(Person( + name: p.name, + role: p.role!, + group: p.group!, + image: p.image, + link: p.link, + )); + } + } else if (persons.isNotEmpty) { + episodePersons.addAll(persons); + } + + if (existingEpisode == null) { + pc.newEpisodes = highlightNewEpisodes && pc.id != null; + + pc.episodes.add(Episode( + highlight: pc.newEpisodes, + pguid: pc.guid, + guid: episode.guid, + podcast: pc.title, + title: title, + description: description, + content: content, + author: author, + season: episode.season ?? 0, + episode: episode.episode ?? 0, + contentUrl: episode.contentUrl, + link: episode.link, + imageUrl: episodeImage, + thumbImageUrl: episodeThumbImage, + duration: duration, + publicationDate: episode.publicationDate, + chaptersUrl: episode.chapters?.url, + transcriptUrls: transcriptUrls, + persons: episodePersons, + chapters: [], + )); + } else { + /// Check if the ancillary episode data has changed. + if (!listEquals(existingEpisode.persons, episodePersons) || + !listEquals(existingEpisode.transcriptUrls, transcriptUrls)) { + pc.updatedEpisodes = true; + } + + existingEpisode.title = title; + existingEpisode.description = description; + existingEpisode.content = content; + existingEpisode.author = author; + existingEpisode.season = episode.season ?? 0; + existingEpisode.episode = episode.episode ?? 0; + existingEpisode.contentUrl = episode.contentUrl; + existingEpisode.link = episode.link; + existingEpisode.imageUrl = episodeImage; + existingEpisode.thumbImageUrl = episodeThumbImage; + existingEpisode.publicationDate = episode.publicationDate; + existingEpisode.chaptersUrl = episode.chapters?.url; + existingEpisode.transcriptUrls = transcriptUrls; + existingEpisode.persons = episodePersons; + + // If the source duration is 0 do not update any saved, calculated duration. + if (duration > 0) { + existingEpisode.duration = duration; + } + + pc.episodes.add(existingEpisode); + + // Clear this episode from our existing list + existingEpisodes.remove(existingEpisode); + } + } + + // Add any downloaded episodes that are no longer in the feed - they + // may have expired but we still want them. + var expired = []; + + for (final episode in existingEpisodes) { + var feedEpisode = loadedPodcast.episodes.firstWhereOrNull((ep) => ep.guid == episode!.guid); + + if (feedEpisode == null && episode!.downloaded) { + pc.episodes.add(episode); + } else { + expired.add(episode!); + } + } + + // If we are subscribed to this podcast and are simply refreshing we need to save the updated subscription. + // A non-null ID indicates this podcast is subscribed too. We also need to delete any expired episodes. + if (podcast.id != null && refresh) { + await repository.deleteEpisodes(expired); + + pc = await repository.savePodcast(pc); + + // Phew! Now, after all that, we have have a podcast filter in place. All episodes will have + // been saved, but we might not want to display them all. Let's filter. + pc.episodes = _sortAndFilterEpisodes(pc); + } + + return pc; + } else { + return await loadPodcastById(id: podcast.id ?? 0); + } + } + + @override + Future loadPodcastById({required int id}) { + return repository.findPodcastById(id); + } + + @override + Future> loadChaptersByUrl({required String url}) async { + var c = await _loadChaptersByUrl(url); + var chapters = []; + + if (c != null) { + for (var chapter in c.chapters) { + chapters.add(Chapter( + title: chapter.title, + url: chapter.url, + imageUrl: chapter.imageUrl, + startTime: chapter.startTime, + endTime: chapter.endTime, + toc: chapter.toc, + )); + } + } + + return chapters; + } + + /// This method will load either of the supported transcript types. Currently, we do not support + /// word level highlighting of transcripts, therefore this routine will also group transcript + /// lines together by speaker and/or timeframe. + @override + Future loadTranscriptByUrl({required TranscriptUrl transcriptUrl}) async { + var subtitles = []; + var result = await _loadTranscriptByUrl(transcriptUrl); + var threshold = const Duration(seconds: 5); + Subtitle? groupSubtitle; + + if (result != null) { + for (var index = 0; index < result.subtitles.length; index++) { + var subtitle = result.subtitles[index]; + var completeGroup = true; + var data = subtitle.data; + + if (groupSubtitle != null) { + if (transcriptUrl.type == TranscriptFormat.json) { + if (groupSubtitle.speaker == subtitle.speaker && + (subtitle.start.compareTo(groupSubtitle.start + threshold) < 0 || subtitle.data.length == 1)) { + /// We need to handle transcripts that have spaces between sentences, and those + /// which do not. + if (groupSubtitle.data != null && + (groupSubtitle.data!.endsWith(' ') || subtitle.data.startsWith(' ') || subtitle.data.length == 1)) { + data = '${groupSubtitle.data}${subtitle.data}'; + } else { + data = '${groupSubtitle.data} ${subtitle.data.trim()}'; + } + completeGroup = false; + } + } else { + if (groupSubtitle.start == subtitle.start) { + if (groupSubtitle.data != null && + (groupSubtitle.data!.endsWith(' ') || subtitle.data.startsWith(' ') || subtitle.data.length == 1)) { + data = '${groupSubtitle.data}${subtitle.data}'; + } else { + data = '${groupSubtitle.data} ${subtitle.data.trim()}'; + } + completeGroup = false; + } + } + } else { + completeGroup = false; + groupSubtitle = Subtitle( + data: subtitle.data, + speaker: subtitle.speaker, + start: subtitle.start, + end: subtitle.end, + index: subtitle.index, + ); + } + + /// If we have a complete group, or we're the very last subtitle - add it. + if (completeGroup || index == result.subtitles.length - 1) { + groupSubtitle.data = groupSubtitle.data?.trim(); + + subtitles.add(groupSubtitle); + + groupSubtitle = Subtitle( + data: subtitle.data, + speaker: subtitle.speaker, + start: subtitle.start, + end: subtitle.end, + index: subtitle.index, + ); + } else { + groupSubtitle = Subtitle( + data: data, + speaker: subtitle.speaker, + start: groupSubtitle.start, + end: subtitle.end, + index: groupSubtitle.index, + ); + } + } + } + + return Transcript(subtitles: subtitles); + } + + @override + Future> loadDownloads() async { + return repository.findDownloads(); + } + + @override + Future> loadEpisodes() async { + return repository.findAllEpisodes(); + } + + @override + Future deleteDownload(Episode episode) async { + // If this episode is currently downloading, cancel the download first. + if (episode.downloadState == DownloadState.downloaded) { + if (settingsService.markDeletedEpisodesAsPlayed) { + episode.played = true; + } + } else if (episode.downloadState == DownloadState.downloading && episode.downloadPercentage! < 100) { + await FlutterDownloader.cancel(taskId: episode.downloadTaskId!); + } + + episode.downloadTaskId = null; + episode.downloadPercentage = 0; + episode.position = 0; + episode.downloadState = DownloadState.none; + + if (episode.transcriptId != null && episode.transcriptId! > 0) { + await repository.deleteTranscriptById(episode.transcriptId!); + } + + await repository.saveEpisode(episode); + + if (await hasStoragePermission()) { + final f = File.fromUri(Uri.file(await resolvePath(episode))); + + log.fine('Deleting file ${f.path}'); + + if (await f.exists()) { + f.delete(); + } + } + + return; + } + + @override + Future toggleEpisodePlayed(Episode episode) async { + episode.played = !episode.played; + episode.position = 0; + + repository.saveEpisode(episode); + } + + @override + Future> subscriptions() { + return repository.subscriptions(); + } + + @override + Future unsubscribe(Podcast podcast) async { + if (await hasStoragePermission()) { + final filename = join(await getStorageDirectory(), safeFile(podcast.title)); + + final d = Directory.fromUri(Uri.file(filename)); + + if (await d.exists()) { + await d.delete(recursive: true); + } + } + + return repository.deletePodcast(podcast); + } + + @override + Future subscribe(Podcast? podcast) async { + // We may already have episodes download for this podcast before the user + // hit subscribe. + if (podcast != null && podcast.guid != null) { + var savedEpisodes = await repository.findEpisodesByPodcastGuid(podcast.guid!); + + if (podcast.episodes.isNotEmpty) { + for (var episode in podcast.episodes) { + var savedEpisode = savedEpisodes.firstWhereOrNull((ep) => ep!.guid == episode.guid); + + if (savedEpisode != null) { + episode.pguid = podcast.guid; + } + } + } + + return repository.savePodcast(podcast); + } + + return Future.value(null); + } + + @override + Future save(Podcast podcast, {bool withEpisodes = true}) async { + return repository.savePodcast(podcast, withEpisodes: withEpisodes); + } + + @override + Future saveEpisode(Episode episode) async { + return repository.saveEpisode(episode); + } + + @override + Future> saveEpisodes(List episodes) async { + return repository.saveEpisodes(episodes); + } + + @override + Future saveTranscript(Transcript transcript) async { + return repository.saveTranscript(transcript); + } + + @override + Future saveQueue(List episodes) async { + await repository.saveQueue(episodes); + } + + @override + Future> loadQueue() async { + return await repository.loadQueue(); + } + + /// Remove HTML padding from the content. The padding may look fine within + /// the context of a browser, but can look out of place on a mobile screen. + String _format(String? input) { + return input?.trim().replaceAll(descriptionRegExp2, '').replaceAll(descriptionRegExp1, '

') ?? ''; + } + + Future _loadChaptersByUrl(String url) { + return compute<_FeedComputer, podcast_search.Chapters?>( + _loadChaptersByUrlCompute, _FeedComputer(api: api, url: url)); + } + + static Future _loadChaptersByUrlCompute(_FeedComputer c) async { + podcast_search.Chapters? result; + + try { + result = await c.api.loadChapters(c.url); + } catch (e) { + final log = Logger('MobilePodcastService'); + + log.fine('Failed to download chapters'); + log.fine(e); + } + + return result; + } + + Future _loadTranscriptByUrl(TranscriptUrl transcriptUrl) { + return compute<_TranscriptComputer, podcast_search.Transcript?>( + _loadTranscriptByUrlCompute, _TranscriptComputer(api: api, transcriptUrl: transcriptUrl)); + } + + static Future _loadTranscriptByUrlCompute(_TranscriptComputer c) async { + podcast_search.Transcript? result; + + try { + result = await c.api.loadTranscript(c.transcriptUrl); + } catch (e) { + final log = Logger('MobilePodcastService'); + + log.fine('Failed to download transcript'); + log.fine(e); + } + + return result; + } + + /// Loading and parsing a podcast feed can take several seconds. Larger feeds + /// can end up blocking the UI thread. We perform our feed load in a + /// separate isolate so that the UI can continue to present a loading + /// indicator whilst the data is fetched without locking the UI. + Future _loadPodcastFeed({required String url}) { + return compute<_FeedComputer, podcast_search.Podcast>(_loadPodcastFeedCompute, _FeedComputer(api: api, url: url)); + } + + /// We have to separate the process of calling compute as you cannot use + /// named parameters with compute. The podcast feed load API uses named + /// parameters so we need to change it to a single, positional parameter. + static Future _loadPodcastFeedCompute(_FeedComputer c) { + return c.api.loadFeed(c.url); + } + + /// The service providers expect the genre to be passed in English. This function takes + /// the selected genre and returns the English version. + String _decodeGenre(String? genre) { + var index = _intlCategories.indexOf(genre); + var decodedGenre = ''; + + if (index >= 0) { + decodedGenre = _categories[index]; + + if (decodedGenre == '') { + decodedGenre = ''; + } + } + + return decodedGenre; + } + + List _sortAndFilterEpisodes(Podcast podcast) { + var filteredEpisodes = []; + + switch (podcast.filter) { + case PodcastEpisodeFilter.none: + filteredEpisodes = podcast.episodes; + break; + case PodcastEpisodeFilter.started: + filteredEpisodes = podcast.episodes.where((e) => e.highlight || e.position > 0).toList(); + break; + case PodcastEpisodeFilter.played: + filteredEpisodes = podcast.episodes.where((e) => e.highlight || e.played).toList(); + break; + case PodcastEpisodeFilter.notPlayed: + filteredEpisodes = podcast.episodes.where((e) => e.highlight || !e.played).toList(); + break; + } + + switch (podcast.sort) { + case PodcastEpisodeSort.none: + case PodcastEpisodeSort.latestFirst: + filteredEpisodes.sort((e1, e2) => e2.publicationDate!.compareTo(e1.publicationDate!)); + case PodcastEpisodeSort.earliestFirst: + filteredEpisodes.sort((e1, e2) => e1.publicationDate!.compareTo(e2.publicationDate!)); + case PodcastEpisodeSort.alphabeticalAscending: + filteredEpisodes.sort((e1, e2) => e1.title!.toLowerCase().compareTo(e2.title!.toLowerCase())); + case PodcastEpisodeSort.alphabeticalDescending: + filteredEpisodes.sort((e1, e2) => e2.title!.toLowerCase().compareTo(e1.title!.toLowerCase())); + } + + return filteredEpisodes; + } + + @override + Stream? get podcastListener => repository.podcastListener; + + @override + Stream? get episodeListener => repository.episodeListener; +} + +/// A simple cache to reduce the number of network calls when loading podcast +/// feeds. We can cache up to [maxItems] items with each item having an +/// expiration time of [expiration]. The cache works as a FIFO queue, so if we +/// attempt to store a new item in the cache and it is full we remove the +/// first (and therefore oldest) item from the cache. Cache misses are returned +/// as null. +class _PodcastCache { + final int maxItems; + final Duration expiration; + final Queue<_CacheItem> _queue; + + _PodcastCache({required this.maxItems, required this.expiration}) : _queue = Queue<_CacheItem>(); + + podcast_search.Podcast? item(String key) { + var hit = _queue.firstWhereOrNull((_CacheItem i) => i.podcast.url == key); + podcast_search.Podcast? p; + + if (hit != null) { + var now = DateTime.now(); + + if (now.difference(hit.dateAdded) <= expiration) { + p = hit.podcast; + } else { + _queue.remove(hit); + } + } + + return p; + } + + void store(podcast_search.Podcast podcast) { + if (_queue.length == maxItems) { + _queue.removeFirst(); + } + + _queue.addLast(_CacheItem(podcast)); + } +} + +/// A simple class that stores an instance of a Podcast and the +/// date and time it was added. This can be used by the cache to +/// keep a small and up-to-date list of searched for Podcasts. +class _CacheItem { + final podcast_search.Podcast podcast; + final DateTime dateAdded; + + _CacheItem(this.podcast) : dateAdded = DateTime.now(); +} + +class _FeedComputer { + final PodcastApi api; + final String url; + + _FeedComputer({required this.api, required this.url}); +} + +class _TranscriptComputer { + final PodcastApi api; + final TranscriptUrl transcriptUrl; + + _TranscriptComputer({required this.api, required this.transcriptUrl}); +} diff --git a/PinePods-0.8.2/mobile/lib/services/podcast/podcast_service.dart b/PinePods-0.8.2/mobile/lib/services/podcast/podcast_service.dart new file mode 100644 index 0000000..667da7f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/podcast/podcast_service.dart @@ -0,0 +1,230 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/api/podcast/podcast_api.dart'; +import 'package:pinepods_mobile/entities/chapter.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:pinepods_mobile/repository/repository.dart'; +import 'package:pinepods_mobile/services/settings/settings_service.dart'; +import 'package:pinepods_mobile/state/episode_state.dart'; +import 'package:podcast_search/podcast_search.dart' as pcast; + +/// The [PodcastService] handles interactions around podcasts including searching, fetching +/// the trending/charts podcasts, loading the podcast RSS feed and anciallary items such as +/// chapters and transcripts. +abstract class PodcastService { + final PodcastApi api; + final Repository repository; + final SettingsService settingsService; + + static const itunesGenres = [ + '', + 'Arts', + 'Business', + 'Comedy', + 'Education', + 'Fiction', + 'Government', + 'Health & Fitness', + 'History', + 'Kids & Family', + 'Leisure', + 'Music', + 'News', + 'Religion & Spirituality', + 'Science', + 'Society & Culture', + 'Sports', + 'TV & Film', + 'Technology', + 'True Crime', + ]; + + static const podcastIndexGenres = [ + '', + 'After-Shows', + 'Alternative', + 'Animals', + 'Animation', + 'Arts', + 'Astronomy', + 'Automotive', + 'Aviation', + 'Baseball', + 'Basketball', + 'Beauty', + 'Books', + 'Buddhism', + 'Business', + 'Careers', + 'Chemistry', + 'Christianity', + 'Climate', + 'Comedy', + 'Commentary', + 'Courses', + 'Crafts', + 'Cricket', + 'Cryptocurrency', + 'Culture', + 'Daily', + 'Design', + 'Documentary', + 'Drama', + 'Earth', + 'Education', + 'Entertainment', + 'Entrepreneurship', + 'Family', + 'Fantasy', + 'Fashion', + 'Fiction', + 'Film', + 'Fitness', + 'Food', + 'Football', + 'Games', + 'Garden', + 'Golf', + 'Government', + 'Health', + 'Hinduism', + 'History', + 'Hobbies', + 'Hockey', + 'Home', + 'How-To', + 'Improv', + 'Interviews', + 'Investing', + 'Islam', + 'Journals', + 'Judaism', + 'Kids', + 'Language', + 'Learning', + 'Leisure', + 'Life', + 'Management', + 'Manga', + 'Marketing', + 'Mathematics', + 'Medicine', + 'Mental', + 'Music', + 'Natural', + 'Nature', + 'News', + 'Non-Profit', + 'Nutrition', + 'Parenting', + 'Performing', + 'Personal', + 'Pets', + 'Philosophy', + 'Physics', + 'Places', + 'Politics', + 'Relationships', + 'Religion', + 'Reviews', + 'Role-Playing', + 'Rugby', + 'Running', + 'Science', + 'Self-Improvement', + 'Sexuality', + 'Soccer', + 'Social', + 'Society', + 'Spirituality', + 'Sports', + 'Stand-Up', + 'Stories', + 'Swimming', + 'TV', + 'Tabletop', + 'Technology', + 'Tennis', + 'Travel', + 'True Crime', + 'Video-Games', + 'Visual', + 'Volleyball', + 'Weather', + 'Wilderness', + 'Wrestling', + ]; + + PodcastService({ + required this.api, + required this.repository, + required this.settingsService, + }); + + Future search({ + required String term, + String? country, + String? attribute, + int? limit, + String? language, + int version = 0, + bool explicit = false, + }); + + Future charts({ + required int size, + String? genre, + String? countryCode, + String? languageCode, + }); + + List genres(); + + Future loadPodcast({ + required Podcast podcast, + bool highlightNewEpisodes = false, + bool refresh = false, + }); + + Future loadPodcastById({ + required int id, + }); + + Future> loadDownloads(); + + Future> loadEpisodes(); + + Future> loadChaptersByUrl({required String url}); + + Future loadTranscriptByUrl({required TranscriptUrl transcriptUrl}); + + Future deleteDownload(Episode episode); + + Future toggleEpisodePlayed(Episode episode); + + Future> subscriptions(); + + Future subscribe(Podcast podcast); + + Future unsubscribe(Podcast podcast); + + Future save(Podcast podcast, {bool withEpisodes = true}); + + Future saveEpisode(Episode episode); + + Future> saveEpisodes(List episodes); + + Future saveTranscript(Transcript transcript); + + Future saveQueue(List episodes); + + Future> loadQueue(); + + /// Event listeners + Stream? podcastListener; + Stream? episodeListener; +} diff --git a/PinePods-0.8.2/mobile/lib/services/search_history_service.dart b/PinePods-0.8.2/mobile/lib/services/search_history_service.dart new file mode 100644 index 0000000..9f2a012 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/search_history_service.dart @@ -0,0 +1,162 @@ +// lib/services/search_history_service.dart + +import 'dart:convert'; +import 'package:logging/logging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service for managing search history for different search types +/// Stores search terms separately for episode search and podcast search +class SearchHistoryService { + final log = Logger('SearchHistoryService'); + + static const int maxHistoryItems = 30; + static const String episodeSearchKey = 'episode_search_history'; + static const String podcastSearchKey = 'podcast_search_history'; + + SearchHistoryService(); + + /// Adds a search term to episode search history + /// Moves existing term to top if already exists, otherwise adds as new + Future addEpisodeSearchTerm(String searchTerm) async { + print('SearchHistoryService.addEpisodeSearchTerm called with: "$searchTerm"'); + await _addSearchTerm(episodeSearchKey, searchTerm); + } + + /// Adds a search term to podcast search history + /// Moves existing term to top if already exists, otherwise adds as new + Future addPodcastSearchTerm(String searchTerm) async { + print('SearchHistoryService.addPodcastSearchTerm called with: "$searchTerm"'); + await _addSearchTerm(podcastSearchKey, searchTerm); + } + + /// Gets episode search history, most recent first + Future> getEpisodeSearchHistory() async { + print('SearchHistoryService.getEpisodeSearchHistory called'); + return await _getSearchHistory(episodeSearchKey); + } + + /// Gets podcast search history, most recent first + Future> getPodcastSearchHistory() async { + print('SearchHistoryService.getPodcastSearchHistory called'); + return await _getSearchHistory(podcastSearchKey); + } + + /// Clears episode search history + Future clearEpisodeSearchHistory() async { + await _clearSearchHistory(episodeSearchKey); + } + + /// Clears podcast search history + Future clearPodcastSearchHistory() async { + await _clearSearchHistory(podcastSearchKey); + } + + /// Removes a specific term from episode search history + Future removeEpisodeSearchTerm(String searchTerm) async { + await _removeSearchTerm(episodeSearchKey, searchTerm); + } + + /// Removes a specific term from podcast search history + Future removePodcastSearchTerm(String searchTerm) async { + await _removeSearchTerm(podcastSearchKey, searchTerm); + } + + /// Internal method to add a search term to specified history type + Future _addSearchTerm(String historyKey, String searchTerm) async { + if (searchTerm.trim().isEmpty) return; + + final trimmedTerm = searchTerm.trim(); + print('SearchHistoryService: Adding search term "$trimmedTerm" to $historyKey'); + + try { + final prefs = await SharedPreferences.getInstance(); + + // Get existing history + final historyJson = prefs.getString(historyKey); + List history = []; + + if (historyJson != null) { + final List decodedList = jsonDecode(historyJson); + history = decodedList.cast(); + } + + print('SearchHistoryService: Existing data for $historyKey: $history'); + + // Remove if already exists (to avoid duplicates) + history.remove(trimmedTerm); + + // Add to beginning (most recent first) + history.insert(0, trimmedTerm); + + // Limit to max items + if (history.length > maxHistoryItems) { + history = history.take(maxHistoryItems).toList(); + } + + // Save updated history + await prefs.setString(historyKey, jsonEncode(history)); + + print('SearchHistoryService: Updated $historyKey with ${history.length} terms: $history'); + } catch (e) { + print('SearchHistoryService: Failed to add search term to $historyKey: $e'); + log.warning('Failed to add search term to $historyKey: $e'); + } + } + + /// Internal method to get search history for specified type + Future> _getSearchHistory(String historyKey) async { + try { + final prefs = await SharedPreferences.getInstance(); + final historyJson = prefs.getString(historyKey); + + print('SearchHistoryService: Getting history for $historyKey: $historyJson'); + + if (historyJson != null) { + final List decodedList = jsonDecode(historyJson); + final history = decodedList.cast(); + print('SearchHistoryService: Returning history for $historyKey: $history'); + return history; + } + } catch (e) { + print('SearchHistoryService: Failed to get search history for $historyKey: $e'); + } + + print('SearchHistoryService: Returning empty history for $historyKey'); + return []; + } + + /// Internal method to clear search history for specified type + Future _clearSearchHistory(String historyKey) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(historyKey); + print('SearchHistoryService: Cleared search history for $historyKey'); + } catch (e) { + print('SearchHistoryService: Failed to clear search history for $historyKey: $e'); + } + } + + /// Internal method to remove specific term from history + Future _removeSearchTerm(String historyKey, String searchTerm) async { + try { + final prefs = await SharedPreferences.getInstance(); + final historyJson = prefs.getString(historyKey); + + if (historyJson == null) return; + + final List decodedList = jsonDecode(historyJson); + List history = decodedList.cast(); + history.remove(searchTerm); + + if (history.isEmpty) { + await prefs.remove(historyKey); + } else { + await prefs.setString(historyKey, jsonEncode(history)); + } + + print('SearchHistoryService: Removed "$searchTerm" from $historyKey'); + } catch (e) { + print('SearchHistoryService: Failed to remove search term from $historyKey: $e'); + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/services/settings/mobile_settings_service.dart b/PinePods-0.8.2/mobile/lib/services/settings/mobile_settings_service.dart new file mode 100644 index 0000000..bc6d278 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/settings/mobile_settings_service.dart @@ -0,0 +1,277 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math'; + +import 'package:pinepods_mobile/core/environment.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/services/settings/settings_service.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// An implementation [SettingsService] for mobile devices backed by +/// shared preferences. +class MobileSettingsService extends SettingsService { + static late SharedPreferences _sharedPreferences; + static MobileSettingsService? _instance; + + final settingsNotifier = PublishSubject(); + + MobileSettingsService._create(); + + static Future instance() async { + if (_instance == null) { + _instance = MobileSettingsService._create(); + + _sharedPreferences = await SharedPreferences.getInstance(); + } + + return _instance; + } + + @override + bool get markDeletedEpisodesAsPlayed => _sharedPreferences.getBool('markplayedasdeleted') ?? false; + + @override + set markDeletedEpisodesAsPlayed(bool value) { + _sharedPreferences.setBool('markplayedasdeleted', value); + settingsNotifier.sink.add('markplayedasdeleted'); + } + + @override + String? get pinepodsServer => _sharedPreferences.getString('pinepods_server'); + + @override + set pinepodsServer(String? value) { + if (value == null) { + _sharedPreferences.remove('pinepods_server'); + } else { + _sharedPreferences.setString('pinepods_server', value); + } + settingsNotifier.sink.add('pinepods_server'); + } + + @override + String? get pinepodsApiKey => _sharedPreferences.getString('pinepods_api_key'); + + @override + set pinepodsApiKey(String? value) { + if (value == null) { + _sharedPreferences.remove('pinepods_api_key'); + } else { + _sharedPreferences.setString('pinepods_api_key', value); + } + settingsNotifier.sink.add('pinepods_api_key'); + } + + @override + int? get pinepodsUserId => _sharedPreferences.getInt('pinepods_user_id'); + + @override + set pinepodsUserId(int? value) { + if (value == null) { + _sharedPreferences.remove('pinepods_user_id'); + } else { + _sharedPreferences.setInt('pinepods_user_id', value); + } + settingsNotifier.sink.add('pinepods_user_id'); + } + + @override + String? get pinepodsUsername => _sharedPreferences.getString('pinepods_username'); + + @override + set pinepodsUsername(String? value) { + if (value == null) { + _sharedPreferences.remove('pinepods_username'); + } else { + _sharedPreferences.setString('pinepods_username', value); + } + settingsNotifier.sink.add('pinepods_username'); + } + + @override + String? get pinepodsEmail => _sharedPreferences.getString('pinepods_email'); + + @override + set pinepodsEmail(String? value) { + if (value == null) { + _sharedPreferences.remove('pinepods_email'); + } else { + _sharedPreferences.setString('pinepods_email', value); + } + settingsNotifier.sink.add('pinepods_email'); + } + + @override + bool get deleteDownloadedPlayedEpisodes => _sharedPreferences.getBool('deleteDownloadedPlayedEpisodes') ?? false; + + @override + set deleteDownloadedPlayedEpisodes(bool value) { + _sharedPreferences.setBool('deleteDownloadedPlayedEpisodes', value); + settingsNotifier.sink.add('deleteDownloadedPlayedEpisodes'); + } + + @override + bool get storeDownloadsSDCard => _sharedPreferences.getBool('savesdcard') ?? false; + + @override + set storeDownloadsSDCard(bool value) { + _sharedPreferences.setBool('savesdcard', value); + settingsNotifier.sink.add('savesdcard'); + } + + @override + bool get themeDarkMode { + var theme = _sharedPreferences.getString('theme') ?? 'Dark'; + + return theme == 'Dark'; + } + + @override + set themeDarkMode(bool value) { + _sharedPreferences.setString('theme', value ? 'Dark' : 'Light'); + settingsNotifier.sink.add('theme'); + } + + String get theme { + return _sharedPreferences.getString('theme') ?? 'Dark'; + } + + set theme(String value) { + _sharedPreferences.setString('theme', value); + settingsNotifier.sink.add('theme'); + } + + @override + set playbackSpeed(double playbackSpeed) { + _sharedPreferences.setDouble('speed', playbackSpeed); + settingsNotifier.sink.add('speed'); + } + + @override + double get playbackSpeed { + var speed = _sharedPreferences.getDouble('speed') ?? 1.0; + + // We used to use 0.25 increments and now we use 0.1. Round + // any setting that uses the old 0.25. + var mod = pow(10.0, 1).toDouble(); + return ((speed * mod).round().toDouble() / mod); + } + + @override + set searchProvider(String provider) { + _sharedPreferences.setString('search', provider); + settingsNotifier.sink.add('search'); + } + + @override + String get searchProvider { + // If we do not have PodcastIndex key, fallback to iTunes + if (podcastIndexKey.isEmpty) { + return 'itunes'; + } else { + return _sharedPreferences.getString('search') ?? 'itunes'; + } + } + + @override + set externalLinkConsent(bool consent) { + _sharedPreferences.setBool('elconsent', consent); + settingsNotifier.sink.add('elconsent'); + } + + @override + bool get externalLinkConsent { + return _sharedPreferences.getBool('elconsent') ?? false; + } + + @override + set autoOpenNowPlaying(bool autoOpenNowPlaying) { + _sharedPreferences.setBool('autoopennowplaying', autoOpenNowPlaying); + settingsNotifier.sink.add('autoopennowplaying'); + } + + @override + bool get autoOpenNowPlaying { + return _sharedPreferences.getBool('autoopennowplaying') ?? false; + } + + @override + set showFunding(bool show) { + _sharedPreferences.setBool('showFunding', show); + settingsNotifier.sink.add('showFunding'); + } + + @override + bool get showFunding { + return _sharedPreferences.getBool('showFunding') ?? true; + } + + @override + set autoUpdateEpisodePeriod(int period) { + _sharedPreferences.setInt('autoUpdateEpisodePeriod', period); + settingsNotifier.sink.add('autoUpdateEpisodePeriod'); + } + + @override + int get autoUpdateEpisodePeriod { + /// Default to 3 hours. + return _sharedPreferences.getInt('autoUpdateEpisodePeriod') ?? 180; + } + + @override + set trimSilence(bool trim) { + _sharedPreferences.setBool('trimSilence', trim); + settingsNotifier.sink.add('trimSilence'); + } + + @override + bool get trimSilence { + return _sharedPreferences.getBool('trimSilence') ?? false; + } + + @override + set volumeBoost(bool boost) { + _sharedPreferences.setBool('volumeBoost', boost); + settingsNotifier.sink.add('volumeBoost'); + } + + @override + bool get volumeBoost { + return _sharedPreferences.getBool('volumeBoost') ?? false; + } + + @override + set layoutMode(int mode) { + _sharedPreferences.setInt('layout', mode); + settingsNotifier.sink.add('layout'); + } + + @override + int get layoutMode { + return _sharedPreferences.getInt('layout') ?? 0; + } + + @override + List get bottomBarOrder { + final orderString = _sharedPreferences.getString('bottom_bar_order'); + if (orderString != null) { + return orderString.split(','); + } + return ['Home', 'Feed', 'Saved', 'Podcasts', 'Downloads', 'History', 'Playlists', 'Search']; + } + + @override + set bottomBarOrder(List value) { + _sharedPreferences.setString('bottom_bar_order', value.join(',')); + settingsNotifier.sink.add('bottom_bar_order'); + } + + @override + AppSettings? settings; + + @override + Stream get settingsListener => settingsNotifier.stream; +} diff --git a/PinePods-0.8.2/mobile/lib/services/settings/settings_service.dart b/PinePods-0.8.2/mobile/lib/services/settings/settings_service.dart new file mode 100644 index 0000000..4925a95 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/services/settings/settings_service.dart @@ -0,0 +1,87 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/app_settings.dart'; + +abstract class SettingsService { + AppSettings? get settings; + + set settings(AppSettings? settings); + + bool get themeDarkMode; + + set themeDarkMode(bool value); + + String get theme; + + set theme(String value); + + bool get markDeletedEpisodesAsPlayed; + + set markDeletedEpisodesAsPlayed(bool value); + + bool get deleteDownloadedPlayedEpisodes; + + set deleteDownloadedPlayedEpisodes(bool value); + + bool get storeDownloadsSDCard; + + set storeDownloadsSDCard(bool value); + + set playbackSpeed(double playbackSpeed); + + double get playbackSpeed; + + set searchProvider(String provider); + + String get searchProvider; + + set externalLinkConsent(bool consent); + + bool get externalLinkConsent; + + set autoOpenNowPlaying(bool autoOpenNowPlaying); + + bool get autoOpenNowPlaying; + + set showFunding(bool show); + + bool get showFunding; + + set autoUpdateEpisodePeriod(int period); + + int get autoUpdateEpisodePeriod; + + set trimSilence(bool trim); + + bool get trimSilence; + + set volumeBoost(bool boost); + + bool get volumeBoost; + + set layoutMode(int mode); + + int get layoutMode; + + Stream get settingsListener; + + String? get pinepodsServer; + set pinepodsServer(String? value); + + String? get pinepodsApiKey; + set pinepodsApiKey(String? value); + + int? get pinepodsUserId; + set pinepodsUserId(int? value); + + String? get pinepodsUsername; + set pinepodsUsername(String? value); + + String? get pinepodsEmail; + set pinepodsEmail(String? value); + + List get bottomBarOrder; + set bottomBarOrder(List value); +} diff --git a/PinePods-0.8.2/mobile/lib/state/bloc_state.dart b/PinePods-0.8.2/mobile/lib/state/bloc_state.dart new file mode 100644 index 0000000..e3fe424 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/state/bloc_state.dart @@ -0,0 +1,45 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// The BLoCs in this application share common states, such as loading, error +/// or populated. +/// +/// Rather than having a separate selection of state classes, we create this generic one. +enum BlocErrorType { unknown, connectivity, timeout } + +abstract class BlocState {} + +class BlocDefaultState extends BlocState {} + +class BlocLoadingState extends BlocState { + final T? data; + + BlocLoadingState([this.data]); +} + +class BlocBackgroundLoadingState extends BlocState { + final T? data; + + BlocBackgroundLoadingState([this.data]); +} + +class BlocSuccessfulState extends BlocState {} + +class BlocEmptyState extends BlocState {} + +class BlocErrorState extends BlocState { + final BlocErrorType error; + + BlocErrorState({ + this.error = BlocErrorType.unknown, + }); +} + +class BlocNoInputState extends BlocState {} + +class BlocPopulatedState extends BlocState { + final T? results; + + BlocPopulatedState({this.results}); +} diff --git a/PinePods-0.8.2/mobile/lib/state/episode_state.dart b/PinePods-0.8.2/mobile/lib/state/episode_state.dart new file mode 100644 index 0000000..fa27ae6 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/state/episode_state.dart @@ -0,0 +1,19 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/episode.dart'; + +abstract class EpisodeState { + final Episode episode; + + EpisodeState(this.episode); +} + +class EpisodeUpdateState extends EpisodeState { + EpisodeUpdateState(super.episode); +} + +class EpisodeDeleteState extends EpisodeState { + EpisodeDeleteState(super.episode); +} diff --git a/PinePods-0.8.2/mobile/lib/state/persistent_state.dart b/PinePods-0.8.2/mobile/lib/state/persistent_state.dart new file mode 100644 index 0000000..f352275 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/state/persistent_state.dart @@ -0,0 +1,95 @@ +// Copyright 2020 Ben Hills. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:pinepods_mobile/entities/persistable.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +class PersistentState { + static Future persistState(Persistable persistable) async { + var d = await getApplicationSupportDirectory(); + + var file = File(join(d.path, 'state.json')); + var sink = file.openWrite(); + var json = jsonEncode(persistable.toMap()); + + sink.write(json); + await sink.flush(); + await sink.close(); + } + + static Future fetchState() async { + var d = await getApplicationSupportDirectory(); + + var file = File(join(d.path, 'state.json')); + var p = Persistable.empty(); + + if (file.existsSync()) { + var result = file.readAsStringSync(); + + if (result.isNotEmpty) { + var data = jsonDecode(result) as Map; + + p = Persistable.fromMap(data); + } + } + + return Future.value(p); + } + + static Future clearState() async { + var file = await _getFile(); + + if (file.existsSync()) { + file.delete(); + } + } + + static Future writeInt(String name, int value) async { + return _writeValue(name, value.toString()); + } + + static Future readInt(String name) async { + var result = await _readValue(name); + + return result.isEmpty ? 0 : int.parse(result); + } + + static Future writeString(String name, String value) async { + return _writeValue(name, value); + } + + static Future readString(String name) async { + return _readValue(name); + } + + static Future _readValue(String name) async { + var d = await getApplicationSupportDirectory(); + + var file = File(join(d.path, name)); + var result = file.readAsStringSync(); + + return result; + } + + static Future _writeValue(String name, String value) async { + var d = await getApplicationSupportDirectory(); + + var file = File(join(d.path, name)); + var sink = file.openWrite(); + + sink.write(value.toString()); + await sink.flush(); + await sink.close(); + } + + static Future _getFile() async { + var d = await getApplicationSupportDirectory(); + + return File(join(d.path, 'state.json')); + } +} diff --git a/PinePods-0.8.2/mobile/lib/state/queue_event_state.dart b/PinePods-0.8.2/mobile/lib/state/queue_event_state.dart new file mode 100644 index 0000000..78e8fca --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/state/queue_event_state.dart @@ -0,0 +1,58 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/episode.dart'; + +abstract class QueueEvent { + Episode? episode; + int? position; + + QueueEvent({ + this.episode, + this.position, + }); +} + +class QueueAddEvent extends QueueEvent { + QueueAddEvent({required Episode super.episode, super.position}); +} + +class QueueRemoveEvent extends QueueEvent { + QueueRemoveEvent({required Episode episode}) : super(episode: episode); +} + +class QueueMoveEvent extends QueueEvent { + final int oldIndex; + final int newIndex; + + QueueMoveEvent({ + required Episode episode, + required this.oldIndex, + required this.newIndex, + }) : super(episode: episode); +} + +class QueueClearEvent extends QueueEvent {} + +abstract class QueueState { + final Episode? playing; + final List queue; + + QueueState({ + required this.playing, + required this.queue, + }); +} + +class QueueListState extends QueueState { + QueueListState({ + required super.playing, + required super.queue, + }); +} + +class QueueEmptyState extends QueueState { + QueueEmptyState() + : super(playing: Episode(guid: '', pguid: '', podcast: '', title: '', description: ''), queue: []); +} diff --git a/PinePods-0.8.2/mobile/lib/state/transcript_state_event.dart b/PinePods-0.8.2/mobile/lib/state/transcript_state_event.dart new file mode 100644 index 0000000..92ee58e --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/state/transcript_state_event.dart @@ -0,0 +1,35 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/transcript.dart'; + +/// Events +abstract class TranscriptEvent {} + +class TranscriptClearEvent extends TranscriptEvent {} + +class TranscriptFilterEvent extends TranscriptEvent { + final String search; + + TranscriptFilterEvent({required this.search}); +} + +/// State +abstract class TranscriptState { + final Transcript? transcript; + final bool isFiltered; + + TranscriptState({ + this.transcript, + this.isFiltered = false, + }); +} + +class TranscriptUnavailableState extends TranscriptState {} + +class TranscriptLoadingState extends TranscriptState {} + +class TranscriptUpdateState extends TranscriptState { + TranscriptUpdateState({required Transcript transcript}) : super(transcript: transcript); +} diff --git a/PinePods-0.8.2/mobile/lib/ui/auth/auth_wrapper.dart b/PinePods-0.8.2/mobile/lib/ui/auth/auth_wrapper.dart new file mode 100644 index 0000000..cebe781 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/auth/auth_wrapper.dart @@ -0,0 +1,163 @@ +// lib/ui/auth/auth_wrapper.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/ui/auth/pinepods_startup_login.dart'; +import 'package:provider/provider.dart'; + +class AuthWrapper extends StatefulWidget { + final Widget child; + + const AuthWrapper({ + Key? key, + required this.child, + }) : super(key: key); + + @override + State createState() => _AuthWrapperState(); +} + +class _AuthWrapperState extends State { + bool _hasInitializedTheme = false; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settingsBloc, _) { + return StreamBuilder( + stream: settingsBloc.settings, + initialData: settingsBloc.currentSettings, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + final settings = snapshot.data!; + + // Check if PinePods server is configured + final hasServer = settings.pinepodsServer != null && + settings.pinepodsServer!.isNotEmpty; + final hasApiKey = settings.pinepodsApiKey != null && + settings.pinepodsApiKey!.isNotEmpty; + + if (hasServer && hasApiKey) { + // User is logged in, fetch theme from server if not already done + if (!_hasInitializedTheme) { + _hasInitializedTheme = true; + // Fetch theme from server on next frame to avoid modifying state during build + WidgetsBinding.instance.addPostFrameCallback((_) { + settingsBloc.fetchThemeFromServer(); + }); + } + + // Show main app + return widget.child; + } else { + // User needs to login, reset theme initialization flag + _hasInitializedTheme = false; + + // Show startup login + return PinepodsStartupLogin( + onLoginSuccess: () { + // Force rebuild to check auth state again + // The StreamBuilder will automatically rebuild when settings change + }, + ); + } + }, + ); + }, + ); + } +} + +// Alternative version if you want more explicit control +class AuthChecker extends StatefulWidget { + final Widget authenticatedChild; + final Widget? unauthenticatedChild; + + const AuthChecker({ + Key? key, + required this.authenticatedChild, + this.unauthenticatedChild, + }) : super(key: key); + + @override + State createState() => _AuthCheckerState(); +} + +class _AuthCheckerState extends State { + bool _isCheckingAuth = true; + bool _isAuthenticated = false; + + @override + void initState() { + super.initState(); + _checkAuthStatus(); + } + + void _checkAuthStatus() { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + final hasServer = settings.pinepodsServer != null && + settings.pinepodsServer!.isNotEmpty; + final hasApiKey = settings.pinepodsApiKey != null && + settings.pinepodsApiKey!.isNotEmpty; + + setState(() { + _isAuthenticated = hasServer && hasApiKey; + _isCheckingAuth = false; + }); + } + + void _onLoginSuccess() { + setState(() { + _isAuthenticated = true; + }); + } + + @override + Widget build(BuildContext context) { + if (_isCheckingAuth) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (_isAuthenticated) { + return widget.authenticatedChild; + } else { + return widget.unauthenticatedChild ?? + PinepodsStartupLogin(onLoginSuccess: _onLoginSuccess); + } + } +} + +// Simple authentication status provider +class AuthStatus extends InheritedWidget { + final bool isAuthenticated; + final VoidCallback? onAuthChanged; + + const AuthStatus({ + Key? key, + required this.isAuthenticated, + this.onAuthChanged, + required Widget child, + }) : super(key: key, child: child); + + static AuthStatus? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(AuthStatus oldWidget) { + return isAuthenticated != oldWidget.isAuthenticated; + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/auth/oidc_browser.dart b/PinePods-0.8.2/mobile/lib/ui/auth/oidc_browser.dart new file mode 100644 index 0000000..a00d7dd --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/auth/oidc_browser.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:pinepods_mobile/services/pinepods/oidc_service.dart'; + +class OidcBrowser extends StatefulWidget { + final String authUrl; + final String serverUrl; + final Function(String apiKey) onSuccess; + final Function(String error) onError; + + const OidcBrowser({ + super.key, + required this.authUrl, + required this.serverUrl, + required this.onSuccess, + required this.onError, + }); + + @override + State createState() => _OidcBrowserState(); +} + +class _OidcBrowserState extends State { + late final WebViewController _controller; + bool _isLoading = true; + String _currentUrl = ''; + bool _callbackTriggered = false; // Prevent duplicate callbacks + + @override + void initState() { + super.initState(); + _initializeWebView(); + } + + void _initializeWebView() { + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (String url) { + setState(() { + _currentUrl = url; + _isLoading = true; + }); + + _checkForCallback(url); + }, + onPageFinished: (String url) { + setState(() { + _isLoading = false; + }); + + _checkForCallback(url); + }, + onNavigationRequest: (NavigationRequest request) { + _checkForCallback(request.url); + return NavigationDecision.navigate; + }, + ), + ) + ..loadRequest(Uri.parse(widget.authUrl)); + } + + void _checkForCallback(String url) { + if (_callbackTriggered) return; // Prevent duplicate callbacks + + // Check if we've reached the callback URL with an API key + final apiKey = OidcService.extractApiKeyFromUrl(url); + if (apiKey != null) { + _callbackTriggered = true; // Mark callback as triggered + widget.onSuccess(apiKey); + return; + } + + // Check for error in callback URL + final uri = Uri.tryParse(url); + if (uri != null && uri.path.contains('/oauth/callback')) { + final error = uri.queryParameters['error']; + if (error != null) { + _callbackTriggered = true; // Mark callback as triggered + final errorDescription = uri.queryParameters['description'] ?? uri.queryParameters['details'] ?? 'Authentication failed'; + widget.onError('$error: $errorDescription'); + return; + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Sign In'), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + widget.onError('User cancelled authentication'); + }, + ), + actions: [ + if (_isLoading) + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + ], + ), + body: Column( + children: [ + // URL bar for debugging + if (MediaQuery.of(context).size.height > 600) + Container( + padding: const EdgeInsets.all(8.0), + color: Colors.grey[200], + child: Row( + children: [ + const Icon(Icons.link, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + _currentUrl, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + // WebView + Expanded( + child: WebViewWidget( + controller: _controller, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart b/PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart new file mode 100644 index 0000000..eb6083c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart @@ -0,0 +1,777 @@ +// lib/ui/auth/pinepods_startup_login.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/login_service.dart'; +import 'package:pinepods_mobile/services/pinepods/oidc_service.dart'; +import 'package:pinepods_mobile/services/auth_notifier.dart'; +import 'package:pinepods_mobile/ui/auth/oidc_browser.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'dart:math'; +import 'dart:async'; + +class PinepodsStartupLogin extends StatefulWidget { + final VoidCallback? onLoginSuccess; + + const PinepodsStartupLogin({ + Key? key, + this.onLoginSuccess, + }) : super(key: key); + + @override + State createState() => _PinepodsStartupLoginState(); +} + +class _PinepodsStartupLoginState extends State { + final _serverController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _mfaController = TextEditingController(); + final _formKey = GlobalKey(); + + bool _isLoading = false; + bool _showMfaField = false; + bool _isLoadingOidc = false; + String _errorMessage = ''; + String? _tempServerUrl; + String? _tempUsername; + int? _tempUserId; + String? _tempMfaSessionToken; + List _oidcProviders = []; + bool _hasCheckedOidc = false; + Timer? _oidcCheckTimer; + + // List of background images - you can add your own images to assets/images/ + final List _backgroundImages = [ + 'assets/images/1.webp', + 'assets/images/2.webp', + 'assets/images/3.webp', + 'assets/images/4.webp', + 'assets/images/5.webp', + 'assets/images/6.webp', + 'assets/images/7.webp', + 'assets/images/8.webp', + 'assets/images/9.webp', + ]; + + late String _selectedBackground; + + @override + void initState() { + super.initState(); + // Select a random background image + final random = Random(); + _selectedBackground = _backgroundImages[random.nextInt(_backgroundImages.length)]; + + // Listen for server URL changes to check OIDC providers + _serverController.addListener(_onServerUrlChanged); + + // Register global login success callback + AuthNotifier.setGlobalLoginSuccessCallback(_handleLoginSuccess); + } + + void _onServerUrlChanged() { + final serverUrl = _serverController.text.trim(); + + // Cancel any existing timer + _oidcCheckTimer?.cancel(); + + // Reset OIDC state + setState(() { + _oidcProviders.clear(); + _hasCheckedOidc = false; + _isLoadingOidc = false; + }); + + // Only check if URL looks complete and valid + if (serverUrl.isNotEmpty && + (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) && + _isValidUrl(serverUrl)) { + + // Debounce the API call - wait 1 second after user stops typing + _oidcCheckTimer = Timer(const Duration(seconds: 1), () { + _checkOidcProviders(serverUrl); + }); + } + } + + bool _isValidUrl(String url) { + try { + final uri = Uri.parse(url); + // Check if it has a proper host (not just protocol) + return uri.hasScheme && + uri.host.isNotEmpty && + uri.host.contains('.') && // Must have at least one dot for domain + uri.host.length > 3; // Minimum reasonable length + } catch (e) { + return false; + } + } + + Future _checkOidcProviders(String serverUrl) async { + // Allow rechecking if server URL changed + final currentUrl = _serverController.text.trim(); + if (currentUrl != serverUrl) return; // URL changed while we were waiting + + setState(() { + _isLoadingOidc = true; + }); + + try { + final providers = await OidcService.getPublicProviders(serverUrl); + // Double-check the URL hasn't changed during the API call + if (mounted && _serverController.text.trim() == serverUrl) { + setState(() { + _oidcProviders = providers; + _hasCheckedOidc = true; + _isLoadingOidc = false; + }); + } + } catch (e) { + // Only update state if URL hasn't changed + if (mounted && _serverController.text.trim() == serverUrl) { + setState(() { + _oidcProviders.clear(); + _hasCheckedOidc = true; + _isLoadingOidc = false; + }); + } + } + } + + // Manual retry when user focuses on other fields (like username) + void _retryOidcCheck() { + final serverUrl = _serverController.text.trim(); + if (serverUrl.isNotEmpty && + _isValidUrl(serverUrl) && + !_hasCheckedOidc && + !_isLoadingOidc) { + _checkOidcProviders(serverUrl); + } + } + + Future _handleOidcLogin(OidcProvider provider) async { + final serverUrl = _serverController.text.trim(); + if (serverUrl.isEmpty) { + setState(() { + _errorMessage = 'Please enter a server URL first'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + // Generate PKCE and state parameters for security + final pkce = OidcService.generatePkce(); + final state = OidcService.generateState(); + + // Build authorization URL for in-app browser + final authUrl = await OidcService.buildOidcLoginUrl( + provider: provider, + serverUrl: serverUrl, + state: state, + pkce: pkce, + ); + + if (authUrl == null) { + setState(() { + _errorMessage = 'Failed to prepare OIDC authentication URL'; + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = false; + }); + + // Launch in-app browser + if (mounted) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => OidcBrowser( + authUrl: authUrl, + serverUrl: serverUrl, + onSuccess: (apiKey) async { + Navigator.of(context).pop(); // Close the browser + await _completeOidcLogin(apiKey, serverUrl); + }, + onError: (error) { + Navigator.of(context).pop(); // Close the browser + setState(() { + _errorMessage = 'Authentication failed: $error'; + }); + }, + ), + ), + ); + } + + } catch (e) { + setState(() { + _errorMessage = 'OIDC login error: ${e.toString()}'; + _isLoading = false; + }); + } + } + + Future _completeOidcLogin(String apiKey, String serverUrl) async { + if (!mounted) return; + + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + // Verify API key + final isValidKey = await PinepodsLoginService.verifyApiKey(serverUrl, apiKey); + if (!isValidKey) { + throw Exception('API key verification failed'); + } + + // Get user ID + final userId = await PinepodsLoginService.getUserId(serverUrl, apiKey); + if (userId == null) { + throw Exception('Failed to get user ID'); + } + + // Get user details + final userDetails = await PinepodsLoginService.getUserDetails(serverUrl, apiKey, userId); + if (userDetails == null) { + throw Exception('Failed to get user details'); + } + + // Store credentials + final settingsBloc = Provider.of(context, listen: false); + settingsBloc.setPinepodsServer(serverUrl); + settingsBloc.setPinepodsApiKey(apiKey); + settingsBloc.setPinepodsUserId(userId); + + // Set additional user details if available + if (userDetails.username != null) { + settingsBloc.setPinepodsUsername(userDetails.username!); + } + if (userDetails.email != null) { + settingsBloc.setPinepodsEmail(userDetails.email!); + } + + // Fetch theme from server + try { + await settingsBloc.fetchThemeFromServer(); + } catch (e) { + // Theme fetch failure is non-critical + } + + // Notify login success + AuthNotifier.notifyLoginSuccess(); + + // Call the callback if provided + if (widget.onLoginSuccess != null) { + widget.onLoginSuccess!(); + } + + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Failed to complete login: ${e.toString()}'; + _isLoading = false; + }); + } + } + } + + Future _connectToPinepods() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + if (_showMfaField && _tempMfaSessionToken != null) { + // Complete MFA login flow + final mfaCode = _mfaController.text.trim(); + final result = await PinepodsLoginService.completeMfaLogin( + serverUrl: _tempServerUrl!, + username: _tempUsername!, + mfaSessionToken: _tempMfaSessionToken!, + mfaCode: mfaCode, + ); + + if (result.isSuccess) { + // Save the connection details including user ID + final settingsBloc = Provider.of(context, listen: false); + settingsBloc.setPinepodsServer(result.serverUrl!); + settingsBloc.setPinepodsApiKey(result.apiKey!); + settingsBloc.setPinepodsUserId(result.userId!); + + // Fetch theme from server after successful login + await settingsBloc.fetchThemeFromServer(); + + setState(() { + _isLoading = false; + }); + + // Call success callback + if (widget.onLoginSuccess != null) { + widget.onLoginSuccess!(); + } + } else { + setState(() { + _errorMessage = result.errorMessage ?? 'MFA verification failed'; + _isLoading = false; + }); + } + } else { + // Initial login flow + final serverUrl = _serverController.text.trim(); + final username = _usernameController.text.trim(); + final password = _passwordController.text; + + final result = await PinepodsLoginService.login( + serverUrl, + username, + password, + ); + + if (result.isSuccess) { + // Save the connection details including user ID + final settingsBloc = Provider.of(context, listen: false); + settingsBloc.setPinepodsServer(result.serverUrl!); + settingsBloc.setPinepodsApiKey(result.apiKey!); + settingsBloc.setPinepodsUserId(result.userId!); + + // Fetch theme from server after successful login + await settingsBloc.fetchThemeFromServer(); + + setState(() { + _isLoading = false; + }); + + // Call success callback + if (widget.onLoginSuccess != null) { + widget.onLoginSuccess!(); + } + } else if (result.requiresMfa) { + // Store MFA session info and show MFA field + setState(() { + _tempServerUrl = result.serverUrl; + _tempUsername = result.username; + _tempUserId = result.userId; + _tempMfaSessionToken = result.mfaSessionToken; + _showMfaField = true; + _isLoading = false; + _errorMessage = 'Please enter your MFA code'; + }); + } else { + setState(() { + _errorMessage = result.errorMessage ?? 'Login failed'; + _isLoading = false; + }); + } + } + } catch (e) { + setState(() { + _errorMessage = 'Error: ${e.toString()}'; + _isLoading = false; + }); + } + } + + void _resetMfa() { + setState(() { + _showMfaField = false; + _tempServerUrl = null; + _tempUsername = null; + _tempUserId = null; + _tempMfaSessionToken = null; + _mfaController.clear(); + _errorMessage = ''; + }); + } + + /// Parse hex color string to Color object + Color _parseColor(String hexColor) { + try { + final hex = hexColor.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('FF$hex', radix: 16)); + } else if (hex.length == 8) { + return Color(int.parse(hex, radix: 16)); + } + } catch (e) { + // Fallback to default color on parsing error + } + return Theme.of(context).primaryColor; + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(_selectedBackground), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.6), + BlendMode.darken, + ), + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Card( + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // App Logo/Title + Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.asset( + 'assets/images/favicon.png', + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.headset, + size: 48, + color: Colors.white, + ), + ); + }, + ), + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Welcome to PinePods', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Connect to your PinePods server to get started', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + + // Server URL Field + TextFormField( + controller: _serverController, + decoration: InputDecoration( + labelText: 'Server URL', + hintText: 'https://your-pinepods-server.com', + prefixIcon: const Icon(Icons.dns), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a server URL'; + } + if (!value.startsWith('http://') && !value.startsWith('https://')) { + return 'URL must start with http:// or https://'; + } + return null; + }, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 16), + + // Username Field + Focus( + onFocusChange: (hasFocus) { + if (hasFocus) { + // User focused on username field, retry OIDC check if needed + _retryOidcCheck(); + } + }, + child: TextFormField( + controller: _usernameController, + decoration: InputDecoration( + labelText: 'Username', + prefixIcon: const Icon(Icons.person), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your username'; + } + return null; + }, + textInputAction: TextInputAction.next, + ), + ), + const SizedBox(height: 16), + + // Password Field + TextFormField( + controller: _passwordController, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + return null; + }, + textInputAction: _showMfaField ? TextInputAction.next : TextInputAction.done, + onFieldSubmitted: (_) { + if (!_showMfaField) { + _connectToPinepods(); + } + }, + enabled: !_showMfaField, + ), + + // MFA Field (shown when MFA is required) + if (_showMfaField) ...[ + const SizedBox(height: 16), + TextFormField( + controller: _mfaController, + decoration: InputDecoration( + labelText: 'MFA Code', + hintText: 'Enter 6-digit code', + prefixIcon: const Icon(Icons.security), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + suffixIcon: IconButton( + icon: const Icon(Icons.close), + onPressed: _resetMfa, + tooltip: 'Cancel MFA', + ), + ), + keyboardType: TextInputType.number, + maxLength: 6, + validator: (value) { + if (_showMfaField && (value == null || value.isEmpty)) { + return 'Please enter your MFA code'; + } + if (_showMfaField && value!.length != 6) { + return 'MFA code must be 6 digits'; + } + return null; + }, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _connectToPinepods(), + ), + ], + + // Error Message + if (_errorMessage.isNotEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage, + style: TextStyle(color: Colors.red.shade700), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 24), + + // Connect Button + ElevatedButton( + onPressed: _isLoading ? null : _connectToPinepods, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text( + _showMfaField ? 'Verify MFA Code' : 'Connect to PinePods', + style: const TextStyle(fontSize: 16), + ), + ), + + const SizedBox(height: 16), + + // OIDC Providers Section + if (_oidcProviders.isNotEmpty && !_showMfaField) ...[ + // Divider + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Or continue with', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 16), + + // OIDC Provider Buttons + ..._oidcProviders.map((provider) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : () => _handleOidcLogin(provider), + style: ElevatedButton.styleFrom( + backgroundColor: _parseColor(provider.buttonColorHex), + foregroundColor: _parseColor(provider.buttonTextColorHex), + padding: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (provider.iconSvg != null && provider.iconSvg!.isNotEmpty) + Container( + width: 20, + height: 20, + margin: const EdgeInsets.only(right: 8), + child: const Icon(Icons.account_circle, size: 20), + ), + Text( + provider.displayText, + style: const TextStyle(fontSize: 16), + ), + ], + ), + ), + ), + )), + + const SizedBox(height: 16), + ], + + // Loading indicator for OIDC discovery + if (_isLoadingOidc) ...[ + const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(height: 16), + ], + + + // Additional Info + Text( + 'Don\'t have a PinePods server? Visit pinepods.online to learn more.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + /// Handle login success from any source (traditional or OIDC) + void _handleLoginSuccess() { + if (mounted) { + widget.onLoginSuccess?.call(); + } + } + + @override + void dispose() { + _oidcCheckTimer?.cancel(); + _serverController.removeListener(_onServerUrlChanged); + _serverController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _mfaController.dispose(); + + // Clear global callback to prevent memory leaks + AuthNotifier.clearGlobalLoginSuccessCallback(); + + super.dispose(); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/debug/debug_logs_page.dart b/PinePods-0.8.2/mobile/lib/ui/debug/debug_logs_page.dart new file mode 100644 index 0000000..bdf0670 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/debug/debug_logs_page.dart @@ -0,0 +1,656 @@ +// lib/ui/debug/debug_logs_page.dart +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:pinepods_mobile/services/logging/app_logger.dart'; + +class DebugLogsPage extends StatefulWidget { + const DebugLogsPage({Key? key}) : super(key: key); + + @override + State createState() => _DebugLogsPageState(); +} + +class _DebugLogsPageState extends State { + final AppLogger _logger = AppLogger(); + final ScrollController _scrollController = ScrollController(); + List _logs = []; + LogLevel? _selectedLevel; + bool _showDeviceInfo = true; + List _sessionFiles = []; + bool _hasPreviousCrash = false; + + @override + void initState() { + super.initState(); + _loadLogs(); + _loadSessionFiles(); + } + + void _loadLogs() { + setState(() { + if (_selectedLevel == null) { + _logs = _logger.logs; + } else { + _logs = _logger.getLogsByLevel(_selectedLevel!); + } + }); + } + + Future _copyLogsToClipboard() async { + try { + final formattedLogs = _logger.getFormattedLogs(); + await Clipboard.setData(ClipboardData(text: formattedLogs)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logs copied to clipboard!'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to copy logs: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + Future _loadSessionFiles() async { + try { + final files = await _logger.getSessionFiles(); + final hasCrash = await _logger.hasPreviousCrash(); + setState(() { + _sessionFiles = files; + _hasPreviousCrash = hasCrash; + }); + } catch (e) { + print('Failed to load session files: $e'); + } + } + + Future _copyCurrentSessionToClipboard() async { + try { + final formattedLogs = _logger.getFormattedLogsWithSessionInfo(); + await Clipboard.setData(ClipboardData(text: formattedLogs)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Current session logs copied to clipboard!'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to copy logs: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + Future _copySessionFileToClipboard(File sessionFile) async { + try { + final content = await sessionFile.readAsString(); + final deviceInfo = _logger.deviceInfo?.formattedInfo ?? 'Device info not available'; + final formattedContent = '$deviceInfo\n\n${'=' * 50}\nSession File: ${sessionFile.path.split('/').last}\n${'=' * 50}\n\n$content'; + + await Clipboard.setData(ClipboardData(text: formattedContent)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Session ${sessionFile.path.split('/').last} copied to clipboard!'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to copy session file: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + Future _copyCrashLogToClipboard() async { + try { + final crashPath = _logger.crashLogPath; + if (crashPath == null) { + throw Exception('Crash log path not available'); + } + + final crashFile = File(crashPath); + final content = await crashFile.readAsString(); + + await Clipboard.setData(ClipboardData(text: content)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Crash log copied to clipboard!'), + backgroundColor: Colors.orange, + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to copy crash log: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + Future _openBugTracker() async { + const url = 'https://github.com/madeofpendletonwool/pinepods/issues'; + try { + final uri = Uri.parse(url); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open bug tracker: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _clearLogs() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Logs'), + content: const Text('Are you sure you want to clear all logs? This action cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + _logger.clearLogs(); + _loadLogs(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logs cleared'), + backgroundColor: Colors.orange, + ), + ); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + Color _getLevelColor(LogLevel level) { + switch (level) { + case LogLevel.debug: + return Colors.grey; + case LogLevel.info: + return Colors.blue; + case LogLevel.warning: + return Colors.orange; + case LogLevel.error: + return Colors.red; + case LogLevel.critical: + return Colors.purple; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Debug Logs'), + elevation: 0, + actions: [ + PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'filter': + _showFilterDialog(); + break; + case 'clear': + _clearLogs(); + break; + case 'refresh': + _loadLogs(); + break; + case 'scroll_bottom': + _scrollToBottom(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'filter', + child: Row( + children: [ + Icon(Icons.filter_list), + SizedBox(width: 8), + Text('Filter'), + ], + ), + ), + const PopupMenuItem( + value: 'refresh', + child: Row( + children: [ + Icon(Icons.refresh), + SizedBox(width: 8), + Text('Refresh'), + ], + ), + ), + const PopupMenuItem( + value: 'scroll_bottom', + child: Row( + children: [ + Icon(Icons.vertical_align_bottom), + SizedBox(width: 8), + Text('Scroll to Bottom'), + ], + ), + ), + const PopupMenuItem( + value: 'clear', + child: Row( + children: [ + Icon(Icons.clear_all), + SizedBox(width: 8), + Text('Clear Logs'), + ], + ), + ), + ], + ), + ], + ), + body: Column( + children: [ + // Header with device info toggle and stats + Container( + padding: const EdgeInsets.all(16.0), + color: Theme.of(context).cardColor, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Total Entries: ${_logs.length}', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (_selectedLevel != null) + Chip( + label: Text(_selectedLevel!.name.toUpperCase()), + backgroundColor: _getLevelColor(_selectedLevel!).withOpacity(0.2), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () { + setState(() { + _selectedLevel = null; + }); + _loadLogs(); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _copyCurrentSessionToClipboard, + icon: const Icon(Icons.copy), + label: const Text('Copy Current'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: _openBugTracker, + icon: const Icon(Icons.bug_report), + label: const Text('Report Bug'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + + // Session Files Section + if (_sessionFiles.isNotEmpty || _hasPreviousCrash) + ExpansionTile( + title: const Text('Session Files & Crash Logs'), + leading: const Icon(Icons.folder), + initiallyExpanded: false, + children: [ + if (_hasPreviousCrash) + ListTile( + leading: const Icon(Icons.warning, color: Colors.red), + title: const Text('Previous Crash Log'), + subtitle: const Text('Tap to copy crash log to clipboard'), + trailing: IconButton( + icon: const Icon(Icons.copy), + onPressed: _copyCrashLogToClipboard, + ), + onTap: _copyCrashLogToClipboard, + ), + ..._sessionFiles.map((file) { + final fileName = file.path.split('/').last; + final isCurrentSession = fileName.contains(_logger.currentSessionPath?.split('/').last?.replaceFirst('session_', '').replaceFirst('.log', '') ?? ''); + + return ListTile( + leading: Icon( + isCurrentSession ? Icons.play_circle : Icons.history, + color: isCurrentSession ? Colors.green : Colors.grey, + ), + title: Text(fileName), + subtitle: Text( + 'Modified: ${file.lastModifiedSync().toString().substring(0, 16)}${isCurrentSession ? ' (Current)' : ''}', + style: TextStyle( + fontSize: 12, + color: isCurrentSession ? Colors.green : Colors.grey[600], + ), + ), + trailing: IconButton( + icon: const Icon(Icons.copy), + onPressed: () => _copySessionFileToClipboard(file), + ), + onTap: () => _copySessionFileToClipboard(file), + ); + }).toList(), + if (_sessionFiles.isEmpty && !_hasPreviousCrash) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'No session files available yet', + style: TextStyle(color: Colors.grey), + ), + ), + ], + ), + + // Device info section (collapsible) + if (_showDeviceInfo && _logger.deviceInfo != null) + ExpansionTile( + title: const Text('Device Information'), + leading: const Icon(Icons.phone_android), + initiallyExpanded: false, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.withOpacity(0.3)), + ), + child: Text( + _logger.deviceInfo!.formattedInfo, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + ), + ], + ), + + // Logs list + Expanded( + child: _logs.isEmpty + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'No logs found', + style: TextStyle(fontSize: 18, color: Colors.grey), + ), + SizedBox(height: 8), + Text( + 'Use the app to generate logs', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ) + : ListView.builder( + controller: _scrollController, + itemCount: _logs.length, + itemBuilder: (context, index) { + final log = _logs[index]; + return _buildLogEntry(log); + }, + ), + ), + ], + ), + floatingActionButton: _logs.isNotEmpty + ? FloatingActionButton( + onPressed: _scrollToBottom, + tooltip: 'Scroll to bottom', + child: const Icon(Icons.vertical_align_bottom), + ) + : null, + ); + } + + Widget _buildLogEntry(LogEntry log) { + final levelColor = _getLevelColor(log.level); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Card( + elevation: 1, + child: ExpansionTile( + leading: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: levelColor, + shape: BoxShape.circle, + ), + ), + title: Text( + log.message, + style: const TextStyle(fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${log.timestamp.toString().substring(0, 19)} • ${log.levelString} • ${log.tag}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + log.formattedMessage, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + if (log.stackTrace != null && log.stackTrace!.isNotEmpty) ...[ + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + const Text( + 'Stack Trace:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + SelectableText( + log.stackTrace!, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ], + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: log.formattedMessage)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Log entry copied to clipboard')), + ); + }, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy'), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + void _showFilterDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Filter Logs'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Show only logs of level:'), + const SizedBox(height: 16), + ...LogLevel.values.map((level) => RadioListTile( + title: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: _getLevelColor(level), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text(level.name.toUpperCase()), + ], + ), + value: level, + groupValue: _selectedLevel, + onChanged: (value) { + setState(() { + _selectedLevel = value; + }); + }, + )), + RadioListTile( + title: const Text('All Levels'), + value: null, + groupValue: _selectedLevel, + onChanged: (value) { + setState(() { + _selectedLevel = null; + }); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + _loadLogs(); + Navigator.of(context).pop(); + }, + child: const Text('Apply'), + ), + ], + ), + ); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/library/downloads.dart b/PinePods-0.8.2/mobile/lib/ui/library/downloads.dart new file mode 100644 index 0000000..771f5fe --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/library/downloads.dart @@ -0,0 +1,19 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/ui/pinepods/downloads.dart'; +import 'package:flutter/material.dart'; + +/// Displays a list of currently downloaded podcast episodes. +/// This is a wrapper that redirects to the new PinePods downloads implementation. +class Downloads extends StatelessWidget { + const Downloads({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const PinepodsDownloads(); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/library/library.dart b/PinePods-0.8.2/mobile/lib/ui/library/library.dart new file mode 100644 index 0000000..563e695 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/library/library.dart @@ -0,0 +1,115 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_grid_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// This class displays the list of podcasts the user is currently following. +class Library extends StatefulWidget { + const Library({ + super.key, + }); + + @override + State createState() => _LibraryState(); +} + +class _LibraryState extends State { + @override + Widget build(BuildContext context) { + final podcastBloc = Provider.of(context); + final settingsBloc = Provider.of(context); + + return StreamBuilder>( + stream: podcastBloc.subscriptions, + builder: (context, snapshot) { + if (snapshot.hasData) { + if (snapshot.data!.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.headset, + size: 75, + color: Theme.of(context).primaryColor, + ), + Text( + L.of(context)!.no_subscriptions_message, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } else { + return StreamBuilder( + stream: settingsBloc.settings, + builder: (context, settingsSnapshot) { + if (settingsSnapshot.hasData) { + var mode = settingsSnapshot.data!.layout; + var size = mode == 1 ? 100.0 : 160.0; + + if (mode == 0) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return PodcastTile(podcast: snapshot.data!.elementAt(index)); + }, + childCount: snapshot.data!.length, + addAutomaticKeepAlives: false, + )); + } + return SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: size, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return PodcastGridTile(podcast: snapshot.data!.elementAt(index)); + }, + childCount: snapshot.data!.length, + ), + ); + } else { + return const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox( + height: 0, + width: 0, + ), + ); + } + }); + } + } else { + return const SliverFillRemaining( + hasScrollBody: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + PlatformProgressIndicator(), + ], + ), + ); + } + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart new file mode 100644 index 0000000..251cf14 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart @@ -0,0 +1,390 @@ +// lib/ui/pinepods/create_playlist.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:provider/provider.dart'; + +class CreatePlaylistPage extends StatefulWidget { + const CreatePlaylistPage({Key? key}) : super(key: key); + + @override + State createState() => _CreatePlaylistPageState(); +} + +class _CreatePlaylistPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + final PinepodsService _pinepodsService = PinepodsService(); + + bool _isLoading = false; + String _selectedIcon = 'ph-playlist'; + bool _includeUnplayed = true; + bool _includePartiallyPlayed = true; + bool _includePlayed = false; + String _minDuration = ''; + String _maxDuration = ''; + String _sortOrder = 'newest_first'; + bool _groupByPodcast = false; + String _maxEpisodes = ''; + + final List> _availableIcons = [ + {'name': 'ph-playlist', 'icon': '🎵'}, + {'name': 'ph-music-notes', 'icon': '🎶'}, + {'name': 'ph-play-circle', 'icon': '▶️'}, + {'name': 'ph-headphones', 'icon': '🎧'}, + {'name': 'ph-star', 'icon': '⭐'}, + {'name': 'ph-heart', 'icon': '❤️'}, + {'name': 'ph-bookmark', 'icon': '🔖'}, + {'name': 'ph-clock', 'icon': '⏰'}, + {'name': 'ph-calendar', 'icon': '📅'}, + {'name': 'ph-timer', 'icon': '⏲️'}, + {'name': 'ph-shuffle', 'icon': '🔀'}, + {'name': 'ph-repeat', 'icon': '🔁'}, + {'name': 'ph-microphone', 'icon': '🎤'}, + {'name': 'ph-queue', 'icon': '📋'}, + {'name': 'ph-fire', 'icon': '🔥'}, + {'name': 'ph-lightning', 'icon': '⚡'}, + {'name': 'ph-coffee', 'icon': '☕'}, + {'name': 'ph-moon', 'icon': '🌙'}, + {'name': 'ph-sun', 'icon': '☀️'}, + {'name': 'ph-rocket', 'icon': '🚀'}, + ]; + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _createPlaylist() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not connected to PinePods server')), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + + final request = CreatePlaylistRequest( + userId: settings.pinepodsUserId!, + name: _nameController.text.trim(), + description: _descriptionController.text.trim().isNotEmpty + ? _descriptionController.text.trim() + : null, + podcastIds: const [], // For now, we'll create without podcast filtering + includeUnplayed: _includeUnplayed, + includePartiallyPlayed: _includePartiallyPlayed, + includePlayed: _includePlayed, + minDuration: _minDuration.isNotEmpty ? int.tryParse(_minDuration) : null, + maxDuration: _maxDuration.isNotEmpty ? int.tryParse(_maxDuration) : null, + sortOrder: _sortOrder, + groupByPodcast: _groupByPodcast, + maxEpisodes: _maxEpisodes.isNotEmpty ? int.tryParse(_maxEpisodes) : null, + iconName: _selectedIcon, + playProgressMin: null, // Simplified for now + playProgressMax: null, + timeFilterHours: null, + ); + + final success = await _pinepodsService.createPlaylist(request); + + if (success) { + if (mounted) { + Navigator.of(context).pop(true); // Return true to indicate success + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Playlist created successfully!')), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to create playlist')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error creating playlist: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Create Playlist'), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0, + actions: [ + if (_isLoading) + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + TextButton( + onPressed: _createPlaylist, + child: const Text('Create'), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + // Name field + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Playlist Name', + border: OutlineInputBorder(), + hintText: 'Enter playlist name', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a playlist name'; + } + return null; + }, + ), + + const SizedBox(height: 16), + + // Description field + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (Optional)', + border: OutlineInputBorder(), + hintText: 'Enter playlist description', + ), + maxLines: 3, + ), + + const SizedBox(height: 16), + + // Icon selector + Text( + 'Icon', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Container( + height: 120, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: _availableIcons.length, + itemBuilder: (context, index) { + final icon = _availableIcons[index]; + final isSelected = _selectedIcon == icon['name']; + + return GestureDetector( + onTap: () { + setState(() { + _selectedIcon = icon['name']!; + }); + }, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).primaryColor.withOpacity(0.2) + : null, + border: Border.all( + color: isSelected + ? Theme.of(context).primaryColor + : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + icon['icon']!, + style: const TextStyle(fontSize: 20), + ), + ), + ), + ); + }, + ), + ), + + const SizedBox(height: 24), + + Text( + 'Episode Filters', + style: Theme.of(context).textTheme.titleMedium, + ), + + const SizedBox(height: 8), + + // Episode filters + CheckboxListTile( + title: const Text('Include Unplayed'), + value: _includeUnplayed, + onChanged: (value) { + setState(() { + _includeUnplayed = value ?? true; + }); + }, + ), + CheckboxListTile( + title: const Text('Include Partially Played'), + value: _includePartiallyPlayed, + onChanged: (value) { + setState(() { + _includePartiallyPlayed = value ?? true; + }); + }, + ), + CheckboxListTile( + title: const Text('Include Played'), + value: _includePlayed, + onChanged: (value) { + setState(() { + _includePlayed = value ?? false; + }); + }, + ), + + const SizedBox(height: 16), + + // Duration range + Text( + 'Duration Range (minutes)', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + decoration: const InputDecoration( + labelText: 'Min', + border: OutlineInputBorder(), + hintText: 'Any', + ), + keyboardType: TextInputType.number, + onChanged: (value) { + _minDuration = value; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + decoration: const InputDecoration( + labelText: 'Max', + border: OutlineInputBorder(), + hintText: 'Any', + ), + keyboardType: TextInputType.number, + onChanged: (value) { + _maxDuration = value; + }, + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Sort order + DropdownButtonFormField( + value: _sortOrder, + decoration: const InputDecoration( + labelText: 'Sort Order', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'newest_first', child: Text('Newest First')), + DropdownMenuItem(value: 'oldest_first', child: Text('Oldest First')), + DropdownMenuItem(value: 'shortest_first', child: Text('Shortest First')), + DropdownMenuItem(value: 'longest_first', child: Text('Longest First')), + ], + onChanged: (value) { + setState(() { + _sortOrder = value!; + }); + }, + ), + + const SizedBox(height: 16), + + // Max episodes + TextFormField( + decoration: const InputDecoration( + labelText: 'Max Episodes (Optional)', + border: OutlineInputBorder(), + hintText: 'Leave blank for no limit', + ), + keyboardType: TextInputType.number, + onChanged: (value) { + _maxEpisodes = value; + }, + ), + + const SizedBox(height: 16), + + // Group by podcast + CheckboxListTile( + title: const Text('Group by Podcast'), + subtitle: const Text('Group episodes by their podcast'), + value: _groupByPodcast, + onChanged: (value) { + setState(() { + _groupByPodcast = value ?? false; + }); + }, + ), + + const SizedBox(height: 32), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart new file mode 100644 index 0000000..2c6f1f9 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart @@ -0,0 +1,968 @@ +// lib/ui/pinepods/downloads.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/download/download_service.dart'; +import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/services/error_handling_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:provider/provider.dart'; +import 'package:logging/logging.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +class PinepodsDownloads extends StatefulWidget { + const PinepodsDownloads({super.key}); + + @override + State createState() => _PinepodsDownloadsState(); +} + +class _PinepodsDownloadsState extends State { + final log = Logger('PinepodsDownloads'); + final PinepodsService _pinepodsService = PinepodsService(); + + List _serverDownloads = []; + List _localDownloads = []; + Map> _serverDownloadsByPodcast = {}; + Map> _localDownloadsByPodcast = {}; + + bool _isLoadingServerDownloads = false; + bool _isLoadingLocalDownloads = false; + String? _errorMessage; + + Set _expandedPodcasts = {}; + int? _contextMenuEpisodeIndex; + bool _isServerEpisode = false; + + // Search functionality + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + Map> _filteredServerDownloadsByPodcast = {}; + Map> _filteredLocalDownloadsByPodcast = {}; + + @override + void initState() { + super.initState(); + _loadDownloads(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + setState(() { + _searchQuery = _searchController.text; + _filterDownloads(); + }); + } + + void _filterDownloads() { + // Filter server downloads + _filteredServerDownloadsByPodcast = {}; + for (final entry in _serverDownloadsByPodcast.entries) { + final podcastName = entry.key; + final episodes = entry.value; + + if (_searchQuery.isEmpty) { + _filteredServerDownloadsByPodcast[podcastName] = List.from(episodes); + } else { + final filteredEpisodes = episodes.where((episode) { + return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + + if (filteredEpisodes.isNotEmpty) { + _filteredServerDownloadsByPodcast[podcastName] = filteredEpisodes; + } + } + } + + // Filter local downloads (will be called when local downloads are loaded) + _filterLocalDownloads(); + } + + void _filterLocalDownloads([Map>? localDownloadsByPodcast]) { + final downloadsToFilter = localDownloadsByPodcast ?? _localDownloadsByPodcast; + _filteredLocalDownloadsByPodcast = {}; + + for (final entry in downloadsToFilter.entries) { + final podcastName = entry.key; + final episodes = entry.value; + + if (_searchQuery.isEmpty) { + _filteredLocalDownloadsByPodcast[podcastName] = List.from(episodes); + } else { + final filteredEpisodes = episodes.where((episode) { + return (episode.title ?? '').toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + + if (filteredEpisodes.isNotEmpty) { + _filteredLocalDownloadsByPodcast[podcastName] = filteredEpisodes; + } + } + } + } + + Future _loadDownloads() async { + await Future.wait([ + _loadServerDownloads(), + _loadLocalDownloads(), + ]); + } + + Future _loadServerDownloads() async { + setState(() { + _isLoadingServerDownloads = true; + _errorMessage = null; + }); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && + settings.pinepodsApiKey != null && + settings.pinepodsUserId != null) { + + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + + final downloads = await _pinepodsService.getServerDownloads(settings.pinepodsUserId!); + + setState(() { + _serverDownloads = downloads; + _serverDownloadsByPodcast = _groupEpisodesByPodcast(downloads); + _filterDownloads(); // Initialize filtered data + _isLoadingServerDownloads = false; + }); + } else { + setState(() { + _isLoadingServerDownloads = false; + }); + } + } catch (e) { + log.severe('Error loading server downloads: $e'); + setState(() { + _errorMessage = 'Failed to load server downloads: $e'; + _isLoadingServerDownloads = false; + }); + } + } + + Future _loadLocalDownloads() async { + setState(() { + _isLoadingLocalDownloads = true; + }); + + try { + final episodeBloc = Provider.of(context, listen: false); + episodeBloc.fetchDownloads(false); + + // Debug: Let's also directly check what the repository returns + final podcastBloc = Provider.of(context, listen: false); + final directDownloads = await podcastBloc.podcastService.loadDownloads(); + print('DEBUG: Direct downloads from repository: ${directDownloads.length} episodes'); + for (var episode in directDownloads) { + print('DEBUG: Episode: ${episode.title}, GUID: ${episode.guid}, Downloaded: ${episode.downloaded}, Percentage: ${episode.downloadPercentage}'); + } + + setState(() { + _isLoadingLocalDownloads = false; + }); + } catch (e) { + log.severe('Error loading local downloads: $e'); + setState(() { + _isLoadingLocalDownloads = false; + }); + } + } + + Map> _groupEpisodesByPodcast(List episodes) { + final grouped = >{}; + + for (final episode in episodes) { + final podcastName = episode.podcastName; + if (!grouped.containsKey(podcastName)) { + grouped[podcastName] = []; + } + grouped[podcastName]!.add(episode); + } + + // Sort episodes within each podcast by publication date (newest first) + for (final episodes in grouped.values) { + episodes.sort((a, b) { + try { + final dateA = DateTime.parse(a.episodePubDate); + final dateB = DateTime.parse(b.episodePubDate); + return dateB.compareTo(dateA); // newest first + } catch (e) { + return 0; + } + }); + } + + return grouped; + } + + Map> _groupLocalEpisodesByPodcast(List episodes) { + final grouped = >{}; + + for (final episode in episodes) { + final podcastName = episode.podcast ?? 'Unknown Podcast'; + if (!grouped.containsKey(podcastName)) { + grouped[podcastName] = []; + } + grouped[podcastName]!.add(episode); + } + + // Sort episodes within each podcast by publication date (newest first) + for (final episodes in grouped.values) { + episodes.sort((a, b) { + if (a.publicationDate == null || b.publicationDate == null) { + return 0; + } + return b.publicationDate!.compareTo(a.publicationDate!); + }); + } + + return grouped; + } + + void _togglePodcastExpansion(String podcastKey) { + setState(() { + if (_expandedPodcasts.contains(podcastKey)) { + _expandedPodcasts.remove(podcastKey); + } else { + _expandedPodcasts.add(podcastKey); + } + }); + } + + Future _handleServerEpisodeDelete(PinepodsEpisode episode) async { + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsUserId != null) { + final success = await _pinepodsService.deleteEpisode( + episode.episodeId, + settings.pinepodsUserId!, + episode.isYoutube, + ); + + if (success) { + // Remove from local state + setState(() { + _serverDownloads.removeWhere((e) => e.episodeId == episode.episodeId); + _serverDownloadsByPodcast = _groupEpisodesByPodcast(_serverDownloads); + _filterDownloads(); // Update filtered lists after removal + }); + } else { + _showErrorSnackBar('Failed to delete episode from server'); + } + } + } catch (e) { + log.severe('Error deleting server episode: $e'); + _showErrorSnackBar('Error deleting episode: $e'); + } + } + + void _handleLocalEpisodeDelete(Episode episode) { + final episodeBloc = Provider.of(context, listen: false); + episodeBloc.deleteDownload(episode); + + // The episode bloc will automatically update the downloads stream + // which will trigger a UI refresh + } + + void _showContextMenu(int episodeIndex, bool isServerEpisode) { + setState(() { + _contextMenuEpisodeIndex = episodeIndex; + _isServerEpisode = isServerEpisode; + }); + } + + void _hideContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + _isServerEpisode = false; + }); + } + + Future _localDownloadServerEpisode(int episodeIndex) async { + final episode = _serverDownloads[episodeIndex]; + + try { + // Convert PinepodsEpisode to Episode for local download + final localEpisode = Episode( + guid: 'pinepods_${episode.episodeId}_${DateTime.now().millisecondsSinceEpoch}', + pguid: 'pinepods_${episode.podcastName.replaceAll(' ', '_').toLowerCase()}', + podcast: episode.podcastName, + title: episode.episodeTitle, + description: episode.episodeDescription, + imageUrl: episode.episodeArtwork, + contentUrl: episode.episodeUrl, + duration: episode.episodeDuration, + publicationDate: DateTime.tryParse(episode.episodePubDate), + author: episode.podcastName, + season: 0, + episode: 0, + position: episode.listenDuration ?? 0, + played: episode.completed, + chapters: [], + transcriptUrls: [], + ); + + final podcastBloc = Provider.of(context, listen: false); + + // First save the episode to the repository so it can be tracked + await podcastBloc.podcastService.saveEpisode(localEpisode); + + // Use the download service from podcast bloc + final success = await podcastBloc.downloadService.downloadEpisode(localEpisode); + + if (success) { + _showSnackBar('Episode download started', Colors.green); + } else { + _showSnackBar('Failed to start download', Colors.red); + } + } catch (e) { + _showSnackBar('Error starting local download: $e', Colors.red); + } + + _hideContextMenu(); + } + + void _showErrorSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + + void _showSnackBar(String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + + Widget _buildPodcastDropdown(String podcastKey, List episodes, {bool isServerDownload = false, String? displayName}) { + final isExpanded = _expandedPodcasts.contains(podcastKey); + final title = displayName ?? podcastKey; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: Column( + children: [ + ListTile( + leading: Icon( + isServerDownload ? Icons.cloud_download : Icons.file_download, + color: isServerDownload ? Colors.blue : Colors.green, + ), + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${episodes.length} episode${episodes.length != 1 ? 's' : ''}' + + (episodes.length > 20 ? ' (showing 20 at a time)' : '') + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (episodes.length > 20) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Large', + style: TextStyle( + fontSize: 10, + color: Colors.orange[800], + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + ), + ], + ), + onTap: () => _togglePodcastExpansion(podcastKey), + ), + if (isExpanded) + PaginatedEpisodeList( + episodes: episodes, + isServerEpisodes: isServerDownload, + onEpisodeTap: isServerDownload + ? (episode) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: episode, + ), + ), + ); + } + : null, + onEpisodeLongPress: isServerDownload + ? (episode, globalIndex) { + // Find the index in the full _serverDownloads list + final serverIndex = _serverDownloads.indexWhere((e) => e.episodeId == episode.episodeId); + _showContextMenu(serverIndex >= 0 ? serverIndex : globalIndex, true); + } + : null, + onPlayPressed: isServerDownload + ? (episode) => _playServerEpisode(episode) + : (episode) => _playLocalEpisode(episode), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final episodeBloc = Provider.of(context); + + // Show context menu as a modal overlay if needed + if (_contextMenuEpisodeIndex != null) { + final episodeIndex = _contextMenuEpisodeIndex!; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isServerEpisode) { + // Show server episode context menu + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: _serverDownloads[episodeIndex], + onDownload: () { + Navigator.of(context).pop(); + _handleServerEpisodeDelete(_serverDownloads[episodeIndex]); + _hideContextMenu(); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadServerEpisode(episodeIndex); + }, + onDismiss: () { + Navigator.of(context).pop(); + _hideContextMenu(); + }, + ), + ); + } + }); + // Reset the context menu index after storing it locally + _contextMenuEpisodeIndex = null; + } + + return StreamBuilder( + stream: episodeBloc.downloads, + builder: (context, snapshot) { + final localDownloadsState = snapshot.data; + List currentLocalDownloads = []; + Map> currentLocalDownloadsByPodcast = {}; + + if (localDownloadsState is BlocPopulatedState>) { + currentLocalDownloads = localDownloadsState.results ?? []; + currentLocalDownloadsByPodcast = _groupLocalEpisodesByPodcast(currentLocalDownloads); + } + + final isLoading = _isLoadingServerDownloads || + _isLoadingLocalDownloads || + (localDownloadsState is BlocLoadingState); + + if (isLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: PlatformProgressIndicator()), + ); + } + + // Update filtered local downloads when local downloads change + _filterLocalDownloads(currentLocalDownloadsByPodcast); + + if (_errorMessage != null) { + // Check if this is a server connection error - show offline mode for downloads + if (_errorMessage!.isServerConnectionError) { + // Show offline downloads only with special UI + return _buildOfflineDownloadsView(_filteredLocalDownloadsByPodcast); + } else { + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red[300], + ), + const SizedBox(height: 16), + Text( + _errorMessage!.userFriendlyMessage, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadDownloads, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + } + + if (_filteredLocalDownloadsByPodcast.isEmpty && _filteredServerDownloadsByPodcast.isEmpty) { + if (_searchQuery.isNotEmpty) { + // Show no search results message + return MultiSliver( + children: [ + _buildSearchBar(), + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 16), + Text( + 'No downloads found', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'No downloads match "$_searchQuery"', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + ], + ); + } else { + // Show empty downloads message + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.download_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No downloads found', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Downloaded episodes will appear here', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } + } + + return MultiSliver( + children: [ + _buildSearchBar(), + _buildDownloadsList(), + ], + ); + }, + ); + } + + Widget _buildSearchBar() { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Filter episodes...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: Theme.of(context).cardColor, + ), + ), + ), + ); + } + + Widget _buildDownloadsList() { + return SliverList( + delegate: SliverChildListDelegate([ + // Local Downloads Section + if (_filteredLocalDownloadsByPodcast.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Icon(Icons.smartphone, color: Colors.green[600]), + const SizedBox(width: 8), + Text( + _searchQuery.isEmpty + ? 'Local Downloads' + : 'Local Downloads (${_countFilteredEpisodes(_filteredLocalDownloadsByPodcast)})', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.green[600], + ), + ), + ], + ), + ), + + ..._filteredLocalDownloadsByPodcast.entries.map((entry) { + final podcastName = entry.key; + final episodes = entry.value; + final podcastKey = 'local_$podcastName'; + + return _buildPodcastDropdown( + podcastKey, + episodes, + isServerDownload: false, + displayName: podcastName, + ); + }).toList(), + ], + + // Server Downloads Section + if (_filteredServerDownloadsByPodcast.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + Icon(Icons.cloud_download, color: Colors.blue[600]), + const SizedBox(width: 8), + Text( + _searchQuery.isEmpty + ? 'Server Downloads' + : 'Server Downloads (${_countFilteredEpisodes(_filteredServerDownloadsByPodcast)})', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.blue[600], + ), + ), + ], + ), + ), + + ..._filteredServerDownloadsByPodcast.entries.map((entry) { + final podcastName = entry.key; + final episodes = entry.value; + final podcastKey = 'server_$podcastName'; + + return _buildPodcastDropdown( + podcastKey, + episodes, + isServerDownload: true, + displayName: podcastName, + ); + }).toList(), + ], + + // Bottom padding + const SizedBox(height: 100), + ]), + ); + } + + int _countFilteredEpisodes(Map> downloadsByPodcast) { + return downloadsByPodcast.values.fold(0, (sum, episodes) => sum + episodes.length); + } + + void _playServerEpisode(PinepodsEpisode episode) { + // TODO: Implement server episode playback + // This would involve getting the stream URL from the server + // and playing it through the audio service + log.info('Playing server episode: ${episode.episodeTitle}'); + + _showErrorSnackBar('Server episode playback not yet implemented'); + } + + Future _playLocalEpisode(Episode episode) async { + try { + log.info('Playing local episode: ${episode.title}'); + + final audioPlayerService = Provider.of(context, listen: false); + + // Use the regular audio player service for offline playback + // This bypasses the PinePods service and server dependencies + await audioPlayerService.playEpisode(episode: episode, resume: true); + + log.info('Successfully started local episode playback'); + } catch (e) { + log.severe('Error playing local episode: $e'); + _showErrorSnackBar('Failed to play episode: $e'); + } + } + + Widget _buildOfflinePodcastDropdown(String podcastKey, List episodes, {String? displayName}) { + final isExpanded = _expandedPodcasts.contains(podcastKey); + final title = displayName ?? podcastKey; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: Column( + children: [ + ListTile( + leading: Icon( + Icons.offline_pin, + color: Colors.green[700], + ), + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${episodes.length} episode${episodes.length != 1 ? 's' : ''} available offline' + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Offline', + style: TextStyle( + fontSize: 10, + color: Colors.green[700], + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + ), + ], + ), + onTap: () => _togglePodcastExpansion(podcastKey), + ), + if (isExpanded) + PaginatedEpisodeList( + episodes: episodes, + isServerEpisodes: false, + isOfflineMode: true, + onPlayPressed: (episode) => _playLocalEpisode(episode), + ), + ], + ), + ); + } + + Widget _buildOfflineDownloadsView(Map> localDownloadsByPodcast) { + return MultiSliver( + children: [ + // Offline banner + SliverToBoxAdapter( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Colors.orange[100], + border: Border.all(color: Colors.orange[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.cloud_off, + color: Colors.orange[800], + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Offline Mode', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.orange[800], + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + 'Server unavailable. Showing local downloads only.', + style: TextStyle( + color: Colors.orange[700], + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: () { + setState(() { + _errorMessage = null; + }); + _loadDownloads(); + }, + icon: Icon( + Icons.refresh, + size: 16, + color: Colors.orange[800], + ), + label: Text( + 'Retry', + style: TextStyle( + color: Colors.orange[800], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange[50], + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ), + ), + + // Search bar for filtering local downloads + _buildSearchBar(), + + // Local downloads content + if (localDownloadsByPodcast.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No local downloads', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Download episodes while online to access them here', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else + SliverList( + delegate: SliverChildListDelegate([ + // Local downloads header + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Icon(Icons.smartphone, color: Colors.green[600]), + const SizedBox(width: 8), + Text( + _searchQuery.isEmpty + ? 'Local Downloads' + : 'Local Downloads (${_countFilteredEpisodes(localDownloadsByPodcast)})', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.green[600], + ), + ), + ], + ), + ), + + // Local downloads by podcast + ...localDownloadsByPodcast.entries.map((entry) { + final podcastName = entry.key; + final episodes = entry.value; + final podcastKey = 'offline_local_$podcastName'; + + return _buildOfflinePodcastDropdown( + podcastKey, + episodes, + displayName: podcastName, + ); + }).toList(), + + // Bottom padding + const SizedBox(height: 100), + ]), + ), + ], + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart new file mode 100644 index 0000000..0fb271a --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart @@ -0,0 +1,963 @@ +// lib/ui/pinepods/episode_details.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/audio/default_audio_player_service.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/entities/person.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_html.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_description.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_image.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/ui/podcast/mini_player.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:pinepods_mobile/ui/utils/local_download_utils.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:provider/provider.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; + +class PinepodsEpisodeDetails extends StatefulWidget { + final PinepodsEpisode initialEpisode; + + const PinepodsEpisodeDetails({ + Key? key, + required this.initialEpisode, + }) : super(key: key); + + @override + State createState() => _PinepodsEpisodeDetailsState(); +} + +class _PinepodsEpisodeDetailsState extends State { + final PinepodsService _pinepodsService = PinepodsService(); + // Use global audio service instead of creating local instance + PinepodsEpisode? _episode; + bool _isLoading = true; + String _errorMessage = ''; + List _persons = []; + bool _isDownloadedLocally = false; + + @override + void initState() { + super.initState(); + _episode = widget.initialEpisode; + _loadEpisodeDetails(); + _checkLocalDownloadStatus(); + } + + PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + + Future _checkLocalDownloadStatus() async { + if (_episode == null) return; + + final isDownloaded = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, _episode!); + + if (mounted) { + setState(() { + _isDownloadedLocally = isDownloaded; + }); + } + } + + Future _localDownloadEpisode() async { + if (_episode == null) return; + + final success = await LocalDownloadUtils.localDownloadEpisode(context, _episode!); + + if (success) { + LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green); + await _checkLocalDownloadStatus(); // Update button state + } else { + LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red); + } + } + + Future _deleteLocalDownload() async { + if (_episode == null) return; + + final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, _episode!); + + if (deletedCount > 0) { + LocalDownloadUtils.showSnackBar( + context, + 'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}', + Colors.orange + ); + await _checkLocalDownloadStatus(); // Update button state + } else { + LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red); + } + } + + Future _loadEpisodeDetails() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please login first.'; + _isLoading = false; + }); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + final userId = settings.pinepodsUserId!; + + final episodeDetails = await _pinepodsService.getEpisodeMetadata( + _episode!.episodeId, + userId, + isYoutube: _episode!.isYoutube, + personEpisode: false, // Adjust if needed + ); + + if (episodeDetails != null) { + // Fetch podcast 2.0 data for persons information + final podcast2Data = await _pinepodsService.fetchPodcasting2Data( + episodeDetails.episodeId, + userId, + ); + + List persons = []; + if (podcast2Data != null) { + final personsData = podcast2Data['people'] as List?; + if (personsData != null) { + try { + persons = personsData.map((personData) { + return Person( + name: personData['name'] ?? '', + role: personData['role'] ?? '', + group: personData['group'] ?? '', + image: personData['img'], + link: personData['href'], + ); + }).toList(); + print('Loaded ${persons.length} persons from episode 2.0 data'); + } catch (e) { + print('Error parsing persons data: $e'); + } + } + } + + setState(() { + _episode = episodeDetails; + _persons = persons; + _isLoading = false; + }); + } else { + setState(() { + _errorMessage = 'Failed to load episode details'; + _isLoading = false; + }); + } + } catch (e) { + setState(() { + _errorMessage = 'Error loading episode details: ${e.toString()}'; + _isLoading = false; + }); + } + } + + bool _isCurrentEpisodePlaying() { + try { + final audioPlayerService = Provider.of(context, listen: false); + final currentEpisode = audioPlayerService.nowPlaying; + return currentEpisode != null && currentEpisode.guid == _episode!.episodeUrl; + } catch (e) { + return false; + } + } + + bool _isAudioPlaying() { + try { + final audioPlayerService = Provider.of(context, listen: false); + // This method is no longer needed since we're using StreamBuilder + return false; + } catch (e) { + return false; + } + } + + Future _togglePlayPause() async { + + if (_audioService == null) { + _showSnackBar('Audio service not available', Colors.red); + return; + } + + try { + final audioPlayerService = Provider.of(context, listen: false); + + // Check if this episode is currently playing + if (_isCurrentEpisodePlaying()) { + // This episode is loaded, check current state and toggle + final currentState = audioPlayerService.playingState; + if (currentState != null) { + // Listen to the current state + final state = await currentState.first; + if (state == AudioState.playing) { + await audioPlayerService.pause(); + } else { + await audioPlayerService.play(); + } + } else { + await audioPlayerService.play(); + } + } else { + // Start playing this episode + await playPinepodsEpisodeWithOptionalFullScreen( + context, + _audioService!, + _episode!, + resume: _episode!.isStarted, + ); + } + } catch (e) { + _showSnackBar('Failed to control playback: ${e.toString()}', Colors.red); + } + } + + Future _handleTimestampTap(Duration timestamp) async { + + if (_audioService == null) { + _showSnackBar('Audio service not available', Colors.red); + return; + } + + try { + final audioPlayerService = Provider.of(context, listen: false); + + // Check if this episode is currently playing + final currentEpisode = audioPlayerService.nowPlaying; + final isCurrentEpisode = currentEpisode != null && + currentEpisode.guid == _episode!.episodeUrl; + + if (!isCurrentEpisode) { + // Start playing the episode first + await playPinepodsEpisodeWithOptionalFullScreen( + context, + _audioService!, + _episode!, + resume: false, // Start from beginning initially + ); + + // Wait a moment for the episode to start loading + await Future.delayed(const Duration(milliseconds: 500)); + } + + // Seek to the timestamp (convert Duration to seconds as int) + await audioPlayerService.seek(position: timestamp.inSeconds); + + } catch (e) { + _showSnackBar('Failed to jump to timestamp: ${e.toString()}', Colors.red); + } + } + + String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + } + + Future _saveEpisode() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + final success = await _pinepodsService.saveEpisode( + _episode!.episodeId, + userId, + _episode!.isYoutube, + ); + + if (success) { + setState(() { + _episode = _updateEpisodeProperty(_episode!, saved: true); + }); + _showSnackBar('Episode saved!', Colors.green); + } else { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + } + + Future _removeSavedEpisode() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + final success = await _pinepodsService.removeSavedEpisode( + _episode!.episodeId, + userId, + _episode!.isYoutube, + ); + + if (success) { + setState(() { + _episode = _updateEpisodeProperty(_episode!, saved: false); + }); + _showSnackBar('Removed from saved episodes', Colors.orange); + } else { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + } + + Future _toggleQueue() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + bool success; + if (_episode!.queued) { + success = await _pinepodsService.removeQueuedEpisode( + _episode!.episodeId, + userId, + _episode!.isYoutube, + ); + if (success) { + setState(() { + _episode = _updateEpisodeProperty(_episode!, queued: false); + }); + _showSnackBar('Removed from queue', Colors.orange); + } + } else { + success = await _pinepodsService.queueEpisode( + _episode!.episodeId, + userId, + _episode!.isYoutube, + ); + if (success) { + setState(() { + _episode = _updateEpisodeProperty(_episode!, queued: true); + }); + _showSnackBar('Added to queue!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update queue', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + } + + Future _toggleDownload() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + bool success; + if (_episode!.downloaded) { + success = await _pinepodsService.deleteEpisode( + _episode!.episodeId, + userId, + _episode!.isYoutube, + ); + if (success) { + setState(() { + _episode = _updateEpisodeProperty(_episode!, downloaded: false); + }); + _showSnackBar('Episode deleted from server', Colors.orange); + } + } else { + success = await _pinepodsService.downloadEpisode( + _episode!.episodeId, + userId, + _episode!.isYoutube, + ); + if (success) { + setState(() { + _episode = _updateEpisodeProperty(_episode!, downloaded: true); + }); + _showSnackBar('Episode download queued!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update download', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating download: $e', Colors.red); + } + } + + Future _toggleComplete() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + bool success; + if (_episode!.completed) { + success = await _pinepodsService.markEpisodeUncompleted( + _episode!.episodeId, + userId, + _episode!.isYoutube, + ); + if (success) { + setState(() { + _episode = _updateEpisodeProperty(_episode!, completed: false); + }); + _showSnackBar('Marked as incomplete', Colors.orange); + } + } else { + success = await _pinepodsService.markEpisodeCompleted( + _episode!.episodeId, + userId, + _episode!.isYoutube, + ); + if (success) { + setState(() { + _episode = _updateEpisodeProperty(_episode!, completed: true); + }); + _showSnackBar('Marked as complete!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update completion status', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating completion: $e', Colors.red); + } + } + + PinepodsEpisode _updateEpisodeProperty( + PinepodsEpisode episode, { + bool? saved, + bool? downloaded, + bool? queued, + bool? completed, + }) { + return PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: completed ?? episode.completed, + saved: saved ?? episode.saved, + queued: queued ?? episode.queued, + downloaded: downloaded ?? episode.downloaded, + isYoutube: episode.isYoutube, + podcastId: episode.podcastId, + ); + } + + void _showSnackBar(String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + + Future _navigateToPodcast() async { + if (_episode!.podcastId == null) { + _showSnackBar('Podcast ID not available', Colors.orange); + return; + } + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + // Fetch the actual podcast details to get correct episode count + final podcastDetails = await _pinepodsService.getPodcastDetailsById(_episode!.podcastId!, userId); + + final podcast = UnifiedPinepodsPodcast( + id: _episode!.podcastId!, + indexId: 0, + title: _episode!.podcastName, + url: podcastDetails?['feedurl'] ?? '', + originalUrl: podcastDetails?['feedurl'] ?? '', + link: podcastDetails?['websiteurl'] ?? '', + description: podcastDetails?['description'] ?? '', + author: podcastDetails?['author'] ?? '', + ownerName: podcastDetails?['author'] ?? '', + image: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork, + artwork: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork, + lastUpdateTime: 0, + explicit: podcastDetails?['explicit'] ?? false, + episodeCount: podcastDetails?['episodecount'] ?? 0, + ); + + // Navigate to podcast details - same as podcast tile does + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepods_podcast_details'), + builder: (context) => PinepodsPodcastDetails( + podcast: podcast, + isFollowing: true, // Assume following since we have a podcast ID + ), + ), + ); + } catch (e) { + _showSnackBar('Error navigating to podcast: $e', Colors.red); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Scaffold( + appBar: AppBar( + title: const Text('Episode Details'), + ), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading episode details...'), + ], + ), + ), + ); + } + + if (_errorMessage.isNotEmpty) { + return Scaffold( + appBar: AppBar( + title: const Text('Episode Details'), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: 48, + ), + const SizedBox(height: 16), + Text( + _errorMessage, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadEpisodeDetails, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text(_episode!.podcastName), + elevation: 0, + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode artwork and basic info + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode artwork + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _episode!.episodeArtwork.isNotEmpty + ? Image.network( + _episode!.episodeArtwork, + width: 120, + height: 120, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.music_note, + color: Colors.grey, + size: 48, + ), + ); + }, + ) + : Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.music_note, + color: Colors.grey, + size: 48, + ), + ), + ), + const SizedBox(width: 16), + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Clickable podcast name + GestureDetector( + onTap: () => _navigateToPodcast(), + child: Text( + _episode!.podcastName, + style: Theme.of(context).textTheme.titleMedium!.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w500, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).primaryColor, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 4), + Text( + _episode!.episodeTitle, + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + _episode!.formattedDuration, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 4), + Text( + _episode!.formattedPubDate, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Colors.grey[600], + ), + ), + if (_episode!.isStarted) ...[ + const SizedBox(height: 8), + Text( + 'Listened: ${_episode!.formattedListenDuration}', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context).primaryColor, + ), + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: _episode!.progressPercentage / 100, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColor, + ), + ), + ], + ], + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Action buttons + Column( + children: [ + // First row: Play, Save, Queue (3 buttons, each 1/3 width) + Row( + children: [ + // Play/Pause button + Expanded( + child: StreamBuilder( + stream: Provider.of(context, listen: false).playingState, + builder: (context, snapshot) { + final isCurrentEpisode = _isCurrentEpisodePlaying(); + final isPlaying = snapshot.data == AudioState.playing; + final isCurrentlyPlaying = isCurrentEpisode && isPlaying; + + IconData icon; + String label; + + if (_episode!.completed) { + icon = Icons.replay; + label = 'Replay'; + } else if (isCurrentlyPlaying) { + icon = Icons.pause; + label = 'Pause'; + } else { + icon = Icons.play_arrow; + label = 'Play'; + } + + return OutlinedButton.icon( + onPressed: _togglePlayPause, + icon: Icon(icon), + label: Text(label), + ); + }, + ), + ), + const SizedBox(width: 8), + + // Save/Unsave button + Expanded( + child: OutlinedButton.icon( + onPressed: _episode!.saved ? _removeSavedEpisode : _saveEpisode, + icon: Icon( + _episode!.saved ? Icons.bookmark : Icons.bookmark_outline, + color: _episode!.saved ? Colors.orange : null, + ), + label: Text(_episode!.saved ? 'Saved' : 'Save'), + ), + ), + const SizedBox(width: 8), + + // Queue button + Expanded( + child: OutlinedButton.icon( + onPressed: _toggleQueue, + icon: Icon( + _episode!.queued ? Icons.queue_music : Icons.queue_music_outlined, + color: _episode!.queued ? Colors.purple : null, + ), + label: Text(_episode!.queued ? 'Queued' : 'Queue'), + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Second row: Download, Complete (2 buttons, each 1/2 width) + Row( + children: [ + // Download button + Expanded( + child: OutlinedButton.icon( + onPressed: _toggleDownload, + icon: Icon( + _episode!.downloaded ? Icons.download_done : Icons.download_outlined, + color: _episode!.downloaded ? Colors.blue : null, + ), + label: Text(_episode!.downloaded ? 'Downloaded' : 'Download'), + ), + ), + const SizedBox(width: 8), + + // Complete button + Expanded( + child: OutlinedButton.icon( + onPressed: _toggleComplete, + icon: Icon( + _episode!.completed ? Icons.check_circle : Icons.check_circle_outline, + color: _episode!.completed ? Colors.green : null, + ), + label: Text(_episode!.completed ? 'Complete' : 'Mark Complete'), + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Third row: Local Download (full width) + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _isDownloadedLocally ? _deleteLocalDownload : _localDownloadEpisode, + icon: Icon( + _isDownloadedLocally ? Icons.delete_forever_outlined : Icons.file_download_outlined, + color: _isDownloadedLocally ? Colors.red : Colors.green, + ), + label: Text(_isDownloadedLocally ? 'Delete Local Download' : 'Download Locally'), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: _isDownloadedLocally ? Colors.red : Colors.green, + ), + ), + ), + ), + ], + ), + ], + ), + + // Hosts/Guests section + if (_persons.isNotEmpty) ...[ + const SizedBox(height: 24), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Hosts & Guests', + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 80, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _persons.length, + itemBuilder: (context, index) { + final person = _persons[index]; + return Container( + width: 70, + margin: const EdgeInsets.only(right: 12), + child: Column( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey[300], + ), + child: person.image != null && person.image!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(25), + child: PodcastImage( + url: person.image!, + width: 50, + height: 50, + fit: BoxFit.cover, + ), + ) + : const Icon( + Icons.person, + size: 30, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + person.name, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, + ), + ), + ], + + const SizedBox(height: 32), + + // Episode description + Text( + 'Description', + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + EpisodeDescription( + content: _episode!.episodeDescription, + onTimestampTap: _handleTimestampTap, + ), + ], + ), + ), + ), + const MiniPlayer(), + ], + ), + ); + } + + @override + void dispose() { + // Don't dispose global audio service - it should persist across pages + super.dispose(); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart new file mode 100644 index 0000000..becd910 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart @@ -0,0 +1,817 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:pinepods_mobile/services/search_history_service.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:provider/provider.dart'; + +/// Episode search page for finding episodes in user's subscriptions +/// +/// This page allows users to search through episodes in their subscribed podcasts +/// with debounced search input and animated loading states. +class EpisodeSearchPage extends StatefulWidget { + const EpisodeSearchPage({Key? key}) : super(key: key); + + @override + State createState() => _EpisodeSearchPageState(); +} + +class _EpisodeSearchPageState extends State with TickerProviderStateMixin { + final PinepodsService _pinepodsService = PinepodsService(); + final SearchHistoryService _searchHistoryService = SearchHistoryService(); + final TextEditingController _searchController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + Timer? _debounceTimer; + + List _searchResults = []; + List _searchHistory = []; + bool _isLoading = false; + bool _hasSearched = false; + bool _showHistory = false; + String? _errorMessage; + String _currentQuery = ''; + + // Use global audio service instead of creating local instance + int? _contextMenuEpisodeIndex; + + // Animation controllers + late AnimationController _fadeAnimationController; + late AnimationController _slideAnimationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _setupAnimations(); + _setupSearch(); + } + + void _setupAnimations() { + // Fade animation for results + _fadeAnimationController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _fadeAnimationController, + curve: Curves.easeInOut, + )); + + // Slide animation for search bar + _slideAnimationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _slideAnimation = Tween( + begin: const Offset(0, 0), + end: const Offset(0, -0.2), + ).animate(CurvedAnimation( + parent: _slideAnimationController, + curve: Curves.easeInOut, + )); + } + + void _setupSearch() { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) { + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + } + + _searchController.addListener(_onSearchChanged); + _loadSearchHistory(); + } + + Future _loadSearchHistory() async { + final history = await _searchHistoryService.getEpisodeSearchHistory(); + if (mounted) { + setState(() { + _searchHistory = history; + }); + } + } + + void _selectHistoryItem(String searchTerm) { + _searchController.text = searchTerm; + _performSearch(searchTerm); + } + + Future _removeHistoryItem(String searchTerm) async { + await _searchHistoryService.removeEpisodeSearchTerm(searchTerm); + await _loadSearchHistory(); + } + + PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + + Future _playEpisode(PinepodsEpisode episode) async { + if (_audioService == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Audio service not available'), + backgroundColor: Colors.red, + ), + ); + return; + } + + try { + await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Playing ${episode.episodeTitle}'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to play episode: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _showContextMenu(int episodeIndex) { + setState(() { + _contextMenuEpisodeIndex = episodeIndex; + }); + } + + void _hideContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + }); + } + + Future _saveEpisode(int episodeIndex) async { + final episode = _searchResults[episodeIndex].toPinepodsEpisode(); + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + final success = await _pinepodsService.saveEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode saved', Colors.green); + // Update local state + setState(() { + _searchResults[episodeIndex] = SearchEpisodeResult( + podcastId: _searchResults[episodeIndex].podcastId, + podcastName: _searchResults[episodeIndex].podcastName, + artworkUrl: _searchResults[episodeIndex].artworkUrl, + author: _searchResults[episodeIndex].author, + categories: _searchResults[episodeIndex].categories, + description: _searchResults[episodeIndex].description, + episodeCount: _searchResults[episodeIndex].episodeCount, + feedUrl: _searchResults[episodeIndex].feedUrl, + websiteUrl: _searchResults[episodeIndex].websiteUrl, + explicit: _searchResults[episodeIndex].explicit, + userId: _searchResults[episodeIndex].userId, + episodeId: _searchResults[episodeIndex].episodeId, + episodeTitle: _searchResults[episodeIndex].episodeTitle, + episodeDescription: _searchResults[episodeIndex].episodeDescription, + episodePubDate: _searchResults[episodeIndex].episodePubDate, + episodeArtwork: _searchResults[episodeIndex].episodeArtwork, + episodeUrl: _searchResults[episodeIndex].episodeUrl, + episodeDuration: _searchResults[episodeIndex].episodeDuration, + completed: _searchResults[episodeIndex].completed, + saved: true, // We just saved it + queued: _searchResults[episodeIndex].queued, + downloaded: _searchResults[episodeIndex].downloaded, + isYoutube: _searchResults[episodeIndex].isYoutube, + listenDuration: _searchResults[episodeIndex].listenDuration, + ); + }); + } else if (mounted) { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + if (mounted) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + } + } + + Future _removeSavedEpisode(int episodeIndex) async { + final episode = _searchResults[episodeIndex].toPinepodsEpisode(); + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + final success = await _pinepodsService.removeSavedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode removed from saved', Colors.orange); + } else if (mounted) { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + if (mounted) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + } + } + + Future _downloadEpisode(int episodeIndex) async { + final episode = _searchResults[episodeIndex].toPinepodsEpisode(); + _showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue); + // Note: Actual download implementation would depend on download service integration + } + + Future _deleteEpisode(int episodeIndex) async { + final episode = _searchResults[episodeIndex].toPinepodsEpisode(); + _showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange); + // Note: Actual delete implementation would depend on download service integration + } + + Future _localDownloadEpisode(int episodeIndex) async { + final episode = _searchResults[episodeIndex].toPinepodsEpisode(); + _showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue); + // Note: Actual local download implementation would depend on download service integration + } + + Future _toggleQueueEpisode(int episodeIndex) async { + final episode = _searchResults[episodeIndex].toPinepodsEpisode(); + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + if (episode.queued) { + final success = await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode removed from queue', Colors.orange); + } + } else { + final success = await _pinepodsService.queueEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode added to queue', Colors.green); + } + } + } catch (e) { + if (mounted) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + } + } + + Future _toggleMarkComplete(int episodeIndex) async { + final episode = _searchResults[episodeIndex].toPinepodsEpisode(); + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + if (episode.completed) { + final success = await _pinepodsService.markEpisodeUncompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode marked as incomplete', Colors.orange); + } + } else { + final success = await _pinepodsService.markEpisodeCompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode marked as complete', Colors.green); + } + } + } catch (e) { + if (mounted) { + _showSnackBar('Error updating completion status: $e', Colors.red); + } + } + } + + void _showSnackBar(String message, Color backgroundColor) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + } + + void _onSearchChanged() { + final query = _searchController.text.trim(); + + setState(() { + _showHistory = query.isEmpty && _searchHistory.isNotEmpty; + }); + + if (_debounceTimer?.isActive ?? false) { + _debounceTimer!.cancel(); + } + + _debounceTimer = Timer(const Duration(milliseconds: 500), () { + if (query.isNotEmpty && query != _currentQuery) { + _currentQuery = query; + _performSearch(query); + } else if (query.isEmpty) { + _clearResults(); + } + }); + } + + Future _performSearch(String query) async { + setState(() { + _isLoading = true; + _errorMessage = null; + _showHistory = false; + }); + + // Save search term to history + await _searchHistoryService.addEpisodeSearchTerm(query); + await _loadSearchHistory(); + + // Animate search bar to top + _slideAnimationController.forward(); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + throw Exception('Not logged in'); + } + + final results = await _pinepodsService.searchEpisodes(userId, query); + + setState(() { + _searchResults = results; + _isLoading = false; + _hasSearched = true; + }); + + // Animate results in + _fadeAnimationController.forward(); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + _hasSearched = true; + _searchResults = []; + }); + } + } + + void _clearResults() { + setState(() { + _searchResults = []; + _hasSearched = false; + _errorMessage = null; + _currentQuery = ''; + _showHistory = _searchHistory.isNotEmpty; + }); + _fadeAnimationController.reset(); + _slideAnimationController.reverse(); + } + + Widget _buildSearchBar() { + return SlideTransition( + position: _slideAnimation, + child: Container( + padding: const EdgeInsets.all(16), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + gradient: LinearGradient( + colors: [ + Theme.of(context).primaryColor.withOpacity(0.1), + Theme.of(context).primaryColor.withOpacity(0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: TextField( + controller: _searchController, + focusNode: _focusNode, + style: Theme.of(context).textTheme.bodyLarge, + onTap: () { + setState(() { + _showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty; + }); + }, + decoration: InputDecoration( + hintText: 'Search for episodes...', + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + prefixIcon: Icon( + Icons.search, + color: Theme.of(context).primaryColor, + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: Icon( + Icons.clear, + color: Theme.of(context).primaryColor, + ), + onPressed: () { + _searchController.clear(); + _clearResults(); + _focusNode.requestFocus(); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildLoadingIndicator() { + return Container( + padding: const EdgeInsets.all(64), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + 'Searching...', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + if (!_hasSearched) { + return Container( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 64, + color: Theme.of(context).primaryColor.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'Search Your Episodes', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Find episodes from your subscribed podcasts', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return Container( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of(context).hintColor, + ), + const SizedBox(height: 16), + Text( + 'No Episodes Found', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Try adjusting your search terms', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ], + ), + ); + } + + Widget _buildErrorState() { + return Container( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Search Error', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + const SizedBox(height: 8), + Text( + _errorMessage ?? 'Unknown error occurred', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + if (_currentQuery.isNotEmpty) { + _performSearch(_currentQuery); + } + }, + child: const Text('Try Again'), + ), + ], + ), + ); + } + + Widget _buildResults() { + // Convert search results to PinepodsEpisode objects + final episodes = _searchResults.map((result) => result.toPinepodsEpisode()).toList(); + + return FadeTransition( + opacity: _fadeAnimation, + child: PaginatedEpisodeList( + episodes: episodes, + isServerEpisodes: true, + pageSize: 20, // Show 20 episodes at a time for good performance + onEpisodeTap: (episode) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: episode, + ), + ), + ); + }, + onEpisodeLongPress: (episode, globalIndex) { + // Find the original index in _searchResults for context menu + final originalIndex = _searchResults.indexWhere( + (result) => result.episodeId == episode.episodeId + ); + if (originalIndex != -1) { + _showContextMenu(originalIndex); + } + }, + onPlayPressed: (episode) => _playEpisode(episode), + ), + ); + } + + Widget _buildSearchHistory() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Recent Searches', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (_searchHistory.isNotEmpty) + TextButton( + onPressed: () async { + await _searchHistoryService.clearEpisodeSearchHistory(); + await _loadSearchHistory(); + }, + child: Text( + 'Clear All', + style: TextStyle( + color: Theme.of(context).hintColor, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ..._searchHistory.take(10).map((searchTerm) => Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + leading: Icon( + Icons.history, + color: Theme.of(context).hintColor, + size: 20, + ), + title: Text( + searchTerm, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: Icon( + Icons.close, + size: 18, + color: Theme.of(context).hintColor, + ), + onPressed: () => _removeHistoryItem(searchTerm), + ), + onTap: () => _selectHistoryItem(searchTerm), + ), + )).toList(), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + // Show context menu as a modal overlay if needed + if (_contextMenuEpisodeIndex != null) { + final episodeIndex = _contextMenuEpisodeIndex!; // Store locally to avoid null issues + final episode = _searchResults[episodeIndex].toPinepodsEpisode(); + WidgetsBinding.instance.addPostFrameCallback((_) { + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: episode, + onSave: () { + Navigator.of(context).pop(); + _saveEpisode(episodeIndex); + }, + onRemoveSaved: () { + Navigator.of(context).pop(); + _removeSavedEpisode(episodeIndex); + }, + onDownload: episode.downloaded + ? () { + Navigator.of(context).pop(); + _deleteEpisode(episodeIndex); + } + : () { + Navigator.of(context).pop(); + _downloadEpisode(episodeIndex); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadEpisode(episodeIndex); + }, + onQueue: () { + Navigator.of(context).pop(); + _toggleQueueEpisode(episodeIndex); + }, + onMarkComplete: () { + Navigator.of(context).pop(); + _toggleMarkComplete(episodeIndex); + }, + onDismiss: () { + Navigator.of(context).pop(); + _hideContextMenu(); + }, + ), + ); + }); + // Reset the context menu index after storing it locally + _contextMenuEpisodeIndex = null; + } + + return SliverFillRemaining( + child: GestureDetector( + onTap: () { + // Dismiss keyboard when tapping outside + FocusScope.of(context).unfocus(); + }, + child: Column( + children: [ + _buildSearchBar(), + Expanded( + child: SingleChildScrollView( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _showHistory + ? _buildSearchHistory() + : _isLoading + ? _buildLoadingIndicator() + : _errorMessage != null + ? _buildErrorState() + : _searchResults.isEmpty + ? _buildEmptyState() + : _buildResults(), + ), + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.dispose(); + _focusNode.dispose(); + _fadeAnimationController.dispose(); + _slideAnimationController.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart new file mode 100644 index 0000000..f7bcd15 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart @@ -0,0 +1,1050 @@ +// lib/ui/pinepods/feed.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/audio/default_audio_player_service.dart'; +import 'package:pinepods_mobile/services/download/download_service.dart'; +import 'package:pinepods_mobile/services/logging/app_logger.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:pinepods_mobile/ui/utils/position_utils.dart'; +import 'package:pinepods_mobile/ui/widgets/server_error_page.dart'; +import 'package:pinepods_mobile/services/error_handling_service.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:provider/provider.dart'; + +class PinepodsFeed extends StatefulWidget { + // Constructor with optional key parameter + const PinepodsFeed({Key? key}) : super(key: key); + + @override + State createState() => _PinepodsFeedState(); +} + +class _PinepodsFeedState extends State { + bool _isLoading = false; + String _errorMessage = ''; + List _episodes = []; + final PinepodsService _pinepodsService = PinepodsService(); + // Use global audio service instead of creating local instance + int? _contextMenuEpisodeIndex; // Index of episode showing context menu + Map _localDownloadStatus = {}; // Cache for local download status + + @override + void initState() { + super.initState(); + _loadRecentEpisodes(); + } + + PinepodsAudioService? get _audioService { + final service = GlobalServices.pinepodsAudioService; + if (service == null) { + final logger = AppLogger(); + logger.error('Feed', 'Global audio service is null - this should not happen'); + } + return service; + } + + Future _loadRecentEpisodes() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please login first.'; + _isLoading = false; + }); + return; + } + + // Set credentials in both local and global services + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + // Use the stored user ID from login + final userId = settings.pinepodsUserId!; + + final episodes = await _pinepodsService.getRecentEpisodes(userId); + + // Enrich episodes with best available positions (local vs server) + final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions( + context, + _pinepodsService, + episodes, + userId, + ); + + setState(() { + _episodes = enrichedEpisodes; + _isLoading = false; + }); + + // After loading episodes, check their local download status + await _loadLocalDownloadStatuses(); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load recent episodes: ${e.toString()}'; + _isLoading = false; + }); + } + } + + // Proactively load local download status for all episodes + Future _loadLocalDownloadStatuses() async { + final logger = AppLogger(); + logger.debug('Feed', 'Loading local download statuses for ${_episodes.length} episodes'); + + try { + final podcastBloc = Provider.of(context, listen: false); + + // Get all downloaded episodes from repository + final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes(); + logger.debug('Feed', 'Found ${allEpisodes.length} total episodes in repository'); + + // Filter to PinePods episodes only and log them + final pinepodsEpisodes = allEpisodes.where((ep) => ep.guid.startsWith('pinepods_')).toList(); + logger.debug('Feed', 'Found ${pinepodsEpisodes.length} PinePods episodes in repository'); + + // Found pinepods episodes in repository + + // Now check each feed episode against the repository + for (final episode in _episodes) { + final guid = _generateEpisodeGuid(episode); + + // Look for episodes with either new format (pinepods_123) or old format (pinepods_123_timestamp) + final matchingEpisodes = allEpisodes.where((ep) => + ep.guid == guid || ep.guid.startsWith('${guid}_') + ).toList(); + + // Checking for matching episodes + + // Consider downloaded if ANY matching episode is downloaded + final isDownloaded = matchingEpisodes.any((ep) => + ep.downloaded || ep.downloadState == DownloadState.downloaded + ); + + _localDownloadStatus[guid] = isDownloaded; + // Episode status checked + } + + // Download statuses cached + + } catch (e) { + logger.error('Feed', 'Error loading local download statuses', e.toString()); + } + } + + Future _refresh() async { + // Clear local download status cache on refresh + _localDownloadStatus.clear(); + await _loadRecentEpisodes(); + } + + Future _playEpisode(PinepodsEpisode episode) async { + final logger = AppLogger(); + logger.info('Feed', 'Attempting to play episode: ${episode.episodeTitle}'); + + if (_audioService == null) { + logger.error('Feed', 'Audio service not available for episode: ${episode.episodeTitle}'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Audio service not available'), + backgroundColor: Colors.red, + ), + ); + return; + } + + try { + // Show loading indicator + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text('Starting ${episode.episodeTitle}...'), + ], + ), + duration: const Duration(seconds: 2), + ), + ); + + // Start playing the episode with full PinePods integration + await playPinepodsEpisodeWithOptionalFullScreen( + context, + _audioService!, + episode, + resume: episode.isStarted, // Resume if episode was previously started + ); + + logger.info('Feed', 'Successfully started playing episode: ${episode.episodeTitle}'); + + // Show success message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Now playing: ${episode.episodeTitle}'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + logger.error('Feed', 'Failed to play episode: ${episode.episodeTitle}', e.toString()); + + // Show error message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to play episode: ${e.toString()}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + + Future _showContextMenu(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final isDownloadedLocally = await _isEpisodeDownloadedLocally(episode); + + if (!mounted) return; + + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: episode, + isDownloadedLocally: isDownloadedLocally, + onSave: () { + Navigator.of(context).pop(); + _saveEpisode(episodeIndex); + }, + onRemoveSaved: () { + Navigator.of(context).pop(); + _removeSavedEpisode(episodeIndex); + }, + onDownload: episode.downloaded + ? () { + Navigator.of(context).pop(); + _deleteEpisode(episodeIndex); + } + : () { + Navigator.of(context).pop(); + _downloadEpisode(episodeIndex); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadEpisode(episodeIndex); + }, + onDeleteLocalDownload: () { + Navigator.of(context).pop(); + _deleteLocalDownload(episodeIndex); + }, + onQueue: () { + Navigator.of(context).pop(); + _toggleQueueEpisode(episodeIndex); + }, + onMarkComplete: () { + Navigator.of(context).pop(); + _toggleMarkComplete(episodeIndex); + }, + onDismiss: () { + Navigator.of(context).pop(); + }, + ), + ); + } + + void _hideContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + }); + } + + Future _saveEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + // Set credentials if not already set + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.saveEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + // Update local state + setState(() { + _episodes[episodeIndex] = PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: episode.completed, + saved: true, // Mark as saved + queued: episode.queued, + downloaded: episode.downloaded, + isYoutube: episode.isYoutube, + ); + }); + _showSnackBar('Episode saved!', Colors.green); + } else { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _removeSavedEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + // Set credentials if not already set + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.removeSavedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + // Update local state + setState(() { + _episodes[episodeIndex] = PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: episode.completed, + saved: false, // Mark as not saved + queued: episode.queued, + downloaded: episode.downloaded, + isYoutube: episode.isYoutube, + ); + }); + _showSnackBar('Removed from saved episodes', Colors.orange); + } else { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _downloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.downloadEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true); + }); + _showSnackBar('Episode download queued!', Colors.green); + } else { + _showSnackBar('Failed to queue download', Colors.red); + } + } catch (e) { + _showSnackBar('Error downloading episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _deleteEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.deleteEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false); + }); + _showSnackBar('Episode deleted from server', Colors.orange); + } else { + _showSnackBar('Failed to delete episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error deleting episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _toggleQueueEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.queued) { + success = await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false); + }); + _showSnackBar('Removed from queue', Colors.orange); + } + } else { + success = await _pinepodsService.queueEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true); + }); + _showSnackBar('Added to queue!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update queue', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _toggleMarkComplete(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.completed) { + success = await _pinepodsService.markEpisodeUncompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false); + }); + _showSnackBar('Marked as incomplete', Colors.orange); + } + } else { + success = await _pinepodsService.markEpisodeCompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true); + }); + _showSnackBar('Marked as complete!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update completion status', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating completion: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _localDownloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + try { + // Convert PinepodsEpisode to Episode for local download + final localEpisode = Episode( + guid: _generateEpisodeGuid(episode), + pguid: 'pinepods_${episode.podcastName.replaceAll(' ', '_').toLowerCase()}', + podcast: episode.podcastName, + title: episode.episodeTitle, + description: episode.episodeDescription, + imageUrl: episode.episodeArtwork, + contentUrl: episode.episodeUrl, + duration: episode.episodeDuration, + publicationDate: DateTime.tryParse(episode.episodePubDate), + author: episode.podcastName, + season: 0, + episode: 0, + position: episode.listenDuration ?? 0, + played: episode.completed, + chapters: [], + transcriptUrls: [], + ); + final logger = AppLogger(); + logger.debug('Feed', 'Created local episode with GUID: ${localEpisode.guid}'); + logger.debug('Feed', 'Episode title: ${localEpisode.title}'); + logger.debug('Feed', 'Episode URL: ${localEpisode.contentUrl}'); + + final podcastBloc = Provider.of(context, listen: false); + + // First save the episode to the repository so it can be tracked + await podcastBloc.podcastService.saveEpisode(localEpisode); + logger.debug('Feed', 'Episode saved to repository'); + + // Use the download service from podcast bloc + final success = await podcastBloc.downloadService.downloadEpisode(localEpisode); + logger.debug('Feed', 'Download service result: $success'); + + if (success) { + _updateLocalDownloadStatus(episode, true); + _showSnackBar('Episode download started', Colors.green); + } else { + _showSnackBar('Failed to start download', Colors.red); + } + } catch (e) { + final logger = AppLogger(); + logger.error('Feed', 'Error in local download for episode: ${episode.episodeTitle}', e.toString()); + _showSnackBar('Error starting local download: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _deleteLocalDownload(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final logger = AppLogger(); + + try { + final podcastBloc = Provider.of(context, listen: false); + final guid = _generateEpisodeGuid(episode); + + // Get all episodes and find matches with both new and old GUID formats + final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes(); + final matchingEpisodes = allEpisodes.where((ep) => + ep.guid == guid || ep.guid.startsWith('${guid}_') + ).toList(); + + logger.debug('Feed', 'Found ${matchingEpisodes.length} episodes to delete for $guid'); + + if (matchingEpisodes.isNotEmpty) { + // Delete ALL matching episodes (handles duplicates from old timestamp GUIDs) + for (final localEpisode in matchingEpisodes) { + logger.debug('Feed', 'Deleting episode: ${localEpisode.guid}'); + await podcastBloc.podcastService.repository.deleteEpisode(localEpisode); + } + + // Update cache + _updateLocalDownloadStatus(episode, false); + + final deletedCount = matchingEpisodes.length; + _showSnackBar('Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}', Colors.orange); + } else { + _showSnackBar('Local download not found', Colors.red); + } + } catch (e) { + logger.error('Feed', 'Error deleting local download for episode: ${episode.episodeTitle}', e.toString()); + _showSnackBar('Error deleting local download: $e', Colors.red); + } + + _hideContextMenu(); + } + + // Generate consistent GUID for PinePods episodes for local downloads + String _generateEpisodeGuid(PinepodsEpisode episode) { + return 'pinepods_${episode.episodeId}'; + } + + // Check if episode is downloaded locally + Future _isEpisodeDownloadedLocally(PinepodsEpisode episode) async { + final guid = _generateEpisodeGuid(episode); + final logger = AppLogger(); + logger.debug('Feed', 'Checking download status for episode: ${episode.episodeTitle}, GUID: $guid'); + + // Check cache first + if (_localDownloadStatus.containsKey(guid)) { + logger.debug('Feed', 'Found cached status for $guid: ${_localDownloadStatus[guid]}'); + return _localDownloadStatus[guid]!; + } + + try { + final podcastBloc = Provider.of(context, listen: false); + + // Get all episodes and find matches with both new and old GUID formats + final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes(); + final matchingEpisodes = allEpisodes.where((ep) => + ep.guid == guid || ep.guid.startsWith('${guid}_') + ).toList(); + + logger.debug('Feed', 'Repository lookup for $guid: found ${matchingEpisodes.length} matching episodes'); + + // Found matching episodes + + // Consider downloaded if ANY matching episode is downloaded + final isDownloaded = matchingEpisodes.any((ep) => + ep.downloaded || ep.downloadState == DownloadState.downloaded + ); + + logger.debug('Feed', 'Final download status for $guid: $isDownloaded'); + + // Cache the result + _localDownloadStatus[guid] = isDownloaded; + return isDownloaded; + } catch (e) { + final logger = AppLogger(); + logger.error('Feed', 'Error checking local download status for episode: ${episode.episodeTitle}', e.toString()); + return false; + } + } + + // Update local download status cache + void _updateLocalDownloadStatus(PinepodsEpisode episode, bool isDownloaded) { + final guid = _generateEpisodeGuid(episode); + _localDownloadStatus[guid] = isDownloaded; + } + + // Helper method to update episode properties efficiently + PinepodsEpisode _updateEpisodeProperty( + PinepodsEpisode episode, { + bool? saved, + bool? downloaded, + bool? queued, + bool? completed, + }) { + return PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: completed ?? episode.completed, + saved: saved ?? episode.saved, + queued: queued ?? episode.queued, + downloaded: downloaded ?? episode.downloaded, + isYoutube: episode.isYoutube, + ); + } + + void _showSnackBar(String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + void dispose() { + // Don't dispose global audio service - it should persist across pages + super.dispose(); + } + + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading recent episodes...'), + ], + ), + ), + ); + } + + if (_errorMessage.isNotEmpty) { + return SliverServerErrorPage( + errorMessage: _errorMessage.isServerConnectionError + ? null + : _errorMessage, + onRetry: _refresh, + title: 'Feed Unavailable', + subtitle: _errorMessage.isServerConnectionError + ? 'Unable to connect to the PinePods server' + : 'Failed to load recent episodes', + ); + } + + if (_episodes.isEmpty) { + return const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inbox_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'No recent episodes found', + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + ), + SizedBox(height: 8), + Text( + 'Episodes from the last 30 days will appear here', + style: TextStyle( + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return _buildEpisodesList(); + } + + Widget _buildEpisodesList() { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + // Header + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Recent Episodes', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _refresh, + ), + ], + ), + ); + } + // Episodes (index - 1 because of header) + final episodeIndex = index - 1; + return PinepodsEpisodeCard( + episode: _episodes[episodeIndex], + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: _episodes[episodeIndex], + ), + ), + ); + }, + onLongPress: () => _showContextMenu(episodeIndex), + onPlayPressed: () => _playEpisode(_episodes[episodeIndex]), + ); + }, + childCount: _episodes.length + 1, // +1 for header + ), + ); + } + + Widget _buildEpisodeCard(PinepodsEpisode episode, int episodeIndex) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + elevation: 1, + child: InkWell( + onTap: () { + // TODO: Navigate to episode details or start playing + }, + onLongPress: () => _showContextMenu(episodeIndex), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode artwork (smaller) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: episode.episodeArtwork.isNotEmpty + ? Image.network( + episode.episodeArtwork, + width: 50, + height: 50, + fit: BoxFit.cover, + cacheWidth: 100, // Optimize memory usage + cacheHeight: 100, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.music_note, + color: Colors.grey, + size: 24, + ), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(6), + ), + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + }, + ) + : Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.music_note, + color: Colors.grey, + size: 24, + ), + ), + ), + const SizedBox(width: 12), + + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + episode.episodeTitle, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + episode.podcastName, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + episode.formattedPubDate, + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 8), + Text( + episode.formattedDuration, + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + ], + ), + + // Progress bar if episode has been started + if (episode.isStarted) ...[ + const SizedBox(height: 6), + LinearProgressIndicator( + value: episode.progressPercentage / 100, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColor, + ), + minHeight: 2, + ), + ], + ], + ), + ), + + // Action button (just play) + IconButton( + icon: Icon( + episode.completed ? Icons.replay : Icons.play_arrow, + color: Theme.of(context).primaryColor, + ), + onPressed: () => _playEpisode(episode), + iconSize: 24, + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + ), + + // Status indicators (compact) + if (episode.saved || episode.downloaded || episode.queued) + SizedBox( + width: 20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (episode.saved) + Icon( + Icons.bookmark, + color: Colors.orange[600], + size: 14, + ), + if (episode.downloaded) + Icon( + Icons.download_done, + color: Colors.blue[600], + size: 14, + ), + if (episode.queued) + Icon( + Icons.queue_music, + color: Colors.purple[600], + size: 14, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart new file mode 100644 index 0000000..0a56a79 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart @@ -0,0 +1,745 @@ +// lib/ui/pinepods/history.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:pinepods_mobile/ui/utils/local_download_utils.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:pinepods_mobile/ui/utils/position_utils.dart'; +import 'package:pinepods_mobile/ui/widgets/server_error_page.dart'; +import 'package:pinepods_mobile/services/error_handling_service.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:provider/provider.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +class PinepodsHistory extends StatefulWidget { + const PinepodsHistory({Key? key}) : super(key: key); + + @override + State createState() => _PinepodsHistoryState(); +} + +class _PinepodsHistoryState extends State { + bool _isLoading = false; + String _errorMessage = ''; + List _episodes = []; + List _filteredEpisodes = []; + final PinepodsService _pinepodsService = PinepodsService(); + // Use global audio service instead of creating local instance + int? _contextMenuEpisodeIndex; + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _loadHistory(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.dispose(); + // Don't dispose global audio service - it should persist across pages + super.dispose(); + } + + void _onSearchChanged() { + setState(() { + _searchQuery = _searchController.text; + _filterEpisodes(); + }); + } + + void _filterEpisodes() { + if (_searchQuery.isEmpty) { + _filteredEpisodes = List.from(_episodes); + } else { + _filteredEpisodes = _episodes.where((episode) { + return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) || + episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + } + } + + PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + + Future _loadHistory() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please login first.'; + _isLoading = false; + }); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + final userId = settings.pinepodsUserId!; + + final episodes = await _pinepodsService.getUserHistory(userId); + + // Enrich episodes with best available positions (local vs server) + final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions( + context, + _pinepodsService, + episodes, + userId, + ); + + setState(() { + _episodes = enrichedEpisodes; + // Sort episodes by publication date (newest first) + _episodes.sort((a, b) { + try { + final dateA = DateTime.parse(a.episodePubDate); + final dateB = DateTime.parse(b.episodePubDate); + return dateB.compareTo(dateA); // Newest first + } catch (e) { + return 0; // Keep original order if parsing fails + } + }); + _filterEpisodes(); // Initialize filtered list + _isLoading = false; + }); + + // After loading episodes, check their local download status + await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load listening history: ${e.toString()}'; + _isLoading = false; + }); + } + } + + Future _refresh() async { + // Clear local download status cache on refresh + LocalDownloadUtils.clearCache(); + await _loadHistory(); + } + + Future _playEpisode(PinepodsEpisode episode) async { + + if (_audioService == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Audio service not available'), + backgroundColor: Colors.red, + ), + ); + return; + } + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text('Starting ${episode.episodeTitle}...'), + ], + ), + duration: const Duration(seconds: 2), + ), + ); + + await _audioService!.playPinepodsEpisode( + pinepodsEpisode: episode, + resume: episode.isStarted, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Now playing: ${episode.episodeTitle}'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to play episode: ${e.toString()}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + + Future _showContextMenu(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode); + + if (!mounted) return; + + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: episode, + isDownloadedLocally: isDownloadedLocally, + onSave: () { + Navigator.of(context).pop(); + _saveEpisode(episodeIndex); + }, + onRemoveSaved: () { + Navigator.of(context).pop(); + _removeSavedEpisode(episodeIndex); + }, + onDownload: episode.downloaded + ? () { + Navigator.of(context).pop(); + _deleteEpisode(episodeIndex); + } + : () { + Navigator.of(context).pop(); + _downloadEpisode(episodeIndex); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadEpisode(episodeIndex); + }, + onDeleteLocalDownload: () { + Navigator.of(context).pop(); + _deleteLocalDownload(episodeIndex); + }, + onQueue: () { + Navigator.of(context).pop(); + _toggleQueueEpisode(episodeIndex); + }, + onMarkComplete: () { + Navigator.of(context).pop(); + _toggleMarkComplete(episodeIndex); + }, + onDismiss: () { + Navigator.of(context).pop(); + }, + ), + ); + } + + Future _localDownloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + final success = await LocalDownloadUtils.localDownloadEpisode(context, episode); + + if (success) { + LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green); + } else { + LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red); + } + } + + Future _deleteLocalDownload(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode); + + if (deletedCount > 0) { + LocalDownloadUtils.showSnackBar( + context, + 'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}', + Colors.orange + ); + } else { + LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red); + } + } + + void _hideContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + }); + } + + Future _saveEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.saveEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true); + _filterEpisodes(); // Update filtered list to reflect changes + }); + _showSnackBar('Episode saved!', Colors.green); + } else { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _removeSavedEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.removeSavedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false); + _filterEpisodes(); // Update filtered list to reflect changes + }); + _showSnackBar('Removed from saved episodes', Colors.orange); + } else { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _downloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.downloadEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true); + _filterEpisodes(); // Update filtered list to reflect changes + }); + _showSnackBar('Episode download queued!', Colors.green); + } else { + _showSnackBar('Failed to queue download', Colors.red); + } + } catch (e) { + _showSnackBar('Error downloading episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _deleteEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.deleteEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false); + _filterEpisodes(); // Update filtered list to reflect changes + }); + _showSnackBar('Episode deleted from server', Colors.orange); + } else { + _showSnackBar('Failed to delete episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error deleting episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _toggleQueueEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.queued) { + success = await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false); + _filterEpisodes(); // Update filtered list to reflect changes + }); + _showSnackBar('Removed from queue', Colors.orange); + } + } else { + success = await _pinepodsService.queueEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true); + _filterEpisodes(); // Update filtered list to reflect changes + }); + _showSnackBar('Added to queue!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update queue', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _toggleMarkComplete(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.completed) { + success = await _pinepodsService.markEpisodeUncompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false); + _filterEpisodes(); // Update filtered list to reflect changes + }); + _showSnackBar('Marked as incomplete', Colors.orange); + } + } else { + success = await _pinepodsService.markEpisodeCompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true); + _filterEpisodes(); // Update filtered list to reflect changes + }); + _showSnackBar('Marked as complete!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update completion status', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating completion: $e', Colors.red); + } + + _hideContextMenu(); + } + + PinepodsEpisode _updateEpisodeProperty( + PinepodsEpisode episode, { + bool? saved, + bool? downloaded, + bool? queued, + bool? completed, + }) { + return PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: completed ?? episode.completed, + saved: saved ?? episode.saved, + queued: queued ?? episode.queued, + downloaded: downloaded ?? episode.downloaded, + isYoutube: episode.isYoutube, + ); + } + + void _showSnackBar(String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading listening history...'), + ], + ), + ), + ); + } + + if (_errorMessage.isNotEmpty) { + return SliverServerErrorPage( + errorMessage: _errorMessage.isServerConnectionError + ? null + : _errorMessage, + onRetry: _refresh, + title: 'History Unavailable', + subtitle: _errorMessage.isServerConnectionError + ? 'Unable to connect to the PinePods server' + : 'Failed to load listening history', + ); + } + + if (_episodes.isEmpty) { + return const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.history, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'No listening history', + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + ), + SizedBox(height: 8), + Text( + 'Episodes you listen to will appear here', + style: TextStyle( + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return MultiSliver( + children: [ + _buildSearchBar(), + _buildEpisodesList(), + ], + ); + } + + Widget _buildSearchBar() { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Filter episodes...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: Theme.of(context).cardColor, + ), + ), + ), + ); + } + + Widget _buildEpisodesList() { + // Check if search returned no results + if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) { + return SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 16), + Text( + 'No episodes found', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'No episodes match "$_searchQuery"', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + // Header + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _searchQuery.isEmpty + ? 'Listening History' + : 'Search Results (${_filteredEpisodes.length})', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _refresh, + ), + ], + ), + ); + } + // Episodes (index - 1 because of header) + final episodeIndex = index - 1; + final episode = _filteredEpisodes[episodeIndex]; + // Find the original index for context menu operations + final originalIndex = _episodes.indexOf(episode); + return PinepodsEpisodeCard( + episode: episode, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: episode, + ), + ), + ); + }, + onLongPress: originalIndex >= 0 ? () => _showContextMenu(originalIndex) : null, + onPlayPressed: () => _playEpisode(episode), + ); + }, + childCount: _filteredEpisodes.length + 1, // +1 for header + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart new file mode 100644 index 0000000..492dcfb --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart @@ -0,0 +1,1377 @@ +// lib/ui/pinepods/home.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:pinepods_mobile/entities/home_data.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/ui/pinepods/feed.dart'; +import 'package:pinepods_mobile/ui/pinepods/saved.dart'; +import 'package:pinepods_mobile/ui/pinepods/downloads.dart'; +import 'package:pinepods_mobile/ui/pinepods/queue.dart'; +import 'package:pinepods_mobile/ui/pinepods/history.dart'; +import 'package:pinepods_mobile/ui/pinepods/playlists.dart'; +import 'package:pinepods_mobile/ui/pinepods/playlist_episodes.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:pinepods_mobile/ui/widgets/server_error_page.dart'; +import 'package:pinepods_mobile/services/error_handling_service.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +class PinepodsHome extends StatefulWidget { + const PinepodsHome({Key? key}) : super(key: key); + + @override + State createState() => _PinepodsHomeState(); +} + +class _PinepodsHomeState extends State { + bool _isLoading = true; + String _errorMessage = ''; + HomeOverview? _homeData; + PlaylistResponse? _playlistData; + final PinepodsService _pinepodsService = PinepodsService(); + + // Use global audio service instead of creating local instance + int? _contextMenuEpisodeIndex; + bool _isContextMenuForContinueListening = false; + + @override + void initState() { + super.initState(); + _loadHomeContent(); + } + + Future _loadHomeContent() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please connect in Settings.'; + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + // Load home data and playlists in parallel + final futures = await Future.wait([ + _pinepodsService.getHomeOverview(settings.pinepodsUserId!), + _pinepodsService.getPlaylists(settings.pinepodsUserId!), + ]); + + setState(() { + _homeData = futures[0] as HomeOverview; + _playlistData = futures[1] as PlaylistResponse; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Error loading home content: $e'; + _isLoading = false; + }); + } + } + + PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + + Future _playEpisode(HomeEpisode homeEpisode) async { + if (_audioService == null) { + _showSnackBar('Audio service not available', Colors.red); + return; + } + + // Convert HomeEpisode to PinepodsEpisode + final episode = PinepodsEpisode( + podcastName: homeEpisode.podcastName, + episodeTitle: homeEpisode.episodeTitle, + episodePubDate: homeEpisode.episodePubDate, + episodeDescription: homeEpisode.episodeDescription ?? '', + episodeArtwork: homeEpisode.episodeArtwork, + episodeUrl: homeEpisode.episodeUrl, + episodeDuration: homeEpisode.episodeDuration, + listenDuration: homeEpisode.listenDuration, + episodeId: homeEpisode.episodeId, + completed: homeEpisode.completed, + saved: homeEpisode.saved, + queued: homeEpisode.queued, + downloaded: homeEpisode.downloaded, + isYoutube: homeEpisode.isYoutube, + ); + + try { + await playPinepodsEpisodeWithOptionalFullScreen( + context, + _audioService!, + episode, + ); + } catch (e) { + if (mounted) { + _showSnackBar('Failed to play episode: $e', Colors.red); + } + } + } + + void _showContextMenu(int episodeIndex, bool isContinueListening) { + setState(() { + _contextMenuEpisodeIndex = episodeIndex; + _isContextMenuForContinueListening = isContinueListening; + }); + } + + void _hideContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + _isContextMenuForContinueListening = false; + }); + } + + Future _saveEpisode(int episodeIndex, bool isContinueListening) async { + final episodes = isContinueListening + ? _homeData!.inProgressEpisodes + : _homeData!.recentEpisodes; + final homeEpisode = episodes[episodeIndex]; + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.saveEpisode( + homeEpisode.episodeId, + userId, + homeEpisode.isYoutube, + ); + + if (success) { + // Update the local state + setState(() { + if (isContinueListening) { + _homeData!.inProgressEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, saved: true); + } else { + _homeData!.recentEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, saved: true); + } + }); + _showSnackBar('Episode saved!', Colors.green); + } else { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + } + + Future _removeSavedEpisode(int episodeIndex, bool isContinueListening) async { + final episodes = isContinueListening + ? _homeData!.inProgressEpisodes + : _homeData!.recentEpisodes; + final homeEpisode = episodes[episodeIndex]; + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.removeSavedEpisode( + homeEpisode.episodeId, + userId, + homeEpisode.isYoutube, + ); + + if (success) { + setState(() { + if (isContinueListening) { + _homeData!.inProgressEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, saved: false); + } else { + _homeData!.recentEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, saved: false); + } + }); + _showSnackBar('Removed from saved episodes', Colors.orange); + } else { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + } + + Future _downloadEpisode(int episodeIndex, bool isContinueListening) async { + final episodes = isContinueListening + ? _homeData!.inProgressEpisodes + : _homeData!.recentEpisodes; + final homeEpisode = episodes[episodeIndex]; + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.downloadEpisode( + homeEpisode.episodeId, + userId, + homeEpisode.isYoutube, + ); + + if (success) { + setState(() { + if (isContinueListening) { + _homeData!.inProgressEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, downloaded: true); + } else { + _homeData!.recentEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, downloaded: true); + } + }); + _showSnackBar('Episode download queued!', Colors.green); + } else { + _showSnackBar('Failed to queue download', Colors.red); + } + } catch (e) { + _showSnackBar('Error downloading episode: $e', Colors.red); + } + } + + Future _localDownloadEpisode(int episodeIndex, bool isContinueListening) async { + final episodes = isContinueListening + ? _homeData!.inProgressEpisodes + : _homeData!.recentEpisodes; + final homeEpisode = episodes[episodeIndex]; + + try { + // Convert HomeEpisode to Episode for local download + final localEpisode = Episode( + guid: 'pinepods_${homeEpisode.episodeId}_${DateTime.now().millisecondsSinceEpoch}', + pguid: 'pinepods_${homeEpisode.podcastName.replaceAll(' ', '_').toLowerCase()}', + podcast: homeEpisode.podcastName, + title: homeEpisode.episodeTitle, + description: homeEpisode.episodeDescription, + imageUrl: homeEpisode.episodeArtwork, + contentUrl: homeEpisode.episodeUrl, + duration: homeEpisode.episodeDuration, + publicationDate: DateTime.tryParse(homeEpisode.episodePubDate), + author: homeEpisode.podcastName, + season: 0, + episode: 0, + position: homeEpisode.listenDuration ?? 0, + played: homeEpisode.completed, + chapters: [], + transcriptUrls: [], + ); + + final podcastBloc = Provider.of(context, listen: false); + + // First save the episode to the repository so it can be tracked + await podcastBloc.podcastService.saveEpisode(localEpisode); + + // Use the download service from podcast bloc + final success = await podcastBloc.downloadService.downloadEpisode(localEpisode); + + if (success) { + _showSnackBar('Episode download started', Colors.green); + } else { + _showSnackBar('Failed to start download', Colors.red); + } + } catch (e) { + _showSnackBar('Error starting local download: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _deleteEpisode(int episodeIndex, bool isContinueListening) async { + final episodes = isContinueListening + ? _homeData!.inProgressEpisodes + : _homeData!.recentEpisodes; + final homeEpisode = episodes[episodeIndex]; + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.deleteEpisode( + homeEpisode.episodeId, + userId, + homeEpisode.isYoutube, + ); + + if (success) { + setState(() { + if (isContinueListening) { + _homeData!.inProgressEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, downloaded: false); + } else { + _homeData!.recentEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, downloaded: false); + } + }); + _showSnackBar('Episode deleted from server', Colors.orange); + } else { + _showSnackBar('Failed to delete episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error deleting episode: $e', Colors.red); + } + } + + Future _toggleQueueEpisode(int episodeIndex, bool isContinueListening) async { + final episodes = isContinueListening + ? _homeData!.inProgressEpisodes + : _homeData!.recentEpisodes; + final homeEpisode = episodes[episodeIndex]; + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (homeEpisode.queued) { + success = await _pinepodsService.removeQueuedEpisode( + homeEpisode.episodeId, + userId, + homeEpisode.isYoutube, + ); + if (success) { + setState(() { + if (isContinueListening) { + _homeData!.inProgressEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, queued: false); + } else { + _homeData!.recentEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, queued: false); + } + }); + _showSnackBar('Removed from queue', Colors.orange); + } + } else { + success = await _pinepodsService.queueEpisode( + homeEpisode.episodeId, + userId, + homeEpisode.isYoutube, + ); + if (success) { + setState(() { + if (isContinueListening) { + _homeData!.inProgressEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, queued: true); + } else { + _homeData!.recentEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, queued: true); + } + }); + _showSnackBar('Added to queue!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update queue', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + } + + Future _toggleMarkComplete(int episodeIndex, bool isContinueListening) async { + final episodes = isContinueListening + ? _homeData!.inProgressEpisodes + : _homeData!.recentEpisodes; + final homeEpisode = episodes[episodeIndex]; + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (homeEpisode.completed) { + success = await _pinepodsService.markEpisodeUncompleted( + homeEpisode.episodeId, + userId, + homeEpisode.isYoutube, + ); + if (success) { + setState(() { + if (isContinueListening) { + _homeData!.inProgressEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, completed: false); + } else { + _homeData!.recentEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, completed: false); + } + }); + _showSnackBar('Marked as incomplete', Colors.orange); + } + } else { + success = await _pinepodsService.markEpisodeCompleted( + homeEpisode.episodeId, + userId, + homeEpisode.isYoutube, + ); + if (success) { + setState(() { + if (isContinueListening) { + _homeData!.inProgressEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, completed: true); + } else { + _homeData!.recentEpisodes[episodeIndex] = _updateHomeEpisodeProperty(homeEpisode, completed: true); + } + }); + _showSnackBar('Marked as complete!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update completion status', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating completion: $e', Colors.red); + } + } + + HomeEpisode _updateHomeEpisodeProperty( + HomeEpisode episode, { + bool? saved, + bool? downloaded, + bool? queued, + bool? completed, + }) { + return HomeEpisode( + episodeId: episode.episodeId, + podcastId: episode.podcastId, + episodeTitle: episode.episodeTitle, + episodeDescription: episode.episodeDescription, + episodeUrl: episode.episodeUrl, + episodeArtwork: episode.episodeArtwork, + episodePubDate: episode.episodePubDate, + episodeDuration: episode.episodeDuration, + completed: completed ?? episode.completed, + podcastName: episode.podcastName, + isYoutube: episode.isYoutube, + listenDuration: episode.listenDuration, + saved: saved ?? episode.saved, + queued: queued ?? episode.queued, + downloaded: downloaded ?? episode.downloaded, + ); + } + + void _showSnackBar(String message, Color backgroundColor) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + // Show context menu as a modal overlay if needed + if (_contextMenuEpisodeIndex != null) { + final episodeIndex = _contextMenuEpisodeIndex!; + final episodes = _isContextMenuForContinueListening + ? (_homeData?.inProgressEpisodes ?? []) + : (_homeData?.recentEpisodes ?? []); + + if (episodeIndex < episodes.length) { + final homeEpisode = episodes[episodeIndex]; + final episode = PinepodsEpisode( + podcastName: homeEpisode.podcastName, + episodeTitle: homeEpisode.episodeTitle, + episodePubDate: homeEpisode.episodePubDate, + episodeDescription: homeEpisode.episodeDescription ?? '', + episodeArtwork: homeEpisode.episodeArtwork, + episodeUrl: homeEpisode.episodeUrl, + episodeDuration: homeEpisode.episodeDuration, + listenDuration: homeEpisode.listenDuration, + episodeId: homeEpisode.episodeId, + completed: homeEpisode.completed, + saved: homeEpisode.saved, + queued: homeEpisode.queued, + downloaded: homeEpisode.downloaded, + isYoutube: homeEpisode.isYoutube, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: episode, + onSave: episode.saved ? null : () { + Navigator.of(context).pop(); + _saveEpisode(episodeIndex, _isContextMenuForContinueListening); + }, + onRemoveSaved: episode.saved ? () { + Navigator.of(context).pop(); + _removeSavedEpisode(episodeIndex, _isContextMenuForContinueListening); + } : null, + onDownload: episode.downloaded ? () { + Navigator.of(context).pop(); + _deleteEpisode(episodeIndex, _isContextMenuForContinueListening); + } : () { + Navigator.of(context).pop(); + _downloadEpisode(episodeIndex, _isContextMenuForContinueListening); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadEpisode(episodeIndex, _isContextMenuForContinueListening); + }, + onQueue: () { + Navigator.of(context).pop(); + _toggleQueueEpisode(episodeIndex, _isContextMenuForContinueListening); + }, + onMarkComplete: () { + Navigator.of(context).pop(); + _toggleMarkComplete(episodeIndex, _isContextMenuForContinueListening); + }, + onDismiss: () { + Navigator.of(context).pop(); + _hideContextMenu(); + }, + ), + ); + }); + } + // Reset the context menu index after storing it locally + _contextMenuEpisodeIndex = null; + } + + return SliverList( + delegate: SliverChildListDelegate([ + if (_isLoading) + const Padding( + padding: EdgeInsets.all(32.0), + child: Center( + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading your podcasts...'), + ], + ), + ), + ) + else if (_errorMessage.isNotEmpty) + ServerErrorPage( + errorMessage: _errorMessage.isServerConnectionError + ? null + : _errorMessage, + onRetry: _loadHomeContent, + title: 'Home Unavailable', + subtitle: _errorMessage.isServerConnectionError + ? 'Unable to connect to the PinePods server' + : 'Failed to load home content', + ) + else if (_homeData != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stats Overview Section + _buildStatsSection(), + const SizedBox(height: 24), + + // Continue Listening Section + if (_homeData!.inProgressEpisodes.isNotEmpty) ...[ + _buildContinueListeningSection(), + const SizedBox(height: 24), + ], + + // Top Podcasts Section + if (_homeData!.topPodcasts.isNotEmpty) ...[ + _buildTopPodcastsSection(), + const SizedBox(height: 24), + ], + + // Smart Playlists Section + if (_playlistData?.playlists.isNotEmpty == true) ...[ + _buildPlaylistsSection(), + const SizedBox(height: 24), + ], + + // Recent Episodes Section + if (_homeData!.recentEpisodes.isNotEmpty) ...[ + _buildRecentEpisodesSection(), + const SizedBox(height: 24), + ], + + // Empty state if no content + if (_homeData!.recentEpisodes.isEmpty && + _homeData!.inProgressEpisodes.isEmpty && + _homeData!.topPodcasts.isEmpty) + _buildEmptyState(), + ], + ), + ), + ]), + ); + } + + Widget _buildStatsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your Library', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _StatCard( + title: 'Saved', + count: _homeData!.savedCount, + icon: Icons.bookmark, + color: Colors.orange, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _StatCard( + title: 'Downloaded', + count: _homeData!.downloadedCount, + icon: Icons.download, + color: Colors.green, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _StatCard( + title: 'Queue', + count: _homeData!.queueCount, + icon: Icons.queue_music, + color: Colors.blue, + ), + ), + ], + ), + ], + ); + } + + Widget _buildContinueListeningSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Continue Listening', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ...(_homeData!.inProgressEpisodes.take(3).map((episode) => + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _EpisodeCard( + episode: episode, + onTap: () { + // Convert HomeEpisode to PinepodsEpisode for navigation + final pinepodsEpisode = PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription ?? '', + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: episode.completed, + saved: episode.saved, + queued: episode.queued, + downloaded: episode.downloaded, + isYoutube: episode.isYoutube, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: pinepodsEpisode, + ), + ), + ); + }, + onLongPress: () => _showContextMenu(_homeData!.inProgressEpisodes.indexOf(episode), true), + onPlayPressed: () => _playEpisode(episode), + ), + ), + )), + ], + ); + } + + Widget _buildTopPodcastsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Top Podcasts', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 180, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _homeData!.topPodcasts.length, + itemBuilder: (context, index) { + final podcast = _homeData!.topPodcasts[index]; + return Padding( + padding: EdgeInsets.only( + right: index < _homeData!.topPodcasts.length - 1 ? 16 : 0, + ), + child: _PodcastCard(podcast: podcast), + ); + }, + ), + ), + ], + ); + } + + Widget _buildPlaylistsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Smart Playlists', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _playlistData!.playlists.length, + itemBuilder: (context, index) { + final playlist = _playlistData!.playlists[index]; + return Padding( + padding: EdgeInsets.only( + right: index < _playlistData!.playlists.length - 1 ? 16 : 0, + ), + child: _PlaylistCard(playlist: playlist), + ); + }, + ), + ), + ], + ); + } + + Widget _buildRecentEpisodesSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recent Episodes', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ...(_homeData!.recentEpisodes.take(5).map((episode) => + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _EpisodeCard( + episode: episode, + onTap: () { + // Convert HomeEpisode to PinepodsEpisode for navigation + final pinepodsEpisode = PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription ?? '', + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: episode.completed, + saved: episode.saved, + queued: episode.queued, + downloaded: episode.downloaded, + isYoutube: episode.isYoutube, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: pinepodsEpisode, + ), + ), + ); + }, + onLongPress: () => _showContextMenu(_homeData!.recentEpisodes.indexOf(episode), false), + onPlayPressed: () => _playEpisode(episode), + ), + ), + )), + ], + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + Icon( + Icons.podcasts_outlined, + size: 64, + color: Theme.of(context).colorScheme.primary.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'Welcome to PinePods!', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Start by searching for podcasts to subscribe to.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + void _navigateToPage(String pageName) { + // This would be implemented to navigate to the appropriate page + // For now, we'll show a placeholder snackbar + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Navigate to $pageName')), + ); + } +} + +class _QuickLinkCard extends StatelessWidget { + final String title; + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _QuickLinkCard({ + required this.title, + required this.icon, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(height: 8), + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} + +class _StatCard extends StatelessWidget { + final String title; + final int count; + final IconData icon; + final Color color; + + const _StatCard({ + required this.title, + required this.count, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Icon(icon, color: color, size: 24), + const SizedBox(height: 8), + Text( + count.toString(), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ), + ); + } +} + +class _EpisodeCard extends StatelessWidget { + final HomeEpisode episode; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onPlayPressed; + + const _EpisodeCard({ + required this.episode, + this.onTap, + this.onLongPress, + this.onPlayPressed, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + // Episode artwork + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + episode.episodeArtwork, + width: 60, + height: 60, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 60, + height: 60, + color: Theme.of(context).colorScheme.surfaceVariant, + child: Icon( + Icons.podcasts, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + }, + ), + ), + const SizedBox(width: 12), + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + episode.episodeTitle, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + episode.podcastName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + // Progress bar for in-progress episodes + if (episode.listenDuration != null && episode.listenDuration! > 0) ...[ + LinearProgressIndicator( + value: episode.progressPercentage / 100, + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + episode.formattedListenDuration ?? '', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + episode.formattedDuration, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ] else ...[ + Text( + episode.formattedDuration, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + // Status indicators and play button + Column( + children: [ + if (onPlayPressed != null) + IconButton( + onPressed: onPlayPressed, + icon: Icon( + episode.completed + ? Icons.check_circle + : ((episode.listenDuration != null && episode.listenDuration! > 0) + ? Icons.play_circle_filled + : Icons.play_circle_outline), + color: episode.completed + ? Colors.green + : Theme.of(context).primaryColor, + size: 28, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (episode.saved) + Icon( + Icons.bookmark, + size: 16, + color: Colors.orange[600], + ), + if (episode.downloaded) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.download_done, + size: 16, + color: Colors.green[600], + ), + ), + if (episode.queued) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.queue_music, + size: 16, + color: Colors.blue[600], + ), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _PodcastCard extends StatelessWidget { + final HomePodcast podcast; + + const _PodcastCard({required this.podcast}); + + UnifiedPinepodsPodcast _convertToUnifiedPodcast() { + return UnifiedPinepodsPodcast( + id: podcast.podcastId, + indexId: podcast.podcastIndexId ?? 0, + title: podcast.podcastName, + url: podcast.feedUrl ?? '', + originalUrl: podcast.feedUrl ?? '', + link: podcast.websiteUrl ?? '', + description: podcast.description ?? '', + author: podcast.author ?? '', + ownerName: podcast.author ?? '', + image: podcast.artworkUrl ?? '', + artwork: podcast.artworkUrl ?? '', + lastUpdateTime: 0, + categories: podcast.categories != null ? {'0': podcast.categories!} : null, + explicit: podcast.explicit ?? false, + episodeCount: podcast.episodeCount ?? 0, + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsPodcastDetails( + podcast: _convertToUnifiedPodcast(), + isFollowing: true, + ), + ), + ); + }, + child: SizedBox( + width: 140, + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + podcast.artworkUrl ?? '', + width: 140, + height: 140, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 140, + height: 140, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.podcasts, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + }, + ), + ), + const SizedBox(height: 8), + Text( + podcast.podcastName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class _PlaylistCard extends StatelessWidget { + final Playlist playlist; + + const _PlaylistCard({required this.playlist}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 200, + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: InkWell( + onTap: () => _openPlaylist(context), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Icon( + _getIconFromName(playlist.iconName), + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + playlist.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (playlist.episodeCount != null) ...[ + const SizedBox(height: 8), + Text( + '${playlist.episodeCount} episodes', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + Future _openPlaylist(BuildContext context) async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Not connected to PinePods server. Please connect in Settings.'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + try { + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + + final userPlaylists = await pinepodsService.getUserPlaylists(settings.pinepodsUserId!); + final fullPlaylistData = userPlaylists.firstWhere( + (p) => p.playlistId == playlist.playlistId, + orElse: () => throw Exception('Playlist not found'), + ); + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistEpisodesPage(playlist: fullPlaylistData), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error opening playlist: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + IconData _getIconFromName(String iconName) { + switch (iconName) { + case 'ph-music-notes': + return Icons.music_note; + case 'ph-star': + return Icons.star; + case 'ph-clock': + return Icons.access_time; + case 'ph-heart': + return Icons.favorite; + default: + return Icons.playlist_play; + } + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart new file mode 100644 index 0000000..aa1100e --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart @@ -0,0 +1,116 @@ +// lib/ui/pinepods/more_menu.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/ui/library/downloads.dart'; +import 'package:pinepods_mobile/ui/settings/settings.dart'; +import 'package:pinepods_mobile/ui/pinepods/saved.dart'; +import 'package:pinepods_mobile/ui/pinepods/history.dart'; + +class PinepodsMoreMenu extends StatelessWidget { + // Constructor with optional key parameter + const PinepodsMoreMenu({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'More Options', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + _buildMenuItem( + context, + 'Downloads', + Icons.download_outlined, + () => Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: false, + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Downloads')), + body: const CustomScrollView( + slivers: [Downloads()], + ), + ), + ), + ), + ), + _buildMenuItem( + context, + 'Saved Episodes', + Icons.bookmark_outline, + () => Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: false, + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Saved Episodes')), + body: const CustomScrollView( + slivers: [PinepodsSaved()], + ), + ), + ), + ), + ), + _buildMenuItem( + context, + 'History', + Icons.history, + () => Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: false, + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('History')), + body: const CustomScrollView( + slivers: [PinepodsHistory()], + ), + ), + ), + ), + ), + _buildMenuItem( + context, + 'Settings', + Icons.settings_outlined, + () => Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + settings: const RouteSettings(name: 'settings'), + builder: (context) => const Settings(), + ), + ), + ), + ], + ), + ), + ]), + ); + } + + Widget _buildMenuItem( + BuildContext context, + String title, + IconData icon, + VoidCallback onTap, + ) { + return Card( + margin: const EdgeInsets.only(bottom: 12.0), + child: ListTile( + leading: Icon(icon), + title: Text(title), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: onTap, + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart new file mode 100644 index 0000000..1d131b9 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart @@ -0,0 +1,572 @@ +// lib/ui/pinepods/playlist_episodes.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:provider/provider.dart'; + +class PlaylistEpisodesPage extends StatefulWidget { + final PlaylistData playlist; + + const PlaylistEpisodesPage({ + Key? key, + required this.playlist, + }) : super(key: key); + + @override + State createState() => _PlaylistEpisodesPageState(); +} + +class _PlaylistEpisodesPageState extends State { + final PinepodsService _pinepodsService = PinepodsService(); + PlaylistEpisodesResponse? _playlistResponse; + bool _isLoading = true; + String? _errorMessage; + + // Use global audio service instead of creating local instance + int? _contextMenuEpisodeIndex; + + @override + void initState() { + super.initState(); + _loadPlaylistEpisodes(); + } + + Future _loadPlaylistEpisodes() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please connect in Settings.'; + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + final response = await _pinepodsService.getPlaylistEpisodes( + settings.pinepodsUserId!, + widget.playlist.playlistId, + ); + + setState(() { + _playlistResponse = response; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + + IconData _getPlaylistIcon(String? iconName) { + if (iconName == null) return Icons.playlist_play; + + // Map common icon names to Material icons + switch (iconName) { + case 'ph-playlist': + return Icons.playlist_play; + case 'ph-music-notes': + return Icons.music_note; + case 'ph-play-circle': + return Icons.play_circle; + case 'ph-headphones': + return Icons.headphones; + case 'ph-star': + return Icons.star; + case 'ph-heart': + return Icons.favorite; + case 'ph-bookmark': + return Icons.bookmark; + case 'ph-clock': + return Icons.access_time; + case 'ph-calendar': + return Icons.calendar_today; + case 'ph-timer': + return Icons.timer; + case 'ph-shuffle': + return Icons.shuffle; + case 'ph-repeat': + return Icons.repeat; + case 'ph-microphone': + return Icons.mic; + case 'ph-queue': + return Icons.queue_music; + default: + return Icons.playlist_play; + } + } + + String _getEmptyStateMessage() { + switch (widget.playlist.name) { + case 'Fresh Releases': + return 'No new episodes have been released in the last 24 hours. Check back later for fresh content!'; + case 'Currently Listening': + return 'Start listening to some episodes and they\'ll appear here for easy access.'; + case 'Almost Done': + return 'You don\'t have any episodes that are near completion. Keep listening!'; + default: + return 'No episodes match the current playlist criteria. Try adjusting the filters or add more podcasts.'; + } + } + + PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + + Future _playEpisode(PinepodsEpisode episode) async { + if (_audioService == null) { + _showSnackBar('Audio service not available', Colors.red); + return; + } + + try { + await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode); + } catch (e) { + if (mounted) { + _showSnackBar('Failed to play episode: $e', Colors.red); + } + } + } + + void _showContextMenu(int episodeIndex) { + setState(() { + _contextMenuEpisodeIndex = episodeIndex; + }); + } + + void _hideContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + }); + } + + Future _saveEpisode(int episodeIndex) async { + final episode = _playlistResponse!.episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + final success = await _pinepodsService.saveEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode saved', Colors.green); + } else if (mounted) { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + if (mounted) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + } + } + + Future _removeSavedEpisode(int episodeIndex) async { + final episode = _playlistResponse!.episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + final success = await _pinepodsService.removeSavedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode removed from saved', Colors.orange); + } else if (mounted) { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + if (mounted) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + } + } + + Future _downloadEpisode(int episodeIndex) async { + final episode = _playlistResponse!.episodes[episodeIndex]; + _showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue); + // Note: Actual download implementation would depend on download service integration + } + + Future _deleteEpisode(int episodeIndex) async { + final episode = _playlistResponse!.episodes[episodeIndex]; + _showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange); + // Note: Actual delete implementation would depend on download service integration + } + + Future _localDownloadEpisode(int episodeIndex) async { + final episode = _playlistResponse!.episodes[episodeIndex]; + _showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue); + // Note: Actual local download implementation would depend on download service integration + } + + Future _toggleQueueEpisode(int episodeIndex) async { + final episode = _playlistResponse!.episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + if (episode.queued) { + final success = await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode removed from queue', Colors.orange); + } + } else { + final success = await _pinepodsService.queueEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode added to queue', Colors.green); + } + } + } catch (e) { + if (mounted) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + } + } + + Future _toggleMarkComplete(int episodeIndex) async { + final episode = _playlistResponse!.episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + try { + if (episode.completed) { + final success = await _pinepodsService.markEpisodeUncompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode marked as incomplete', Colors.orange); + } + } else { + final success = await _pinepodsService.markEpisodeCompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success && mounted) { + _showSnackBar('Episode marked as complete', Colors.green); + } + } + } catch (e) { + if (mounted) { + _showSnackBar('Error updating completion status: $e', Colors.red); + } + } + } + + void _showSnackBar(String message, Color backgroundColor) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + // Show context menu as a modal overlay if needed + if (_contextMenuEpisodeIndex != null) { + final episodeIndex = _contextMenuEpisodeIndex!; + final episode = _playlistResponse!.episodes[episodeIndex]; + WidgetsBinding.instance.addPostFrameCallback((_) { + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: episode, + onSave: () { + Navigator.of(context).pop(); + _saveEpisode(episodeIndex); + }, + onRemoveSaved: () { + Navigator.of(context).pop(); + _removeSavedEpisode(episodeIndex); + }, + onDownload: episode.downloaded + ? () { + Navigator.of(context).pop(); + _deleteEpisode(episodeIndex); + } + : () { + Navigator.of(context).pop(); + _downloadEpisode(episodeIndex); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadEpisode(episodeIndex); + }, + onQueue: () { + Navigator.of(context).pop(); + _toggleQueueEpisode(episodeIndex); + }, + onMarkComplete: () { + Navigator.of(context).pop(); + _toggleMarkComplete(episodeIndex); + }, + onDismiss: () { + Navigator.of(context).pop(); + _hideContextMenu(); + }, + ), + ); + }); + // Reset the context menu index after storing it locally + _contextMenuEpisodeIndex = null; + } + + return Scaffold( + appBar: AppBar( + title: Text(widget.playlist.name), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0, + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + PlatformProgressIndicator(), + SizedBox(height: 16), + Text('Loading playlist episodes...'), + ], + ), + ); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 75, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Error loading playlist', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + _errorMessage!, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPlaylistEpisodes, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (_playlistResponse == null) { + return const Center( + child: Text('No data available'), + ); + } + + return CustomScrollView( + slivers: [ + // Playlist header + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getPlaylistIcon(_playlistResponse!.playlistInfo.iconName), + size: 48, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _playlistResponse!.playlistInfo.name, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (_playlistResponse!.playlistInfo.description != null && + _playlistResponse!.playlistInfo.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _playlistResponse!.playlistInfo.description!, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${_playlistResponse!.playlistInfo.episodeCount ?? _playlistResponse!.episodes.length} episodes', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + + // Episodes list + if (_playlistResponse!.episodes.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.playlist_remove, + size: 75, + color: Theme.of(context).primaryColor.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'No Episodes Found', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + _getEmptyStateMessage(), + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final episode = _playlistResponse!.episodes[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), + child: PinepodsEpisodeCard( + episode: episode, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: episode, + ), + ), + ); + }, + onLongPress: () => _showContextMenu(index), + onPlayPressed: () => _playEpisode(episode), + ), + ); + }, + childCount: _playlistResponse!.episodes.length, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart new file mode 100644 index 0000000..681845a --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart @@ -0,0 +1,546 @@ +// lib/ui/pinepods/playlists.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/server_error_page.dart'; +import 'package:pinepods_mobile/services/error_handling_service.dart'; +import 'package:pinepods_mobile/ui/pinepods/playlist_episodes.dart'; +import 'package:pinepods_mobile/ui/pinepods/create_playlist.dart'; +import 'package:provider/provider.dart'; + +class PinepodsPlaylists extends StatefulWidget { + const PinepodsPlaylists({Key? key}) : super(key: key); + + @override + State createState() => _PinepodsPlaylistsState(); +} + +class _PinepodsPlaylistsState extends State { + final PinepodsService _pinepodsService = PinepodsService(); + List? _playlists; + bool _isLoading = true; + String? _errorMessage; + Set _selectedPlaylists = {}; + bool _isSelectionMode = false; + + @override + void initState() { + super.initState(); + _loadPlaylists(); + } + + /// Calculate responsive cross axis count for playlist grid + int _getPlaylistCrossAxisCount(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop) + if (screenWidth > 800) return 3; // Wide tablets like iPad + if (screenWidth > 500) return 2; // Standard phones and small tablets + return 1; // Very small phones (< 500px) + } + + /// Calculate responsive aspect ratio for playlist cards + double _getPlaylistAspectRatio(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + if (screenWidth <= 500) { + // Single column on small screens - generous height for multi-line descriptions + padding + return 1.8; // Allows space for title + 2-3 lines of description + proper padding + } + return 1.1; // Standard aspect ratio for multi-column layouts + } + + Future _loadPlaylists() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please connect in Settings.'; + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + + final playlists = await _pinepodsService.getUserPlaylists(settings.pinepodsUserId!); + + setState(() { + _playlists = playlists; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + + void _toggleSelectionMode() { + setState(() { + _isSelectionMode = !_isSelectionMode; + if (!_isSelectionMode) { + _selectedPlaylists.clear(); + } + }); + } + + void _togglePlaylistSelection(int playlistId) { + setState(() { + if (_selectedPlaylists.contains(playlistId)) { + _selectedPlaylists.remove(playlistId); + } else { + _selectedPlaylists.add(playlistId); + } + }); + } + + Future _deleteSelectedPlaylists() async { + if (_selectedPlaylists.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Playlists'), + content: Text('Are you sure you want to delete ${_selectedPlaylists.length} playlist${_selectedPlaylists.length == 1 ? '' : 's'}?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true) return; + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + try { + for (final playlistId in _selectedPlaylists) { + await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlistId); + } + + setState(() { + _selectedPlaylists.clear(); + _isSelectionMode = false; + }); + + _loadPlaylists(); // Refresh the list + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Playlists deleted successfully')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error deleting playlists: $e')), + ); + } + } + } + + Future _deletePlaylist(PlaylistData playlist) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Playlist'), + content: Text('Are you sure you want to delete "${playlist.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true) return; + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + try { + await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlist.playlistId); + _loadPlaylists(); // Refresh the list + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Playlist deleted successfully')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error deleting playlist: $e')), + ); + } + } + } + + void _openPlaylist(PlaylistData playlist) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistEpisodesPage(playlist: playlist), + ), + ); + } + + void _createPlaylist() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CreatePlaylistPage(), + ), + ); + + if (result == true) { + _loadPlaylists(); // Refresh the list + } + } + + IconData _getPlaylistIcon(String? iconName) { + if (iconName == null) return Icons.playlist_play; + + // Map common icon names to Material icons + switch (iconName) { + case 'ph-playlist': + return Icons.playlist_play; + case 'ph-music-notes': + return Icons.music_note; + case 'ph-play-circle': + return Icons.play_circle; + case 'ph-headphones': + return Icons.headphones; + case 'ph-star': + return Icons.star; + case 'ph-heart': + return Icons.favorite; + case 'ph-bookmark': + return Icons.bookmark; + case 'ph-clock': + return Icons.access_time; + case 'ph-calendar': + return Icons.calendar_today; + case 'ph-timer': + return Icons.timer; + case 'ph-shuffle': + return Icons.shuffle; + case 'ph-repeat': + return Icons.repeat; + case 'ph-microphone': + return Icons.mic; + case 'ph-queue': + return Icons.queue_music; + default: + return Icons.playlist_play; + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + PlatformProgressIndicator(), + ], + ), + ); + } + + if (_errorMessage != null) { + return SliverServerErrorPage( + errorMessage: _errorMessage!.isServerConnectionError + ? null + : _errorMessage, + onRetry: _loadPlaylists, + title: 'Playlists Unavailable', + subtitle: _errorMessage!.isServerConnectionError + ? 'Unable to connect to the PinePods server' + : 'Failed to load your playlists', + ); + } + + if (_playlists == null || _playlists!.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.playlist_play, + size: 75, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 16), + Text( + 'No playlists found', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Create a smart playlist to get started!', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _createPlaylist, + icon: const Icon(Icons.add), + label: const Text('Create Playlist'), + ), + ], + ), + ), + ); + } + + return SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with action buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Smart Playlists', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + if (_isSelectionMode) ...[ + IconButton( + icon: const Icon(Icons.close), + onPressed: _toggleSelectionMode, + tooltip: 'Cancel', + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: _selectedPlaylists.isNotEmpty ? _deleteSelectedPlaylists : null, + tooltip: 'Delete selected (${_selectedPlaylists.length})', + ), + ] else ...[ + IconButton( + icon: const Icon(Icons.select_all), + onPressed: _toggleSelectionMode, + tooltip: 'Select multiple', + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: _createPlaylist, + tooltip: 'Create playlist', + ), + ], + ], + ), + ], + ), + + // Info banner for selection mode + if (_isSelectionMode) + Container( + margin: const EdgeInsets.only(top: 8, bottom: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).primaryColor.withOpacity(0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'System playlists cannot be deleted.', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + + // Playlists grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _getPlaylistCrossAxisCount(context), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: _getPlaylistAspectRatio(context), + ), + itemCount: _playlists!.length, + itemBuilder: (context, index) { + final playlist = _playlists![index]; + final isSelected = _selectedPlaylists.contains(playlist.playlistId); + final canSelect = _isSelectionMode && !playlist.isSystemPlaylist; + + return GestureDetector( + onTap: () { + if (_isSelectionMode && !playlist.isSystemPlaylist) { + _togglePlaylistSelection(playlist.playlistId); + } else if (!_isSelectionMode) { + _openPlaylist(playlist); + } + }, + child: Card( + elevation: isSelected ? 8 : 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + color: isSelected + ? Theme.of(context).primaryColor.withOpacity(0.1) + : null, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getPlaylistIcon(playlist.iconName), + size: 32, + color: Theme.of(context).primaryColor, + ), + const Spacer(), + if (playlist.isSystemPlaylist) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'System', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + playlist.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '${playlist.episodeCount ?? 0} episodes', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + if (playlist.description != null && playlist.description!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + playlist.description!, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).textTheme.bodySmall?.color, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + + // Selection checkbox + if (canSelect) + Positioned( + top: 8, + left: 8, + child: Checkbox( + value: isSelected, + onChanged: (value) { + _togglePlaylistSelection(playlist.playlistId); + }, + ), + ), + + // Delete button for non-system playlists (when not in selection mode) + if (!_isSelectionMode && !playlist.isSystemPlaylist) + Positioned( + top: 8, + right: 8, + child: IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: () => _deletePlaylist(playlist), + color: Theme.of(context).colorScheme.error.withOpacity(0.7), + ), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ), + ]), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart new file mode 100644 index 0000000..bbc908c --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart @@ -0,0 +1,1227 @@ +// lib/ui/pinepods/podcast_details.dart + +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/person.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_image.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/ui/podcast/mini_player.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:pinepods_mobile/ui/utils/local_download_utils.dart'; +import 'package:provider/provider.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +class PinepodsPodcastDetails extends StatefulWidget { + final UnifiedPinepodsPodcast podcast; + final bool isFollowing; + final Function(bool)? onFollowChanged; + + const PinepodsPodcastDetails({ + super.key, + required this.podcast, + required this.isFollowing, + this.onFollowChanged, + }); + + @override + State createState() => _PinepodsPodcastDetailsState(); +} + +class _PinepodsPodcastDetailsState extends State { + final PinepodsService _pinepodsService = PinepodsService(); + bool _isLoading = false; + bool _isFollowing = false; + bool _isFollowButtonLoading = false; + String? _errorMessage; + List _episodes = []; + List _filteredEpisodes = []; + int? _contextMenuEpisodeIndex; + // Use global audio service instead of creating local instance + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + List _hosts = []; + + @override + void initState() { + super.initState(); + _isFollowing = widget.isFollowing; + _initializeCredentials(); + _checkFollowStatus(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.dispose(); + // Don't dispose global audio service - it should persist across pages + super.dispose(); + } + + void _onSearchChanged() { + setState(() { + _searchQuery = _searchController.text; + _filterEpisodes(); + }); + } + + void _filterEpisodes() { + if (_searchQuery.isEmpty) { + _filteredEpisodes = List.from(_episodes); + } else { + _filteredEpisodes = _episodes.where((episode) { + return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + } + } + + void _initializeCredentials() { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) { + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + } + } + + Future _checkFollowStatus() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + setState(() { + _isFollowing = false; + }); + _loadPodcastFeed(); + return; + } + + try { + // If we have a valid podcast ID (> 0), assume it's followed since we got it from episode metadata + if (widget.podcast.id > 0 && widget.isFollowing) { + print('Using podcast ID ${widget.podcast.id} - assuming followed'); + setState(() { + _isFollowing = true; + }); + _loadPodcastFeed(); + return; + } + + print('Checking follow status for: ${widget.podcast.title}'); + final isFollowing = await _pinepodsService.checkPodcastExists( + widget.podcast.title, + widget.podcast.url, + userId, + ); + + print('Follow status result: $isFollowing'); + setState(() { + _isFollowing = isFollowing; + }); + + _loadPodcastFeed(); + } catch (e) { + print('Error checking follow status: $e'); + // Use the passed value as fallback + _loadPodcastFeed(); + } + } + + // Convert Episode objects to PinepodsEpisode objects + PinepodsEpisode _convertEpisodeToPinepodsEpisode(Episode episode) { + return PinepodsEpisode( + podcastName: episode.podcast ?? widget.podcast.title, + episodeTitle: episode.title ?? '', + episodePubDate: episode.publicationDate?.toIso8601String() ?? '', + episodeDescription: episode.description ?? '', + episodeArtwork: episode.imageUrl ?? widget.podcast.artwork, + episodeUrl: episode.contentUrl ?? '', + episodeDuration: episode.duration, + listenDuration: 0, // RSS episodes don't have listen duration + episodeId: 0, // RSS episodes don't have server IDs + completed: false, + saved: false, + queued: false, + downloaded: false, + isYoutube: false, + ); + } + + Future _loadPodcastFeed() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + List episodes = []; + + if (_isFollowing && userId != null) { + try { + print('Loading episodes for followed podcast: ${widget.podcast.title}'); + + int? podcastId; + + // If we already have a podcast ID (from episode metadata), use it directly + if (widget.podcast.id > 0) { + podcastId = widget.podcast.id; + print('Using existing podcast ID: $podcastId'); + } else { + // Get the actual podcast ID using the dedicated endpoint + podcastId = await _pinepodsService.getPodcastId( + userId, + widget.podcast.url, + widget.podcast.title, + ); + print('Got podcast ID from lookup: $podcastId'); + } + + if (podcastId != null && podcastId > 0) { + // Get episodes from server + episodes = await _pinepodsService.getPodcastEpisodes(userId, podcastId); + print('Loaded ${episodes.length} episodes from server for podcastId: $podcastId'); + + // If server has no episodes, this podcast may need episode sync + if (episodes.isEmpty) { + print('Server has no episodes for subscribed podcast. This should not happen.'); + print('Podcast ID: $podcastId, Title: ${widget.podcast.title}'); + + // For subscribed podcasts, we should NOT fall back to RSS + // The server should have episodes. This indicates a server-side sync issue. + // Fall back to RSS ONLY as emergency backup, but episodes won't be clickable + try { + final podcastService = Provider.of(context, listen: false); + final rssPodcast = Podcast.fromUrl(url: widget.podcast.url); + + final loadedPodcast = await podcastService.loadPodcast(podcast: rssPodcast); + + if (loadedPodcast != null && loadedPodcast.episodes.isNotEmpty) { + episodes = loadedPodcast.episodes.map(_convertEpisodeToPinepodsEpisode).toList(); + print('Emergency RSS fallback: Loaded ${episodes.length} episodes (NOT CLICKABLE)'); + } + } catch (e) { + print('Emergency RSS fallback also failed: $e'); + } + } + + // Fetch podcast 2.0 data for hosts information + try { + final podcastData = await _pinepodsService.fetchPodcasting2PodData(podcastId, userId); + if (podcastData != null) { + final personsData = podcastData['people'] as List?; + if (personsData != null) { + final hosts = personsData.map((personData) { + return Person( + name: personData['name'] ?? '', + role: personData['role'] ?? '', + group: personData['group'] ?? '', + image: personData['img'], + link: personData['href'], + ); + }).toList(); + + setState(() { + _hosts = hosts; + }); + print('Loaded ${hosts.length} hosts from podcast 2.0 data'); + } + } + } catch (e) { + print('Error loading podcast 2.0 data: $e'); + } + } else { + print('No podcast ID found - podcast may not be properly added'); + } + } catch (e) { + print('Error loading episodes for followed podcast: $e'); + // Fall back to empty episodes list + episodes = []; + } + } else { + try { + print('Loading episodes from RSS feed for non-followed podcast: ${widget.podcast.url}'); + + // Use the existing podcast service to parse RSS feed + final podcastService = Provider.of(context, listen: false); + final rssePodcast = Podcast.fromUrl(url: widget.podcast.url); + + final loadedPodcast = await podcastService.loadPodcast(podcast: rssePodcast); + + if (loadedPodcast != null && loadedPodcast.episodes.isNotEmpty) { + // Convert Episode objects to PinepodsEpisode objects + episodes = loadedPodcast.episodes.map(_convertEpisodeToPinepodsEpisode).toList(); + print('Loaded ${episodes.length} episodes from RSS feed'); + } else { + print('No episodes found in RSS feed'); + } + } catch (e) { + print('Error loading episodes from RSS feed: $e'); + setState(() { + _errorMessage = 'Failed to load podcast feed'; + _isLoading = false; + }); + return; + } + } + + setState(() { + _episodes = episodes; + _filterEpisodes(); // Initialize filtered list + _isLoading = false; + }); + } catch (e) { + print('Error in _loadPodcastFeed: $e'); + setState(() { + _episodes = []; + _isLoading = false; + _errorMessage = 'Failed to load episodes'; + }); + } + } + + Future _toggleFollow() async { + print('PinePods Follow button: CLICKED - Setting loading to true'); + setState(() { + _isFollowButtonLoading = true; + }); + + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + setState(() { + _isFollowButtonLoading = false; + }); + _showSnackBar('Not logged in to PinePods server', Colors.red); + return; + } + + try { + bool success; + final oldFollowingState = _isFollowing; + + if (_isFollowing) { + success = await _pinepodsService.removePodcast( + widget.podcast.title, + widget.podcast.url, + userId, + ); + if (success) { + setState(() { + _isFollowing = false; + }); + widget.onFollowChanged?.call(false); + _showSnackBar('Podcast removed', Colors.orange); + } + } else { + success = await _pinepodsService.addPodcast(widget.podcast, userId); + if (success) { + setState(() { + _isFollowing = true; + }); + widget.onFollowChanged?.call(true); + _showSnackBar('Podcast added', Colors.green); + } + } + + if (success) { + // Always reload episodes when follow status changes + // This will switch between server episodes (followed) and RSS episodes (unfollowed) + await _loadPodcastFeed(); + } else { + // Revert state change if the operation failed + setState(() { + _isFollowing = oldFollowingState; + }); + _showSnackBar('Failed to ${oldFollowingState ? 'remove' : 'add'} podcast', Colors.red); + } + } catch (e) { + _showSnackBar('Error: $e', Colors.red); + } finally { + // Always reset loading state + setState(() { + _isFollowButtonLoading = false; + }); + print('PinePods Follow button: Loading state reset to false'); + } + } + + void _showSnackBar(String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + + + Future _showEpisodeContextMenu(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode); + + if (!mounted) return; + + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: episode, + isDownloadedLocally: isDownloadedLocally, + onSave: () { + Navigator.of(context).pop(); + _saveEpisode(episodeIndex); + }, + onRemoveSaved: () { + Navigator.of(context).pop(); + _removeSavedEpisode(episodeIndex); + }, + onDownload: episode.downloaded + ? () { + Navigator.of(context).pop(); + _deleteEpisode(episodeIndex); + } + : () { + Navigator.of(context).pop(); + _downloadEpisode(episodeIndex); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadEpisode(episodeIndex); + }, + onDeleteLocalDownload: () { + Navigator.of(context).pop(); + _deleteLocalDownload(episodeIndex); + }, + onQueue: () { + Navigator.of(context).pop(); + _queueEpisode(episodeIndex); + }, + onMarkComplete: () { + Navigator.of(context).pop(); + _markEpisodeComplete(episodeIndex); + }, + onDismiss: () { + Navigator.of(context).pop(); + _hideEpisodeContextMenu(); + }, + ), + ); + } + + void _hideEpisodeContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + }); + } + + PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + + Future _playEpisode(PinepodsEpisode episode) async { + if (_audioService == null) { + _showSnackBar('Audio service not available', Colors.red); + return; + } + + try { + await playPinepodsEpisodeWithOptionalFullScreen( + context, + _audioService!, + episode, + resume: episode.isStarted, + ); + } catch (e) { + _showSnackBar('Failed to play episode: $e', Colors.red); + } + } + + Future _saveEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.saveEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, saved: true); + _filteredEpisodes = _episodes.where((e) => + e.episodeTitle.toLowerCase().contains(_searchController.text.toLowerCase()) + ).toList(); + }); + _showSnackBar('Episode saved!', Colors.green); + } else { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + + _hideEpisodeContextMenu(); + } + + Future _removeSavedEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.removeSavedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, saved: false); + _filteredEpisodes = _episodes.where((e) => + e.episodeTitle.toLowerCase().contains(_searchController.text.toLowerCase()) + ).toList(); + }); + _showSnackBar('Removed from saved episodes', Colors.orange); + } else { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + + _hideEpisodeContextMenu(); + } + + Future _downloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.downloadEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true); + _filteredEpisodes = _episodes.where((e) => + e.episodeTitle.toLowerCase().contains(_searchController.text.toLowerCase()) + ).toList(); + }); + _showSnackBar('Episode download started!', Colors.green); + } else { + _showSnackBar('Failed to download episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error downloading episode: $e', Colors.red); + } + + _hideEpisodeContextMenu(); + } + + Future _deleteEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.deleteEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false); + _filteredEpisodes = _episodes.where((e) => + e.episodeTitle.toLowerCase().contains(_searchController.text.toLowerCase()) + ).toList(); + }); + _showSnackBar('Episode deleted from server', Colors.orange); + } else { + _showSnackBar('Failed to delete episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error deleting episode: $e', Colors.red); + } + + _hideEpisodeContextMenu(); + } + + Future _queueEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.queued) { + success = await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false); + _filteredEpisodes = _episodes.where((e) => + e.episodeTitle.toLowerCase().contains(_searchController.text.toLowerCase()) + ).toList(); + }); + _showSnackBar('Removed from queue', Colors.orange); + } + } else { + success = await _pinepodsService.queueEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true); + _filteredEpisodes = _episodes.where((e) => + e.episodeTitle.toLowerCase().contains(_searchController.text.toLowerCase()) + ).toList(); + }); + _showSnackBar('Added to queue!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update queue', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + + _hideEpisodeContextMenu(); + } + + Future _markEpisodeComplete(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.markEpisodeCompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true); + _filteredEpisodes = _episodes.where((e) => + e.episodeTitle.toLowerCase().contains(_searchController.text.toLowerCase()) + ).toList(); + }); + _showSnackBar('Episode marked as complete', Colors.green); + } else { + _showSnackBar('Failed to mark episode complete', Colors.red); + } + } catch (e) { + _showSnackBar('Error marking episode complete: $e', Colors.red); + } + + _hideEpisodeContextMenu(); + } + + Future _localDownloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + final success = await LocalDownloadUtils.localDownloadEpisode(context, episode); + + if (success) { + _showSnackBar('Episode download started', Colors.green); + } else { + _showSnackBar('Failed to start download', Colors.red); + } + + _hideEpisodeContextMenu(); + } + + Future _deleteLocalDownload(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode); + + if (deletedCount > 0) { + _showSnackBar( + 'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}', + Colors.orange + ); + } else { + _showSnackBar('Local download not found', Colors.red); + } + + _hideEpisodeContextMenu(); + } + + PinepodsEpisode _updateEpisodeProperty( + PinepodsEpisode episode, { + bool? saved, + bool? downloaded, + bool? queued, + bool? completed, + }) { + return PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: completed ?? episode.completed, + saved: saved ?? episode.saved, + queued: queued ?? episode.queued, + downloaded: downloaded ?? episode.downloaded, + isYoutube: episode.isYoutube, + podcastId: episode.podcastId, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 300, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + title: Text( + widget.podcast.title, + style: const TextStyle( + shadows: [ + Shadow( + offset: Offset(0, 1), + blurRadius: 3, + color: Colors.black54, + ), + ], + ), + ), + background: Stack( + fit: StackFit.expand, + children: [ + widget.podcast.artwork.isNotEmpty + ? Image.network( + widget.podcast.artwork, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[300], + child: const Icon( + Icons.music_note, + size: 80, + color: Colors.grey, + ), + ); + }, + ) + : Container( + color: Colors.grey[300], + child: const Icon( + Icons.music_note, + size: 80, + color: Colors.grey, + ), + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.7), + ], + ), + ), + ), + ], + ), + ), + actions: [ + IconButton( + onPressed: _isFollowButtonLoading ? null : _toggleFollow, + icon: _isFollowButtonLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.0, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Icon( + _isFollowing ? Icons.favorite : Icons.favorite_border, + color: _isFollowing ? Colors.red : Colors.white, + ), + tooltip: _isFollowing ? 'Unfollow' : 'Follow', + ), + ], + ), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Podcast info with follow/unfollow button + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.podcast.author.isNotEmpty) + Text( + 'By ${widget.podcast.author}', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ElevatedButton.icon( + onPressed: _isFollowButtonLoading ? null : _toggleFollow, + icon: _isFollowButtonLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2.0, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Icon( + _isFollowing ? Icons.remove : Icons.add, + size: 16, + ), + label: Text(_isFollowing ? 'Unfollow' : 'Follow'), + style: ElevatedButton.styleFrom( + backgroundColor: _isFollowing ? Colors.red : Colors.green, + foregroundColor: Colors.white, + ), + ), + ], + ), + + const SizedBox(height: 8), + + Text( + widget.podcast.description, + style: const TextStyle(fontSize: 14), + ), + + const SizedBox(height: 16), + + // Podcast stats + Row( + children: [ + Icon( + Icons.mic, + size: 16, + color: Colors.grey[600], + ), + const SizedBox(width: 4), + Text( + '${widget.podcast.episodeCount} episode${widget.podcast.episodeCount != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 16), + if (widget.podcast.explicit) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Explicit', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + + // Hosts section (filter out "Unknown Host" entries) + if (_hosts.where((host) => host.name != "Unknown Host").isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Hosts', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 80, + child: Builder(builder: (context) { + final actualHosts = _hosts.where((host) => host.name != "Unknown Host").toList(); + return ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: actualHosts.length, + itemBuilder: (context, index) { + final host = actualHosts[index]; + return Container( + width: 70, + margin: const EdgeInsets.only(right: 12), + child: Column( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey[300], + ), + child: host.image != null && host.image!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(25), + child: PodcastImage( + url: host.image!, + width: 50, + height: 50, + fit: BoxFit.cover, + ), + ) + : const Icon( + Icons.person, + size: 30, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + host.name, + style: const TextStyle(fontSize: 12), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, + ); + }), + ), + ], + + const SizedBox(height: 24), + + // Episodes section header + Row( + children: [ + const Text( + 'Episodes', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (_isLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + + const SizedBox(height: 16), + ], + ), + ), + ), + + // Episodes list + if (_isLoading) + const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(32.0), + child: PlatformProgressIndicator(), + ), + ), + ) + else if (_errorMessage != null) + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red[300], + ), + const SizedBox(height: 16), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPodcastFeed, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ) + else if (_episodes.isEmpty) + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + Icon( + Icons.info_outline, + size: 64, + color: Colors.blue[300], + ), + const SizedBox(height: 16), + Text( + _isFollowing ? 'No episodes found' : 'Episodes available after following', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + _isFollowing + ? 'Episodes from your PinePods library will appear here' + : 'Follow this podcast to add it to your library and view episodes', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _toggleFollow, + child: Text(_isFollowing ? 'Unfollow' : 'Follow'), + ), + ], + ), + ), + ), + ) + else + MultiSliver( + children: [ + _buildSearchBar(), + _buildEpisodesList(), + ], + ), + ], + ), + ), + const MiniPlayer(), + ], + ), + ); + } + + Widget _buildSearchBar() { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Filter episodes...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: Theme.of(context).cardColor, + ), + ), + ), + ); + } + + Widget _buildEpisodesList() { + // Check if search returned no results + if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) { + return SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 16), + Text( + 'No episodes found', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'No episodes match "$_searchQuery"', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final episode = _filteredEpisodes[index]; + // Find the original index for context menu operations + final originalIndex = _episodes.indexOf(episode); + final bool hasValidServerEpisodeId = episode.episodeId > 0; + + if (!hasValidServerEpisodeId) { + print('Episode "${episode.episodeTitle}" has no server ID (RSS fallback) - disabling episode details navigation'); + } + + return PinepodsEpisodeCard( + episode: episode, + onTap: _isFollowing && hasValidServerEpisodeId ? () { + // Navigate to episode details only if following AND has valid server episode ID + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: episode, + ), + ), + ); + } : null, // Disable tap if not following or no valid episode ID + onLongPress: _isFollowing && hasValidServerEpisodeId ? () { + _showEpisodeContextMenu(originalIndex); + } : null, // Disable long press if not following or no valid episode ID + onPlayPressed: _isFollowing ? () { + _playEpisode(episode); + } : null, // Allow play for RSS episodes since it uses direct URL + ); + }, + childCount: _filteredEpisodes.length, + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart new file mode 100644 index 0000000..b11d733 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart @@ -0,0 +1,337 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_grid_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/layout_selector.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/ui/widgets/server_error_page.dart'; +import 'package:pinepods_mobile/services/error_handling_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +/// This class displays the list of podcasts the user is subscribed to on the PinePods server. +class PinepodsPodcasts extends StatefulWidget { + const PinepodsPodcasts({ + super.key, + }); + + @override + State createState() => _PinepodsPodcastsState(); +} + +class _PinepodsPodcastsState extends State { + List? _podcasts; + List? _filteredPodcasts; + bool _isLoading = true; + String? _errorMessage; + final PinepodsService _pinepodsService = PinepodsService(); + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _loadPodcasts(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + setState(() { + _searchQuery = _searchController.text; + _filterPodcasts(); + }); + } + + void _filterPodcasts() { + if (_podcasts == null) { + _filteredPodcasts = null; + return; + } + + if (_searchQuery.isEmpty) { + _filteredPodcasts = List.from(_podcasts!); + } else { + _filteredPodcasts = _podcasts!.where((podcast) { + return podcast.title.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + } + } + + Future _loadPodcasts() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please connect in Settings.'; + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // Initialize the service with the stored credentials + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + + final podcasts = await _pinepodsService.getUserPodcasts(settings.pinepodsUserId!); + + setState(() { + _podcasts = podcasts; + _filterPodcasts(); // Initialize filtered list + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + + Widget _buildSearchBar() { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Filter podcasts...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: Theme.of(context).cardColor, + ), + ), + ), + const SizedBox(width: 12), + Material( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () async { + await showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).secondaryHeaderColor, + barrierLabel: L.of(context)!.scrim_layout_selector, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + builder: (context) => const LayoutSelectorWidget(), + ); + }, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.dashboard, + size: 20, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPodcastList(AppSettings settings) { + final podcasts = _filteredPodcasts ?? []; + + if (podcasts.isEmpty && _searchQuery.isNotEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 75, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 16), + Text( + 'No podcasts found', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'No podcasts match "$_searchQuery"', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + var mode = settings.layout; + var size = mode == 1 ? 100.0 : 160.0; + + if (mode == 0) { + // List view + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return PinepodsPodcastTile(podcast: podcasts[index]); + }, + childCount: podcasts.length, + addAutomaticKeepAlives: false, + ), + ); + } + + // Grid view + return SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: size, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return PinepodsPodcastGridTile(podcast: podcasts[index]); + }, + childCount: podcasts.length, + ), + ); + } + + @override + Widget build(BuildContext context) { + final settingsBloc = Provider.of(context); + + if (_isLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + PlatformProgressIndicator(), + ], + ), + ); + } + + if (_errorMessage != null) { + return SliverServerErrorPage( + errorMessage: _errorMessage!.isServerConnectionError + ? null + : _errorMessage, + onRetry: _loadPodcasts, + title: 'Podcasts Unavailable', + subtitle: _errorMessage!.isServerConnectionError + ? 'Unable to connect to the PinePods server' + : 'Failed to load your podcasts', + ); + } + + if (_podcasts == null || _podcasts!.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.podcasts, + size: 75, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 16), + Text( + 'No podcasts found', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'You haven\'t subscribed to any podcasts yet. Search for podcasts to get started!', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return StreamBuilder( + stream: settingsBloc.settings, + builder: (context, settingsSnapshot) { + if (settingsSnapshot.hasData) { + return MultiSliver( + children: [ + _buildSearchBar(), + _buildPodcastList(settingsSnapshot.data!), + ], + ); + } else { + return const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox( + height: 0, + width: 0, + ), + ); + } + }, + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart new file mode 100644 index 0000000..c6f2445 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart @@ -0,0 +1,805 @@ +// lib/ui/pinepods/queue.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/widgets/draggable_queue_episode_card.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:pinepods_mobile/ui/utils/local_download_utils.dart'; +import 'package:pinepods_mobile/ui/utils/position_utils.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:provider/provider.dart'; + +class PinepodsQueue extends StatefulWidget { + const PinepodsQueue({Key? key}) : super(key: key); + + @override + State createState() => _PinepodsQueueState(); +} + +class _PinepodsQueueState extends State { + bool _isLoading = false; + String _errorMessage = ''; + List _episodes = []; + final PinepodsService _pinepodsService = PinepodsService(); + // Use global audio service instead of creating local instance + int? _contextMenuEpisodeIndex; + + // Auto-scroll related variables + bool _isDragging = false; + bool _isAutoScrolling = false; + + @override + void initState() { + super.initState(); + _loadQueuedEpisodes(); + } + + PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + + Future _loadQueuedEpisodes() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please login first.'; + _isLoading = false; + }); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + final userId = settings.pinepodsUserId!; + + final episodes = await _pinepodsService.getQueuedEpisodes(userId); + + // Enrich episodes with best available positions (local vs server) + final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions( + context, + _pinepodsService, + episodes, + userId, + ); + + setState(() { + _episodes = enrichedEpisodes; + _isLoading = false; + }); + + // After loading episodes, check their local download status + await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load queued episodes: ${e.toString()}'; + _isLoading = false; + }); + } + } + + Future _refresh() async { + // Clear local download status cache on refresh + LocalDownloadUtils.clearCache(); + await _loadQueuedEpisodes(); + } + + Future _reorderEpisodes(int oldIndex, int newIndex) async { + // Adjust indices if moving down the list + if (newIndex > oldIndex) { + newIndex -= 1; + } + + // Update local state immediately for smooth UI + setState(() { + final episode = _episodes.removeAt(oldIndex); + _episodes.insert(newIndex, episode); + }); + + // Get episode IDs in new order + final episodeIds = _episodes.map((e) => e.episodeId).toList(); + + // Call API to update order on server + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + // Reload to restore original order if API call fails + await _loadQueuedEpisodes(); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + final success = await _pinepodsService.reorderQueue(userId, episodeIds); + + if (!success) { + _showSnackBar('Failed to update queue order', Colors.red); + // Reload to restore original order if API call fails + await _loadQueuedEpisodes(); + } + } catch (e) { + _showSnackBar('Error updating queue order: $e', Colors.red); + // Reload to restore original order if API call fails + await _loadQueuedEpisodes(); + } + } + + Future _playEpisode(PinepodsEpisode episode) async { + + if (_audioService == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Audio service not available'), + backgroundColor: Colors.red, + ), + ); + return; + } + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text('Starting ${episode.episodeTitle}...'), + ], + ), + duration: const Duration(seconds: 2), + ), + ); + + await _audioService!.playPinepodsEpisode( + pinepodsEpisode: episode, + resume: episode.isStarted, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Now playing: ${episode.episodeTitle}'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to play episode: ${e.toString()}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + + Future _showContextMenu(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode); + + if (!mounted) return; + + showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.3), + builder: (context) => EpisodeContextMenu( + episode: episode, + isDownloadedLocally: isDownloadedLocally, + onSave: () { + Navigator.of(context).pop(); + _saveEpisode(episodeIndex); + }, + onRemoveSaved: () { + Navigator.of(context).pop(); + _removeSavedEpisode(episodeIndex); + }, + onDownload: episode.downloaded + ? () { + Navigator.of(context).pop(); + _deleteEpisode(episodeIndex); + } + : () { + Navigator.of(context).pop(); + _downloadEpisode(episodeIndex); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadEpisode(episodeIndex); + }, + onDeleteLocalDownload: () { + Navigator.of(context).pop(); + _deleteLocalDownload(episodeIndex); + }, + onQueue: () { + Navigator.of(context).pop(); + _toggleQueueEpisode(episodeIndex); + }, + onMarkComplete: () { + Navigator.of(context).pop(); + _toggleMarkComplete(episodeIndex); + }, + onDismiss: () { + Navigator.of(context).pop(); + }, + ), + ); + } + + Future _localDownloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + final success = await LocalDownloadUtils.localDownloadEpisode(context, episode); + + if (success) { + LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green); + } else { + LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red); + } + } + + Future _deleteLocalDownload(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode); + + if (deletedCount > 0) { + LocalDownloadUtils.showSnackBar( + context, + 'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}', + Colors.orange + ); + } else { + LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red); + } + } + + void _hideContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + }); + } + + Future _saveEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.saveEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true); + }); + _showSnackBar('Episode saved!', Colors.green); + } else { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _removeSavedEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.removeSavedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false); + }); + _showSnackBar('Removed from saved episodes', Colors.orange); + } else { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _downloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.downloadEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true); + }); + _showSnackBar('Episode download queued!', Colors.green); + } else { + _showSnackBar('Failed to queue download', Colors.red); + } + } catch (e) { + _showSnackBar('Error downloading episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _deleteEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.deleteEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false); + }); + _showSnackBar('Episode deleted from server', Colors.orange); + } else { + _showSnackBar('Failed to delete episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error deleting episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _toggleQueueEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.queued) { + success = await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + // REMOVE the episode from the list since it's no longer queued + setState(() { + _episodes.removeAt(episodeIndex); + }); + _showSnackBar('Removed from queue', Colors.orange); + } + } else { + // This shouldn't happen since all episodes here are already queued + // But just in case, we'll handle it + success = await _pinepodsService.queueEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true); + }); + _showSnackBar('Added to queue!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update queue', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _toggleMarkComplete(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.completed) { + success = await _pinepodsService.markEpisodeUncompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false); + }); + _showSnackBar('Marked as incomplete', Colors.orange); + } + } else { + success = await _pinepodsService.markEpisodeCompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true); + }); + _showSnackBar('Marked as complete!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update completion status', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating completion: $e', Colors.red); + } + + _hideContextMenu(); + } + + PinepodsEpisode _updateEpisodeProperty( + PinepodsEpisode episode, { + bool? saved, + bool? downloaded, + bool? queued, + bool? completed, + }) { + return PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: completed ?? episode.completed, + saved: saved ?? episode.saved, + queued: queued ?? episode.queued, + downloaded: downloaded ?? episode.downloaded, + isYoutube: episode.isYoutube, + ); + } + + void _showSnackBar(String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + + void _startAutoScroll(bool scrollUp) async { + if (_isAutoScrolling) return; + _isAutoScrolling = true; + + while (_isDragging && _isAutoScrolling) { + // Find the nearest ScrollView controller + final ScrollController? scrollController = Scrollable.maybeOf(context)?.widget.controller; + + if (scrollController != null && scrollController.hasClients) { + final currentOffset = scrollController.offset; + final maxScrollExtent = scrollController.position.maxScrollExtent; + + if (scrollUp && currentOffset > 0) { + // Scroll up + final newOffset = (currentOffset - 8.0).clamp(0.0, maxScrollExtent); + scrollController.jumpTo(newOffset); + } else if (!scrollUp && currentOffset < maxScrollExtent) { + // Scroll down + final newOffset = (currentOffset + 8.0).clamp(0.0, maxScrollExtent); + scrollController.jumpTo(newOffset); + } else { + break; // Reached the edge + } + } + + await Future.delayed(const Duration(milliseconds: 16)); + } + + _isAutoScrolling = false; + } + + void _stopAutoScroll() { + _isAutoScrolling = false; + } + + void _checkAutoScroll(double globalY) { + if (!_isDragging) return; + + final MediaQueryData mediaQuery = MediaQuery.of(context); + final double screenHeight = mediaQuery.size.height; + final double topPadding = mediaQuery.padding.top; + final double bottomPadding = mediaQuery.padding.bottom; + + const double autoScrollThreshold = 80.0; + + if (globalY < topPadding + autoScrollThreshold) { + // Near top, scroll up + if (!_isAutoScrolling) { + _startAutoScroll(true); + } + } else if (globalY > screenHeight - bottomPadding - autoScrollThreshold) { + // Near bottom, scroll down + if (!_isAutoScrolling) { + _startAutoScroll(false); + } + } else { + // In the middle, stop auto-scrolling + _stopAutoScroll(); + } + } + + @override + void dispose() { + _stopAutoScroll(); + // Don't dispose global audio service - it should persist across pages + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading queue...'), + ], + ), + ), + ); + } + + if (_errorMessage.isNotEmpty) { + return SliverFillRemaining( + child: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: 48, + ), + const SizedBox(height: 16), + Text( + _errorMessage, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _refresh, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + if (_episodes.isEmpty) { + return const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.queue_music_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'No queued episodes', + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + ), + SizedBox(height: 8), + Text( + 'Episodes you queue will appear here', + style: TextStyle( + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return _buildEpisodesList(); + } + + Widget _buildEpisodesList() { + return SliverMainAxisGroup( + slivers: [ + // Header + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Queue', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + Text( + 'Drag to reorder', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _refresh, + ), + ], + ), + ], + ), + ), + ), + // Auto-scrolling reorderable episodes list wrapped with pointer detection + SliverToBoxAdapter( + child: Listener( + onPointerMove: (details) { + if (_isDragging) { + _checkAutoScroll(details.position.dy); + } + }, + child: ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorderStart: (index) { + setState(() { + _isDragging = true; + }); + }, + onReorderEnd: (index) { + setState(() { + _isDragging = false; + }); + _stopAutoScroll(); + }, + onReorder: _reorderEpisodes, + itemCount: _episodes.length, + itemBuilder: (context, index) { + final episode = _episodes[index]; + return Container( + key: ValueKey(episode.episodeId), + margin: const EdgeInsets.only(bottom: 4), + child: DraggableQueueEpisodeCard( + episode: episode, + index: index, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: episode, + ), + ), + ); + }, + onLongPress: () => _showContextMenu(index), + onPlayPressed: () => _playEpisode(episode), + ), + ); + }, + ), + ), + ), + ], + ); + } +} + diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart new file mode 100644 index 0000000..24f16e3 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart @@ -0,0 +1,730 @@ +// lib/ui/pinepods/saved.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; +import 'package:pinepods_mobile/ui/utils/local_download_utils.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:pinepods_mobile/ui/utils/position_utils.dart'; +import 'package:pinepods_mobile/ui/widgets/server_error_page.dart'; +import 'package:pinepods_mobile/services/error_handling_service.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:provider/provider.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +class PinepodsSaved extends StatefulWidget { + const PinepodsSaved({Key? key}) : super(key: key); + + @override + State createState() => _PinepodsSavedState(); +} + +class _PinepodsSavedState extends State { + bool _isLoading = false; + String _errorMessage = ''; + List _episodes = []; + List _filteredEpisodes = []; + final PinepodsService _pinepodsService = PinepodsService(); + // Use global audio service instead of creating local instance + int? _contextMenuEpisodeIndex; + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _loadSavedEpisodes(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.dispose(); + // Don't dispose global audio service - it should persist across pages + super.dispose(); + } + + void _onSearchChanged() { + setState(() { + _searchQuery = _searchController.text; + _filterEpisodes(); + }); + } + + void _filterEpisodes() { + if (_searchQuery.isEmpty) { + _filteredEpisodes = List.from(_episodes); + } else { + _filteredEpisodes = _episodes.where((episode) { + return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) || + episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + } + } + + PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + + Future _loadSavedEpisodes() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server. Please login first.'; + _isLoading = false; + }); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + final userId = settings.pinepodsUserId!; + + final episodes = await _pinepodsService.getSavedEpisodes(userId); + + // Enrich episodes with best available positions (local vs server) + final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions( + context, + _pinepodsService, + episodes, + userId, + ); + + setState(() { + _episodes = enrichedEpisodes; + _filterEpisodes(); // Initialize filtered list + _isLoading = false; + }); + + // After loading episodes, check their local download status + await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load saved episodes: ${e.toString()}'; + _isLoading = false; + }); + } + } + + Future _refresh() async { + // Clear local download status cache on refresh + LocalDownloadUtils.clearCache(); + await _loadSavedEpisodes(); + } + + Future _playEpisode(PinepodsEpisode episode) async { + + if (_audioService == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Audio service not available'), + backgroundColor: Colors.red, + ), + ); + return; + } + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text('Starting ${episode.episodeTitle}...'), + ], + ), + duration: const Duration(seconds: 2), + ), + ); + + await _audioService!.playPinepodsEpisode( + pinepodsEpisode: episode, + resume: episode.isStarted, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Now playing: ${episode.episodeTitle}'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to play episode: ${e.toString()}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + + Future _showContextMenu(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode); + + if (!mounted) return; + + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: episode, + isDownloadedLocally: isDownloadedLocally, + onSave: () { + Navigator.of(context).pop(); + _saveEpisode(episodeIndex); + }, + onRemoveSaved: () { + Navigator.of(context).pop(); + _removeSavedEpisode(episodeIndex); + }, + onDownload: episode.downloaded + ? () { + Navigator.of(context).pop(); + _deleteEpisode(episodeIndex); + } + : () { + Navigator.of(context).pop(); + _downloadEpisode(episodeIndex); + }, + onLocalDownload: () { + Navigator.of(context).pop(); + _localDownloadEpisode(episodeIndex); + }, + onDeleteLocalDownload: () { + Navigator.of(context).pop(); + _deleteLocalDownload(episodeIndex); + }, + onQueue: () { + Navigator.of(context).pop(); + _toggleQueueEpisode(episodeIndex); + }, + onMarkComplete: () { + Navigator.of(context).pop(); + _toggleMarkComplete(episodeIndex); + }, + onDismiss: () { + Navigator.of(context).pop(); + }, + ), + ); + } + + Future _localDownloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + final success = await LocalDownloadUtils.localDownloadEpisode(context, episode); + + if (success) { + LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green); + } else { + LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red); + } + } + + Future _deleteLocalDownload(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + + final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode); + + if (deletedCount > 0) { + LocalDownloadUtils.showSnackBar( + context, + 'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}', + Colors.orange + ); + } else { + LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red); + } + } + + void _hideContextMenu() { + setState(() { + _contextMenuEpisodeIndex = null; + }); + } + + Future _saveEpisode(int episodeIndex) async { + // This shouldn't be called since all episodes here are already saved + // But just in case, we'll handle it + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.saveEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true); + }); + _showSnackBar('Episode saved!', Colors.green); + } else { + _showSnackBar('Failed to save episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error saving episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _removeSavedEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.removeSavedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + // REMOVE the episode from the list since it's no longer saved + setState(() { + _episodes.removeAt(episodeIndex); + _filterEpisodes(); // Update filtered list after removal + }); + _showSnackBar('Removed from saved episodes', Colors.orange); + } else { + _showSnackBar('Failed to remove saved episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _downloadEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.downloadEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true); + }); + _showSnackBar('Episode download queued!', Colors.green); + } else { + _showSnackBar('Failed to queue download', Colors.red); + } + } catch (e) { + _showSnackBar('Error downloading episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _deleteEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final success = await _pinepodsService.deleteEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false); + }); + _showSnackBar('Episode deleted from server', Colors.orange); + } else { + _showSnackBar('Failed to delete episode', Colors.red); + } + } catch (e) { + _showSnackBar('Error deleting episode: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _toggleQueueEpisode(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.queued) { + success = await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false); + }); + _showSnackBar('Removed from queue', Colors.orange); + } + } else { + success = await _pinepodsService.queueEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true); + }); + _showSnackBar('Added to queue!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update queue', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating queue: $e', Colors.red); + } + + _hideContextMenu(); + } + + Future _toggleMarkComplete(int episodeIndex) async { + final episode = _episodes[episodeIndex]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in', Colors.red); + return; + } + + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + bool success; + if (episode.completed) { + success = await _pinepodsService.markEpisodeUncompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false); + }); + _showSnackBar('Marked as incomplete', Colors.orange); + } + } else { + success = await _pinepodsService.markEpisodeCompleted( + episode.episodeId, + userId, + episode.isYoutube, + ); + if (success) { + setState(() { + _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true); + }); + _showSnackBar('Marked as complete!', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to update completion status', Colors.red); + } + } catch (e) { + _showSnackBar('Error updating completion: $e', Colors.red); + } + + _hideContextMenu(); + } + + PinepodsEpisode _updateEpisodeProperty( + PinepodsEpisode episode, { + bool? saved, + bool? downloaded, + bool? queued, + bool? completed, + }) { + return PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: episode.listenDuration, + episodeId: episode.episodeId, + completed: completed ?? episode.completed, + saved: saved ?? episode.saved, + queued: queued ?? episode.queued, + downloaded: downloaded ?? episode.downloaded, + isYoutube: episode.isYoutube, + ); + } + + void _showSnackBar(String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading saved episodes...'), + ], + ), + ), + ); + } + + if (_errorMessage.isNotEmpty) { + return SliverServerErrorPage( + errorMessage: _errorMessage.isServerConnectionError + ? null + : _errorMessage, + onRetry: _refresh, + title: 'Saved Episodes Unavailable', + subtitle: _errorMessage.isServerConnectionError + ? 'Unable to connect to the PinePods server' + : 'Failed to load saved episodes', + ); + } + + if (_episodes.isEmpty) { + return const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.bookmark_outline, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'No saved episodes', + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + ), + SizedBox(height: 8), + Text( + 'Episodes you save will appear here', + style: TextStyle( + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return MultiSliver( + children: [ + _buildSearchBar(), + _buildEpisodesList(), + ], + ); + } + + Widget _buildSearchBar() { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Filter episodes...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: Theme.of(context).cardColor, + ), + ), + ), + ); + } + + Widget _buildEpisodesList() { + // Check if search returned no results + if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) { + return SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 16), + Text( + 'No episodes found', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'No episodes match "$_searchQuery"', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + // Header + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _searchQuery.isEmpty + ? 'Saved Episodes' + : 'Search Results (${_filteredEpisodes.length})', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _refresh, + ), + ], + ), + ); + } + // Episodes (index - 1 because of header) + final episodeIndex = index - 1; + final episode = _filteredEpisodes[episodeIndex]; + // Find the original index for context menu operations + final originalIndex = _episodes.indexOf(episode); + return PinepodsEpisodeCard( + episode: episode, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails( + initialEpisode: episode, + ), + ), + ); + }, + onLongPress: () => _showContextMenu(originalIndex), + onPlayPressed: () => _playEpisode(episode), + ); + }, + childCount: _filteredEpisodes.length + 1, // +1 for header + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart new file mode 100644 index 0000000..d252f1b --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart @@ -0,0 +1,674 @@ +// lib/ui/pinepods/search.dart + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/search_history_service.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/server_error_page.dart'; +import 'package:pinepods_mobile/services/error_handling_service.dart'; +import 'package:provider/provider.dart'; + +class PinepodsSearch extends StatefulWidget { + final String? searchTerm; + + const PinepodsSearch({ + super.key, + this.searchTerm, + }); + + @override + State createState() => _PinepodsSearchState(); +} + +class _PinepodsSearchState extends State { + late TextEditingController _searchController; + late FocusNode _searchFocusNode; + final PinepodsService _pinepodsService = PinepodsService(); + final SearchHistoryService _searchHistoryService = SearchHistoryService(); + + SearchProvider _selectedProvider = SearchProvider.podcastIndex; + bool _isLoading = false; + bool _showHistory = false; + String? _errorMessage; + List _searchResults = []; + List _searchHistory = []; + Set _addedPodcastUrls = {}; + + @override + void initState() { + super.initState(); + + _searchFocusNode = FocusNode(); + _searchController = TextEditingController(); + + if (widget.searchTerm != null) { + _searchController.text = widget.searchTerm!; + _performSearch(widget.searchTerm!); + } else { + _loadSearchHistory(); + } + + _initializeCredentials(); + _searchController.addListener(_onSearchChanged); + } + + void _initializeCredentials() { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) { + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + } + } + + @override + void dispose() { + _searchFocusNode.dispose(); + _searchController.dispose(); + super.dispose(); + } + + Future _loadSearchHistory() async { + final history = await _searchHistoryService.getPodcastSearchHistory(); + if (mounted) { + setState(() { + _searchHistory = history; + _showHistory = _searchController.text.isEmpty && history.isNotEmpty; + }); + } + } + + void _onSearchChanged() { + final query = _searchController.text.trim(); + setState(() { + _showHistory = query.isEmpty && _searchHistory.isNotEmpty; + }); + } + + void _selectHistoryItem(String searchTerm) { + _searchController.text = searchTerm; + _performSearch(searchTerm); + } + + Future _removeHistoryItem(String searchTerm) async { + await _searchHistoryService.removePodcastSearchTerm(searchTerm); + await _loadSearchHistory(); + } + + Future _performSearch(String query) async { + if (query.trim().isEmpty) { + setState(() { + _searchResults = []; + _errorMessage = null; + _showHistory = _searchHistory.isNotEmpty; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + _showHistory = false; + }); + + // Save search term to history + await _searchHistoryService.addPodcastSearchTerm(query); + await _loadSearchHistory(); + + try { + final result = await _pinepodsService.searchPodcasts(query, _selectedProvider); + final podcasts = result.getUnifiedPodcasts(); + + setState(() { + _searchResults = podcasts; + _isLoading = false; + }); + + // Check which podcasts are already added + await _checkAddedPodcasts(); + } catch (e) { + setState(() { + _errorMessage = 'Search failed: $e'; + _isLoading = false; + _searchResults = []; + }); + } + } + + Future _checkAddedPodcasts() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) return; + + for (final podcast in _searchResults) { + try { + final exists = await _pinepodsService.checkPodcastExists( + podcast.title, + podcast.url, + userId, + ); + if (exists) { + setState(() { + _addedPodcastUrls.add(podcast.url); + }); + } + } catch (e) { + // Ignore individual check failures + print('Failed to check podcast ${podcast.title}: $e'); + } + } + } + + Future _togglePodcast(UnifiedPinepodsPodcast podcast) async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + _showSnackBar('Not logged in to PinePods server', Colors.red); + return; + } + + final isAdded = _addedPodcastUrls.contains(podcast.url); + + try { + bool success; + if (isAdded) { + success = await _pinepodsService.removePodcast( + podcast.title, + podcast.url, + userId, + ); + if (success) { + setState(() { + _addedPodcastUrls.remove(podcast.url); + }); + _showSnackBar('Podcast removed', Colors.orange); + } + } else { + success = await _pinepodsService.addPodcast(podcast, userId); + if (success) { + setState(() { + _addedPodcastUrls.add(podcast.url); + }); + _showSnackBar('Podcast added', Colors.green); + } + } + + if (!success) { + _showSnackBar('Failed to ${isAdded ? 'remove' : 'add'} podcast', Colors.red); + } + } catch (e) { + _showSnackBar('Error: $e', Colors.red); + } + } + + void _showSnackBar(String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } + + Widget _buildSearchHistorySliver() { + return SliverFillRemaining( + hasScrollBody: false, + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Recent Podcast Searches', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (_searchHistory.isNotEmpty) + TextButton( + onPressed: () async { + await _searchHistoryService.clearPodcastSearchHistory(); + await _loadSearchHistory(); + }, + child: Text( + 'Clear All', + style: TextStyle( + color: Theme.of(context).hintColor, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + if (_searchHistory.isEmpty) + Center( + child: Column( + children: [ + const SizedBox(height: 50), + Icon( + Icons.search, + size: 64, + color: Theme.of(context).primaryColor.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'Search for Podcasts', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Enter a search term above to find new podcasts to subscribe to', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + else + ..._searchHistory.take(10).map((searchTerm) => Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + leading: Icon( + Icons.history, + color: Theme.of(context).hintColor, + size: 20, + ), + title: Text( + searchTerm, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: Icon( + Icons.close, + size: 18, + color: Theme.of(context).hintColor, + ), + onPressed: () => _removeHistoryItem(searchTerm), + ), + onTap: () => _selectHistoryItem(searchTerm), + ), + )).toList(), + ], + ), + ), + ); + } + + Widget _buildPodcastCard(UnifiedPinepodsPodcast podcast) { + final isAdded = _addedPodcastUrls.contains(podcast.url); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsPodcastDetails( + podcast: podcast, + isFollowing: isAdded, + onFollowChanged: (following) { + setState(() { + if (following) { + _addedPodcastUrls.add(podcast.url); + } else { + _addedPodcastUrls.remove(podcast.url); + } + }); + }, + ), + ), + ); + }, + child: Column( + children: [ + // Podcast image and info + Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Podcast artwork + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: podcast.artwork.isNotEmpty + ? Image.network( + podcast.artwork, + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.music_note, + color: Colors.grey, + size: 32, + ), + ); + }, + ) + : Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.music_note, + color: Colors.grey, + size: 32, + ), + ), + ), + const SizedBox(width: 12), + + // Podcast info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + podcast.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + if (podcast.author.isNotEmpty) + Text( + 'By ${podcast.author}', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).primaryColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + podcast.description, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.mic, + size: 16, + color: Colors.grey[600], + ), + const SizedBox(width: 4), + Text( + '${podcast.episodeCount} episode${podcast.episodeCount != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 16), + if (podcast.explicit) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'E', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ), + + // Follow/Unfollow button + IconButton( + onPressed: () => _togglePodcast(podcast), + icon: Icon( + isAdded ? Icons.remove_circle : Icons.add_circle, + color: isAdded ? Colors.red : Colors.green, + ), + tooltip: isAdded ? 'Remove podcast' : 'Add podcast', + ), + ], + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + leading: IconButton( + tooltip: 'Back', + icon: Platform.isAndroid + ? Icon(Icons.arrow_back, color: Theme.of(context).appBarTheme.foregroundColor) + : const Icon(Icons.arrow_back_ios), + onPressed: () => Navigator.pop(context), + ), + title: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + autofocus: widget.searchTerm != null ? false : true, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + onTap: () { + setState(() { + _showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty; + }); + }, + decoration: const InputDecoration( + hintText: 'Search for podcasts', + border: InputBorder.none, + ), + style: TextStyle( + color: Theme.of(context).primaryIconTheme.color, + fontSize: 18.0, + decorationColor: Theme.of(context).scaffoldBackgroundColor, + ), + onSubmitted: _performSearch, + ), + floating: false, + pinned: true, + snap: false, + actions: [ + IconButton( + tooltip: 'Clear search', + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchResults = []; + _errorMessage = null; + _showHistory = _searchHistory.isNotEmpty; + }); + FocusScope.of(context).requestFocus(_searchFocusNode); + SystemChannels.textInput.invokeMethod('TextInput.show'); + }, + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Container( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + const Text( + 'Search Provider: ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Expanded( + child: DropdownButton( + value: _selectedProvider, + isExpanded: true, + items: SearchProvider.values.map((provider) { + return DropdownMenuItem( + value: provider, + child: Text(provider.name), + ); + }).toList(), + onChanged: (provider) { + if (provider != null) { + setState(() { + _selectedProvider = provider; + }); + // Re-search with new provider if there's a current search + if (_searchController.text.isNotEmpty) { + _performSearch(_searchController.text); + } + } + }, + ), + ), + ], + ), + ), + ), + ), + + // Search results or history + if (_showHistory) + _buildSearchHistorySliver() + else if (_isLoading) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: PlatformProgressIndicator()), + ) + else if (_errorMessage != null) + SliverServerErrorPage( + errorMessage: _errorMessage!.isServerConnectionError + ? null + : _errorMessage, + onRetry: () => _performSearch(_searchController.text), + title: 'Search Unavailable', + subtitle: _errorMessage!.isServerConnectionError + ? 'Unable to connect to the PinePods server' + : 'Failed to search for podcasts', + ) + else if (_searchResults.isEmpty && _searchController.text.isNotEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No podcasts found', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Try searching with different keywords or switch search provider', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else if (_searchResults.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Search for podcasts', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Enter a search term to find podcasts', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return _buildPodcastCard(_searchResults[index]); + }, + childCount: _searchResults.length, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart new file mode 100644 index 0000000..d9fa33a --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart @@ -0,0 +1,503 @@ +// lib/ui/pinepods/user_stats.dart + +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/user_stats.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/logging/app_logger.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/core/environment.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PinepodsUserStats extends StatefulWidget { + const PinepodsUserStats({super.key}); + + @override + State createState() => _PinepodsUserStatsState(); +} + +class _PinepodsUserStatsState extends State { + final PinepodsService _pinepodsService = PinepodsService(); + UserStats? _userStats; + String? _pinepodsVersion; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _initializeCredentials(); + _loadUserStats(); + } + + void _initializeCredentials() { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) { + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + } + } + + /// Calculate responsive cross axis count for stats grid + int _getStatsCrossAxisCount(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop) + if (screenWidth > 800) return 3; // Wide tablets like iPad + if (screenWidth > 500) return 2; // Standard phones and small tablets + return 1; // Very small phones (< 500px) + } + + /// Calculate responsive aspect ratio for stats cards + double _getStatsAspectRatio(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + if (screenWidth <= 500) { + // Single column on small screens - generous height for content + proper padding + return 2.2; // Allows space for icon + title + value + padding, handles text wrapping + } + return 1.0; // Square aspect ratio for multi-column layouts + } + + Future _loadUserStats() async { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + setState(() { + _errorMessage = 'Not logged in'; + _isLoading = false; + }); + return; + } + + try { + final futures = await Future.wait([ + _pinepodsService.getUserStats(userId), + _pinepodsService.getPinepodsVersion(), + ]); + + setState(() { + _userStats = futures[0] as UserStats; + _pinepodsVersion = futures[1] as String; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load stats: $e'; + _isLoading = false; + }); + } + } + + Future _launchUrl(String url) async { + final logger = AppLogger(); + logger.info('UserStats', 'Attempting to launch URL: $url'); + + try { + final uri = Uri.parse(url); + + // Try to launch directly first (works better on Android) + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + logger.warning('UserStats', 'Direct URL launch failed, checking if URL can be launched'); + // If direct launch fails, check if URL can be launched + final canLaunch = await canLaunchUrl(uri); + if (!canLaunch) { + throw Exception('No app available to handle this URL'); + } + } else { + logger.info('UserStats', 'Successfully launched URL: $url'); + } + } catch (e) { + logger.error('UserStats', 'Failed to launch URL: $url', e.toString()); + // Show error if URL can't be launched + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open link: $url'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Widget _buildStatCard(String label, String value, {IconData? icon, Color? iconColor}) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 32, + color: iconColor ?? Theme.of(context).primaryColor, + ), + const SizedBox(height: 8), + ], + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + /// Build sync status card that fits in the grid with consistent styling + Widget _buildSyncStatCard() { + if (_userStats == null) return const SizedBox.shrink(); + + final stats = _userStats!; + final isNotSyncing = stats.podSyncType.toLowerCase() == 'none'; + + return _buildStatCard( + 'Sync Status', + stats.syncStatusDescription, + icon: isNotSyncing ? Icons.sync_disabled : Icons.sync, + iconColor: isNotSyncing ? Colors.grey : null, + ); + } + + Widget _buildSyncStatusCard() { + if (_userStats == null) return const SizedBox.shrink(); + + final stats = _userStats!; + final isNotSyncing = stats.podSyncType.toLowerCase() == 'none'; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Icon( + isNotSyncing ? Icons.sync_disabled : Icons.sync, + size: 32, + color: isNotSyncing ? Colors.grey : Theme.of(context).primaryColor, + ), + const SizedBox(height: 8), + Text( + 'Podcast Sync Status', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + stats.syncStatusDescription, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (!isNotSyncing && stats.gpodderUrl.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + stats.gpodderUrl, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ); + } + + Widget _buildInfoCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + // PinePods Logo + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + image: const DecorationImage( + image: AssetImage('assets/images/pinepods-logo.png'), + fit: BoxFit.contain, + ), + ), + ), + const SizedBox(height: 16), + + Text( + 'App Version: v${Environment.projectVersion}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Server Version: ${_pinepodsVersion ?? "Unknown"}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + + Text( + 'Thanks for using PinePods! This app was born from a love for podcasts, of homelabs, and a desire to have a secure and central location to manage personal data.', + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.4, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + + Text( + 'Copyright © 2025 Gooseberry Development', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + Text( + 'The PinePods Mobile App is an open-source podcast player adapted from the Anytime Podcast Player (© 2020 Ben Hills). Portions of this application retain the original BSD 3-Clause license.', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + height: 1.3, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + GestureDetector( + onTap: () => _launchUrl('https://github.com/amugofjava/anytime_podcast_player'), + child: Text( + 'View original project on GitHub', + style: TextStyle( + fontSize: 12, + decoration: TextDecoration.underline, + color: Theme.of(context).primaryColor, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 20), + + // Buttons + Column( + children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _launchUrl('https://pinepods.online'), + icon: const Icon(Icons.description), + label: const Text('PinePods Documentation'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _launchUrl('https://github.com/madeofpendletonwool/pinepods'), + icon: const Icon(Icons.code), + label: const Text('PinePods GitHub Repo'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _launchUrl('https://www.buymeacoffee.com/collinscoffee'), + icon: const Icon(Icons.coffee), + label: const Text('Buy me a Coffee'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + showLicensePage(context: context); + }, + icon: const Icon(Icons.article_outlined), + label: const Text('Open Source Licenses'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('User Statistics'), + centerTitle: true, + ), + body: _isLoading + ? const Center(child: PlatformProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red[300], + ), + const SizedBox(height: 16), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + _loadUserStats(); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + // Statistics Grid + GridView.count( + crossAxisCount: _getStatsCrossAxisCount(context), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: _getStatsAspectRatio(context), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + children: [ + _buildStatCard( + 'User Created', + _userStats?.formattedUserCreated ?? '', + icon: Icons.calendar_today, + ), + _buildStatCard( + 'Podcasts Played', + _userStats?.podcastsPlayed.toString() ?? '', + icon: Icons.play_circle, + ), + _buildStatCard( + 'Time Listened', + _userStats?.formattedTimeListened ?? '', + icon: Icons.access_time, + ), + _buildStatCard( + 'Podcasts Added', + _userStats?.podcastsAdded.toString() ?? '', + icon: Icons.library_add, + ), + _buildStatCard( + 'Episodes Saved', + _userStats?.episodesSaved.toString() ?? '', + icon: Icons.bookmark, + ), + _buildStatCard( + 'Episodes Downloaded', + _userStats?.episodesDownloaded.toString() ?? '', + icon: Icons.download, + ), + // Add sync status as a stat card to maintain consistent layout + _buildSyncStatCard(), + ], + ), + + const SizedBox(height: 16), + + // Info Card + _buildInfoCard(), + ], + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/pinepods_podcast_app.dart b/PinePods-0.8.2/mobile/lib/ui/pinepods_podcast_app.dart new file mode 100644 index 0000000..677fdd6 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/pinepods_podcast_app.dart @@ -0,0 +1,1329 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/api/podcast/mobile_podcast_api.dart'; +import 'package:pinepods_mobile/api/podcast/podcast_api.dart'; +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart'; +import 'package:pinepods_mobile/bloc/search/search_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/bloc/ui/pager_bloc.dart'; +import 'package:pinepods_mobile/core/environment.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/entities/feed.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/navigation/navigation_route_observer.dart'; +import 'package:pinepods_mobile/repository/repository.dart'; +import 'package:pinepods_mobile/repository/sembast/sembast_repository.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/audio/default_audio_player_service.dart'; +import 'package:pinepods_mobile/services/download/download_service.dart'; +import 'package:pinepods_mobile/services/download/mobile_download_manager.dart'; +import 'package:pinepods_mobile/services/download/mobile_download_service.dart'; +import 'package:pinepods_mobile/services/podcast/mobile_podcast_service.dart'; +import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:pinepods_mobile/services/pinepods/oidc_service.dart'; +import 'package:pinepods_mobile/services/pinepods/login_service.dart'; +import 'package:pinepods_mobile/services/auth_notifier.dart'; +import 'package:pinepods_mobile/services/settings/mobile_settings_service.dart'; +import 'package:pinepods_mobile/ui/library/downloads.dart'; +import 'package:pinepods_mobile/ui/library/library.dart'; +import 'package:pinepods_mobile/ui/podcast/mini_player.dart'; +import 'package:pinepods_mobile/ui/podcast/podcast_details.dart'; +import 'package:pinepods_mobile/ui/search/search.dart'; +import 'package:pinepods_mobile/ui/pinepods/search.dart'; +import 'package:pinepods_mobile/ui/settings/settings.dart'; +import 'package:pinepods_mobile/ui/themes.dart'; +import 'package:pinepods_mobile/ui/widgets/action_text.dart'; +import 'package:pinepods_mobile/ui/widgets/layout_selector.dart'; +import 'package:pinepods_mobile/ui/widgets/search_slide_route.dart'; +import 'package:pinepods_mobile/ui/pinepods/home.dart'; +import 'package:pinepods_mobile/ui/pinepods/feed.dart'; +import 'package:pinepods_mobile/ui/pinepods/saved.dart'; +import 'package:pinepods_mobile/ui/pinepods/queue.dart'; +import 'package:pinepods_mobile/ui/pinepods/history.dart'; +import 'package:pinepods_mobile/ui/pinepods/playlists.dart'; +import 'package:pinepods_mobile/ui/auth/auth_wrapper.dart'; +import 'package:pinepods_mobile/ui/pinepods/user_stats.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcasts.dart'; +import 'package:pinepods_mobile/ui/pinepods/episode_search.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/podcast/mobile_podcast_service.dart'; +import 'package:pinepods_mobile/api/podcast/mobile_podcast_api.dart'; +import 'package:app_links/app_links.dart'; +import 'package:crypto/crypto.dart'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; + +var theme = Themes.lightTheme().themeData; + +/// PinePods is a Podcast player. You can search and subscribe to podcasts, +/// download and stream episodes and view the latest podcast charts. +// ignore: must_be_immutable +class PinepodsPodcastApp extends StatefulWidget { + final Repository repository; + late PodcastApi podcastApi; + late DownloadService downloadService; + late AudioPlayerService audioPlayerService; + PodcastService? podcastService; + SettingsBloc? settingsBloc; + MobileSettingsService mobileSettingsService; + List certificateAuthorityBytes; + late PinepodsAudioService pinepodsAudioService; + late PinepodsService pinepodsService; + + PinepodsPodcastApp({ + super.key, + required this.mobileSettingsService, + required this.certificateAuthorityBytes, + }) : repository = SembastRepository() { + podcastApi = MobilePodcastApi(); + + podcastService = MobilePodcastService( + api: podcastApi, + repository: repository, + settingsService: mobileSettingsService, + ); + + assert(podcastService != null); + + downloadService = MobileDownloadService( + repository: repository, + downloadManager: MobileDownloaderManager(), + podcastService: podcastService!, + ); + + audioPlayerService = DefaultAudioPlayerService( + repository: repository, + settingsService: mobileSettingsService, + podcastService: podcastService!, + ); + + settingsBloc = SettingsBloc(mobileSettingsService); + + // Create and connect PinepodsAudioService for listen duration tracking + pinepodsService = PinepodsService(); + pinepodsAudioService = PinepodsAudioService( + audioPlayerService!, + pinepodsService, + settingsBloc!, + ); + + // Connect the services for listen duration recording + (audioPlayerService as DefaultAudioPlayerService).setPinepodsAudioService( + pinepodsAudioService, + ); + + // Initialize global services for app-wide access + GlobalServices.initialize( + pinepodsAudioService: pinepodsAudioService, + pinepodsService: pinepodsService, + ); + + podcastApi.addClientAuthorityBytes(certificateAuthorityBytes); + } + + @override + PinepodsPodcastAppState createState() => PinepodsPodcastAppState(); +} + +class PinepodsPodcastAppState extends State { + ThemeData? theme; + + @override + void initState() { + super.initState(); + + /// Listen to theme change events from settings. + widget.settingsBloc!.settings.listen((event) { + setState(() { + var newTheme = ThemeRegistry.getThemeData(event.theme); + + /// Only update the theme if it has changed. + if (newTheme != theme) { + theme = newTheme; + } + }); + }); + + // Initialize theme from current settings + theme = ThemeRegistry.getThemeData(widget.mobileSettingsService.theme); + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider( + create: (_) => SearchBloc(podcastService: widget.podcastService!), + dispose: (_, value) => value.dispose(), + ), + Provider( + create: (_) => EpisodeBloc( + podcastService: widget.podcastService!, + audioPlayerService: widget.audioPlayerService, + ), + dispose: (_, value) => value.dispose(), + ), + Provider( + create: (_) => PodcastBloc( + podcastService: widget.podcastService!, + audioPlayerService: widget.audioPlayerService, + downloadService: widget.downloadService, + settingsService: widget.mobileSettingsService, + ), + dispose: (_, value) => value.dispose(), + ), + Provider( + create: (_) => PagerBloc(), + dispose: (_, value) => value.dispose(), + ), + Provider( + create: (_) => + AudioBloc(audioPlayerService: widget.audioPlayerService), + dispose: (_, value) => value.dispose(), + ), + Provider( + create: (_) => widget.settingsBloc, + dispose: (_, value) => value!.dispose(), + ), + Provider( + create: (_) => QueueBloc( + audioPlayerService: widget.audioPlayerService, + podcastService: widget.podcastService!, + ), + dispose: (_, value) => value.dispose(), + ), + Provider(create: (_) => widget.audioPlayerService), + Provider(create: (_) => widget.podcastService!), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + showSemanticsDebugger: false, + title: 'Pinepods Podcast Client', + navigatorObservers: [NavigationRouteObserver()], + localizationsDelegates: const >[ + PinepodsLocalisationsDelegate(), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('en', ''), + Locale('de', ''), + Locale('it', ''), + ], + theme: theme, + // Uncomment builder below to enable accessibility checker tool. + // builder: (context, child) => AccessibilityTools(child: child), + home: const AuthWrapper( + child: PinepodsHomePage(title: 'PinePods Podcast Player'), + ), + ), + ); + } +} + +class PinepodsHomePage extends StatefulWidget { + final String? title; + final bool topBarVisible; + + const PinepodsHomePage({super.key, this.title, this.topBarVisible = true}); + + @override + State createState() => _PinepodsHomePageState(); +} + +class _PinepodsHomePageState extends State + with WidgetsBindingObserver { + StreamSubscription? deepLinkSubscription; + + final log = Logger('_PinepodsHomePageState'); + bool handledInitialLink = false; + Widget? library; + + @override + void initState() { + super.initState(); + + final audioBloc = Provider.of(context, listen: false); + + WidgetsBinding.instance.addObserver(this); + + audioBloc.transitionLifecycleState(LifecycleState.resume); + + /// Handle deep links + _setupLinkListener(); + } + + /// We listen to external links from outside the app. For example, someone may navigate + /// to a web page that supports 'Open with Pinepods'. + void _setupLinkListener() async { + print('Deep Link: Setting up link listener...'); + final appLinks = AppLinks(); // AppLinks is singleton + + // Handle initial link if app was launched by one (cold start) + try { + final initialUri = await appLinks.getInitialLink(); + if (initialUri != null) { + print('Deep Link: App launched with initial link: $initialUri'); + _handleLinkEvent(initialUri); + } else { + print('Deep Link: No initial link found'); + } + } catch (e) { + print('Deep Link: Error getting initial link: $e'); + } + + // Subscribe to all events (further links while app is running) + print('Deep Link: Setting up stream listener...'); + deepLinkSubscription = appLinks.uriLinkStream.listen((uri) { + print('Deep Link: App received link while running: $uri'); + _handleLinkEvent(uri); + }, onError: (err) { + print('Deep Link: Stream error: $err'); + }); + + print('Deep Link: Link listener setup complete'); + } + + /// This method handles the actual link supplied from [uni_links], either + /// at app startup or during running. + void _handleLinkEvent(Uri uri) async { + print('Deep Link: Received link: $uri'); + print('Deep Link: Scheme: ${uri.scheme}, Host: ${uri.host}, Path: ${uri.path}'); + print('Deep Link: Query: ${uri.query}'); + print('Deep Link: QueryParameters: ${uri.queryParameters}'); + + // Handle OIDC authentication callback - be more flexible with path matching + if (uri.scheme == 'pinepods' && uri.host == 'auth') { + print('Deep Link: OIDC callback detected (flexible match)'); + await _handleOidcCallback(uri); + return; + } + + // Handle OIDC authentication callback - strict match + if (uri.scheme == 'pinepods' && uri.host == 'auth' && uri.path == '/callback') { + print('Deep Link: OIDC callback detected (strict match)'); + await _handleOidcCallback(uri); + return; + } + + // Handle podcast subscription links + if ((uri.scheme == 'pinepods-subscribe' || uri.scheme == 'https') && + (uri.query.startsWith('uri=') || uri.query.startsWith('url='))) { + var path = uri.query.substring(4); + var loadPodcastBloc = Provider.of(context, listen: false); + var routeName = NavigationRouteObserver().top!.settings.name; + + /// If we are currently on the podcast details page, we can simply request (via + /// the BLoC) that we load this new URL. If not, we pop the stack until we are + /// back at root and then load the podcast details page. + if (routeName != null && routeName == 'podcastdetails') { + loadPodcastBloc.load( + Feed( + podcast: Podcast.fromUrl(url: path), + backgroundFresh: false, + silently: false, + ), + ); + } else { + /// Pop back to route. + Navigator.of(context).popUntil((route) { + var currentRouteName = NavigationRouteObserver().top!.settings.name; + + return currentRouteName == null || + currentRouteName == '' || + currentRouteName == '/'; + }); + + /// Once we have reached the root route, push podcast details. + await Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + settings: const RouteSettings(name: 'podcastdetails'), + builder: (context) => + PodcastDetails(Podcast.fromUrl(url: path), loadPodcastBloc), + ), + ); + } + } + } + + /// Handle OIDC authentication callback + Future _handleOidcCallback(Uri uri) async { + try { + print('OIDC Callback: Received callback URL: $uri'); + + // Parse the callback result + final callbackResult = OidcService.parseCallback(uri.toString()); + + if (!callbackResult.isSuccess) { + print('OIDC Callback: Authentication failed: ${callbackResult.error}'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('OIDC authentication failed: ${callbackResult.error}'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Check if we have an API key directly from the callback + if (callbackResult.hasApiKey) { + print('OIDC Callback: Found API key in callback, completing login'); + await _completeOidcLogin(callbackResult.apiKey!); + } else { + print('OIDC Callback: No API key found, traditional OAuth flow not implemented yet'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('OIDC callback received but no API key found'), + backgroundColor: Colors.orange, + ), + ); + } + } + + } catch (e) { + print('OIDC Callback: Error processing callback: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error processing OIDC callback: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// Complete OIDC login with the provided API key + Future _completeOidcLogin(String apiKey) async { + try { + print('OIDC Callback: Completing login with API key'); + + // We need to get the server URL - we can get it from the current settings + // since the user would have entered it during the initial OIDC flow + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + // Check if we have a server URL from a previous attempt + String? serverUrl = settings.pinepodsServer; + + if (serverUrl == null || serverUrl.isEmpty) { + throw Exception('No server URL available for OIDC completion'); + } + + // Verify the API key works and get user details + // Verify API key + final isValidKey = await PinepodsLoginService.verifyApiKey(serverUrl, apiKey); + if (!isValidKey) { + throw Exception('API key verification failed'); + } + + // Get user ID + final userId = await PinepodsLoginService.getUserId(serverUrl, apiKey); + if (userId == null) { + throw Exception('Failed to get user ID'); + } + + // Get user details + final userDetails = await PinepodsLoginService.getUserDetails(serverUrl, apiKey, userId); + if (userDetails == null) { + throw Exception('Failed to get user details'); + } + + // Save the authentication details + settingsBloc.setPinepodsServer(serverUrl); + settingsBloc.setPinepodsApiKey(apiKey); + settingsBloc.setPinepodsUserId(userId); + + // Set additional user details if available + if (userDetails.username != null) { + settingsBloc.setPinepodsUsername(userDetails.username!); + } + if (userDetails.email != null) { + settingsBloc.setPinepodsEmail(userDetails.email!); + } + + // Fetch theme from server + await settingsBloc.fetchThemeFromServer(); + + print('OIDC Callback: Login completed successfully'); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('OIDC authentication successful!'), + backgroundColor: Colors.green, + ), + ); + + // Log current settings state for debugging + final currentSettings = settingsBloc.currentSettings; + print('OIDC Callback: Current settings after update:'); + print(' Server: ${currentSettings.pinepodsServer}'); + print(' API Key: ${currentSettings.pinepodsApiKey != null ? '[SET]' : '[NOT SET]'}'); + print(' User ID: ${currentSettings.pinepodsUserId}'); + print(' Username: ${currentSettings.pinepodsUsername}'); + + // Notify login success globally + AuthNotifier.notifyLoginSuccess(); + } + + } catch (e) { + print('OIDC Callback: Error completing login: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to complete OIDC login: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + void dispose() { + final audioBloc = Provider.of(context, listen: false); + audioBloc.transitionLifecycleState(LifecycleState.pause); + + deepLinkSubscription?.cancel(); + + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + print('Deep Link: App lifecycle state changed to: $state'); + final audioBloc = Provider.of(context, listen: false); + + switch (state) { + case AppLifecycleState.resumed: + print('Deep Link: App resumed - checking for pending deep links...'); + audioBloc.transitionLifecycleState(LifecycleState.resume); + + // Check for any pending deep links when app resumes + try { + final appLinks = AppLinks(); + final initialUri = await appLinks.getInitialLink(); + if (initialUri != null) { + print('Deep Link: Found pending link on resume: $initialUri'); + _handleLinkEvent(initialUri); + } + } catch (e) { + print('Deep Link: Error checking for pending links on resume: $e'); + } + break; + case AppLifecycleState.paused: + print('Deep Link: App paused'); + audioBloc.transitionLifecycleState(LifecycleState.pause); + break; + default: + break; + } + } + + @override + Widget build(BuildContext context) { + final pager = Provider.of(context); + final searchBloc = Provider.of(context); + final backgroundColour = Theme.of(context).scaffoldBackgroundColor; + + return AnnotatedRegion( + value: Theme.of(context).appBarTheme.systemOverlayStyle!, + child: Scaffold( + backgroundColor: backgroundColour, + body: Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + SliverVisibility( + visible: widget.topBarVisible, + sliver: SliverAppBar( + title: ExcludeSemantics(child: TitleWidget()), + backgroundColor: backgroundColour, + floating: false, + pinned: true, + snap: false, + actions: [ + IconButton( + tooltip: 'Queue', + icon: const Icon(Icons.queue_music), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: false, + settings: const RouteSettings(name: 'queue'), + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Queue')), + body: const Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [PinepodsQueue()], + ), + ), + MiniPlayer(), + ], + ), + ), + ), + ); + }, + ), + IconButton( + tooltip: L.of(context)!.search_for_podcasts_hint, + icon: const Icon(Icons.search), + onPressed: () async { + await Navigator.push( + context, + defaultTargetPlatform == TargetPlatform.iOS + ? MaterialPageRoute( + fullscreenDialog: false, + settings: const RouteSettings( + name: 'pinepods_search', + ), + builder: (context) => + const PinepodsSearch(), + ) + : SlideRightRoute( + widget: const PinepodsSearch(), + settings: const RouteSettings( + name: 'pinepods_search', + ), + ), + ); + }, + ), + PopupMenuButton( + onSelected: _menuSelect, + icon: const Icon(Icons.more_vert), + itemBuilder: (BuildContext context) { + return >[ + if (feedbackUrl.isNotEmpty) + PopupMenuItem( + textStyle: Theme.of( + context, + ).textTheme.titleMedium, + value: 'feedback', + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(right: 8.0), + child: Icon( + Icons.feedback_outlined, + size: 18.0, + ), + ), + Text( + L.of(context)!.feedback_menu_item_label, + ), + ], + ), + ), + PopupMenuItem( + textStyle: Theme.of( + context, + ).textTheme.titleMedium, + value: 'rss', + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(right: 8.0), + child: Icon(Icons.rss_feed, size: 18.0), + ), + Text(L.of(context)!.add_rss_feed_option), + ], + ), + ), + PopupMenuItem( + textStyle: Theme.of( + context, + ).textTheme.titleMedium, + value: 'settings', + child: Row( + children: [ + const Padding( + padding: EdgeInsets.only(right: 8.0), + child: Icon(Icons.settings, size: 18.0), + ), + Text(L.of(context)!.settings_label), + ], + ), + ), + ]; + }, + ), + ], + ), + ), + StreamBuilder( + stream: pager.currentPage, + builder: + (BuildContext context, AsyncSnapshot snapshot) { + return _fragment(snapshot.data, searchBloc); + }, + ), + ], + ), + ), + const MiniPlayer(), + ], + ), + bottomNavigationBar: StreamBuilder( + stream: pager.currentPage, + initialData: 0, + builder: (BuildContext context, AsyncSnapshot snapshot) { + int index = snapshot.data ?? 0; + + return StreamBuilder( + stream: Provider.of(context).settings, + builder: + ( + BuildContext context, + AsyncSnapshot settingsSnapshot, + ) { + final bottomBarOrder = + settingsSnapshot.data?.bottomBarOrder ?? + [ + 'Home', + 'Feed', + 'Saved', + 'Podcasts', + 'Downloads', + 'History', + 'Playlists', + 'Search', + ]; + + // Create a map of all available nav items + final Map allNavItems = { + 'Home': BottomNavItem( + icon: Icons.home, + label: 'Home', + isSelected: false, + ), + 'Feed': BottomNavItem( + icon: Icons.rss_feed, + label: 'Feed', + isSelected: false, + ), + 'Saved': BottomNavItem( + icon: Icons.bookmark, + label: 'Saved', + isSelected: false, + ), + 'Podcasts': BottomNavItem( + icon: Icons.podcasts, + label: 'Podcasts', + isSelected: false, + ), + 'Downloads': BottomNavItem( + icon: Icons.download, + label: 'Downloads', + isSelected: false, + ), + 'History': BottomNavItem( + icon: Icons.history, + label: 'History', + isSelected: false, + ), + 'Playlists': BottomNavItem( + icon: Icons.playlist_play, + label: 'Playlists', + isSelected: false, + ), + 'Search': BottomNavItem( + icon: Icons.search, + label: 'Search', + isSelected: false, + ), + }; + + // Create the ordered nav items based on settings + final List navItems = bottomBarOrder.map(( + label, + ) { + final baseItem = allNavItems[label]!; + final itemIndex = bottomBarOrder.indexOf(label); + return BottomNavItem( + icon: index == itemIndex + ? _getSelectedIcon(label) + : _getUnselectedIcon(label), + label: label, + isSelected: index == itemIndex, + ); + }).toList(); + + // Calculate if all icons fit in the current screen width + final screenWidth = MediaQuery.of(context).size.width; + final iconWidth = 80.0; + final totalIconsWidth = navItems.length * iconWidth; + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final shouldCenterInPortrait = !isLandscape && totalIconsWidth <= screenWidth; + + return Container( + height: 70 + MediaQuery.of(context).padding.bottom, + decoration: BoxDecoration( + color: Theme.of(context).bottomAppBarTheme.color, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 0.5, + ), + ), + ), + child: (isLandscape || shouldCenterInPortrait) + ? Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: navItems.asMap().entries.map((entry) { + int itemIndex = entry.key; + BottomNavItem item = entry.value; + + return GestureDetector( + onTap: () => pager.changePage(itemIndex), + child: Container( + width: 80, + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + item.icon, + color: item.isSelected + ? Theme.of( + context, + ).iconTheme.color + : HSLColor.fromColor( + Theme.of(context) + .bottomAppBarTheme + .color!, + ) + .withLightness(0.8) + .toColor(), + size: 24, + ), + const SizedBox(height: 4), + Text( + item.label, + style: TextStyle( + fontSize: 11, + color: item.isSelected + ? Theme.of( + context, + ).iconTheme.color + : HSLColor.fromColor( + Theme.of(context) + .bottomAppBarTheme + .color!, + ) + .withLightness(0.8) + .toColor(), + fontWeight: item.isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }).toList(), + ), + ), + ) + : Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: navItems.asMap().entries.map((entry) { + int itemIndex = entry.key; + BottomNavItem item = entry.value; + + return GestureDetector( + onTap: () => pager.changePage(itemIndex), + child: Container( + width: 80, + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + item.icon, + color: item.isSelected + ? Theme.of( + context, + ).iconTheme.color + : HSLColor.fromColor( + Theme.of(context) + .bottomAppBarTheme + .color!, + ) + .withLightness(0.8) + .toColor(), + size: 24, + ), + const SizedBox(height: 4), + Text( + item.label, + style: TextStyle( + fontSize: 11, + color: item.isSelected + ? Theme.of( + context, + ).iconTheme.color + : HSLColor.fromColor( + Theme.of(context) + .bottomAppBarTheme + .color!, + ) + .withLightness(0.8) + .toColor(), + fontWeight: item.isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }).toList(), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ); + } + + Widget _fragment(int? index, EpisodeBloc searchBloc) { + final settingsBloc = Provider.of(context, listen: false); + final bottomBarOrder = settingsBloc.currentSettings.bottomBarOrder; + + if (index == null || index < 0 || index >= bottomBarOrder.length) { + return const PinepodsHome(); // Default to Home + } + + final pageLabel = bottomBarOrder[index]; + + switch (pageLabel) { + case 'Home': + return const PinepodsHome(); + case 'Feed': + return const PinepodsFeed(); + case 'Saved': + return const PinepodsSaved(); + case 'Podcasts': + return const PinepodsPodcasts(); + case 'Downloads': + return const Downloads(); + case 'History': + return const PinepodsHistory(); + case 'Playlists': + return const PinepodsPlaylists(); + case 'Search': + return const EpisodeSearchPage(); + default: + return const PinepodsHome(); // Default to Home + } + } + + IconData _getSelectedIcon(String label) { + switch (label) { + case 'Home': + return Icons.home; + case 'Feed': + return Icons.rss_feed; + case 'Saved': + return Icons.bookmark; + case 'Podcasts': + return Icons.podcasts; + case 'Downloads': + return Icons.download; + case 'History': + return Icons.history; + case 'Playlists': + return Icons.playlist_play; + case 'Search': + return Icons.search; + default: + return Icons.home; + } + } + + IconData _getUnselectedIcon(String label) { + switch (label) { + case 'Home': + return Icons.home_outlined; + case 'Feed': + return Icons.rss_feed_outlined; + case 'Saved': + return Icons.bookmark_outline; + case 'Podcasts': + return Icons.podcasts_outlined; + case 'Downloads': + return Icons.download_outlined; + case 'History': + return Icons.history_outlined; + case 'Playlists': + return Icons.playlist_play_outlined; + case 'Search': + return Icons.search_outlined; + default: + return Icons.home_outlined; + } + } + + void _menuSelect(String choice) async { + var textFieldController = TextEditingController(); + var podcastBloc = Provider.of(context, listen: false); + final theme = Theme.of(context); + var url = ''; + + switch (choice) { + case 'settings': + await Navigator.push( + context, + MaterialPageRoute( + fullscreenDialog: true, + settings: const RouteSettings(name: 'settings'), + builder: (context) => const Settings(), + ), + ); + break; + case 'feedback': + _launchFeedback(); + break; + case 'rss': + await showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (_) => BasicDialogAlert( + title: Text(L.of(context)!.add_rss_feed_option), + content: Material( + color: Colors.transparent, + child: TextField( + onChanged: (value) { + setState(() { + url = value; + }); + }, + controller: textFieldController, + decoration: const InputDecoration(hintText: 'https://'), + ), + ), + actions: [ + BasicDialogAction( + title: ActionText(L.of(context)!.cancel_button_label), + onPressed: () { + Navigator.pop(context); + }, + ), + BasicDialogAction( + title: ActionText(L.of(context)!.ok_button_label), + iosIsDefaultAction: true, + onPressed: () async { + Navigator.of(context).pop(); // Close the dialog first + + // Show loading indicator + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + const Center(child: CircularProgressIndicator()), + ); + + try { + await _handleRssUrl(url); + } catch (e) { + if (mounted) { + Navigator.of(context).pop(); // Close loading dialog + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to load podcast: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + }, + ), + ], + ), + ); + break; + } + } + + Future _handleRssUrl(String url) async { + try { + // Get services + final podcastApi = MobilePodcastApi(); + final pinepodsService = PinepodsService(); + + // Load podcast feed from RSS + final podcast = await podcastApi.loadFeed(url); + + // Create UnifiedPinepodsPodcast from the loaded feed + final unifiedPodcast = UnifiedPinepodsPodcast( + id: 0, + indexId: 0, + title: podcast.title ?? 'Unknown Podcast', + url: url, + originalUrl: url, + link: podcast.link ?? url, + description: podcast.description ?? '', + author: podcast.copyright ?? '', + ownerName: podcast.copyright ?? '', + image: podcast.image ?? '', + artwork: podcast.image ?? '', + lastUpdateTime: 0, + categories: null, + explicit: false, + episodeCount: podcast.episodes?.length ?? 0, + ); + + // Check if podcast is already followed + bool isFollowing = false; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId != null) { + try { + isFollowing = await pinepodsService.checkPodcastExists( + podcast.title ?? 'Unknown Podcast', + url, + userId, + ); + } catch (e) { + print('Failed to check if podcast exists: $e'); + } + } + + if (mounted) { + Navigator.of(context).pop(); // Close loading dialog + + // Navigate to podcast details page + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepodspodcastdetails'), + builder: (context) => PinepodsPodcastDetails( + podcast: unifiedPodcast, + isFollowing: isFollowing, + onFollowChanged: (following) { + // Handle follow state change if needed + }, + ), + ), + ); + } + } catch (e) { + rethrow; + } + } + + void _launchFeedback() async { + final uri = Uri.parse(feedbackUrl); + + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + throw Exception('Could not launch $uri'); + } + } + + void _launchEmail() async { + final uri = Uri.parse('mailto:mobile-support@pinepods.online'); + + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + throw 'Could not launch $uri'; + } + } +} + +class TitleWidget extends StatelessWidget { + TitleWidget({super.key}); + + String _generateGravatarUrl(String email, {int size = 40}) { + final hash = md5 + .convert(utf8.encode(email.toLowerCase().trim())) + .toString(); + return 'https://www.gravatar.com/avatar/$hash?s=$size&d=identicon'; + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settingsBloc, child) { + final settings = settingsBloc.currentSettings; + final username = settings.pinepodsUsername; + final email = settings.pinepodsEmail; + + if (username == null || username.isEmpty) { + // Fallback to PinePods logo if no user is logged in - make it clickable + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PinepodsUserStats(), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.only(left: 2.0), + child: Row( + children: [ + Text( + 'Pine', + style: TextStyle( + color: const Color(0xFF539e8a), + fontWeight: FontWeight.bold, + fontFamily: 'MontserratRegular', + fontSize: 18, + ), + ), + Text( + 'Pods', + style: TextStyle( + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + fontWeight: FontWeight.bold, + fontFamily: 'MontserratRegular', + fontSize: 18, + ), + ), + ], + ), + ), + ); + } + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PinepodsUserStats(), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.only(left: 2.0), + child: Row( + children: [ + // User Avatar + CircleAvatar( + radius: 18, + backgroundColor: Colors.grey[300], + child: email != null && email.isNotEmpty + ? ClipOval( + child: Image.network( + _generateGravatarUrl(email), + width: 36, + height: 36, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pinepods-logo.png', + width: 36, + height: 36, + fit: BoxFit.cover, + ); + }, + ), + ) + : Image.asset( + 'assets/images/pinepods-logo.png', + width: 36, + height: 36, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + // Username + Flexible( + child: Text( + username, + style: TextStyle( + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class BottomNavItem { + final IconData icon; + final String label; + final bool isSelected; + + BottomNavItem({ + required this.icon, + required this.label, + required this.isSelected, + }); +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart new file mode 100644 index 0000000..2bb85d6 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart @@ -0,0 +1,170 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/entities/chapter.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +/// A [Widget] for displaying a list of Podcast chapters for those +/// podcasts that support that chapter tag. +// ignore: must_be_immutable +class ChapterSelector extends StatefulWidget { + final ItemScrollController itemScrollController = ItemScrollController(); + Episode episode; + Chapter? chapter; + StreamSubscription? positionSubscription; + var chapters = []; + + ChapterSelector({ + super.key, + required this.episode, + }) { + chapters = episode.chapters.where((c) => c.toc).toList(growable: false); + } + + @override + State createState() => _ChapterSelectorState(); +} + +class _ChapterSelectorState extends State { + @override + void initState() { + super.initState(); + + final audioBloc = Provider.of(context, listen: false); + Chapter? lastChapter; + bool first = true; + + // Listen for changes in position. If the change in position results in + // a change in chapter we scroll to it. This ensures that the current + // chapter is always visible. + // TODO: Jump only if current chapter is not visible. + widget.positionSubscription = audioBloc.playPosition!.listen((event) { + var episode = event.episode; + + if (widget.itemScrollController.isAttached) { + lastChapter ??= episode!.currentChapter; + + if (lastChapter != episode!.currentChapter) { + lastChapter = episode.currentChapter; + + if (!episode.chaptersLoading && episode.chapters.isNotEmpty) { + var index = widget.chapters.indexWhere((element) => element == lastChapter); + + if (index >= 0) { + if (first) { + widget.itemScrollController.jumpTo(index: index); + first = false; + } + // Removed auto-scroll to current chapter during playback + // to prevent annoying bouncing behavior + } + } + } + } + }); + } + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context); + + return StreamBuilder( + stream: audioBloc.nowPlaying, + builder: (context, snapshot) { + return !snapshot.hasData || snapshot.data!.chaptersLoading + ? const Align( + alignment: Alignment.center, + child: PlatformProgressIndicator(), + ) + : ScrollablePositionedList.builder( + initialScrollIndex: _initialIndex(snapshot.data), + itemScrollController: widget.itemScrollController, + itemCount: widget.chapters.length, + itemBuilder: (context, i) { + final index = i < 0 ? 0 : i; + final chapter = widget.chapters[index]; + final chapterSelected = chapter == snapshot.data!.currentChapter; + final textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: 14, + fontWeight: FontWeight.normal, + ); + + /// We should be able to use the selectedTileColor property but, if we do, when + /// we scroll the currently selected item out of view, the selected colour is + /// still visible behind the transport control. This is a little hack, but fixes + /// the issue until I can get ListTile to work correctly. + return Padding( + padding: const EdgeInsets.fromLTRB(4.0, 0.0, 4.0, 0.0), + child: ListTile( + selectedTileColor: Theme.of(context).cardTheme.color, + onTap: () { + audioBloc.transitionPosition(chapter.startTime); + }, + selected: chapterSelected, + leading: Padding( + padding: const EdgeInsets.all(4.0), + child: Text( + '${index + 1}.', + style: textStyle, + ), + ), + title: Text( + widget.chapters[index].title.trim(), + overflow: TextOverflow.ellipsis, + softWrap: false, + maxLines: 3, + style: textStyle, + ), + trailing: Text( + _formatStartTime(widget.chapters[index].startTime), + style: textStyle, + ), + ), + ); + }, + ); + }); + } + + @override + void dispose() { + widget.positionSubscription?.cancel(); + super.dispose(); + } + + int _initialIndex(Episode? e) { + var init = 0; + + if (e != null && e.currentChapter != null) { + init = widget.chapters.indexWhere((c) => c == e.currentChapter); + + if (init < 0) { + init = 0; + } + } + + return init; + } + + String _formatStartTime(double startTime) { + var time = Duration(seconds: startTime.ceil()); + var result = ''; + + if (time.inHours > 0) { + result = + '${time.inHours}:${time.inMinutes.remainder(60).toString().padLeft(2, '0')}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}'; + } else { + result = '${time.inMinutes}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}'; + } + + return result; + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart new file mode 100644 index 0000000..2c9570e --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart @@ -0,0 +1,49 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// Custom [Decoration] for the chapters, episode & notes tab selector +/// shown in the [NowPlaying] page. +class DotDecoration extends Decoration { + final Color colour; + + const DotDecoration({required this.colour}); + + @override + BoxPainter createBoxPainter([void Function()? onChanged]) { + return _DotDecorationPainter(decoration: this); + } +} + +class _DotDecorationPainter extends BoxPainter { + final DotDecoration decoration; + + _DotDecorationPainter({required this.decoration}); + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + const double pillWidth = 8.0; + const double pillHeight = 3.0; + + final center = configuration.size!.center(offset); + final height = configuration.size!.height; + + final newOffset = Offset(center.dx, height - 8); + + final paint = Paint(); + paint.color = decoration.colour; + paint.style = PaintingStyle.fill; + + canvas.drawRRect( + RRect.fromLTRBR( + newOffset.dx - pillWidth, + newOffset.dy - pillHeight, + newOffset.dx + pillWidth, + newOffset.dy + pillHeight, + const Radius.circular(12.0), + ), + paint); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart new file mode 100644 index 0000000..2c5a29f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart @@ -0,0 +1,101 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/ui/podcast/person_avatar.dart'; +import 'package:pinepods_mobile/ui/podcast/transport_controls.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_html.dart'; +import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; +import 'package:flutter/material.dart'; + +/// This class renders the more info widget that is accessed from the 'more' +/// button on an episode. +/// +/// The widget is displayed as a draggable, scrollable sheet. This contains +/// episode icon and play/pause control, below which the episode title, show +/// notes and person(s) details (if available). +class EpisodeDetails extends StatefulWidget { + final Episode episode; + + const EpisodeDetails({ + super.key, + required this.episode, + }); + + @override + State createState() => _EpisodeDetailsState(); +} + +class _EpisodeDetailsState extends State { + @override + Widget build(BuildContext context) { + final episode = widget.episode; + + /// Ensure we do not highlight this as a new episode + episode.highlight = false; + + return DraggableScrollableSheet( + initialChildSize: 0.6, + expand: false, + builder: (BuildContext context, ScrollController scrollController) { + return SingleChildScrollView( + controller: scrollController, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ExpansionTile( + key: const Key('episodemoreinfo'), + trailing: PlayControl( + episode: episode, + ), + leading: TileImage( + url: episode.thumbImageUrl ?? episode.imageUrl!, + size: 56.0, + highlight: episode.highlight, + ), + subtitle: EpisodeSubtitle(episode), + title: Text( + episode.title!, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: false, + style: Theme.of(context).textTheme.bodyMedium, + )), + const Divider(), + Padding( + padding: const EdgeInsets.all(16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + episode.title!, + style: Theme.of(context).textTheme.titleLarge!.copyWith(fontWeight: FontWeight.bold), + ), + ), + ), + if (episode.persons.isNotEmpty) + SizedBox( + height: 120.0, + child: ListView.builder( + itemCount: episode.persons.length, + scrollDirection: Axis.horizontal, + itemBuilder: (BuildContext context, int index) { + return PersonAvatar(person: episode.persons[index]); + }, + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + ), + child: PodcastHtml(content: episode.content ?? episode.description!), + ) + ], + ), + ); + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart new file mode 100644 index 0000000..c5ec294 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart @@ -0,0 +1,222 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/entities/funding.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/action_text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// This class is responsible for rendering the funding menu on the podcast details page. +/// +/// It returns either a Material or Cupertino style menu instance depending upon which +/// platform we are running on. +/// +/// The target platform is based on the current [Theme]: [ThemeData.platform]. +class FundingMenu extends StatelessWidget { + final List? funding; + + const FundingMenu( + this.funding, { + super.key, + }); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return _MaterialFundingMenu(funding); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return _CupertinoFundingMenu(funding); + } + } +} + +/// This is the material design version of the context menu. This will be rendered +/// for all platforms that are not iOS. +class _MaterialFundingMenu extends StatelessWidget { + final List? funding; + + const _MaterialFundingMenu(this.funding); + + @override + Widget build(BuildContext context) { + final settingsBloc = Provider.of(context); + + return funding == null || funding!.isEmpty + ? const SizedBox( + width: 0.0, + height: 0.0, + ) + : StreamBuilder( + stream: settingsBloc.settings, + initialData: AppSettings.sensibleDefaults(), + builder: (context, snapshot) { + return Semantics( + label: L.of(context)!.podcast_funding_dialog_header, + child: PopupMenuButton( + onSelected: (url) { + FundingLink.fundingLink( + url, + snapshot.data!.externalLinkConsent, + context, + ).then((value) { + settingsBloc.setExternalLinkConsent(value); + }); + }, + icon: const Icon( + Icons.payment, + ), + itemBuilder: (BuildContext context) { + return List>.generate(funding!.length, (index) { + return PopupMenuItem( + value: funding![index].url, + enabled: true, + child: Text(funding![index].value), + ); + }); + }, + ), + ); + }); + } +} + +/// This is the Cupertino context menu and is rendered only when running on +/// an iOS device. +class _CupertinoFundingMenu extends StatelessWidget { + final List? funding; + + const _CupertinoFundingMenu(this.funding); + + @override + Widget build(BuildContext context) { + final settingsBloc = Provider.of(context); + + return funding == null || funding!.isEmpty + ? const SizedBox( + width: 0.0, + height: 0.0, + ) + : StreamBuilder( + stream: settingsBloc.settings, + initialData: AppSettings.sensibleDefaults(), + builder: (context, snapshot) { + return IconButton( + tooltip: L.of(context)!.podcast_funding_dialog_header, + icon: const Icon(Icons.payment), + visualDensity: VisualDensity.compact, + onPressed: () => showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + ...List.generate(funding!.length, (index) { + return CupertinoActionSheetAction( + onPressed: () { + FundingLink.fundingLink( + funding![index].url, + snapshot.data!.externalLinkConsent, + context, + ).then((value) { + settingsBloc.setExternalLinkConsent(value); + if (context.mounted) { + Navigator.of(context).pop('Cancel'); + } + }); + }, + child: Text(funding![index].value), + ); + }), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.cancel_option_label), + ), + ); + }, + ), + ); + }); + } +} + +class FundingLink { + /// Check the consent status. If this is the first time we have been + /// requested to open a funding link, present the user with and + /// information dialog first to make clear that the link is provided + /// by the podcast owner and not Pinepods. + static Future fundingLink(String url, bool consent, BuildContext context) async { + bool? result = false; + + if (consent) { + result = true; + final uri = Uri.parse(url); + + if (!await launchUrl( + uri, + mode: LaunchMode.externalApplication, + )) { + throw Exception('Could not launch $uri'); + } + } else { + result = await showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (_) => BasicDialogAlert( + title: Semantics( + header: true, + child: Text(L.of(context)!.podcast_funding_dialog_header), + ), + content: Text(L.of(context)!.consent_message), + actions: [ + BasicDialogAction( + title: ActionText( + L.of(context)!.go_back_button_label, + ), + onPressed: () { + Navigator.pop(context, false); + }, + ), + BasicDialogAction( + title: ActionText( + L.of(context)!.continue_button_label, + ), + iosIsDefaultAction: true, + onPressed: () { + Navigator.pop(context, true); + }, + ), + ], + ), + ); + + if (result!) { + var uri = Uri.parse(url); + + unawaited( + canLaunchUrl(uri).then((value) => launchUrl(uri)), + ); + } + } + + return Future.value(result); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart new file mode 100644 index 0000000..010b14d --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart @@ -0,0 +1,360 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:ui'; + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/ui/podcast/now_playing.dart'; +import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_image.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Displays a mini podcast player widget if a podcast is playing or paused. +/// +/// If stopped a zero height box is built instead. Tapping on the mini player +/// will open the main player window. +class MiniPlayer extends StatelessWidget { + const MiniPlayer({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + + return StreamBuilder( + stream: audioBloc.playingState, + initialData: AudioState.stopped, + builder: (context, snapshot) { + return snapshot.data != AudioState.stopped && + snapshot.data != AudioState.none && + snapshot.data != AudioState.error + ? _MiniPlayerBuilder() + : const SizedBox( + height: 0.0, + ); + }); + } +} + +class _MiniPlayerBuilder extends StatefulWidget { + @override + _MiniPlayerBuilderState createState() => _MiniPlayerBuilderState(); +} + +class _MiniPlayerBuilderState extends State<_MiniPlayerBuilder> + with SingleTickerProviderStateMixin { + late AnimationController _playPauseController; + late StreamSubscription _audioStateSubscription; + + @override + void initState() { + super.initState(); + + _playPauseController = AnimationController( + vsync: this, duration: const Duration(milliseconds: 300)); + _playPauseController.value = 1; + + _audioStateListener(); + } + + @override + void dispose() { + _audioStateSubscription.cancel(); + _playPauseController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final audioBloc = Provider.of(context, listen: false); + final width = MediaQuery.of(context).size.width; + final placeholderBuilder = PlaceholderBuilder.of(context); + + return Dismissible( + key: UniqueKey(), + confirmDismiss: (direction) async { + await _audioStateSubscription.cancel(); + audioBloc.transitionState(TransitionState.stop); + return true; + }, + direction: DismissDirection.startToEnd, + background: Container( + color: Theme.of(context).colorScheme.surface, + height: 64.0, + ), + child: GestureDetector( + key: const Key('miniplayergesture'), + onTap: () async { + await _audioStateSubscription.cancel(); + + if (context.mounted) { + showModalBottomSheet( + context: context, + routeSettings: const RouteSettings(name: 'nowplaying'), + isScrollControlled: true, + builder: (BuildContext modalContext) { + final contextPadding = MediaQuery.of(context).padding.top; + final modalPadding = MediaQuery.of(modalContext).padding.top; + + // Get the actual system safe area from the window (works on both iOS and Android) + final window = PlatformDispatcher.instance.views.first; + final systemPadding = window.padding.top / window.devicePixelRatio; + + // Use the best available padding value + double topPadding; + if (contextPadding > 0) { + topPadding = contextPadding; + } else if (modalPadding > 0) { + topPadding = modalPadding; + } else { + // Fall back to system padding if both contexts have 0 + topPadding = systemPadding; + } + + + return Padding( + padding: EdgeInsets.only(top: topPadding), + child: const NowPlaying(), + ); + }, + ).then((_) { + _audioStateListener(); + }); + } + }, + child: Semantics( + header: true, + label: L.of(context)!.semantics_mini_player_header, + child: Container( + height: 66, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: Divider.createBorderSide(context, + width: 1.0, color: Theme.of(context).dividerColor), + bottom: Divider.createBorderSide(context, + width: 0.0, color: Theme.of(context).dividerColor), + )), + child: Padding( + padding: const EdgeInsets.only(left: 4.0, right: 4.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamBuilder( + stream: audioBloc.nowPlaying, + initialData: audioBloc.nowPlaying?.valueOrNull, + builder: (context, snapshot) { + return StreamBuilder( + stream: audioBloc.playingState, + builder: (context, stateSnapshot) { + var playing = + stateSnapshot.data == AudioState.playing; + + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + height: 58.0, + width: 58.0, + child: ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: snapshot.hasData + ? PodcastImage( + key: Key( + 'mini${snapshot.data!.imageUrl}'), + url: snapshot.data!.imageUrl!, + width: 58.0, + height: 58.0, + borderRadius: 4.0, + placeholder: placeholderBuilder != + null + ? placeholderBuilder + .builder()(context) + : const Image( + image: AssetImage( + 'assets/images/favicon.png')), + errorPlaceholder: + placeholderBuilder != null + ? placeholderBuilder + .errorBuilder()( + context) + : const Image( + image: AssetImage( + 'assets/images/favicon.png')), + ) + : Container(), + ), + ), + ), + Expanded( + flex: 1, + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + snapshot.data?.title ?? '', + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium, + ), + Padding( + padding: + const EdgeInsets.only(top: 4.0), + child: Text( + snapshot.data?.author ?? '', + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall, + ), + ), + ], + )), + SizedBox( + height: 52.0, + width: 52.0, + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 0.0), + shape: CircleBorder( + side: BorderSide( + color: Theme.of(context) + .colorScheme + .surface, + width: 0.0)), + ), + onPressed: () { + if (playing) { + audioBloc.transitionState( + TransitionState.fastforward); + } + }, + child: Icon( + Icons.forward_30, + semanticLabel: L + .of(context)! + .fast_forward_button_label, + size: 36.0, + ), + ), + ), + SizedBox( + height: 52.0, + width: 52.0, + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 0.0), + shape: CircleBorder( + side: BorderSide( + color: Theme.of(context) + .colorScheme + .surface, + width: 0.0)), + ), + onPressed: () { + if (playing) { + _pause(audioBloc); + } else { + _play(audioBloc); + } + }, + child: AnimatedIcon( + semanticLabel: playing + ? L.of(context)!.pause_button_label + : L.of(context)!.play_button_label, + size: 48.0, + icon: AnimatedIcons.play_pause, + color: + Theme.of(context).iconTheme.color, + progress: _playPauseController, + ), + ), + ), + ], + ); + }); + }), + StreamBuilder( + stream: audioBloc.playPosition, + initialData: audioBloc.playPosition?.valueOrNull, + builder: (context, snapshot) { + var cw = 0.0; + var position = snapshot.hasData + ? snapshot.data!.position + : const Duration(seconds: 0); + var length = snapshot.hasData + ? snapshot.data!.length + : const Duration(seconds: 0); + + if (length.inSeconds > 0) { + final pc = length.inSeconds / position.inSeconds; + cw = width / pc; + } + + return Container( + width: cw, + height: 1.0, + color: Theme.of(context).primaryColor, + ); + }), + ], + ), + ), + ), + ), + ), + ); + } + + /// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController] + /// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll + /// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to + /// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move + /// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a + /// little odd. + void _audioStateListener() { + if (mounted) { + final audioBloc = Provider.of(context, listen: false); + var firstEvent = true; + + _audioStateSubscription = audioBloc.playingState!.listen((event) { + if (event == AudioState.playing || event == AudioState.buffering) { + if (firstEvent) { + _playPauseController.value = 1; + firstEvent = false; + } else { + _playPauseController.forward(); + } + } else { + if (firstEvent) { + _playPauseController.value = 0; + firstEvent = false; + } else { + _playPauseController.reverse(); + } + } + }); + } + } + + void _play(AudioBloc audioBloc) { + audioBloc.transitionState(TransitionState.play); + } + + void _pause(AudioBloc audioBloc) { + audioBloc.transitionState(TransitionState.pause); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing.dart new file mode 100644 index 0000000..d9ff536 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing.dart @@ -0,0 +1,654 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/ui/podcast/chapter_selector.dart'; +import 'package:pinepods_mobile/ui/podcast/dot_decoration.dart'; +import 'package:pinepods_mobile/ui/podcast/now_playing_floating_player.dart'; +import 'package:pinepods_mobile/ui/podcast/now_playing_options.dart'; +import 'package:pinepods_mobile/ui/podcast/person_avatar.dart'; +import 'package:pinepods_mobile/ui/podcast/playback_error_listener.dart'; +import 'package:pinepods_mobile/ui/podcast/player_position_controls.dart'; +import 'package:pinepods_mobile/ui/podcast/player_transport_controls.dart'; +import 'package:pinepods_mobile/ui/widgets/delayed_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_html.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_image.dart'; +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// This is the full-screen player Widget which is invoked by touching the mini player. +/// +/// This is the parent widget of the now playing screen(s). If we are running on a mobile in +/// portrait mode, we display the episode details, controls and additional options +/// as a draggable view. For tablets in portrait or on desktop, we display a split +/// screen. The main details and controls appear in one pane with the additional +/// controls in another. +/// +/// TODO: The fade in/out transition applied when scrolling the queue is the first implementation. +/// Using [Opacity] is a very inefficient way of achieving this effect, but will do as a place +/// holder until a better animation can be achieved. +class NowPlaying extends StatefulWidget { + const NowPlaying({ + super.key, + }); + + @override + State createState() => _NowPlayingState(); +} + +class _NowPlayingState extends State with WidgetsBindingObserver { + late StreamSubscription playingStateSubscription; + var textGroup = AutoSizeGroup(); + double scrollPos = 0.0; + double opacity = 0.0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + final audioBloc = Provider.of(context, listen: false); + var popped = false; + + // If the episode finishes we can close. + playingStateSubscription = + audioBloc.playingState!.where((state) => state == AudioState.stopped).listen((playingState) async { + // Prevent responding to multiple stop events after we've popped and lost context. + if (!popped) { + popped = true; + if (mounted) { + Navigator.of(context).pop(); + } + } + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + playingStateSubscription.cancel(); + + super.dispose(); + } + + bool isMobilePortrait(BuildContext context) { + final query = MediaQuery.of(context); + return (query.orientation == Orientation.portrait || query.size.width <= 1000); + } + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + final playerBuilder = PlayerControlsBuilder.of(context); + + return Semantics( + header: false, + label: L.of(context)!.semantics_main_player_header, + explicitChildNodes: true, + child: StreamBuilder( + stream: audioBloc.nowPlaying, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Container(); + } + + var duration = snapshot.data == null ? 0 : snapshot.data!.duration; + final WidgetBuilder? transportBuilder = playerBuilder?.builder(duration); + + return isMobilePortrait(context) + ? NotificationListener( + onNotification: (notification) { + setState(() { + if (notification.extent > (notification.minExtent)) { + opacity = 1 - (notification.maxExtent - notification.extent); + scrollPos = 1.0; + } else { + opacity = 0.0; + scrollPos = 0.0; + } + }); + + return true; + }, + child: Stack( + fit: StackFit.expand, + children: [ + // We need to hide the main player when the floating player is visible to prevent + // screen readers from reading both parts of the stack. + Visibility( + visible: opacity < 1, + child: NowPlayingTabs( + episode: snapshot.data!, + transportBuilder: transportBuilder, + ), + ), + SizedBox.expand( + child: SafeArea( + child: Column( + children: [ + /// Sized boxes without a child are 'invisible' so they do not prevent taps below + /// the stack but are still present in the layout. We have a sized box here to stop + /// the draggable panel from jumping as you start to pull it up. I am really looking + /// forward to the Dart team fixing the nested scroll issues with [DraggableScrollableSheet] + SizedBox( + height: 64.0, + child: scrollPos == 1 + ? Opacity( + opacity: opacity, + child: const FloatingPlayer(), + ) + : null, + ), + if (MediaQuery.of(context).orientation == Orientation.portrait) + const Expanded( + child: NowPlayingOptionsSelector(), + ), + ], + ), + )), + ], + ), + ) + : Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + flex: 1, + child: NowPlayingTabs(episode: snapshot.data!, transportBuilder: transportBuilder), + ), + const Expanded( + flex: 1, + child: NowPlayingOptionsSelectorWide(), + ), + ], + ); + }), + ); + } +} + +/// This widget displays the episode logo, episode title and current +/// chapter if available. +/// +/// If running in portrait this will be in a vertical format; if in +/// landscape this will be in a horizontal format. The actual displaying +/// of the episode text is handed off to [NowPlayingEpisodeDetails]. +class NowPlayingEpisode extends StatelessWidget { + final String? imageUrl; + final Episode episode; + final AutoSizeGroup? textGroup; + + const NowPlayingEpisode({ + super.key, + required this.imageUrl, + required this.episode, + required this.textGroup, + }); + + @override + Widget build(BuildContext context) { + final placeholderBuilder = PlaceholderBuilder.of(context); + + return OrientationBuilder( + builder: (context, orientation) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: MediaQuery.of(context).orientation == Orientation.portrait || MediaQuery.of(context).size.width >= 1000 + ? Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + flex: 7, + child: Semantics( + label: L.of(context)!.semantic_podcast_artwork_label, + child: PodcastImage( + key: Key('nowplaying$imageUrl'), + url: imageUrl!, + width: MediaQuery.of(context).size.width * .75, + height: MediaQuery.of(context).size.height * .75, + fit: BoxFit.contain, + borderRadius: 6.0, + placeholder: placeholderBuilder != null + ? placeholderBuilder.builder()(context) + : DelayedCircularProgressIndicator(), + errorPlaceholder: placeholderBuilder != null + ? placeholderBuilder.errorBuilder()(context) + : const Image(image: AssetImage('assets/images/favicon.png')), + ), + ), + ), + Expanded( + flex: 3, + child: NowPlayingEpisodeDetails( + episode: episode, + textGroup: textGroup, + ), + ), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.only( + left: 8.0, + bottom: 8.0, + ), + child: PodcastImage( + key: Key('nowplaying$imageUrl'), + url: imageUrl!, + height: 280, + width: 280, + fit: BoxFit.contain, + borderRadius: 8.0, + placeholder: placeholderBuilder != null + ? placeholderBuilder.builder()(context) + : DelayedCircularProgressIndicator(), + errorPlaceholder: placeholderBuilder != null + ? placeholderBuilder.errorBuilder()(context) + : const Image(image: AssetImage('assets/images/favicon.png')), + ), + ), + ), + Expanded( + flex: 5, + child: NowPlayingEpisodeDetails( + episode: episode, + textGroup: textGroup, + ), + ), + ], + ), + ); + }, + ); + } +} + +/// This widget is responsible for displaying the main episode details. +/// +/// This displays the current episode title and, if available, the +/// current chapter title and optional link. +class NowPlayingEpisodeDetails extends StatelessWidget { + final Episode? episode; + final AutoSizeGroup? textGroup; + static const minFontSize = 14.0; + + const NowPlayingEpisodeDetails({ + super.key, + this.episode, + this.textGroup, + }); + + @override + Widget build(BuildContext context) { + final chapterTitle = episode?.currentChapter?.title ?? ''; + final chapterUrl = episode?.currentChapter?.url ?? ''; + + return Column( + children: [ + Expanded( + flex: 5, + child: Padding( + padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0), + child: Semantics( + container: true, + child: AutoSizeText( + episode?.title ?? '', + group: textGroup, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + minFontSize: minFontSize, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24.0, + ), + maxLines: episode!.hasChapters ? 3 : 4, + ), + ), + ), + ), + if (episode!.hasChapters) + Expanded( + flex: 4, + child: Padding( + padding: const EdgeInsets.fromLTRB(8.0, 0.0, 0.0, 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Semantics( + label: L.of(context)!.semantic_current_chapter_label, + container: true, + child: AutoSizeText( + chapterTitle, + group: textGroup, + minFontSize: minFontSize, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.grey[300], + fontWeight: FontWeight.normal, + fontSize: 16.0, + ), + maxLines: 2, + ), + ), + ), + chapterUrl.isEmpty + ? const SizedBox( + height: 0, + width: 0, + ) + : Semantics( + label: L.of(context)!.semantic_chapter_link_label, + container: true, + child: IconButton( + padding: EdgeInsets.zero, + icon: const Icon( + Icons.link, + ), + color: Theme.of(context).primaryIconTheme.color, + onPressed: () { + _chapterLink(chapterUrl); + }), + ), + ], + ), + ), + ), + ], + ); + } + + void _chapterLink(String url) async { + final uri = Uri.parse(url); + + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + throw 'Could not launch chapter link: $url'; + } + } +} + +/// This widget handles the displaying of the episode show notes. +/// +/// This consists of title, show notes and person details +/// (where available). +class NowPlayingShowNotes extends StatelessWidget { + final Episode? episode; + + const NowPlayingShowNotes({ + super.key, + required this.episode, + }); + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 16.0, + ), + child: Text( + episode!.title!, + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (episode!.persons.isNotEmpty) + SizedBox( + height: 120.0, + child: ListView.builder( + itemCount: episode!.persons.length, + scrollDirection: Axis.horizontal, + itemBuilder: (BuildContext context, int index) { + return PersonAvatar(person: episode!.persons[index]); + }, + ), + ), + Padding( + padding: const EdgeInsets.only( + top: 8.0, + left: 8.0, + right: 8.0, + ), + child: PodcastHtml(content: episode?.content ?? episode?.description ?? ''), + ), + ], + ), + ), + ); + } +} + +/// Widget for rendering main episode tabs. +/// +/// This will be episode details and show notes. If the episode supports chapters +/// this will be included also. This is the parent widget. The tabs are +/// rendered via [EpisodeTabBar] and the tab contents via. [EpisodeTabBarView]. +class NowPlayingTabs extends StatelessWidget { + const NowPlayingTabs({ + super.key, + required this.transportBuilder, + required this.episode, + }); + + final WidgetBuilder? transportBuilder; + final Episode episode; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: episode.hasChapters ? 3 : 2, + initialIndex: episode.hasChapters ? 1 : 0, + child: AnnotatedRegion( + value: Theme.of(context) + .appBarTheme + .systemOverlayStyle! + .copyWith(systemNavigationBarColor: Theme.of(context).secondaryHeaderColor), + child: Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0.0, + leading: IconButton( + tooltip: L.of(context)!.minimise_player_window_button_label, + icon: Icon( + Icons.keyboard_arrow_down, + color: Theme.of(context).primaryIconTheme.color, + ), + onPressed: () => { + Navigator.pop(context), + }, + ), + flexibleSpace: PlaybackErrorListener( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + EpisodeTabBar( + chapters: episode.hasChapters, + ), + ], + ), + ), + ), + body: Column( + children: [ + Expanded( + flex: 5, + child: EpisodeTabBarView( + episode: episode, + chapters: episode.hasChapters, + ), + ), + transportBuilder != null + ? transportBuilder!(context) + : const SizedBox( + height: 148.0, + child: NowPlayingTransport(), + ), + if (MediaQuery.of(context).orientation == Orientation.portrait) + const Expanded( + flex: 1, + child: NowPlayingOptionsScaffold(), + ), + ], + ), + ), + )); + } +} + +/// This class is responsible for rendering the tab selection at the top of the screen. +/// +/// It displays two or three tabs depending upon whether the current episode supports +/// (and contains) chapters. +class EpisodeTabBar extends StatelessWidget { + final bool chapters; + + const EpisodeTabBar({ + super.key, + this.chapters = false, + }); + + @override + Widget build(BuildContext context) { + return TabBar( + isScrollable: true, + indicatorSize: TabBarIndicatorSize.tab, + indicator: DotDecoration(colour: Theme.of(context).primaryColor), + tabs: [ + if (chapters) + Tab( + child: Align( + alignment: Alignment.center, + child: Text(L.of(context)!.chapters_label), + ), + ), + Tab( + child: Align( + alignment: Alignment.center, + child: Text(L.of(context)!.episode_label), + ), + ), + Tab( + child: Align( + alignment: Alignment.center, + child: Text(L.of(context)!.notes_label), + ), + ), + ], + ); + } +} + +/// This class is responsible for rendering the tab bodies. +/// +/// This includes the chapter selection view (if the episode supports chapters), +/// the episode details (image and description) and the show notes view. +class EpisodeTabBarView extends StatelessWidget { + final Episode? episode; + final AutoSizeGroup? textGroup; + final bool chapters; + + const EpisodeTabBarView({ + super.key, + this.episode, + this.textGroup, + this.chapters = false, + }); + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context); + + return TabBarView( + children: [ + if (chapters) + ChapterSelector( + episode: episode!, + ), + StreamBuilder( + stream: audioBloc.nowPlaying, + builder: (context, snapshot) { + final e = snapshot.hasData ? snapshot.data! : episode!; + + return NowPlayingEpisode( + episode: e, + imageUrl: e.positionalImageUrl, + textGroup: textGroup, + ); + }), + NowPlayingShowNotes(episode: episode), + ], + ); + } +} + +/// This is the parent widget for the episode position and transport +/// controls. +class NowPlayingTransport extends StatelessWidget { + const NowPlayingTransport({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + children: [ + Divider( + height: 0.0, + ), + PlayerPositionControls(), + PlayerTransportControls(), + ], + ); + } +} + +/// This widget allows users to inject their own transport controls +/// into the app. +/// +/// When rendering the controls, Pinepods will check if a PlayerControlsBuilder +/// is in the tree. If so, it will use the builder rather than its own +/// transport controls. +class PlayerControlsBuilder extends InheritedWidget { + final WidgetBuilder Function(int duration) builder; + + const PlayerControlsBuilder({ + super.key, + required this.builder, + required super.child, + }); + + static PlayerControlsBuilder? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(PlayerControlsBuilder oldWidget) { + return builder != oldWidget.builder; + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_floating_player.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_floating_player.dart new file mode 100644 index 0000000..9e24baa --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_floating_player.dart @@ -0,0 +1,219 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_image.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// This widget is based upon [MiniPlayer] and provides an additional play/pause control when +/// the episode queue is expanded. +/// +/// At some point we should try to merge the common code between this and [MiniPlayer]. +class FloatingPlayer extends StatelessWidget { + const FloatingPlayer({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + + return StreamBuilder( + stream: audioBloc.playingState, + builder: (context, snapshot) { + return (snapshot.hasData && + !(snapshot.data == AudioState.stopped || + snapshot.data == AudioState.none || + snapshot.data == AudioState.error)) + ? _FloatingPlayerBuilder() + : const SizedBox( + height: 0.0, + ); + }); + } +} + +class _FloatingPlayerBuilder extends StatefulWidget { + @override + _FloatingPlayerBuilderState createState() => _FloatingPlayerBuilderState(); +} + +class _FloatingPlayerBuilderState extends State<_FloatingPlayerBuilder> with SingleTickerProviderStateMixin { + late AnimationController _playPauseController; + late StreamSubscription _audioStateSubscription; + + @override + void initState() { + super.initState(); + + _playPauseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); + _playPauseController.value = 1; + + _audioStateListener(); + } + + @override + void dispose() { + _audioStateSubscription.cancel(); + _playPauseController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final audioBloc = Provider.of(context, listen: false); + final placeholderBuilder = PlaceholderBuilder.of(context); + + return Container( + height: 64, + color: Theme.of(context).canvasColor, + child: StreamBuilder( + stream: audioBloc.nowPlaying, + builder: (context, snapshot) { + return StreamBuilder( + stream: audioBloc.playingState, + builder: (context, stateSnapshot) { + var playing = stateSnapshot.data == AudioState.playing; + return Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: snapshot.hasData + ? PodcastImage( + key: Key('float${snapshot.data!.imageUrl}'), + url: snapshot.data!.imageUrl!, + width: 58.0, + height: 58.0, + borderRadius: 4.0, + placeholder: placeholderBuilder != null + ? placeholderBuilder.builder()(context) + : const Image(image: AssetImage('assets/images/favicon.png')), + errorPlaceholder: placeholderBuilder != null + ? placeholderBuilder.errorBuilder()(context) + : const Image(image: AssetImage('assets/images/favicon.png')), + ) + : Container(), + ), + Expanded( + flex: 1, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + snapshot.data?.title ?? '', + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium, + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + snapshot.data?.author ?? '', + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall, + ), + ), + ], + )), + SizedBox( + height: 52.0, + width: 52.0, + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 0.0), + shape: CircleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.surface, width: 0.0)), + ), + onPressed: () { + if (playing) { + audioBloc.transitionState(TransitionState.fastforward); + } + }, + child: const Icon( + Icons.forward_30, + size: 36.0, + ), + ), + ), + SizedBox( + height: 52.0, + width: 52.0, + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 0.0), + shape: CircleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.surface, width: 0.0)), + ), + onPressed: () { + if (playing) { + _pause(audioBloc); + } else { + _play(audioBloc); + } + }, + child: AnimatedIcon( + semanticLabel: + playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label, + size: 48.0, + icon: AnimatedIcons.play_pause, + color: Theme.of(context).iconTheme.color, + progress: _playPauseController, + ), + ), + ), + ], + ); + }); + }), + ); + } + + /// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController] + /// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll + /// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to + /// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move + /// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a + /// little odd. + void _audioStateListener() { + final audioBloc = Provider.of(context, listen: false); + var firstEvent = true; + + _audioStateSubscription = audioBloc.playingState!.listen((event) { + if (event == AudioState.playing || event == AudioState.buffering) { + if (firstEvent) { + _playPauseController.value = 1; + firstEvent = false; + } else { + _playPauseController.forward(); + } + } else { + if (firstEvent) { + _playPauseController.value = 0; + firstEvent = false; + } else { + _playPauseController.reverse(); + } + } + }); + } + + void _play(AudioBloc audioBloc) { + audioBloc.transitionState(TransitionState.play); + } + + void _pause(AudioBloc audioBloc) { + audioBloc.transitionState(TransitionState.pause); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_options.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_options.dart new file mode 100644 index 0000000..37b99a0 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_options.dart @@ -0,0 +1,317 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/state/queue_event_state.dart'; +import 'package:pinepods_mobile/ui/podcast/transcript_view.dart'; +import 'package:pinepods_mobile/ui/podcast/up_next_view.dart'; +import 'package:pinepods_mobile/ui/podcast/pinepods_up_next_view.dart'; +import 'package:pinepods_mobile/ui/widgets/slider_handle.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// This class gives us options that can be dragged up from the bottom of the main player +/// window. +/// +/// Currently these options are Up Next & Transcript. +/// +/// This class is an initial version and should by much simpler than it is; however, +/// a [NestedScrollView] is the widget we need to implement this UI, there is a current +/// issue whereby the scroll view and [DraggableScrollableSheet] clash and therefore cannot +/// be used together. +/// +/// See issues [64157](https://github.com/flutter/flutter/issues/64157) +/// [67219](https://github.com/flutter/flutter/issues/67219) +/// +/// If anyone can come up with a more elegant solution (and one that does not throw +/// an overflow error in debug) please raise and issue/submit a PR. +/// +class NowPlayingOptionsSelector extends StatefulWidget { + final double? scrollPos; + static const baseSize = 68.0; + + const NowPlayingOptionsSelector({super.key, this.scrollPos}); + + @override + State createState() => _NowPlayingOptionsSelectorState(); +} + +class _NowPlayingOptionsSelectorState extends State { + DraggableScrollableController? draggableController; + + @override + Widget build(BuildContext context) { + final queueBloc = Provider.of(context, listen: false); + final theme = Theme.of(context); + final windowHeight = MediaQuery.of(context).size.height; + final minSize = NowPlayingOptionsSelector.baseSize / (windowHeight - NowPlayingOptionsSelector.baseSize); + + return DraggableScrollableSheet( + initialChildSize: minSize, + minChildSize: minSize, + maxChildSize: 1.0, + controller: draggableController, + // Snap doesn't work as the sheet and scroll controller just don't get along + // snap: true, + // snapSizes: [minSize, maxSize], + builder: (BuildContext context, ScrollController scrollController) { + return StreamBuilder( + initialData: QueueEmptyState(), + stream: queueBloc.queue, + builder: (context, queueSnapshot) { + final hasTranscript = queueSnapshot.hasData && + queueSnapshot.data?.playing != null && + queueSnapshot.data!.playing!.hasTranscripts; + + return DefaultTabController( + animationDuration: !draggableController!.isAttached || draggableController!.size <= minSize + ? const Duration(seconds: 0) + : kTabScrollDuration, + length: hasTranscript ? 2 : 1, + child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) { + return SingleChildScrollView( + controller: scrollController, + child: ConstrainedBox( + constraints: BoxConstraints.expand( + height: constraints.maxHeight, + ), + child: Material( + color: theme.secondaryHeaderColor, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).highlightColor, + width: 0.0, + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(18.0), + topRight: Radius.circular(18.0), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SliderHandle( + label: optionsSliderOpen() + ? L.of(context)!.semantic_playing_options_collapse_label + : L.of(context)!.semantic_playing_options_expand_label, + onTap: () { + if (draggableController != null) { + if (draggableController!.size < 1.0) { + draggableController!.animateTo( + 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + ); + } else { + draggableController!.animateTo( + 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + ); + } + } + }, + ), + DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.0), + border: Border( + bottom: draggableController != null && + (!draggableController!.isAttached || draggableController!.size <= minSize) + ? BorderSide.none + : BorderSide(color: Colors.grey[800]!, width: 1.0), + ), + ), + child: TabBar( + onTap: (index) { + DefaultTabController.of(ctx).animateTo(index); + + if (draggableController != null && draggableController!.size < 1.0) { + draggableController!.animateTo( + 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + ); + } + }, + automaticIndicatorColorAdjustment: false, + indicatorPadding: EdgeInsets.zero, + + /// Little hack to hide the indicator when closed + indicatorColor: draggableController != null && + (!draggableController!.isAttached || draggableController!.size <= minSize) + ? Theme.of(context).secondaryHeaderColor + : null, + tabs: [ + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Text( + L.of(context)!.up_next_queue_label.toUpperCase(), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + if (hasTranscript) + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Text( + L.of(context)!.transcript_label.toUpperCase(), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ], + ), + ), + const Padding(padding: EdgeInsets.only(bottom: 12.0)), + Expanded( + child: Consumer( + builder: (context, settingsBloc, child) { + final settings = settingsBloc.currentSettings; + final isPinepodsConnected = settings.pinepodsServer != null && + settings.pinepodsApiKey != null && + settings.pinepodsUserId != null; + + return TabBarView( + children: [ + isPinepodsConnected + ? const PinepodsUpNextView() + : const UpNextView(), + if (hasTranscript) + const TranscriptView(), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ); + }), + ); + }); + }, + ); + } + + bool optionsSliderOpen() { + return (draggableController != null && draggableController!.isAttached && draggableController!.size == 1.0); + } + + @override + void initState() { + draggableController = DraggableScrollableController(); + super.initState(); + } +} + +class NowPlayingOptionsScaffold extends StatelessWidget { + const NowPlayingOptionsScaffold({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: NowPlayingOptionsSelector.baseSize - 8.0, + ); + } +} + +/// This implementation displays the additional options in a tab set outside of a +/// draggable sheet. +/// +/// Currently these options are Up Next & Transcript. +class NowPlayingOptionsSelectorWide extends StatefulWidget { + final double? scrollPos; + static const baseSize = 68.0; + + const NowPlayingOptionsSelectorWide({super.key, this.scrollPos}); + + @override + State createState() => _NowPlayingOptionsSelectorWideState(); +} + +class _NowPlayingOptionsSelectorWideState extends State { + DraggableScrollableController? draggableController; + + @override + Widget build(BuildContext context) { + final queueBloc = Provider.of(context, listen: false); + final theme = Theme.of(context); + final scrollController = ScrollController(); + + return StreamBuilder( + initialData: QueueEmptyState(), + stream: queueBloc.queue, + builder: (context, queueSnapshot) { + final hasTranscript = queueSnapshot.hasData && + queueSnapshot.data?.playing != null && + queueSnapshot.data!.playing!.hasTranscripts; + + return DefaultTabController( + length: hasTranscript ? 2 : 1, + child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) { + return SingleChildScrollView( + controller: scrollController, + child: ConstrainedBox( + constraints: BoxConstraints.expand( + height: constraints.maxHeight, + ), + child: Material( + color: theme.secondaryHeaderColor, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.0), + border: Border( + bottom: BorderSide(color: Colors.grey[800]!, width: 1.0), + ), + ), + child: TabBar( + automaticIndicatorColorAdjustment: false, + tabs: [ + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 16.0), + child: Text( + L.of(context)!.up_next_queue_label.toUpperCase(), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + if (hasTranscript) + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 16.0), + child: Text( + L.of(context)!.transcript_label.toUpperCase(), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ], + ), + ), + Expanded( + child: TabBarView( + children: [ + const UpNextView(), + if (hasTranscript) + const TranscriptView(), + ], + ), + ), + ], + ), + ), + ), + ); + }), + ); + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/person_avatar.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/person_avatar.dart new file mode 100644 index 0000000..d576cc3 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/person_avatar.dart @@ -0,0 +1,82 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// ignore_for_file: must_be_immutable + +import 'dart:async'; + +import 'package:pinepods_mobile/entities/person.dart'; +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// This Widget handles rendering of a person avatar. +/// +/// The data comes from the tag in the Podcasting 2.0 namespace. +/// +/// https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#person +class PersonAvatar extends StatelessWidget { + final Person person; + String initials = ''; + String role = ''; + + PersonAvatar({ + super.key, + required this.person, + }) { + if (person.name.isNotEmpty) { + var parts = person.name.split(' '); + + for (var i in parts) { + if (i.isNotEmpty) { + initials += i.substring(0, 1).toUpperCase(); + } + } + } + + if (person.role.isNotEmpty) { + role = person.role.substring(0, 1).toUpperCase() + person.role.substring(1); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: person.link != null && person.link!.isNotEmpty + ? () { + final uri = Uri.parse(person.link!); + + unawaited( + canLaunchUrl(uri).then((value) => launchUrl(uri)), + ); + } + : null, + child: SizedBox( + width: 96, + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 32, + foregroundImage: ExtendedImage.network( + person.image!, + cache: true, + ).image, + child: Text(initials), + ), + Text( + person.name, + maxLines: 3, + textAlign: TextAlign.center, + ), + Text(role), + ], + ), + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/pinepods_up_next_view.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/pinepods_up_next_view.dart new file mode 100644 index 0000000..6e15d53 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/pinepods_up_next_view.dart @@ -0,0 +1,390 @@ +// lib/ui/podcast/pinepods_up_next_view.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/ui/widgets/draggable_queue_episode_card.dart'; +import 'package:provider/provider.dart'; +import 'dart:async'; + +/// PinePods version of the Up Next queue that shows the server queue. +/// +/// This replaces the local queue functionality with server-based queue management. +class PinepodsUpNextView extends StatefulWidget { + const PinepodsUpNextView({ + Key? key, + }) : super(key: key); + + @override + State createState() => _PinepodsUpNextViewState(); +} + +class _PinepodsUpNextViewState extends State { + final PinepodsService _pinepodsService = PinepodsService(); + List _queuedEpisodes = []; + bool _isLoading = true; + String? _errorMessage; + StreamSubscription? _episodeSubscription; + + @override + void initState() { + super.initState(); + _loadQueue(); + _listenToEpisodeChanges(); + } + + @override + void dispose() { + _episodeSubscription?.cancel(); + super.dispose(); + } + + /// Listen to episode changes to refresh queue when episodes advance + void _listenToEpisodeChanges() { + try { + final audioPlayerService = Provider.of(context, listen: false); + final episodeStream = audioPlayerService.episodeEvent; + + // Check if episodeEvent stream is available + if (episodeStream == null) { + print('Episode event stream not available'); + return; + } + + String? lastEpisodeGuid; + + _episodeSubscription = episodeStream.listen((episode) { + // Only refresh if the episode actually changed (avoid unnecessary refreshes) + if (episode != null && episode.guid != lastEpisodeGuid && mounted) { + lastEpisodeGuid = episode.guid; + + // Add a small delay to ensure server queue has been updated + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + _loadQueue(); + } + }); + } + }); + } catch (e) { + // Provider not available, continue without episode listening + print('Could not set up episode change listener: $e'); + } + } + + Future _loadQueue() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || + settings.pinepodsUserId == null) { + setState(() { + _errorMessage = 'Not connected to PinePods server'; + _isLoading = false; + }); + return; + } + + _pinepodsService.setCredentials( + settings.pinepodsServer!, + settings.pinepodsApiKey!, + ); + + final episodes = await _pinepodsService.getQueuedEpisodes(settings.pinepodsUserId!); + + setState(() { + _queuedEpisodes = episodes; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + + Future _reorderQueue(int oldIndex, int newIndex) async { + // Adjust indices if moving down the list + if (newIndex > oldIndex) { + newIndex -= 1; + } + + // Update local state immediately for smooth UI + setState(() { + final episode = _queuedEpisodes.removeAt(oldIndex); + _queuedEpisodes.insert(newIndex, episode); + }); + + // Get episode IDs in new order + final episodeIds = _queuedEpisodes.map((e) => e.episodeId).toList(); + + // Call API to update order on server + try { + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not logged in')), + ); + await _loadQueue(); // Reload to restore original order + return; + } + + final success = await _pinepodsService.reorderQueue(userId, episodeIds); + + if (!success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to update queue order')), + ); + await _loadQueue(); // Reload to restore original order + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error updating queue: $e')), + ); + await _loadQueue(); // Reload to restore original order + } + } + + Future _removeFromQueue(int index) async { + final episode = _queuedEpisodes[index]; + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not logged in')), + ); + return; + } + + try { + final success = await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + + if (success) { + setState(() { + _queuedEpisodes.removeAt(index); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Removed from queue')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to remove from queue')), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error removing from queue: $e')), + ); + } + } + + Future _clearQueue() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Queue'), + content: const Text('Are you sure you want to clear the entire queue?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Clear'), + ), + ], + ), + ); + + if (confirmed != true) return; + + // Remove all episodes from queue + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + final userId = settings.pinepodsUserId; + + if (userId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not logged in')), + ); + return; + } + + try { + // Remove each episode from the queue + for (final episode in _queuedEpisodes) { + await _pinepodsService.removeQueuedEpisode( + episode.episodeId, + userId, + episode.isYoutube, + ); + } + + setState(() { + _queuedEpisodes.clear(); + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Queue cleared')), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error clearing queue: $e')), + ); + await _loadQueue(); // Reload to get current state + } + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Header with title and clear button + Row( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 24.0, 8.0), + child: Text( + 'Up Next', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0), + child: TextButton( + onPressed: _queuedEpisodes.isEmpty ? null : _clearQueue, + child: Text( + 'Clear', + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontSize: 12.0, + color: _queuedEpisodes.isEmpty + ? Theme.of(context).disabledColor + : Theme.of(context).primaryColor, + ), + ), + ), + ), + ], + ), + + // Content area + if (_isLoading) + const Padding( + padding: EdgeInsets.all(24.0), + child: Center( + child: CircularProgressIndicator(), + ), + ) + else if (_errorMessage != null) + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + Text( + 'Error loading queue', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _errorMessage!, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadQueue, + child: const Text('Retry'), + ), + ], + ), + ) + else if (_queuedEpisodes.isEmpty) + Padding( + padding: const EdgeInsets.all(24.0), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).dividerColor, + border: Border.all( + color: Theme.of(context).dividerColor, + ), + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + 'Your queue is empty. Add episodes to see them here.', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ), + ), + ) + else + Expanded( + child: ReorderableListView.builder( + buildDefaultDragHandles: false, + shrinkWrap: true, + padding: const EdgeInsets.all(8), + itemCount: _queuedEpisodes.length, + itemBuilder: (BuildContext context, int index) { + final episode = _queuedEpisodes[index]; + return Dismissible( + key: ValueKey('queue_${episode.episodeId}'), + direction: DismissDirection.endToStart, + onDismissed: (direction) { + _removeFromQueue(index); + }, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon( + Icons.delete, + color: Colors.white, + ), + ), + child: Container( + key: ValueKey('episode_${episode.episodeId}'), + margin: const EdgeInsets.only(bottom: 4), + child: DraggableQueueEpisodeCard( + episode: episode, + index: index, + onTap: () { + // Could navigate to episode details if needed + }, + onPlayPressed: () { + // Could implement play functionality if needed + }, + ), + ), + ); + }, + onReorder: _reorderQueue, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/playback_error_listener.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/playback_error_listener.dart new file mode 100644 index 0000000..db2e5ae --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/playback_error_listener.dart @@ -0,0 +1,70 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Listens for errors on the audio BLoC. +/// +/// We receive a code which we then map to an error message. This needs to be placed +/// below a [Scaffold]. +class PlaybackErrorListener extends StatefulWidget { + final Widget child; + + const PlaybackErrorListener({ + super.key, + required this.child, + }); + + @override + State createState() => _PlaybackErrorListenerState(); +} + +class _PlaybackErrorListenerState extends State { + StreamSubscription? errorSubscription; + + @override + Widget build(BuildContext context) { + return widget.child; + } + + @override + void initState() { + super.initState(); + final audioBloc = Provider.of(context, listen: false); + + errorSubscription = audioBloc.playbackError!.listen((code) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_codeToMessage(context, code)))); + } + }); + } + + @override + void dispose() { + errorSubscription?.cancel(); + super.dispose(); + } + + /// Ideally the BLoC would pass us the message to display; however, as we need a + /// context to fetch the correct version of any text string we need to work it out here. + String _codeToMessage(BuildContext context, int code) { + var result = ''; + + switch (code) { + case 401: + result = L.of(context)!.error_no_connection; + break; + case 501: + result = L.of(context)!.error_playback_fail; + break; + } + + return result; + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/player_position_controls.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/player_position_controls.dart new file mode 100644 index 0000000..17f1a42 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/player_position_controls.dart @@ -0,0 +1,170 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// This class handles the rendering of the positional controls: the current playback +/// time, time remaining and the time [Slider]. +class PlayerPositionControls extends StatefulWidget { + const PlayerPositionControls({ + super.key, + }); + + @override + State createState() => _PlayerPositionControlsState(); +} + +class _PlayerPositionControlsState extends State { + /// Current playback position + var currentPosition = 0; + + /// Indicates the user is moving the position slide. We should ignore + /// position updates until the user releases the slide. + var dragging = false; + + /// Seconds left of this episode. + var timeRemaining = 0; + + /// The length of the episode in seconds. + var episodeLength = 0; + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context); + final screenReader = MediaQuery.of(context).accessibleNavigation; + + return StreamBuilder( + stream: audioBloc.playPosition, + builder: (context, snapshot) { + var position = snapshot.hasData ? snapshot.data!.position.inSeconds : 0; + episodeLength = snapshot.hasData ? snapshot.data!.length.inSeconds : 0; + var divisions = episodeLength == 0 ? 1 : episodeLength; + + // If a screen reader is enabled, will make divisions ten seconds each. + if (screenReader) { + divisions = episodeLength ~/ 10; + } + + if (!dragging) { + currentPosition = position; + + if (currentPosition < 0) { + currentPosition = 0; + } + + if (currentPosition > episodeLength) { + currentPosition = episodeLength; + } + + timeRemaining = episodeLength - position; + + if (timeRemaining < 0) { + timeRemaining = 0; + } + } + + return Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + top: 0.0, + bottom: 4.0, + ), + child: Row( + children: [ + FittedBox( + child: Text( + _formatDuration(Duration(seconds: currentPosition)), + semanticsLabel: + '${L.of(context)!.now_playing_episode_position} ${_formatDuration(Duration(seconds: currentPosition))}', + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + Expanded( + child: snapshot.hasData + ? Slider( + label: _formatDuration(Duration(seconds: currentPosition)), + onChanged: (value) { + setState(() { + _calculatePositions(value.toInt()); + + // Normally, we only want to trigger a position change when the user has finished + // sliding; however, with a screen reader enabled that will never trigger. Instead, + // we'll use the 'normal' change event. + if (screenReader) { + return snapshot.data!.buffering ? null : audioBloc.transitionPosition(value); + } + }); + }, + onChangeStart: (value) { + if (!snapshot.data!.buffering) { + setState(() { + dragging = true; + _calculatePositions(currentPosition); + }); + } + }, + onChangeEnd: (value) { + setState(() { + dragging = false; + }); + + return snapshot.data!.buffering ? null : audioBloc.transitionPosition(value); + }, + value: currentPosition.toDouble(), + min: 0.0, + max: episodeLength.toDouble(), + divisions: divisions, + activeColor: Theme.of(context).primaryColor, + semanticFormatterCallback: (double newValue) { + return _formatDuration(Duration(seconds: currentPosition)); + }) + : Slider( + onChanged: null, + value: 0, + min: 0.0, + max: 1.0, + activeColor: Theme.of(context).primaryColor, + ), + ), + FittedBox( + child: Text( + _formatDuration(Duration(seconds: timeRemaining)), + textAlign: TextAlign.right, + semanticsLabel: + '${L.of(context)!.now_playing_episode_time_remaining} ${_formatDuration(Duration(seconds: timeRemaining))}', + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + }); + } + + void _calculatePositions(int p) { + currentPosition = p; + timeRemaining = episodeLength - p; + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) { + if (n >= 10) return '$n'; + return '0$n'; + } + + var twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).toInt()); + var twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).toInt()); + + return '${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds'; + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/player_transport_controls.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/player_transport_controls.dart new file mode 100644 index 0000000..3cf2629 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/player_transport_controls.dart @@ -0,0 +1,202 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/ui/widgets/sleep_selector.dart'; +import 'package:pinepods_mobile/ui/widgets/speed_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:provider/provider.dart'; + +/// Builds a transport control bar for rewind, play and fast-forward. +/// See [NowPlaying]. +class PlayerTransportControls extends StatefulWidget { + const PlayerTransportControls({ + super.key, + }); + + @override + State createState() => _PlayerTransportControlsState(); +} + +class _PlayerTransportControlsState extends State { + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: StreamBuilder( + stream: audioBloc.playingState, + initialData: AudioState.none, + builder: (context, snapshot) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + const SleepSelectorWidget(), + IconButton( + onPressed: () { + return snapshot.data == AudioState.buffering ? null : _rewind(audioBloc); + }, + tooltip: L.of(context)!.rewind_button_label, + padding: const EdgeInsets.all(0.0), + icon: const Icon( + Icons.replay_10, + size: 48.0, + ), + ), + AnimatedPlayButton(audioState: snapshot.data!), + IconButton( + onPressed: () { + return snapshot.data == AudioState.buffering ? null : _fastforward(audioBloc); + }, + tooltip: L.of(context)!.fast_forward_button_label, + padding: const EdgeInsets.all(0.0), + icon: const Icon( + Icons.forward_30, + size: 48.0, + ), + ), + const SpeedSelectorWidget(), + ], + ); + }), + ); + } + + void _rewind(AudioBloc audioBloc) { + audioBloc.transitionState(TransitionState.rewind); + } + + void _fastforward(AudioBloc audioBloc) { + audioBloc.transitionState(TransitionState.fastforward); + } +} + +typedef PlayHandler = Function(AudioBloc audioBloc); + +class AnimatedPlayButton extends StatefulWidget { + final AudioState audioState; + final PlayHandler onPlay; + final PlayHandler onPause; + + const AnimatedPlayButton({ + super.key, + required this.audioState, + this.onPlay = _onPlay, + this.onPause = _onPause, + }); + + @override + State createState() => _AnimatedPlayButtonState(); +} + +void _onPlay(AudioBloc audioBloc) { + audioBloc.transitionState(TransitionState.play); +} + +void _onPause(AudioBloc audioBloc) { + audioBloc.transitionState(TransitionState.pause); +} + +class _AnimatedPlayButtonState extends State with SingleTickerProviderStateMixin { + late AnimationController _playPauseController; + late StreamSubscription _audioStateSubscription; + bool init = true; + + @override + void initState() { + super.initState(); + + final audioBloc = Provider.of(context, listen: false); + + _playPauseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); + + /// Seems a little hacky, but when we load the form we want the play/pause + /// button to be in the correct state. If we are building the first frame, + /// just set the animation controller to the correct state; for all other + /// frames we want to animate. Doing it this way prevents the play/pause + /// button from animating when the form is first loaded. + _audioStateSubscription = audioBloc.playingState!.listen((event) { + if (event == AudioState.playing || event == AudioState.buffering) { + if (init) { + _playPauseController.value = 1; + init = false; + } else { + _playPauseController.forward(); + } + } else { + if (init) { + _playPauseController.value = 0; + init = false; + } else { + _playPauseController.reverse(); + } + } + }); + } + + @override + void dispose() { + _playPauseController.dispose(); + _audioStateSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + + final playing = widget.audioState == AudioState.playing; + final buffering = widget.audioState == AudioState.buffering; + + return Stack( + alignment: AlignmentDirectional.center, + children: [ + if (buffering) + SpinKitRing( + lineWidth: 4.0, + color: Theme.of(context).primaryColor, + size: 84, + ), + if (!buffering) + const SizedBox( + height: 84, + width: 84, + ), + Tooltip( + message: playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label, + child: TextButton( + style: TextButton.styleFrom( + shape: CircleBorder(side: BorderSide(color: Theme.of(context).highlightColor, width: 0.0)), + backgroundColor: Theme.of(context).brightness == Brightness.light ? Colors.orange : Colors.grey[800], + foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.orange : Colors.grey[800], + padding: const EdgeInsets.all(6.0), + ), + onPressed: () { + if (playing) { + widget.onPause(audioBloc); + } else { + widget.onPlay(audioBloc); + } + }, + child: AnimatedIcon( + size: 60.0, + semanticLabel: playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label, + icon: AnimatedIcons.play_pause, + color: Colors.white, + progress: _playPauseController, + ), + ), + ), + ], + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_context_menu.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_context_menu.dart new file mode 100644 index 0000000..fc8f887 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_context_menu.dart @@ -0,0 +1,206 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/entities/feed.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// This class is responsible for rendering the context menu on the podcast details +/// page. +/// +/// It returns either a [_MaterialPodcastMenu] or a [_CupertinoContextMenu} +/// instance depending upon which platform we are running on. +/// +/// The target platform is based on the current [Theme]: [ThemeData.platform]. +class PodcastContextMenu extends StatelessWidget { + final Podcast podcast; + + const PodcastContextMenu( + this.podcast, { + super.key, + }); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return _MaterialPodcastMenu(podcast); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return _CupertinoContextMenu(podcast); + } + } +} + +/// This is the material design version of the context menu. This will be rendered +/// for all platforms that are not iOS. +class _MaterialPodcastMenu extends StatelessWidget { + final Podcast podcast; + + const _MaterialPodcastMenu(this.podcast); + + @override + Widget build(BuildContext context) { + final bloc = Provider.of(context); + + return StreamBuilder>( + stream: bloc.details, + builder: (context, snapshot) { + return PopupMenuButton( + onSelected: (event) { + togglePlayed(value: event, bloc: bloc); + }, + icon: const Icon( + Icons.more_vert, + ), + itemBuilder: (BuildContext context) { + return >[ + PopupMenuItem( + value: 'ma', + enabled: podcast.subscribed, + child: Text(L.of(context)!.mark_episodes_played_label), + ), + PopupMenuItem( + value: 'ua', + enabled: podcast.subscribed, + child: Text(L.of(context)!.mark_episodes_not_played_label), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'refresh', + enabled: podcast.link?.isNotEmpty ?? false, + child: Text(L.of(context)!.refresh_feed_label), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'web', + enabled: podcast.link?.isNotEmpty ?? false, + child: Text(L.of(context)!.open_show_website_label), + ), + ]; + }, + ); + }); + } + + void togglePlayed({ + required String value, + required PodcastBloc bloc, + }) async { + if (value == 'ma') { + bloc.podcastEvent(PodcastEvent.markAllPlayed); + } else if (value == 'ua') { + bloc.podcastEvent(PodcastEvent.clearAllPlayed); + } else if (value == 'refresh') { + bloc.load(Feed( + podcast: podcast, + refresh: true, + )); + } else if (value == 'web') { + final uri = Uri.parse(podcast.link!); + + if (!await launchUrl( + uri, + mode: LaunchMode.externalApplication, + )) { + throw Exception('Could not launch $uri'); + } + } + } +} + +/// This is the Cupertino context menu and is rendered only when running on +/// an iOS device. +class _CupertinoContextMenu extends StatelessWidget { + final Podcast podcast; + + const _CupertinoContextMenu(this.podcast); + + @override + Widget build(BuildContext context) { + final bloc = Provider.of(context); + + return StreamBuilder>( + stream: bloc.details, + builder: (context, snapshot) { + return IconButton( + tooltip: L.of(context)!.podcast_options_overflow_menu_semantic_label, + icon: const Icon(CupertinoIcons.ellipsis), + onPressed: () => showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + bloc.podcastEvent(PodcastEvent.markAllPlayed); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.mark_episodes_played_label), + ), + CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + bloc.podcastEvent(PodcastEvent.clearAllPlayed); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.mark_episodes_not_played_label), + ), + CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + bloc.load(Feed( + podcast: podcast, + refresh: true, + )); + if (context.mounted) { + Navigator.pop(context, 'Cancel'); + } + }, + child: Text(L.of(context)!.refresh_feed_label), + ), + CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () async { + final uri = Uri.parse(podcast.link!); + + if (!await launchUrl( + uri, + mode: LaunchMode.externalApplication, + )) { + throw Exception('Could not launch $uri'); + } + + if (context.mounted) { + Navigator.pop(context, 'Cancel'); + } + }, + child: Text(L.of(context)!.open_show_website_label), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.cancel_option_label), + ), + ); + }, + ), + ); + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_details.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_details.dart new file mode 100644 index 0000000..38fef44 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_details.dart @@ -0,0 +1,1000 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/feed.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:pinepods_mobile/ui/podcast/funding_menu.dart'; +import 'package:pinepods_mobile/ui/podcast/playback_error_listener.dart'; +import 'package:pinepods_mobile/ui/podcast/podcast_context_menu.dart'; +import 'package:pinepods_mobile/ui/podcast/podcast_episode_list.dart'; +import 'package:pinepods_mobile/ui/widgets/action_text.dart'; +import 'package:pinepods_mobile/ui/widgets/delayed_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_filter_selector.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_sort_selector.dart'; +import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_back_button.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_html.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_image.dart'; +import 'package:pinepods_mobile/ui/widgets/sync_spinner.dart'; +import 'package:pinepods_mobile/ui/podcast/mini_player.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; + +/// This Widget takes a search result and builds a list of currently available podcasts. +/// +/// From here a user can option to subscribe/unsubscribe or play a podcast directly +/// from a search result. +class PodcastDetails extends StatefulWidget { + final Podcast podcast; + final PodcastBloc _podcastBloc; + + const PodcastDetails( + this.podcast, + this._podcastBloc, { + super.key, + }); + + @override + State createState() => _PodcastDetailsState(); +} + +class _PodcastDetailsState extends State { + final log = Logger('PodcastDetails'); + final scaffoldMessengerKey = GlobalKey(); + final ScrollController _sliverScrollController = ScrollController(); + var brightness = Brightness.dark; + bool toolbarCollapsed = false; + SystemUiOverlayStyle? _systemOverlayStyle; + + @override + void initState() { + super.initState(); + + // Load the details of the Podcast specified in the URL + log.fine('initState() - load feed'); + + widget._podcastBloc.load(Feed( + podcast: widget.podcast, + backgroundFresh: true, + silently: true, + )); + + // We only want to display the podcast title when the toolbar is in a + // collapsed state. Add a listener and set toollbarCollapsed variable + // as required. The text display property is then based on this boolean. + _sliverScrollController.addListener(() { + if (!toolbarCollapsed && + _sliverScrollController.hasClients && + _sliverScrollController.offset > (300 - kToolbarHeight)) { + setState(() { + toolbarCollapsed = true; + _updateSystemOverlayStyle(); + }); + } else if (toolbarCollapsed && + _sliverScrollController.hasClients && + _sliverScrollController.offset < (300 - kToolbarHeight)) { + setState(() { + toolbarCollapsed = false; + _updateSystemOverlayStyle(); + }); + } + }); + + widget._podcastBloc.backgroundLoading.where((event) => event is BlocPopulatedState).listen((event) { + if (mounted) { + /// If we have not scrolled (save a few pixels) just refresh the episode list; + /// otherwise prompt the user to prevent unexpected list jumping + if (_sliverScrollController.offset < 20) { + widget._podcastBloc.podcastEvent(PodcastEvent.refresh); + } else { + scaffoldMessengerKey.currentState!.showSnackBar(SnackBar( + content: Text(L.of(context)!.new_episodes_label), + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: L.of(context)!.new_episodes_view_now_label, + onPressed: () { + _sliverScrollController.animateTo(100, + duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); + widget._podcastBloc.podcastEvent(PodcastEvent.refresh); + }, + ), + duration: const Duration(seconds: 5), + )); + } + } + }); + } + + @override + void didChangeDependencies() { + _systemOverlayStyle = SystemUiOverlayStyle( + statusBarIconBrightness: Theme.of(context).brightness == Brightness.light ? Brightness.dark : Brightness.light, + statusBarColor: Theme.of(context).appBarTheme.backgroundColor!.withOpacity(toolbarCollapsed ? 1.0 : 0.5), + ); + super.didChangeDependencies(); + } + + @override + void dispose() { + super.dispose(); + } + + Future _handleRefresh() async { + log.fine('_handleRefresh'); + + widget._podcastBloc.load(Feed( + podcast: widget.podcast, + refresh: true, + )); + } + + void _resetSystemOverlayStyle() { + setState(() { + _systemOverlayStyle = SystemUiOverlayStyle( + statusBarIconBrightness: Theme.of(context).brightness == Brightness.light ? Brightness.dark : Brightness.light, + statusBarColor: Colors.transparent, + ); + }); + } + + void _updateSystemOverlayStyle() { + setState(() { + _systemOverlayStyle = SystemUiOverlayStyle( + statusBarIconBrightness: Theme.of(context).brightness == Brightness.light ? Brightness.dark : Brightness.light, + statusBarColor: Theme.of(context).appBarTheme.backgroundColor!.withOpacity(toolbarCollapsed ? 1.0 : 0.5), + ); + }); + } + + /// TODO: This really needs a refactor. There are too many nested streams on this now and it needs simplifying. + @override + Widget build(BuildContext context) { + final podcastBloc = Provider.of(context, listen: false); + final placeholderBuilder = PlaceholderBuilder.of(context); + + return Semantics( + header: false, + label: L.of(context)!.semantics_podcast_details_header, + child: PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, result) { + _resetSystemOverlayStyle(); + podcastBloc.podcastSearchEvent(''); + }, + child: ScaffoldMessenger( + key: scaffoldMessengerKey, + child: Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: Column( + children: [ + Expanded( + child: RefreshIndicator( + displacement: 60.0, + onRefresh: _handleRefresh, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: _sliverScrollController, + slivers: [ + SliverAppBar( + systemOverlayStyle: _systemOverlayStyle, + title: AnimatedOpacity( + opacity: toolbarCollapsed ? 1.0 : 0.0, + duration: const Duration(milliseconds: 500), + child: Text(widget.podcast.title)), + leading: PlatformBackButton( + iconColour: toolbarCollapsed && Theme.of(context).brightness == Brightness.light + ? Theme.of(context).appBarTheme.foregroundColor! + : Colors.white, + decorationColour: toolbarCollapsed ? const Color(0x00000000) : const Color(0x22000000), + onPressed: () { + _resetSystemOverlayStyle(); + Navigator.pop(context); + }, + ), + expandedHeight: 300.0, + floating: false, + pinned: true, + snap: false, + flexibleSpace: FlexibleSpaceBar( + background: Hero( + key: Key('detailhero${widget.podcast.imageUrl}:${widget.podcast.link}'), + tag: '${widget.podcast.imageUrl}:${widget.podcast.link}', + child: ExcludeSemantics( + child: StreamBuilder>( + initialData: BlocEmptyState(), + stream: podcastBloc.details, + builder: (context, snapshot) { + final state = snapshot.data; + Podcast? podcast = widget.podcast; + + if (state is BlocLoadingState) { + podcast = state.data; + } + + if (state is BlocPopulatedState) { + podcast = state.results; + } + + return PodcastHeaderImage( + podcast: podcast!, + placeholderBuilder: placeholderBuilder, + ); + }), + ), + ), + )), + StreamBuilder>( + initialData: BlocEmptyState(), + stream: podcastBloc.details, + builder: (context, snapshot) { + final state = snapshot.data; + + if (state is BlocLoadingState) { + return const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Column( + children: [ + PlatformProgressIndicator(), + ], + ), + ), + ); + } + + if (state is BlocErrorState) { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 50, + ), + Text( + L.of(context)!.no_podcast_details_message, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + if (state is BlocPopulatedState) { + return SliverToBoxAdapter( + child: PlaybackErrorListener( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PodcastTitle(state.results!), + const Divider(), + ], + ), + )); + } + + return const SliverToBoxAdapter( + child: SizedBox( + width: 0.0, + height: 0.0, + ), + ); + }), + StreamBuilder>( + initialData: BlocEmptyState(), + stream: podcastBloc.details, + builder: (context1, snapshot1) { + final state = snapshot1.data; + + if (state is BlocPopulatedState) { + return StreamBuilder?>( + stream: podcastBloc.episodes, + builder: (context, snapshot) { + if (snapshot.hasData) { + return snapshot.data!.isNotEmpty + ? PodcastEpisodeList( + episodes: snapshot.data!, + play: true, + download: true, + ) + : const SliverToBoxAdapter(child: NoEpisodesFound()); + } else { + return const SliverToBoxAdapter( + child: SizedBox( + height: 200, + width: 200, + )); + } + }); + } else { + return const SliverToBoxAdapter( + child: SizedBox( + height: 200, + width: 200, + )); + } + }), + ], + ), + ), + ), + const MiniPlayer(), + ], + ), + ), + ), + ), + ); + } +} + +/// Renders the podcast or episode image. +class PodcastHeaderImage extends StatelessWidget { + const PodcastHeaderImage({ + super.key, + required this.podcast, + required this.placeholderBuilder, + }); + + final Podcast podcast; + final PlaceholderBuilder? placeholderBuilder; + + @override + Widget build(BuildContext context) { + if (podcast.imageUrl == null || podcast.imageUrl!.isEmpty) { + return const SizedBox( + height: 560, + width: 560, + ); + } + + return PodcastBannerImage( + key: Key('details${podcast.imageUrl}'), + url: podcast.imageUrl!, + fit: BoxFit.cover, + placeholder: + placeholderBuilder != null ? placeholderBuilder?.builder()(context) : DelayedCircularProgressIndicator(), + errorPlaceholder: placeholderBuilder != null + ? placeholderBuilder?.errorBuilder()(context) + : const Image(image: AssetImage('assets/images/favicon.png')), + ); + } +} + +/// Renders the podcast title, copyright, description, follow/unfollow and +/// overflow button. +/// +/// If the episode description is fairly long, an overflow icon is also shown +/// and a portion of the episode description is shown. Tapping the overflow +/// icons allows the user to expand and collapse the text. +/// +/// Description is rendered by [PodcastDescription]. +/// Follow/Unfollow button rendered by [FollowButton]. +class PodcastTitle extends StatefulWidget { + final Podcast podcast; + + const PodcastTitle(this.podcast, {super.key}); + + @override + State createState() => _PodcastTitleState(); +} + +class _PodcastTitleState extends State with SingleTickerProviderStateMixin { + final GlobalKey descriptionKey = GlobalKey(); + final maxHeight = 100.0; + PodcastHtml? description; + bool showOverflow = false; + bool showEpisodeSearch = false; + final StreamController isDescriptionExpandedStream = StreamController.broadcast(); + final _episodeSearchController = TextEditingController(); + final _searchFocus = FocusNode(); + + late final AnimationController _controller = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + late final Animation _animation = CurvedAnimation( + parent: _controller, + curve: Curves.fastOutSlowIn, + ); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final settings = Provider.of(context).currentSettings; + final podcastBloc = Provider.of(context, listen: false); + + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: MergeSemantics( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 2.0), + child: Text(widget.podcast.title, style: textTheme.titleLarge), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), + child: Text(widget.podcast.copyright ?? '', style: textTheme.bodySmall), + ), + ], + ), + ), + ), + StreamBuilder( + stream: isDescriptionExpandedStream.stream, + initialData: false, + builder: (context, snapshot) { + final expanded = snapshot.data!; + return Visibility( + visible: showOverflow, + child: SizedBox( + height: 48.0, + width: 48.0, + child: expanded + ? TextButton( + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + ), + child: Icon( + Icons.expand_less, + semanticLabel: L.of(context)!.semantics_collapse_podcast_description, + ), + onPressed: () { + isDescriptionExpandedStream.add(false); + }, + ) + : TextButton( + style: const ButtonStyle(visualDensity: VisualDensity.compact), + child: Icon( + Icons.expand_more, + semanticLabel: L.of(context)!.semantics_expand_podcast_description, + ), + onPressed: () { + isDescriptionExpandedStream.add(true); + }, + ), + ), + ); + }) + ], + ), + PodcastDescription( + key: descriptionKey, + content: description, + isDescriptionExpandedStream: isDescriptionExpandedStream, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FollowButton(widget.podcast), + PodcastContextMenu(widget.podcast), + IconButton( + onPressed: () { + setState(() { + if (showEpisodeSearch) { + _controller.reverse(); + } else { + _controller.forward(); + _searchFocus.requestFocus(); + } + showEpisodeSearch = !showEpisodeSearch; + }); + }, + icon: Icon( + Icons.search, + semanticLabel: L.of(context)!.search_episodes_label, + ), + visualDensity: VisualDensity.compact, + ), + SortButton(widget.podcast), + FilterButton(widget.podcast), + settings.showFunding + ? FundingMenu(widget.podcast.funding) + : const SizedBox( + width: 0.0, + height: 0.0, + ), + const Expanded( + child: Align( + alignment: Alignment.centerRight, + child: SyncSpinner(), + )), + ], + ), + ), + SizeTransition( + sizeFactor: _animation, + child: Padding( + padding: const EdgeInsets.all(7.0), + child: TextField( + focusNode: _searchFocus, + controller: _episodeSearchController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(0.0), + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + icon: Icon( + Icons.close, + semanticLabel: L.of(context)!.clear_search_button_label, + ), + onPressed: () { + _episodeSearchController.clear(); + podcastBloc.podcastSearchEvent(''); + }, + ), + isDense: true, + filled: true, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide.none, + gapPadding: 0.0, + ), + hintText: L.of(context)!.search_episodes_label, + ), + onChanged: ((search) { + podcastBloc.podcastSearchEvent(search); + }), + onSubmitted: ((search) { + podcastBloc.podcastSearchEvent(search); + }), + onTapOutside: (event) => _searchFocus.unfocus())), + ), + ], + ), + ); + } + + @override + void initState() { + super.initState(); + + description = PodcastHtml( + content: widget.podcast.description!, + fontSize: FontSize.medium, + ); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (descriptionKey.currentContext!.size!.height == maxHeight) { + setState(() { + showOverflow = true; + }); + } + }); + } + + @override + void dispose() { + _episodeSearchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } +} + +/// This class wraps the description in an expandable box. +/// +/// This handles the common case whereby the description is very long and, without +/// this constraint, would require the use to always scroll before reaching the +/// podcast episodes. +/// +/// TODO: Animate between the two states. +class PodcastDescription extends StatelessWidget { + final PodcastHtml? content; + final StreamController? isDescriptionExpandedStream; + static const maxHeight = 100.0; + static const padding = 4.0; + + const PodcastDescription({ + super.key, + this.content, + this.isDescriptionExpandedStream, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: PodcastDescription.padding), + child: StreamBuilder( + stream: isDescriptionExpandedStream!.stream, + initialData: false, + builder: (context, snapshot) { + final expanded = snapshot.data!; + return AnimatedSize( + duration: const Duration(milliseconds: 150), + curve: Curves.fastOutSlowIn, + alignment: Alignment.topCenter, + child: Container( + constraints: expanded + ? const BoxConstraints() + : BoxConstraints.loose(const Size(double.infinity, maxHeight - padding)), + child: expanded + ? content + : ShaderMask( + shaderCallback: LinearGradient( + colors: [Colors.white, Colors.white.withAlpha(0)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.9, 1], + ).createShader, + child: content), + ), + ); + }), + ); + } +} + +class NoEpisodesFound extends StatelessWidget { + const NoEpisodesFound({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + L.of(context)!.episode_filter_no_episodes_title_label, + style: Theme.of(context).textTheme.titleLarge, + ), + Padding( + padding: const EdgeInsets.fromLTRB(64.0, 24.0, 64.0, 64.0), + child: Text( + L.of(context)!.episode_filter_no_episodes_title_description, + style: Theme.of(context).textTheme.titleSmall, + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} + +class FollowButton extends StatefulWidget { + final Podcast podcast; + + const FollowButton(this.podcast, {super.key}); + + @override + State createState() => _FollowButtonState(); +} + +class _FollowButtonState extends State { + bool _isLoading = false; + + @override + Widget build(BuildContext context) { + final bloc = Provider.of(context); + + // If we're in loading state, show loading button immediately + if (_isLoading) { + print('Follow button: Showing loading spinner - _isLoading=$_isLoading'); + return Semantics( + liveRegion: true, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.fromLTRB(10.0, 4.0, 10.0, 4.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + ), + icon: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 3.0, + valueColor: AlwaysStoppedAnimation(Colors.blue), + ), + ), + label: Text(L.of(context)!.subscribe_label), + onPressed: null, + ), + ); + } + + return StreamBuilder>( + stream: bloc.details, + builder: (context, snapshot) { + var ready = false; + var subscribed = false; + + // To prevent jumpy UI, we always need to display the follow/unfollow button. + // Display a disabled follow button until the full state it loaded. + if (snapshot.hasData) { + final state = snapshot.data; + + if (state is BlocLoadingState) { + ready = false; + subscribed = state.data?.subscribed ?? false; + print('Follow button: BlocLoadingState - ready=$ready, subscribed=$subscribed, _isLoading=$_isLoading'); + } else if (state is BlocPopulatedState) { + ready = true; + subscribed = state.results!.subscribed; + print('Follow button: BlocPopulatedState - ready=$ready, subscribed=$subscribed, _isLoading=$_isLoading'); + + // Reset loading state when we get populated data + if (_isLoading) { + print('Follow button: Resetting loading state'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isLoading = false; + }); + print('Follow button: Loading state reset to false'); + } + }); + } + } + } + print('Follow button: Rendering normal UI - ready=$ready, subscribed=$subscribed, _isLoading=$_isLoading'); + + return Semantics( + liveRegion: true, + child: subscribed + ? OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.fromLTRB(10.0, 4.0, 10.0, 4.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + ), + icon: const Icon( + Icons.delete_outline, + ), + label: Text(L.of(context)!.unsubscribe_label), + onPressed: ready + ? () { + showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (_) => BasicDialogAlert( + title: Text(L.of(context)!.unsubscribe_label), + content: Text(L.of(context)!.unsubscribe_message), + actions: [ + BasicDialogAction( + title: ActionText( + L.of(context)!.cancel_button_label, + ), + onPressed: () { + Navigator.pop(context); + }, + ), + BasicDialogAction( + title: ActionText( + L.of(context)!.unsubscribe_button_label, + ), + iosIsDefaultAction: true, + iosIsDestructiveAction: true, + onPressed: () { + bloc.podcastEvent(PodcastEvent.unsubscribe); + + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ), + ); + } + : null, + ) + : OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.fromLTRB(10.0, 4.0, 10.0, 4.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + ), + icon: const Icon( + Icons.add, + ), + label: Text(L.of(context)!.subscribe_label), + onPressed: ready && !_isLoading + ? () async { + print('Follow button: CLICKED - Setting loading to true'); + setState(() { + _isLoading = true; + }); + print('Follow button: Loading state set to: $_isLoading'); + + bloc.podcastEvent(PodcastEvent.subscribe); + + // Show loading indicator for a minimum time to be visible + await Future.delayed(const Duration(milliseconds: 300)); + + // After successful subscription, check if we should switch to PinePods context + await _handlePostSubscriptionContextSwitch(context, bloc); + } + : null, + ), + ); + }); + } + + Future _handlePostSubscriptionContextSwitch(BuildContext context, PodcastBloc bloc) async { + print('Follow button: Starting context switch check'); + // Wait a short moment for subscription to complete, then check if we should context switch + await Future.delayed(const Duration(milliseconds: 500)); + + if (!mounted) { + print('Follow button: Widget not mounted, skipping context switch'); + return; + } + + // Check if we're in PinePods environment and should switch contexts + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && + settings.pinepodsApiKey != null && + settings.pinepodsUserId != null) { + + // Check if the podcast is now subscribed to PinePods + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final isSubscribed = await pinepodsService.checkPodcastExists( + widget.podcast.title, + widget.podcast.url ?? '', + settings.pinepodsUserId! + ); + + if (isSubscribed && mounted) { + print('Follow button: Podcast is subscribed, switching to PinePods context'); + + // Reset loading state before context switch + setState(() { + _isLoading = false; + }); + + // Create unified podcast object for PinePods context + final unifiedPodcast = UnifiedPinepodsPodcast( + id: 0, // Will be fetched by PinePods component + indexId: 0, // Default for subscribed podcasts + title: widget.podcast.title, + url: widget.podcast.url ?? '', + originalUrl: widget.podcast.url ?? '', + link: widget.podcast.link ?? '', + description: widget.podcast.description ?? '', + author: widget.podcast.copyright ?? '', + ownerName: widget.podcast.copyright ?? '', + image: widget.podcast.imageUrl ?? '', + artwork: widget.podcast.imageUrl ?? '', + lastUpdateTime: 0, + explicit: false, + episodeCount: 0, // Will be loaded + ); + + // Replace current route with PinePods podcast details + Navigator.pushReplacement( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepodspodcastdetails'), + builder: (context) => PinepodsPodcastDetails( + podcast: unifiedPodcast, + isFollowing: true, + ), + ), + ); + } else { + print('Follow button: Podcast not subscribed or widget not mounted, staying in current context'); + // Reset loading state if not switching contexts + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } catch (e) { + print('Error checking post-subscription status: $e'); + // Reset loading state on error + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } else { + print('Follow button: Not in PinePods environment, staying in RSS context'); + // Reset loading state if not in PinePods environment + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } +} + +class FilterButton extends StatelessWidget { + final Podcast podcast; + + const FilterButton(this.podcast, {super.key}); + + @override + Widget build(BuildContext context) { + final bloc = Provider.of(context); + + return StreamBuilder>( + stream: bloc.details, + builder: (context, snapshot) { + Podcast? podcast; + + if (snapshot.hasData) { + final state = snapshot.data; + + if (state is BlocPopulatedState) { + podcast = state.results!; + } + } + + return EpisodeFilterSelectorWidget( + podcast: podcast, + ); + }); + } +} + +class SortButton extends StatelessWidget { + final Podcast podcast; + + const SortButton(this.podcast, {super.key}); + + @override + Widget build(BuildContext context) { + final bloc = Provider.of(context); + + return StreamBuilder>( + stream: bloc.details, + builder: (context, snapshot) { + Podcast? podcast; + + if (snapshot.hasData) { + final state = snapshot.data; + + if (state is BlocPopulatedState) { + podcast = state.results!; + } + } + + return EpisodeSortSelectorWidget( + podcast: podcast, + ); + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_episode_list.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_episode_list.dart new file mode 100644 index 0000000..c5ac1a8 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/podcast_episode_list.dart @@ -0,0 +1,90 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/state/queue_event_state.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PodcastEpisodeList extends StatelessWidget { + final List? episodes; + final IconData icon; + final String emptyMessage; + final bool play; + final bool download; + + static const _defaultIcon = Icons.add_alert; + + const PodcastEpisodeList({ + super.key, + required this.episodes, + required this.play, + required this.download, + this.icon = _defaultIcon, + this.emptyMessage = '', + }); + + @override + Widget build(BuildContext context) { + if (episodes != null && episodes!.isNotEmpty) { + var queueBloc = Provider.of(context); + + return StreamBuilder( + stream: queueBloc.queue, + builder: (context, snapshot) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + var queued = false; + var playing = false; + var episode = episodes![index]!; + + if (snapshot.hasData) { + var playingGuid = snapshot.data!.playing?.guid; + + queued = snapshot.data!.queue.any((element) => element.guid == episode.guid); + + playing = playingGuid == episode.guid; + } + + return EpisodeTile( + episode: episode, + download: download, + play: play, + playing: playing, + queued: queued, + ); + }, + childCount: episodes!.length, + addAutomaticKeepAlives: false, + )); + }); + } else { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + icon, + size: 75, + color: Theme.of(context).primaryColor, + ), + Text( + emptyMessage, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/show_notes.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/show_notes.dart new file mode 100644 index 0000000..65d408d --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/show_notes.dart @@ -0,0 +1,54 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_html.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; + +/// This class displays the show notes for the selected podcast. +/// +/// We make use of [Html] to render the notes and, if in HTML format, display the +/// correct formatting, links etc. +class ShowNotes extends StatelessWidget { + final ScrollController _sliverScrollController = ScrollController(); + final Episode episode; + + ShowNotes({ + super.key, + required this.episode, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: CustomScrollView(controller: _sliverScrollController, slivers: [ + SliverAppBar( + title: Text(episode.podcast!), + floating: false, + pinned: true, + snap: false, + ), + SliverToBoxAdapter( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0), + child: Text(episode.title ?? '', style: textTheme.titleLarge), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 0.0), + child: PodcastHtml(content: episode.content ?? episode.description!), + ), + ], + ), + ), + ])); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/transcript_view.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/transcript_view.dart new file mode 100644 index 0000000..6be41b0 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/transcript_view.dart @@ -0,0 +1,532 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart'; +import 'package:pinepods_mobile/entities/person.dart'; +import 'package:pinepods_mobile/entities/transcript.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/state/queue_event_state.dart'; +import 'package:pinepods_mobile/state/transcript_state_event.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// This class handles the rendering of the podcast transcript (where available). +// ignore: must_be_immutable +class TranscriptView extends StatefulWidget { + const TranscriptView({ + super.key, + }); + + @override + State createState() => _TranscriptViewState(); +} + +class _TranscriptViewState extends State { + final log = Logger('TranscriptView'); + final ItemScrollController _itemScrollController = ItemScrollController(); + final ScrollOffsetListener _scrollOffsetListener = ScrollOffsetListener.create(recordProgrammaticScrolls: false); + final _transcriptSearchController = TextEditingController(); + late StreamSubscription _positionSubscription; + int position = 0; + bool autoScroll = true; + bool autoScrollEnabled = true; + bool forceTranscriptUpdate = false; + bool first = true; + bool scrolling = false; + bool isHtmlTranscript = false; + String speaker = ''; + RegExp exp = RegExp(r'(^)(\[?)(?[A-Za-z0-9\s]+)(\]?)(\s?)(:)'); + + @override + void initState() { + super.initState(); + final audioBloc = Provider.of(context, listen: false); + + Subtitle? subtitle; + int index = 0; + // If the user initiates scrolling, disable auto scroll. + _scrollOffsetListener.changes.listen((event) { + if (!scrolling) { + setState(() { + autoScroll = false; + }); + } + }); + + // Listen to playback position updates and scroll to the correct items in the transcript + // if we have auto scroll enabled. + _positionSubscription = audioBloc.playPosition!.listen((event) { + if (_itemScrollController.isAttached && !isHtmlTranscript) { + var transcript = event.episode?.transcript; + + if (transcript != null && transcript.subtitles.isNotEmpty) { + subtitle ??= transcript.subtitles[index]; + + if (index == 0) { + var match = exp.firstMatch(subtitle?.data ?? ''); + + if (match != null) { + setState(() { + speaker = match.namedGroup('speaker') ?? ''; + }); + } + } + + // Our we outside the range of our current transcript. + if (event.position.inMilliseconds < subtitle!.start.inMilliseconds || + event.position.inMilliseconds > subtitle!.end!.inMilliseconds || + forceTranscriptUpdate) { + forceTranscriptUpdate = false; + // Will the next in the list do? + if (transcript.subtitles.length > (index + 1) && + event.position.inMilliseconds >= transcript.subtitles[index + 1].start.inMilliseconds && + event.position.inMilliseconds < transcript.subtitles[index + 1].end!.inMilliseconds) { + index++; + subtitle = transcript.subtitles[index]; + + if (subtitle != null && subtitle!.speaker.isNotEmpty) { + speaker = subtitle!.speaker; + } else { + var match = exp.firstMatch(transcript.subtitles[index].data ?? ''); + + if (match != null) { + speaker = match.namedGroup('speaker') ?? ''; + } + } + } else { + try { + subtitle = transcript.subtitles + .where((a) => (event.position.inMilliseconds >= a.start.inMilliseconds && + event.position.inMilliseconds < a.end!.inMilliseconds)) + .first; + + index = transcript.subtitles.indexOf(subtitle!); + + /// If we have had to jump more than one position within the transcript, we may + /// need to back scan the conversation to find the current speaker. + if (subtitle!.speaker.isNotEmpty) { + speaker = subtitle!.speaker; + } else { + /// Scan backwards a maximum of 50 lines to see if we can find a speaker + var speakFound = false; + var count = 50; + var countIndex = index; + + while (!speakFound && count-- > 0 && countIndex >= 0) { + var match = exp.firstMatch(transcript.subtitles[countIndex].data!); + + countIndex--; + + if (match != null) { + speaker = match.namedGroup('speaker') ?? ''; + + if (speaker.isNotEmpty) { + setState(() { + speakFound = true; + }); + } + } + } + } + } catch (e) { + // We don't have a transcript entry for this position. + } + } + + if (subtitle != null) { + setState(() { + position = subtitle!.start.inMilliseconds; + }); + } + + if (autoScroll) { + if (first) { + _itemScrollController.jumpTo(index: index); + first = false; + } else { + scrolling = true; + _itemScrollController.scrollTo(index: index, duration: const Duration(milliseconds: 50)).then((value) { + scrolling = false; + }); + } + } + } + } + } + }); + } + + @override + void dispose() { + super.dispose(); + _positionSubscription.cancel(); + } + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + final queueBloc = Provider.of(context, listen: false); + + return StreamBuilder( + initialData: QueueEmptyState(), + stream: queueBloc.queue, + builder: (context, queueSnapshot) { + return StreamBuilder( + stream: audioBloc.nowPlayingTranscript, + builder: (context, transcriptSnapshot) { + if (transcriptSnapshot.hasData) { + if (transcriptSnapshot.data is TranscriptLoadingState) { + return const Align( + alignment: Alignment.center, + child: PlatformProgressIndicator(), + ); + } else if (transcriptSnapshot.data is TranscriptUnavailableState || + !transcriptSnapshot.data!.transcript!.transcriptAvailable) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Align( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Transcript Error', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Failed to load transcript. The episode has transcript support but there was an error retrieving or parsing the transcript data.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } else { + final items = transcriptSnapshot.data!.transcript?.subtitles ?? []; + + // Detect if this is an HTML transcript (single item with HTMLFULL marker) + final isLikelyHtmlTranscript = items.length == 1 && + items.first.data != null && + items.first.data!.startsWith('{{HTMLFULL}}'); + + // Update the state flag for HTML transcript detection + if (isLikelyHtmlTranscript != isHtmlTranscript) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + isHtmlTranscript = isLikelyHtmlTranscript; + if (isHtmlTranscript) { + autoScroll = false; + autoScrollEnabled = false; + } + }); + }); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 8.0, left: 16.0, right: 16.0), + child: TextField( + controller: _transcriptSearchController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(0.0), + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _transcriptSearchController.clear(); + audioBloc.filterTranscript(TranscriptClearEvent()); + setState(() { + autoScrollEnabled = true; + }); + }, + ), + isDense: true, + filled: true, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide.none, + gapPadding: 0.0, + ), + hintText: L.of(context)!.search_transcript_label, + ), + onSubmitted: ((search) { + if (search.isNotEmpty) { + setState(() { + autoScrollEnabled = false; + autoScroll = false; + }); + audioBloc.filterTranscript(TranscriptFilterEvent(search: search)); + } + }), + ), + ), + if (!isHtmlTranscript) + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(L.of(context)!.auto_scroll_transcript_label), + Switch( + value: autoScroll, + onChanged: autoScrollEnabled + ? (bool enableAutoScroll) { + setState(() { + autoScroll = enableAutoScroll; + + if (enableAutoScroll) { + forceTranscriptUpdate = true; + } + }); + } + : null, + ), + ], + ), + ), + if (!isHtmlTranscript && + queueSnapshot.hasData && + queueSnapshot.data?.playing != null && + queueSnapshot.data!.playing!.persons.isNotEmpty) + Container( + padding: const EdgeInsets.only(left: 16.0), + width: double.infinity, + height: 72.0, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: queueSnapshot.data!.playing!.persons.length, + itemBuilder: (BuildContext context, int index) { + var person = queueSnapshot.data!.playing!.persons[index]; + var selected = false; + + // Some speakers are - delimited so won't match + speaker = speaker.replaceAll('-', ' '); + + if (speaker.isNotEmpty && + person.name.toLowerCase().startsWith(speaker.toLowerCase())) { + selected = true; + } + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Container( + padding: const EdgeInsets.all(4.0), + decoration: BoxDecoration( + color: selected ? Colors.orange : Colors.transparent, shape: BoxShape.circle), + child: CircleAvatar( + radius: 28, + backgroundImage: ExtendedImage.network( + person.image!, + cache: true, + ).image, + child: const Text(''), + ), + ), + ); + }), + ), + Expanded( + /// A simple way to ensure the builder is visible before attempting to use it. + child: LayoutBuilder(builder: (context, constraints) { + return constraints.minHeight > 60.0 + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ScrollablePositionedList.builder( + itemScrollController: _itemScrollController, + scrollOffsetListener: _scrollOffsetListener, + itemCount: items.length, + itemBuilder: (BuildContext context, int index) { + var i = items[index]; + return Wrap( + children: [ + SubtitleWidget( + subtitle: i, + persons: queueSnapshot.data?.playing?.persons ?? [], + highlight: i.start.inMilliseconds == position, + ), + ], + ); + }), + ) + : Container(); + }), + ), + ], + ); + } + } else { + return Container(); + } + }); + }); + } +} + +/// Each transcript is made up of one or more subtitles. Each [Subtitle] represents one +/// line of the transcript. This widget handles rendering the passed line. +class SubtitleWidget extends StatelessWidget { + final Subtitle subtitle; + final List? persons; + final bool highlight; + static const margin = Duration(milliseconds: 1000); + + const SubtitleWidget({ + super.key, + required this.subtitle, + this.persons, + this.highlight = false, + }); + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + final data = subtitle.data ?? ''; + final isFullHtmlTranscript = data.startsWith('{{HTMLFULL}}'); + + // For full HTML transcripts, render as a simple container without timing or clickability + if (isFullHtmlTranscript) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16.0), + child: _buildSubtitleContent(context), + ); + } + + // For timed transcripts (JSON, SRT, chunked HTML), render with timing and clickability + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + final p = subtitle.start + margin; + audioBloc.transitionPosition(p.inSeconds.toDouble()); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0), + color: highlight ? Theme.of(context).cardTheme.color : Colors.transparent, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subtitle.speaker.isEmpty + ? _formatDuration(subtitle.start) + : '${_formatDuration(subtitle.start)} - ${subtitle.speaker}', + style: Theme.of(context).textTheme.titleSmall, + ), + _buildSubtitleContent(context), + const Padding(padding: EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 16.0)) + ], + ), + ), + ); + } + + Widget _buildSubtitleContent(BuildContext context) { + final data = subtitle.data ?? ''; + + // Check if this is full HTML content (single document) + if (data.startsWith('{{HTMLFULL}}')) { + final htmlContent = data.substring(12); // Remove '{{HTMLFULL}}' marker + + return Html( + data: htmlContent, + style: { + 'body': Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + fontSize: FontSize(Theme.of(context).textTheme.bodyMedium?.fontSize ?? 14), + color: Theme.of(context).textTheme.bodyMedium?.color, + fontFamily: Theme.of(context).textTheme.bodyMedium?.fontFamily, + lineHeight: const LineHeight(1.5), + ), + 'a': Style( + color: Theme.of(context).primaryColor, + textDecoration: TextDecoration.underline, + ), + 'p': Style( + margin: Margins.only(bottom: 12), + padding: HtmlPaddings.zero, + ), + 'h1, h2, h3, h4, h5, h6': Style( + margin: Margins.only(top: 16, bottom: 8), + fontWeight: FontWeight.bold, + ), + 'strong, b': Style( + fontWeight: FontWeight.bold, + ), + 'em, i': Style( + fontStyle: FontStyle.italic, + ), + }, + onLinkTap: (url, attributes, element) { + if (url != null) { + final uri = Uri.parse(url); + launchUrl(uri); + } + }, + ); + } + // Check if this is chunked HTML content (legacy) + else if (data.startsWith('{{HTML}}')) { + final htmlContent = data.substring(8); // Remove '{{HTML}}' marker + + return Html( + data: htmlContent, + style: { + 'body': Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + fontSize: FontSize(Theme.of(context).textTheme.titleMedium?.fontSize ?? 16), + color: Theme.of(context).textTheme.titleMedium?.color, + fontFamily: Theme.of(context).textTheme.titleMedium?.fontFamily, + ), + 'a': Style( + color: Theme.of(context).primaryColor, + textDecoration: TextDecoration.underline, + ), + 'p': Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + ), + }, + onLinkTap: (url, attributes, element) { + if (url != null) { + final uri = Uri.parse(url); + launchUrl(uri); + } + }, + ); + } else { + // Render as plain text for non-HTML content + return Text( + data, + style: Theme.of(context).textTheme.titleMedium, + ); + } + } + + String _formatDuration(Duration duration) { + final hh = (duration.inHours).toString().padLeft(2, '0'); + final mm = (duration.inMinutes % 60).toString().padLeft(2, '0'); + final ss = (duration.inSeconds % 60).toString().padLeft(2, '0'); + + return '$hh:$mm:$ss'; + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/transport_controls.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/transport_controls.dart new file mode 100644 index 0000000..308f918 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/transport_controls.dart @@ -0,0 +1,277 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/ui/podcast/now_playing.dart'; +import 'package:pinepods_mobile/ui/widgets/action_text.dart'; +import 'package:pinepods_mobile/ui/widgets/download_button.dart'; +import 'package:pinepods_mobile/ui/widgets/play_pause_button.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:provider/provider.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Handles the state of the episode transport controls. +/// +/// This currently consists of the [PlayControl] and [DownloadControl] +/// to handle the play/pause and download control state respectively. +class PlayControl extends StatelessWidget { + final Episode episode; + + const PlayControl({ + super.key, + required this.episode, + }); + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + final settings = Provider.of(context, listen: false).currentSettings; + + return SizedBox( + height: 48.0, + width: 48.0, + child: StreamBuilder<_PlayerControlState>( + stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!, + (AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)), + builder: (context, snapshot) { + if (snapshot.hasData) { + final audioState = snapshot.data!.audioState; + final nowPlaying = snapshot.data!.episode; + + if (episode.downloadState != DownloadState.downloading && episode.downloadState != DownloadState.queued) { + // If this episode is the one we are playing, allow the user + // to toggle between play and pause. + if (snapshot.hasData && nowPlaying?.guid == episode.guid) { + if (audioState == AudioState.playing) { + return InkWell( + onTap: () { + audioBloc.transitionState(TransitionState.pause); + }, + child: PlayPauseButton( + title: episode.title!, + label: L.of(context)!.pause_button_label, + icon: Icons.pause, + ), + ); + } else if (audioState == AudioState.buffering) { + return PlayPauseBusyButton( + title: episode.title!, + label: L.of(context)!.pause_button_label, + icon: Icons.pause, + ); + } else if (audioState == AudioState.pausing) { + return InkWell( + onTap: () { + audioBloc.transitionState(TransitionState.play); + optionalShowNowPlaying(context, settings); + }, + child: PlayPauseButton( + title: episode.title!, + label: L.of(context)!.play_button_label, + icon: Icons.play_arrow, + ), + ); + } + } + + // If this episode is not the one we are playing, allow the + // user to start playing this episode. + return InkWell( + onTap: () { + audioBloc.play(episode); + optionalShowNowPlaying(context, settings); + }, + child: PlayPauseButton( + title: episode.title!, + label: L.of(context)!.play_button_label, + icon: Icons.play_arrow, + ), + ); + } else { + // We are currently downloading this episode. Do not allow + // the user to play it until the download is complete. + return Opacity( + opacity: 0.2, + child: PlayPauseButton( + title: episode.title!, + label: L.of(context)!.play_button_label, + icon: Icons.play_arrow, + ), + ); + } + } else { + // We have no playing information at the moment. Show a play button + // until the stream wakes up. + if (episode.downloadState != DownloadState.downloading) { + return InkWell( + onTap: () { + audioBloc.play(episode); + optionalShowNowPlaying(context, settings); + }, + child: PlayPauseButton( + title: episode.title!, + label: L.of(context)!.play_button_label, + icon: Icons.play_arrow, + ), + ); + } else { + return Opacity( + opacity: 0.2, + child: PlayPauseButton( + title: episode.title!, + label: L.of(context)!.play_button_label, + icon: Icons.play_arrow, + ), + ); + } + } + }), + ); + } + +} + +class DownloadControl extends StatelessWidget { + final Episode episode; + + const DownloadControl({ + super.key, + required this.episode, + }); + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context); + final podcastBloc = Provider.of(context); + + return SizedBox( + height: 48.0, + width: 48.0, + child: StreamBuilder<_PlayerControlState>( + stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!, + (AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)), + builder: (context, snapshot) { + if (snapshot.hasData) { + final audioState = snapshot.data!.audioState; + final nowPlaying = snapshot.data!.episode; + + if (nowPlaying?.guid == episode.guid && + (audioState == AudioState.playing || audioState == AudioState.buffering)) { + if (episode.downloadState != DownloadState.downloaded) { + return Opacity( + opacity: 0.2, + child: DownloadButton( + onPressed: () => podcastBloc.downloadEpisode(episode), + title: episode.title!, + icon: Icons.save_alt, + percent: 0, + label: L.of(context)!.download_episode_button_label, + ), + ); + } else { + return Opacity( + opacity: 0.2, + child: DownloadButton( + onPressed: () => podcastBloc.downloadEpisode(episode), + title: episode.title!, + icon: Icons.check, + percent: 0, + label: L.of(context)!.download_episode_button_label, + ), + ); + } + } + } + + if (episode.downloadState == DownloadState.downloaded) { + return DownloadButton( + onPressed: () => podcastBloc.downloadEpisode(episode), + title: episode.title!, + icon: Icons.check, + percent: 0, + label: L.of(context)!.download_episode_button_label, + ); + } else if (episode.downloadState == DownloadState.queued) { + return DownloadButton( + onPressed: () => _showCancelDialog(context), + title: episode.title!, + icon: Icons.timer_outlined, + percent: 0, + label: L.of(context)!.download_episode_button_label, + ); + } else if (episode.downloadState == DownloadState.downloading) { + return DownloadButton( + onPressed: () => _showCancelDialog(context), + title: episode.title!, + icon: Icons.timer_outlined, + percent: episode.downloadPercentage!, + label: L.of(context)!.download_episode_button_label, + ); + } + + return DownloadButton( + onPressed: () => podcastBloc.downloadEpisode(episode), + title: episode.title!, + icon: Icons.save_alt, + percent: 0, + label: L.of(context)!.download_episode_button_label, + ); + }), + ); + } + + Future _showCancelDialog(BuildContext context) { + final episodeBloc = Provider.of(context, listen: false); + + return showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (_) => BasicDialogAlert( + title: Text( + L.of(context)!.stop_download_title, + ), + content: Text(L.of(context)!.stop_download_confirmation), + actions: [ + BasicDialogAction( + title: ActionText( + L.of(context)!.continue_button_label, + ), + onPressed: () { + Navigator.pop(context); + }, + ), + BasicDialogAction( + title: ActionText( + L.of(context)!.stop_download_button_label, + ), + iosIsDefaultAction: true, + onPressed: () { + episodeBloc.deleteDownload(episode); + Navigator.pop(context); + }, + ), + ], + ), + ); + } +} + +/// This class acts as a wrapper between the current audio state and +/// downloadables. Saves all that nesting of StreamBuilders. +class _PlayerControlState { + final AudioState audioState; + final Episode? episode; + + _PlayerControlState(this.audioState, this.episode); +} diff --git a/PinePods-0.8.2/mobile/lib/ui/podcast/up_next_view.dart b/PinePods-0.8.2/mobile/lib/ui/podcast/up_next_view.dart new file mode 100644 index 0000000..fc9abac --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/podcast/up_next_view.dart @@ -0,0 +1,185 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/state/queue_event_state.dart'; +import 'package:pinepods_mobile/ui/widgets/action_text.dart'; +import 'package:pinepods_mobile/ui/widgets/draggable_episode_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:provider/provider.dart'; + +/// This class is responsible for rendering the Up Next queue feature. +/// +/// The user can see the currently playing item and the current queue. The user can +/// re-arrange items in the queue, remove individual items or completely clear the queue. +class UpNextView extends StatelessWidget { + const UpNextView({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final queueBloc = Provider.of(context, listen: false); + + return StreamBuilder( + initialData: QueueEmptyState(), + stream: queueBloc.queue, + builder: (context, snapshot) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 24.0, 8.0), + child: Text( + L.of(context)!.now_playing_queue_label, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0.0), + child: DraggableEpisodeTile( + key: const Key('detileplaying'), + episode: snapshot.data!.playing!, + draggable: false, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0), + child: Text( + L.of(context)!.up_next_queue_label, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0), + child: TextButton( + onPressed: snapshot.hasData && snapshot.data!.queue.isEmpty + ? null + : () { + showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (_) => BasicDialogAlert( + title: Text( + L.of(context)!.queue_clear_label_title, + ), + content: Text(L.of(context)!.queue_clear_label), + actions: [ + BasicDialogAction( + title: ActionText( + L.of(context)!.cancel_button_label, + ), + onPressed: () { + Navigator.pop(context); + }, + ), + BasicDialogAction( + title: ActionText( + Theme.of(context).platform == TargetPlatform.iOS + ? L.of(context)!.queue_clear_button_label.toUpperCase() + : L.of(context)!.queue_clear_button_label, + ), + iosIsDefaultAction: true, + iosIsDestructiveAction: true, + onPressed: () { + queueBloc.queueEvent(QueueClearEvent()); + Navigator.pop(context); + }, + ), + ], + ), + ); + }, + child: snapshot.hasData && snapshot.data!.queue.isEmpty + ? Text( + L.of(context)!.clear_queue_button_label, + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontSize: 12.0, + color: Theme.of(context).disabledColor, + ), + ) + : Text( + L.of(context)!.clear_queue_button_label, + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontSize: 12.0, + color: Theme.of(context).primaryColor, + ), + ), + ), + ), + ], + ), + snapshot.hasData && snapshot.data!.queue.isEmpty + ? Padding( + padding: const EdgeInsets.all(24.0), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).dividerColor, + border: Border.all( + color: Theme.of(context).dividerColor, + ), + borderRadius: const BorderRadius.all(Radius.circular(10))), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + L.of(context)!.empty_queue_message, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + ) + : Expanded( + child: ReorderableListView.builder( + buildDefaultDragHandles: false, + shrinkWrap: true, + padding: const EdgeInsets.all(8), + itemCount: snapshot.hasData ? snapshot.data!.queue.length : 0, + itemBuilder: (BuildContext context, int index) { + return Dismissible( + key: ValueKey('disqueue${snapshot.data!.queue[index].guid}'), + direction: DismissDirection.endToStart, + onDismissed: (direction) { + queueBloc.queueEvent(QueueRemoveEvent(episode: snapshot.data!.queue[index])); + }, + child: DraggableEpisodeTile( + key: ValueKey('tilequeue${snapshot.data!.queue[index].guid}'), + index: index, + episode: snapshot.data!.queue[index], + playable: true, + ), + ); + }, + onReorder: (int oldIndex, int newIndex) { + /// Seems odd to have to do this, but this -1 was taken from + /// the Flutter docs. + if (oldIndex < newIndex) { + newIndex -= 1; + } + + queueBloc.queueEvent(QueueMoveEvent( + episode: snapshot.data!.queue[oldIndex], + oldIndex: oldIndex, + newIndex: newIndex, + )); + }, + ), + ), + ], + ); + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/search/search.dart b/PinePods-0.8.2/mobile/lib/ui/search/search.dart new file mode 100644 index 0000000..efa22b8 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/search/search.dart @@ -0,0 +1,111 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:pinepods_mobile/bloc/search/search_bloc.dart'; +import 'package:pinepods_mobile/bloc/search/search_state_event.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/search/search_results.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +/// This widget renders the search bar and allows the user to search for podcasts. +class Search extends StatefulWidget { + final String? searchTerm; + + const Search({ + super.key, + this.searchTerm, + }); + + @override + State createState() => _SearchState(); +} + +class _SearchState extends State { + late TextEditingController _searchController; + late FocusNode _searchFocusNode; + + @override + void initState() { + super.initState(); + + final bloc = Provider.of(context, listen: false); + + bloc.search(SearchClearEvent()); + + _searchFocusNode = FocusNode(); + _searchController = TextEditingController(); + + if (widget.searchTerm != null) { + bloc.search(SearchTermEvent(widget.searchTerm!)); + _searchController.text = widget.searchTerm!; + } + } + + @override + void dispose() { + _searchFocusNode.dispose(); + _searchController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bloc = Provider.of(context); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + leading: IconButton( + tooltip: L.of(context)!.search_back_button_label, + icon: Platform.isAndroid + ? Icon(Icons.arrow_back, color: Theme.of(context).appBarTheme.foregroundColor) + : const Icon(Icons.arrow_back_ios), + onPressed: () => Navigator.pop(context), + ), + title: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + autofocus: widget.searchTerm != null ? false : true, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: L.of(context)!.search_for_podcasts_hint, + border: InputBorder.none, + ), + style: TextStyle( + color: Theme.of(context).primaryIconTheme.color, + fontSize: 18.0, + decorationColor: Theme.of(context).scaffoldBackgroundColor), + onSubmitted: ((value) { + SemanticsService.announce(L.of(context)!.semantic_announce_searching, TextDirection.ltr); + bloc.search(SearchTermEvent(value)); + })), + floating: false, + pinned: true, + snap: false, + actions: [ + IconButton( + tooltip: L.of(context)!.clear_search_button_label, + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + FocusScope.of(context).requestFocus(_searchFocusNode); + SystemChannels.textInput.invokeMethod('TextInput.show'); + }, + ), + ], + ), + SearchResults(data: bloc.results!), + ], + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/search/search_bar.dart b/PinePods-0.8.2/mobile/lib/ui/search/search_bar.dart new file mode 100644 index 0000000..dea3a24 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/search/search_bar.dart @@ -0,0 +1,78 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/search_slide_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'search.dart'; + +class SearchBar extends StatefulWidget { + const SearchBar({super.key}); + + @override + State createState() => _SearchBarState(); +} + +class _SearchBarState extends State { + late TextEditingController _searchController; + late FocusNode _searchFocusNode; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + _searchController.addListener(() { + setState(() {}); + }); + _searchFocusNode = FocusNode(); + } + + @override + void dispose() { + _searchFocusNode.dispose(); + _searchController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.only(left: 16, right: 16), + title: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + decoration: InputDecoration(hintText: L.of(context)!.search_for_podcasts_hint, border: InputBorder.none), + style: TextStyle( + color: Theme.of(context).primaryIconTheme.color, + fontSize: 18.0, + decorationColor: Theme.of(context).scaffoldBackgroundColor), + onSubmitted: (value) async { + await Navigator.push( + context, + SlideRightRoute( + widget: Search(searchTerm: value), + settings: const RouteSettings(name: 'search'), + )); + _searchController.clear(); + }, + ), + trailing: IconButton( + padding: EdgeInsets.zero, + tooltip: _searchFocusNode.hasFocus ? L.of(context)!.clear_search_button_label : null, + color: _searchFocusNode.hasFocus ? Theme.of(context).iconTheme.color : null, + splashColor: _searchFocusNode.hasFocus ? Theme.of(context).splashColor : Colors.transparent, + highlightColor: _searchFocusNode.hasFocus ? Theme.of(context).highlightColor : Colors.transparent, + icon: Icon(_searchController.text.isEmpty && !_searchFocusNode.hasFocus ? Icons.search : Icons.clear), + onPressed: () { + _searchController.clear(); + FocusScope.of(context).requestFocus(FocusNode()); + SystemChannels.textInput.invokeMethod('TextInput.show'); + }), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/search/search_results.dart b/PinePods-0.8.2/mobile/lib/ui/search/search_results.dart new file mode 100644 index 0000000..e81af09 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/search/search_results.dart @@ -0,0 +1,74 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_list.dart'; +import 'package:flutter/material.dart'; +import 'package:podcast_search/podcast_search.dart' as search; + +class SearchResults extends StatelessWidget { + final Stream data; + + const SearchResults({ + super.key, + required this.data, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: data, + builder: (BuildContext context, AsyncSnapshot snapshot) { + final state = snapshot.data; + + if (state is BlocPopulatedState) { + return PodcastList(results: state.results as search.SearchResult); + } else { + if (state is BlocLoadingState) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + PlatformProgressIndicator(), + ], + ), + ); + } else if (state is BlocErrorState) { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 75, + color: Theme.of(context).primaryColor, + ), + Text( + L.of(context)!.no_search_results_message, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return SliverFillRemaining( + hasScrollBody: false, + child: Container(), + ); + } + }, + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/settings/bottom_bar_order.dart b/PinePods-0.8.2/mobile/lib/ui/settings/bottom_bar_order.dart new file mode 100644 index 0000000..8cb27f4 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/settings/bottom_bar_order.dart @@ -0,0 +1,128 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; + +/// A widget that allows users to reorder the bottom navigation bar items +class BottomBarOrderWidget extends StatefulWidget { + const BottomBarOrderWidget({super.key}); + + @override + State createState() => _BottomBarOrderWidgetState(); +} + +class _BottomBarOrderWidgetState extends State { + late List _currentOrder; + + @override + void initState() { + super.initState(); + final settingsBloc = Provider.of(context, listen: false); + _currentOrder = List.from(settingsBloc.currentSettings.bottomBarOrder); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Reorganize Bottom Bar'), + actions: [ + TextButton( + onPressed: () async { + final settingsBloc = Provider.of(context, listen: false); + settingsBloc.setBottomBarOrder(_currentOrder); + + // Show a brief confirmation + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Bottom bar order saved!'), + duration: Duration(seconds: 1), + ), + ); + + // Small delay to let the user see the changes take effect + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + Navigator.pop(context); + } + }, + child: Text( + 'Save', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Drag and drop to reorder the bottom navigation items. The first items will be easier to access.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ), + Expanded( + child: ReorderableListView( + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _currentOrder.removeAt(oldIndex); + _currentOrder.insert(newIndex, item); + }); + }, + children: _currentOrder.map((item) { + return ListTile( + key: Key(item), + leading: Icon(_getIconForItem(item)), + title: Text(item), + trailing: const Icon(Icons.drag_handle), + ); + }).toList(), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + setState(() { + _currentOrder = ['Home', 'Feed', 'Saved', 'Podcasts', 'Downloads', 'History', 'Playlists', 'Search']; + }); + }, + child: const Text('Reset to Default'), + ), + ), + ], + ), + ), + ], + ), + ); + } + + IconData _getIconForItem(String item) { + switch (item) { + case 'Home': return Icons.home; + case 'Feed': return Icons.rss_feed; + case 'Saved': return Icons.bookmark; + case 'Podcasts': return Icons.podcasts; + case 'Downloads': return Icons.download; + case 'History': return Icons.history; + case 'Playlists': return Icons.playlist_play; + case 'Search': return Icons.search; + default: return Icons.home; + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/settings/episode_refresh.dart b/PinePods-0.8.2/mobile/lib/ui/settings/episode_refresh.dart new file mode 100644 index 0000000..bfd1c5f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/settings/episode_refresh.dart @@ -0,0 +1,183 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:provider/provider.dart'; + +class EpisodeRefreshWidget extends StatefulWidget { + const EpisodeRefreshWidget({super.key}); + + @override + State createState() => _EpisodeRefreshWidgetState(); +} + +class _EpisodeRefreshWidgetState extends State { + @override + Widget build(BuildContext context) { + var settingsBloc = Provider.of(context); + + return StreamBuilder( + stream: settingsBloc.settings, + initialData: AppSettings.sensibleDefaults(), + builder: (context, snapshot) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(L.of(context)!.settings_auto_update_episodes), + subtitle: updateSubtitle(snapshot.data!), + onTap: () { + showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + L.of(context)!.settings_auto_update_episodes_heading, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + scrollable: true, + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column(children: [ + RadioListTile( + title: Text(L.of(context)!.settings_auto_update_episodes_never), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + value: -1, + groupValue: snapshot.data!.autoUpdateEpisodePeriod, + onChanged: (int? value) { + setState(() { + settingsBloc.autoUpdatePeriod(value ?? -1); + + Navigator.pop(context); + }); + }, + ), + RadioListTile( + title: Text(L.of(context)!.settings_auto_update_episodes_always), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + value: 0, + groupValue: snapshot.data!.autoUpdateEpisodePeriod, + onChanged: (int? value) { + setState(() { + settingsBloc.autoUpdatePeriod(value ?? 0); + + Navigator.pop(context); + }); + }, + ), + RadioListTile( + title: Text(L.of(context)!.settings_auto_update_episodes_30min), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + value: 30, + groupValue: snapshot.data!.autoUpdateEpisodePeriod, + onChanged: (int? value) { + setState(() { + settingsBloc.autoUpdatePeriod(value ?? 30); + + Navigator.pop(context); + }); + }, + ), + RadioListTile( + title: Text(L.of(context)!.settings_auto_update_episodes_1hour), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + value: 60, + groupValue: snapshot.data!.autoUpdateEpisodePeriod, + onChanged: (int? value) { + setState(() { + settingsBloc.autoUpdatePeriod(value ?? 60); + + Navigator.pop(context); + }); + }, + ), + RadioListTile( + title: Text(L.of(context)!.settings_auto_update_episodes_3hour), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + value: 180, + groupValue: snapshot.data!.autoUpdateEpisodePeriod, + onChanged: (int? value) { + setState(() { + settingsBloc.autoUpdatePeriod(value ?? 180); + + Navigator.pop(context); + }); + }, + ), + RadioListTile( + title: Text(L.of(context)!.settings_auto_update_episodes_6hour), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + value: 360, + groupValue: snapshot.data!.autoUpdateEpisodePeriod, + onChanged: (int? value) { + setState(() { + settingsBloc.autoUpdatePeriod(value ?? 360); + + Navigator.pop(context); + }); + }, + ), + RadioListTile( + title: Text(L.of(context)!.settings_auto_update_episodes_12hour), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + value: 720, + groupValue: snapshot.data!.autoUpdateEpisodePeriod, + onChanged: (int? value) { + setState(() { + settingsBloc.autoUpdatePeriod(value ?? 720); + + Navigator.pop(context); + }); + }, + ), + ]); + }, + )); + }, + ); + }, + ), + ], + ); + }); + } + + Text updateSubtitle(AppSettings settings) { + switch (settings.autoUpdateEpisodePeriod) { + case -1: + return Text(L.of(context)!.settings_auto_update_episodes_never); + case 0: + return Text(L.of(context)!.settings_auto_update_episodes_always); + case 10: + return Text(L.of(context)!.settings_auto_update_episodes_10min); + case 30: + return Text(L.of(context)!.settings_auto_update_episodes_30min); + case 60: + return Text(L.of(context)!.settings_auto_update_episodes_1hour); + case 180: + return Text(L.of(context)!.settings_auto_update_episodes_3hour); + case 360: + return Text(L.of(context)!.settings_auto_update_episodes_6hour); + case 720: + return Text(L.of(context)!.settings_auto_update_episodes_12hour); + } + + return const Text('Never'); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/settings/pinepods_login.dart b/PinePods-0.8.2/mobile/lib/ui/settings/pinepods_login.dart new file mode 100644 index 0000000..816809a --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/settings/pinepods_login.dart @@ -0,0 +1,311 @@ +// lib/ui/settings/pinepods_login.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/pinepods/login_service.dart'; +import 'package:pinepods_mobile/ui/widgets/restart_widget.dart'; +import 'package:pinepods_mobile/ui/settings/settings_section_label.dart'; +import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; + +class PinepodsLoginWidget extends StatefulWidget { + const PinepodsLoginWidget({Key? key}) : super(key: key); + + @override + State createState() => _PinepodsLoginWidgetState(); +} + +class _PinepodsLoginWidgetState extends State { + final _serverController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _mfaController = TextEditingController(); + + bool _isLoading = false; + bool _showMfaField = false; + String _errorMessage = ''; + bool _isLoggedIn = false; + String? _connectedServer; + String? _tempServerUrl; + String? _tempUsername; + int? _tempUserId; + String? _tempMfaSessionToken; + + @override + void initState() { + super.initState(); + // Initialize UI based on saved settings + _loadSavedSettings(); + } + + void _loadSavedSettings() { + var settingsBloc = Provider.of(context, listen: false); + var settings = settingsBloc.currentSettings; + + // Check if we have PinePods settings + setState(() { + _isLoggedIn = false; + _connectedServer = null; + + // We'll add these properties to AppSettings in the next step + if (settings.pinepodsServer != null && + settings.pinepodsServer!.isNotEmpty && + settings.pinepodsApiKey != null && + settings.pinepodsApiKey!.isNotEmpty) { + _isLoggedIn = true; + _connectedServer = settings.pinepodsServer; + } + }); + } + + Future _connectToPinepods() async { + if (!_showMfaField && (_serverController.text.isEmpty || + _usernameController.text.isEmpty || + _passwordController.text.isEmpty)) { + setState(() { + _errorMessage = 'Please fill in all fields'; + }); + return; + } + + if (_showMfaField && _mfaController.text.isEmpty) { + setState(() { + _errorMessage = 'Please enter your MFA code'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + if (_showMfaField && _tempMfaSessionToken != null) { + // Complete MFA login flow + final mfaCode = _mfaController.text.trim(); + final result = await PinepodsLoginService.completeMfaLogin( + serverUrl: _tempServerUrl!, + username: _tempUsername!, + mfaSessionToken: _tempMfaSessionToken!, + mfaCode: mfaCode, + ); + + if (result.isSuccess) { + // Save the connection details including user ID + var settingsBloc = Provider.of(context, listen: false); + settingsBloc.setPinepodsServer(result.serverUrl!); + settingsBloc.setPinepodsApiKey(result.apiKey!); + settingsBloc.setPinepodsUserId(result.userId!); + + setState(() { + _isLoggedIn = true; + _connectedServer = _tempServerUrl; + _showMfaField = false; + _tempServerUrl = null; + _tempUsername = null; + _tempUserId = null; + _tempMfaSessionToken = null; + _isLoading = false; + }); + } else { + setState(() { + _errorMessage = result.errorMessage ?? 'MFA verification failed'; + _isLoading = false; + }); + } + } else { + // Initial login flow + final serverUrl = _serverController.text.trim(); + final username = _usernameController.text.trim(); + final password = _passwordController.text; + + final result = await PinepodsLoginService.login( + serverUrl, + username, + password, + ); + + if (result.isSuccess) { + // Save the connection details including user ID + var settingsBloc = Provider.of(context, listen: false); + settingsBloc.setPinepodsServer(result.serverUrl!); + settingsBloc.setPinepodsApiKey(result.apiKey!); + settingsBloc.setPinepodsUserId(result.userId!); + + setState(() { + _isLoggedIn = true; + _connectedServer = serverUrl; + _isLoading = false; + }); + } else if (result.requiresMfa) { + // Store MFA session info and show MFA field + setState(() { + _tempServerUrl = result.serverUrl; + _tempUsername = result.username; + _tempUserId = result.userId; + _tempMfaSessionToken = result.mfaSessionToken; + _showMfaField = true; + _isLoading = false; + _errorMessage = 'Please enter your MFA code'; + }); + } else { + setState(() { + _errorMessage = result.errorMessage ?? 'Login failed'; + _isLoading = false; + }); + } + } + } catch (e) { + setState(() { + _errorMessage = 'Error: ${e.toString()}'; + _isLoading = false; + }); + } + } + + void _resetMfa() { + setState(() { + _showMfaField = false; + _tempServerUrl = null; + _tempUsername = null; + _tempUserId = null; + _tempMfaSessionToken = null; + _mfaController.clear(); + _errorMessage = ''; + }); + } + + void _logOut() async { + var settingsBloc = Provider.of(context, listen: false); + + // Clear all PinePods user data + settingsBloc.setPinepodsServer(null); + settingsBloc.setPinepodsApiKey(null); + settingsBloc.setPinepodsUserId(null); + settingsBloc.setPinepodsUsername(null); + settingsBloc.setPinepodsEmail(null); + + setState(() { + _isLoggedIn = false; + _connectedServer = null; + }); + + // Wait for the settings to be processed and then restart the app + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + if (mounted) { + // Restart the entire app to reset all state + RestartWidget.restartApp(context); + } + }); + } + + @override + Widget build(BuildContext context) { + // Add a divider label for the PinePods section + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsDividerLabel(label: 'PinePods Server'), + const Divider(), + if (_isLoggedIn) ...[ + // Show connected status + ListTile( + title: const Text('PinePods Connection'), + subtitle: Text(_connectedServer ?? ''), + trailing: TextButton( + onPressed: _logOut, + child: const Text('Log Out'), + ), + ), + ] else ...[ + // Show login form + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _serverController, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'https://your-pinepods-server.com', + ), + ), + const SizedBox(height: 16), + TextField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + ), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + ), + obscureText: true, + enabled: !_showMfaField, + ), + // MFA Field (shown when MFA is required) + if (_showMfaField) ...[ + const SizedBox(height: 16), + TextField( + controller: _mfaController, + decoration: InputDecoration( + labelText: 'MFA Code', + hintText: 'Enter 6-digit code', + suffixIcon: IconButton( + icon: const Icon(Icons.close), + onPressed: _resetMfa, + tooltip: 'Cancel MFA', + ), + ), + keyboardType: TextInputType.number, + maxLength: 6, + ), + ], + if (_errorMessage.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + _errorMessage, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _connectToPinepods, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(_showMfaField ? 'Verify MFA Code' : 'Connect'), + ), + ), + ], + ), + ), + ], + ], + ); + } + + @override + void dispose() { + _serverController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _mfaController.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/settings/search_provider.dart b/PinePods-0.8.2/mobile/lib/ui/settings/search_provider.dart new file mode 100644 index 0000000..88af065 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/settings/search_provider.dart @@ -0,0 +1,118 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/action_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:provider/provider.dart'; + +class SearchProviderWidget extends StatefulWidget { + final ValueChanged? onChanged; + + const SearchProviderWidget({ + super.key, + this.onChanged, + }); + + @override + State createState() => _SearchProviderWidgetState(); +} + +class _SearchProviderWidgetState extends State { + @override + Widget build(BuildContext context) { + var settingsBloc = Provider.of(context); + + return StreamBuilder( + stream: settingsBloc.settings, + initialData: AppSettings.sensibleDefaults(), + builder: (context, snapshot) { + return snapshot.data!.searchProviders.length > 1 + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(L.of(context)!.search_provider_label), + subtitle: Text(snapshot.data!.searchProvider == 'itunes' ? 'iTunes' : 'PodcastIndex'), + onTap: () { + showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (BuildContext context) { + return AlertDialog( + title: Semantics( + header: true, + child: Text(L.of(context)!.search_provider_label, + style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center), + ), + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column(mainAxisSize: MainAxisSize.min, children: [ + RadioListTile( + title: const Text('iTunes'), + value: 'itunes', + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + groupValue: snapshot.data!.searchProvider, + onChanged: (String? value) { + setState(() { + settingsBloc.setSearchProvider(value ?? 'itunes'); + + if (widget.onChanged != null) { + widget.onChanged!(value); + } + + Navigator.pop(context); + }); + }, + ), + RadioListTile( + title: const Text('PodcastIndex'), + value: 'podcastindex', + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + groupValue: snapshot.data!.searchProvider, + onChanged: (String? value) { + setState(() { + settingsBloc.setSearchProvider(value ?? 'podcastindex'); + + if (widget.onChanged != null) { + widget.onChanged!(value); + } + + Navigator.pop(context); + }); + }, + ), + SimpleDialogOption( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + // child: Text(L.of(context)!.close_button_label), + child: Align( + alignment: Alignment.centerRight, + child: TextButton( + child: ActionText(L.of(context)!.close_button_label), + onPressed: () { + Navigator.pop(context, ''); + }, + ), + ), + ), + ]); + }, + )); + }, + ); + }, + ), + ], + ) + : Container(); + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/settings/settings.dart b/PinePods-0.8.2/mobile/lib/ui/settings/settings.dart new file mode 100644 index 0000000..9570e41 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/settings/settings.dart @@ -0,0 +1,339 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/core/utils.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/settings/episode_refresh.dart'; +import 'package:pinepods_mobile/ui/settings/search_provider.dart'; +import 'package:pinepods_mobile/ui/settings/settings_section_label.dart'; +import 'package:pinepods_mobile/ui/settings/bottom_bar_order.dart'; +import 'package:pinepods_mobile/ui/widgets/action_text.dart'; +import 'package:pinepods_mobile/ui/settings/pinepods_login.dart'; +import 'package:pinepods_mobile/ui/debug/debug_logs_page.dart'; +import 'package:pinepods_mobile/ui/themes.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:provider/provider.dart'; + +/// This is the settings page and allows the user to select various +/// options for the app. +/// +/// This is a self contained page and so, unlike the other forms, talks directly +/// to a settings service rather than a BLoC. Whilst this deviates slightly from +/// the overall architecture, adding a BLoC to simply be consistent with the rest +/// of the application would add unnecessary complexity. +/// +/// This page is built with both Android & iOS in mind. However, the +/// rest of the application is not prepared for iOS design; this +/// is in preparation for the iOS version. +class Settings extends StatefulWidget { + const Settings({ + super.key, + }); + + @override + State createState() => _SettingsState(); +} + +class _SettingsState extends State { + bool sdcard = false; + + Widget _buildList(BuildContext context) { + var settingsBloc = Provider.of(context); + var podcastBloc = Provider.of(context); + + return StreamBuilder( + stream: settingsBloc.settings, + initialData: settingsBloc.currentSettings, + builder: (context, snapshot) { + return ListView( + children: [ + SettingsDividerLabel(label: L.of(context)!.settings_personalisation_divider_label), + const Divider(), + MergeSemantics( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + L.of(context)!.settings_theme_switch_label, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + ThemeRegistry.getTheme(snapshot.data!.theme).description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon(Icons.palette, size: 20), + const SizedBox(width: 12), + Expanded( + child: DropdownButton( + value: snapshot.data!.theme, + isExpanded: true, + underline: Container(), + items: ThemeRegistry.themeList.map((theme) { + return DropdownMenuItem( + value: theme.key, + child: Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: theme.isDark ? Colors.grey[800] : Colors.grey[200], + border: Border.all( + color: theme.themeData.colorScheme.primary, + width: 2, + ), + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + theme.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + onChanged: (String? newTheme) { + if (newTheme != null) { + settingsBloc.setTheme(newTheme); + } + }, + ), + ), + ], + ), + ], + ), + ), + ), + sdcard + ? MergeSemantics( + child: ListTile( + title: Text(L.of(context)!.settings_download_sd_card_label), + trailing: Switch.adaptive( + value: snapshot.data!.storeDownloadsSDCard, + onChanged: (value) => sdcard + ? setState(() { + if (value) { + _showStorageDialog(enableExternalStorage: true); + } else { + _showStorageDialog(enableExternalStorage: false); + } + + settingsBloc.storeDownloadonSDCard(value); + }) + : null, + ), + ), + ) + : const SizedBox( + height: 0, + width: 0, + ), + SettingsDividerLabel(label: 'Navigation'), + const Divider(), + ListTile( + title: const Text('Reorganize Bottom Bar'), + subtitle: const Text('Customize the order of bottom navigation items'), + leading: const Icon(Icons.reorder), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BottomBarOrderWidget(), + ), + ); + }, + ), + SettingsDividerLabel(label: L.of(context)!.settings_playback_divider_label), + const Divider(), + MergeSemantics( + child: ListTile( + title: Text(L.of(context)!.settings_auto_open_now_playing), + trailing: Switch.adaptive( + value: snapshot.data!.autoOpenNowPlaying, + onChanged: (value) => setState(() => settingsBloc.setAutoOpenNowPlaying(value)), + ), + ), + ), + const SearchProviderWidget(), + SettingsDividerLabel(label: 'Debug'), + const Divider(), + ListTile( + title: const Text('App Logs'), + subtitle: const Text('View debug logs and device information'), + leading: const Icon(Icons.bug_report), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DebugLogsPage(), + ), + ); + }, + ), + const PinepodsLoginWidget(), + const _WebAppInfoWidget(), + ], + ); + }); + } + + Widget _buildAndroid(BuildContext context) { + return AnnotatedRegion( + value: Theme.of(context).appBarTheme.systemOverlayStyle!, + child: Scaffold( + appBar: AppBar( + elevation: 0.0, + title: Text( + L.of(context)!.settings_label, + ), + ), + body: _buildList(context), + ), + ); + } + + Widget _buildIos(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + padding: const EdgeInsetsDirectional.all(0.0), + leading: CupertinoButton( + child: const Icon(Icons.arrow_back_ios), + onPressed: () { + Navigator.pop(context); + }), + middle: Text( + L.of(context)!.settings_label, + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + backgroundColor: Theme.of(context).colorScheme.surface, + ), + child: Material(child: _buildList(context)), + ); + } + + void _showStorageDialog({required bool enableExternalStorage}) { + showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (_) => BasicDialogAlert( + title: Text(L.of(context)!.settings_download_switch_label), + content: Text( + enableExternalStorage + ? L.of(context)!.settings_download_switch_card + : L.of(context)!.settings_download_switch_internal, + ), + actions: [ + BasicDialogAction( + title: Text( + L.of(context)!.ok_button_label, + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ); + } + + @override + Widget build(context) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return _buildAndroid(context); + case TargetPlatform.iOS: + return _buildIos(context); + default: + assert(false, 'Unexpected platform $defaultTargetPlatform'); + return _buildAndroid(context); + } + } + + @override + void initState() { + super.initState(); + + hasExternalStorage().then((value) { + setState(() { + sdcard = value; + }); + }); + } +} + +class _WebAppInfoWidget extends StatelessWidget { + const _WebAppInfoWidget(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settingsBloc, child) { + final settings = settingsBloc.currentSettings; + final serverUrl = settings.pinepodsServer; + + // Only show if user is connected to a server + if (serverUrl == null || serverUrl.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.web, + color: Theme.of(context).primaryColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Web App Settings', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Many more server side and user settings available from the PinePods web app. Please head to $serverUrl to adjust much more', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/settings/settings_section_label.dart b/PinePods-0.8.2/mobile/lib/ui/settings/settings_section_label.dart new file mode 100644 index 0000000..11fb94a --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/settings/settings_section_label.dart @@ -0,0 +1,33 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class SettingsDividerLabel extends StatelessWidget { + final String label; + final EdgeInsetsGeometry padding; + + const SettingsDividerLabel({ + super.key, + required this.label, + this.padding = const EdgeInsets.fromLTRB(16.0, 24.0, 0.0, 0.0), + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Semantics( + header: true, + child: Text( + label, + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontSize: 12.0, + color: Theme.of(context).primaryColor, + ), + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/themes.dart b/PinePods-0.8.2/mobile/lib/ui/themes.dart new file mode 100644 index 0000000..6d5f3ec --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/themes.dart @@ -0,0 +1,2294 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +final ThemeData _lightTheme = _buildLightTheme(); +final ThemeData _darkTheme = _buildDarkTheme(); +final ThemeData _nordTheme = _buildNordTheme(); +final ThemeData _draculaTheme = _buildDraculaTheme(); +final ThemeData _nordicTheme = _buildNordicTheme(); +final ThemeData _gruvboxDarkTheme = _buildGruvboxDarkTheme(); +final ThemeData _catppuccinMochaTheme = _buildCatppuccinMochaTheme(); +final ThemeData _abyssTheme = _buildAbyssTheme(); +final ThemeData _cyberSynthwaveTheme = _buildCyberSynthwaveTheme(); +final ThemeData _midnightOceanTheme = _buildMidnightOceanTheme(); +final ThemeData _forestDepthsTheme = _buildForestDepthsTheme(); +final ThemeData _sunsetHorizonTheme = _buildSunsetHorizonTheme(); +final ThemeData _arcticFrostTheme = _buildArcticFrostTheme(); +final ThemeData _neonTheme = _buildNeonTheme(); +final ThemeData _kimbieTheme = _buildKimbieTheme(); +final ThemeData _gruvboxLightTheme = _buildGruvboxLightTheme(); +final ThemeData _greenMeanieTheme = _buildGreenMeanieTheme(); +final ThemeData _wildberriesTheme = _buildWildberriesTheme(); +final ThemeData _softLavenderTheme = _buildSoftLavenderTheme(); +final ThemeData _mintyFreshTheme = _buildMintyFreshTheme(); +final ThemeData _warmVanillaTheme = _buildWarmVanillaTheme(); +final ThemeData _coastalBlueTheme = _buildCoastalBlueTheme(); +final ThemeData _paperCreamTheme = _buildPaperCreamTheme(); +final ThemeData _githubLightTheme = _buildGithubLightTheme(); +final ThemeData _hotDogStandTheme = _buildHotDogStandTheme(); + +class ThemeDefinition { + final String key; + final String name; + final String description; + final ThemeData themeData; + final bool isDark; + + const ThemeDefinition({ + required this.key, + required this.name, + required this.description, + required this.themeData, + required this.isDark, + }); +} + +class ThemeRegistry { + static final Map _themes = { + 'Light': ThemeDefinition( + key: 'Light', + name: 'Light', + description: 'Clean and bright theme', + themeData: _lightTheme, + isDark: false, + ), + 'Dark': ThemeDefinition( + key: 'Dark', + name: 'Dark', + description: 'Classic dark theme', + themeData: _darkTheme, + isDark: true, + ), + 'Nordic': ThemeDefinition( + key: 'Nordic', + name: 'Nordic', + description: 'Cool Nordic inspired theme', + themeData: _nordTheme, + isDark: true, + ), + 'Dracula': ThemeDefinition( + key: 'Dracula', + name: 'Dracula', + description: 'Popular dark theme with purple accents', + themeData: _draculaTheme, + isDark: true, + ), + 'Nordic Light': ThemeDefinition( + key: 'Nordic Light', + name: 'Nordic Light', + description: 'Light Nordic inspired theme', + themeData: _nordicTheme, + isDark: false, + ), + 'Gruvbox Dark': ThemeDefinition( + key: 'Gruvbox Dark', + name: 'Gruvbox Dark', + description: 'Retro groove dark theme', + themeData: _gruvboxDarkTheme, + isDark: true, + ), + 'Catppuccin Mocha Mauve': ThemeDefinition( + key: 'Catppuccin Mocha Mauve', + name: 'Catppuccin Mocha Mauve', + description: 'Soothing pastel dark theme', + themeData: _catppuccinMochaTheme, + isDark: true, + ), + 'Abyss': ThemeDefinition( + key: 'Abyss', + name: 'Abyss', + description: 'Deep space darkness', + themeData: _abyssTheme, + isDark: true, + ), + 'Cyber Synthwave': ThemeDefinition( + key: 'Cyber Synthwave', + name: 'Cyber Synthwave', + description: 'Retro cyberpunk vibes', + themeData: _cyberSynthwaveTheme, + isDark: true, + ), + 'Midnight Ocean': ThemeDefinition( + key: 'Midnight Ocean', + name: 'Midnight Ocean', + description: 'Dark blue oceanic theme', + themeData: _midnightOceanTheme, + isDark: true, + ), + 'Forest Depths': ThemeDefinition( + key: 'Forest Depths', + name: 'Forest Depths', + description: 'Deep forest green theme', + themeData: _forestDepthsTheme, + isDark: true, + ), + 'Sunset Horizon': ThemeDefinition( + key: 'Sunset Horizon', + name: 'Sunset Horizon', + description: 'Warm sunset colors', + themeData: _sunsetHorizonTheme, + isDark: true, + ), + 'Arctic Frost': ThemeDefinition( + key: 'Arctic Frost', + name: 'Arctic Frost', + description: 'Cool arctic theme', + themeData: _arcticFrostTheme, + isDark: true, + ), + 'Neon': ThemeDefinition( + key: 'Neon', + name: 'Neon', + description: 'Bright neon colors', + themeData: _neonTheme, + isDark: true, + ), + 'Kimbie': ThemeDefinition( + key: 'Kimbie', + name: 'Kimbie', + description: 'Warm brown theme', + themeData: _kimbieTheme, + isDark: true, + ), + 'Gruvbox Light': ThemeDefinition( + key: 'Gruvbox Light', + name: 'Gruvbox Light', + description: 'Retro groove light theme', + themeData: _gruvboxLightTheme, + isDark: false, + ), + 'Greenie Meanie': ThemeDefinition( + key: 'Greenie Meanie', + name: 'Greenie Meanie', + description: 'Matrix green theme', + themeData: _greenMeanieTheme, + isDark: true, + ), + 'Wildberries': ThemeDefinition( + key: 'Wildberries', + name: 'Wildberries', + description: 'Purple berry theme', + themeData: _wildberriesTheme, + isDark: true, + ), + 'Soft Lavender': ThemeDefinition( + key: 'Soft Lavender', + name: 'Soft Lavender', + description: 'Gentle purple light theme', + themeData: _softLavenderTheme, + isDark: false, + ), + 'Minty Fresh': ThemeDefinition( + key: 'Minty Fresh', + name: 'Minty Fresh', + description: 'Cool mint green theme', + themeData: _mintyFreshTheme, + isDark: false, + ), + 'Warm Vanilla': ThemeDefinition( + key: 'Warm Vanilla', + name: 'Warm Vanilla', + description: 'Cozy vanilla theme', + themeData: _warmVanillaTheme, + isDark: false, + ), + 'Coastal Blue': ThemeDefinition( + key: 'Coastal Blue', + name: 'Coastal Blue', + description: 'Ocean blue theme', + themeData: _coastalBlueTheme, + isDark: false, + ), + 'Paper Cream': ThemeDefinition( + key: 'Paper Cream', + name: 'Paper Cream', + description: 'Vintage paper theme', + themeData: _paperCreamTheme, + isDark: false, + ), + 'Github Light': ThemeDefinition( + key: 'Github Light', + name: 'Github Light', + description: 'Clean GitHub-inspired theme', + themeData: _githubLightTheme, + isDark: false, + ), + 'Hot Dog Stand - MY EYES': ThemeDefinition( + key: 'Hot Dog Stand - MY EYES', + name: 'Hot Dog Stand - MY EYES', + description: 'Eye-searing hot dog stand theme', + themeData: _hotDogStandTheme, + isDark: true, + ), + }; + + static Map get themes => _themes; + static List get themeKeys => _themes.keys.toList(); + static List get themeList => _themes.values.toList(); + + static ThemeDefinition getTheme(String key) { + return _themes[key] ?? _themes['Dark']!; + } + + static ThemeData getThemeData(String key) { + return getTheme(key).themeData; + } + + static bool isValidTheme(String key) { + return _themes.containsKey(key); + } +} + +ThemeData _buildLightTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xffff9800), + secondary: Color(0xfffb8c00), + surface: Color(0xffffffff), + error: Color(0xffd32f2f), + onSurface: Color(0xfffb8c00), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xffffffff), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xffffa900), + shadowColor: const Color(0xfff57c00), + ), + brightness: Brightness.light, + primaryColor: const Color(0xffff9800), + primaryColorLight: const Color(0xffffe0b2), + primaryColorDark: const Color(0xfff57c00), + canvasColor: const Color(0xffffffff), + scaffoldBackgroundColor: const Color(0xffffffff), + cardColor: const Color(0xffffffff), + dividerColor: const Color(0x1f000000), + highlightColor: const Color(0x66bcbcbc), + splashColor: const Color(0x66c8c8c8), + unselectedWidgetColor: const Color(0x8a000000), + disabledColor: const Color(0x61000000), + secondaryHeaderColor: const Color(0xffffffff), + dialogBackgroundColor: const Color(0xffffffff), + indicatorColor: Colors.blueAccent, + hintColor: const Color(0x8a000000), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021( + platform: TargetPlatform.android, + ).black, + primaryIconTheme: IconThemeData(color: Colors.grey[800]), + buttonTheme: base.buttonTheme.copyWith( + buttonColor: Colors.orange, + ), + iconTheme: base.iconTheme.copyWith( + color: Colors.orange, + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: Colors.orange, + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + )), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: Colors.white, + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(foregroundColor: Colors.grey[800]), + ), + ); +} + +ThemeData _buildDarkTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xffffffff), + secondary: Color(0xfffb8c00), + surface: Color(0xff222222), + error: Color(0xffd32f2f), + onSurface: Color(0xffffffff), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff222222), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff444444), + shadowColor: const Color(0x77ffffff), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xffffffff), + primaryColorLight: const Color(0xffffe0b2), + primaryColorDark: const Color(0xfff57c00), + canvasColor: const Color(0xff000000), + scaffoldBackgroundColor: const Color(0xff000000), + cardColor: const Color(0xff0F0F0F), + dividerColor: const Color(0xff444444), + highlightColor: const Color(0xff222222), + splashColor: const Color(0x66c8c8c8), + unselectedWidgetColor: Colors.white, + disabledColor: const Color(0x77ffffff), + secondaryHeaderColor: const Color(0xff222222), + dialogBackgroundColor: const Color(0xff222222), + indicatorColor: Colors.orange, + hintColor: const Color(0x80ffffff), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Colors.white), + iconTheme: base.iconTheme.copyWith( + color: Colors.white, + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff444444), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: Colors.white, + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff222222), + foregroundColor: Colors.white, + shadowColor: const Color(0xff222222), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff222222), + statusBarIconBrightness: Brightness.light, + )), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: Colors.orange, + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffffffff), + side: const BorderSide( + color: Color(0xffffffff), + style: BorderStyle.solid, + ), + ), + ), + ); +} + +ThemeData _buildNordTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xff3550af), + secondary: Color(0xff5d80aa), + surface: Color(0xff2e3440), + error: Color(0xffbf616a), + onSurface: Color(0xfff6f5f4), + background: Color(0xff3C4252), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff2e3440), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff2b2f3a), + shadowColor: const Color(0xff3e4555), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xff3550af), + canvasColor: const Color(0xff3C4252), + scaffoldBackgroundColor: const Color(0xff3C4252), + cardColor: const Color(0xff2b2f3a), + dividerColor: const Color(0xff6d747f), + highlightColor: const Color(0xff5d80aa), + splashColor: const Color(0xff5d80aa), + unselectedWidgetColor: const Color(0xfff6f5f4), + disabledColor: const Color(0x776d747f), + secondaryHeaderColor: const Color(0xff2e3440), + dialogBackgroundColor: const Color(0xff2e3440), + indicatorColor: const Color(0xff3550af), + hintColor: const Color(0x80f6f5f4), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xfff6f5f4)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xfff6f5f4), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff6d747f), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff3550af), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff2e3440), + foregroundColor: const Color(0xfff6f5f4), + shadowColor: const Color(0xff2e3440), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff2e3440), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff3550af), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xfff6f5f4), + side: const BorderSide( + color: Color(0xff3550af), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff3550af), + foregroundColor: const Color(0xfff6f5f4), + ), + ), + ); +} + +ThemeData _buildDraculaTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xffbd93f9), + secondary: Color(0xff6590fd), + surface: Color(0xff282A36), + error: Color(0xffff5555), + onSurface: Color(0xfff6f5f4), + background: Color(0xff282A36), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff262626), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff191a21), + shadowColor: const Color(0xff292e42), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xffbd93f9), + canvasColor: const Color(0xff282A36), + scaffoldBackgroundColor: const Color(0xff282A36), + cardColor: const Color(0xff191a21), + dividerColor: const Color(0xff727580), + highlightColor: const Color(0xff4b5563), + splashColor: const Color(0xff4b5563), + unselectedWidgetColor: const Color(0xfff6f5f4), + disabledColor: const Color(0x77727580), + secondaryHeaderColor: const Color(0xff262626), + dialogBackgroundColor: const Color(0xff262626), + indicatorColor: const Color(0xffbd93f9), + hintColor: const Color(0x80f6f5f4), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xfff6f5f4)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xfff6f5f4), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff727580), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xffbd93f9), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff262626), + foregroundColor: const Color(0xfff6f5f4), + shadowColor: const Color(0xff262626), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff262626), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xffbd93f9), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xfff6f5f4), + side: const BorderSide( + color: Color(0xffbd93f9), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffbd93f9), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildNordicTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xff2a85cf), + secondary: Color(0xff2984ce), + surface: Color(0xffd8dee9), + error: Color(0xffd32f2f), + onSurface: Color(0xff656d76), + background: Color(0xffeceff4), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xffe5e9f0), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xffd8dee9), + shadowColor: const Color(0xffd8dee9), + ), + brightness: Brightness.light, + primaryColor: const Color(0xff2a85cf), + canvasColor: const Color(0xffeceff4), + scaffoldBackgroundColor: const Color(0xffeceff4), + cardColor: const Color(0xffd8dee9), + dividerColor: const Color(0xff878d95), + highlightColor: const Color(0xff2a85cf), + splashColor: const Color(0xff2a85cf), + unselectedWidgetColor: const Color(0xff656d76), + disabledColor: const Color(0x77878d95), + secondaryHeaderColor: const Color(0xffe5e9f0), + dialogBackgroundColor: const Color(0xffe5e9f0), + indicatorColor: const Color(0xff2984ce), + hintColor: const Color(0x80656d76), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff656d76)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff656d76), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff878d95), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff2984ce), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xffe5e9f0), + foregroundColor: const Color(0xff656d76), + shadowColor: const Color(0xffe5e9f0), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xffe5e9f0), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff2a85cf), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff656d76), + side: const BorderSide( + color: Color(0xff2a85cf), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff2a85cf), + foregroundColor: const Color(0xffffffff), + ), + ), + ); +} + +ThemeData _buildGruvboxDarkTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xff424314), + secondary: Color(0xff6f701b), + surface: Color(0xff282828), + error: Color(0xffcc241d), + onSurface: Color(0xff868729), + background: Color(0xff32302f), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff282828), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff302e2e), + shadowColor: const Color(0xff303648), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xff424314), + canvasColor: const Color(0xff32302f), + scaffoldBackgroundColor: const Color(0xff32302f), + cardColor: const Color(0xff302e2e), + dividerColor: const Color(0xffebdbb2), + highlightColor: const Color(0xff59544a), + splashColor: const Color(0xff59544a), + unselectedWidgetColor: const Color(0xff868729), + disabledColor: const Color(0x77ebdbb2), + secondaryHeaderColor: const Color(0xff282828), + dialogBackgroundColor: const Color(0xff282828), + indicatorColor: const Color(0xff424314), + hintColor: const Color(0x80868729), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xff868729)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff868729), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffebdbb2), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff424314), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff282828), + foregroundColor: const Color(0xff868729), + shadowColor: const Color(0xff282828), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff282828), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff424314), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff868729), + side: const BorderSide( + color: Color(0xff424314), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff424314), + foregroundColor: const Color(0xff868729), + ), + ), + ); +} + +ThemeData _buildCatppuccinMochaTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xffcba6f7), + secondary: Color(0xfff5c2e7), + surface: Color(0xff313244), + error: Color(0xfff38ba8), + onSurface: Color(0xffcdd6f4), + background: Color(0xff1e1e2e), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff11111b), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff313244), + shadowColor: const Color(0xff313244), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xffcba6f7), + canvasColor: const Color(0xff1e1e2e), + scaffoldBackgroundColor: const Color(0xff1e1e2e), + cardColor: const Color(0xff313244), + dividerColor: const Color(0xffcba6f7), + highlightColor: const Color(0xff6c7086), + splashColor: const Color(0xff6c7086), + unselectedWidgetColor: const Color(0xffcdd6f4), + disabledColor: const Color(0x77bac2de), + secondaryHeaderColor: const Color(0xff11111b), + dialogBackgroundColor: const Color(0xff11111b), + indicatorColor: const Color(0xffa6e3a1), + hintColor: const Color(0x80cdd6f4), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xffcdd6f4)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xffcdd6f4), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffcba6f7), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xffa6e3a1), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff11111b), + foregroundColor: const Color(0xffcdd6f4), + shadowColor: const Color(0xff11111b), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff11111b), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xffcba6f7), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffcdd6f4), + side: const BorderSide( + color: Color(0xffcba6f7), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffcba6f7), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +class Themes { + final ThemeData themeData; + + Themes({required this.themeData}); + + factory Themes.lightTheme() { + return Themes(themeData: _lightTheme); + } + + factory Themes.darkTheme() { + return Themes(themeData: _darkTheme); + } + + factory Themes.fromKey(String key) { + return Themes(themeData: ThemeRegistry.getThemeData(key)); + } +} + +ThemeData _buildAbyssTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xff326fef), + secondary: Color(0xffc8aa7d), + surface: Color(0xff061940), + error: Color(0xffbf616a), + onSurface: Color(0xfff6f5f4), + background: Color(0xff000C18), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff051336), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff061940), + shadowColor: const Color(0xff303648), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xff326fef), + canvasColor: const Color(0xff000C18), + scaffoldBackgroundColor: const Color(0xff000C18), + cardColor: const Color(0xff061940), + dividerColor: const Color(0xff838385), + highlightColor: const Color(0xff152037), + splashColor: const Color(0xff152037), + unselectedWidgetColor: const Color(0xfff6f5f4), + disabledColor: const Color(0x77838385), + secondaryHeaderColor: const Color(0xff051336), + dialogBackgroundColor: const Color(0xff051336), + indicatorColor: const Color(0xff326fef), + hintColor: const Color(0x80f6f5f4), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xfff6f5f4)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xfff6f5f4), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff838385), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff326fef), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff051336), + foregroundColor: const Color(0xfff6f5f4), + shadowColor: const Color(0xff051336), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff051336), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff326fef), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xfff6f5f4), + side: const BorderSide( + color: Color(0xff326fef), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff326fef), + foregroundColor: const Color(0xffffffff), + ), + ), + ); +} + +ThemeData _buildCyberSynthwaveTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xfff92aad), + secondary: Color(0xffff71ce), + surface: Color(0xff2a1f3a), + error: Color(0xffff2e63), + onSurface: Color(0xffeee6ff), + background: Color(0xff1a1721), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff2a1f3a), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff2a1f3a), + shadowColor: const Color(0xff2a1f3a), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xfff92aad), + canvasColor: const Color(0xff1a1721), + scaffoldBackgroundColor: const Color(0xff1a1721), + cardColor: const Color(0xff2a1f3a), + dividerColor: const Color(0xffc3b7d9), + highlightColor: const Color(0xffb31777), + splashColor: const Color(0xffb31777), + unselectedWidgetColor: const Color(0xffeee6ff), + disabledColor: const Color(0x77c3b7d9), + secondaryHeaderColor: const Color(0xff2a1f3a), + dialogBackgroundColor: const Color(0xff2a1f3a), + indicatorColor: const Color(0xfff92aad), + hintColor: const Color(0x80eee6ff), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xffeee6ff)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xffeee6ff), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffc3b7d9), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xfff92aad), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff2a1f3a), + foregroundColor: const Color(0xffeee6ff), + shadowColor: const Color(0xff2a1f3a), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff2a1f3a), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xfff92aad), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffeee6ff), + side: const BorderSide( + color: Color(0xfff92aad), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xfff92aad), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildMidnightOceanTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xff38bdf8), + secondary: Color(0xff60a5fa), + surface: Color(0xff1e293b), + error: Color(0xffef4444), + onSurface: Color(0xffe2e8f0), + background: Color(0xff0f172a), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff1e293b), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff1e293b), + shadowColor: const Color(0xff1e293b), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xff38bdf8), + canvasColor: const Color(0xff0f172a), + scaffoldBackgroundColor: const Color(0xff0f172a), + cardColor: const Color(0xff1e293b), + dividerColor: const Color(0xff1e293b), + highlightColor: const Color(0xff0ea5e9), + splashColor: const Color(0xff0ea5e9), + unselectedWidgetColor: const Color(0xffe2e8f0), + disabledColor: const Color(0x7794a3b8), + secondaryHeaderColor: const Color(0xff1e293b), + dialogBackgroundColor: const Color(0xff1e293b), + indicatorColor: const Color(0xff0ea5e9), + hintColor: const Color(0x80e2e8f0), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xffe2e8f0)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xffe2e8f0), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff1e293b), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff38bdf8), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff1e293b), + foregroundColor: const Color(0xffe2e8f0), + shadowColor: const Color(0xff1e293b), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff1e293b), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff38bdf8), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffe2e8f0), + side: const BorderSide( + color: Color(0xff38bdf8), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff38bdf8), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildForestDepthsTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xff7fb685), + secondary: Color(0xffa1d0a5), + surface: Color(0xff2d4a33), + error: Color(0xffe67c73), + onSurface: Color(0xffc9e4ca), + background: Color(0xff1a2f1f), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff2d4a33), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff2d4a33), + shadowColor: const Color(0xff2d4a33), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xff7fb685), + canvasColor: const Color(0xff1a2f1f), + scaffoldBackgroundColor: const Color(0xff1a2f1f), + cardColor: const Color(0xff2d4a33), + dividerColor: const Color(0xff2d4a33), + highlightColor: const Color(0xff5c8b61), + splashColor: const Color(0xff5c8b61), + unselectedWidgetColor: const Color(0xffc9e4ca), + disabledColor: const Color(0x778fbb91), + secondaryHeaderColor: const Color(0xff2d4a33), + dialogBackgroundColor: const Color(0xff2d4a33), + indicatorColor: const Color(0xff5c8b61), + hintColor: const Color(0x80c9e4ca), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xffc9e4ca)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xffc9e4ca), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff2d4a33), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff7fb685), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff2d4a33), + foregroundColor: const Color(0xffc9e4ca), + shadowColor: const Color(0xff2d4a33), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff2d4a33), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff7fb685), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffc9e4ca), + side: const BorderSide( + color: Color(0xff7fb685), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff7fb685), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildSunsetHorizonTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xffff9e64), + secondary: Color(0xffffb088), + surface: Color(0xff432e44), + error: Color(0xffff6b6b), + onSurface: Color(0xffffd9c0), + background: Color(0xff2b1c2c), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff432e44), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff432e44), + shadowColor: const Color(0xff432e44), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xffff9e64), + canvasColor: const Color(0xff2b1c2c), + scaffoldBackgroundColor: const Color(0xff2b1c2c), + cardColor: const Color(0xff432e44), + dividerColor: const Color(0xff432e44), + highlightColor: const Color(0xffe8875c), + splashColor: const Color(0xffe8875c), + unselectedWidgetColor: const Color(0xffffd9c0), + disabledColor: const Color(0x77d4a5a5), + secondaryHeaderColor: const Color(0xff432e44), + dialogBackgroundColor: const Color(0xff432e44), + indicatorColor: const Color(0xffe8875c), + hintColor: const Color(0x80ffd9c0), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xffffd9c0)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xffffd9c0), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff432e44), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xffff9e64), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff432e44), + foregroundColor: const Color(0xffffd9c0), + shadowColor: const Color(0xff432e44), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff432e44), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xffff9e64), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffffd9c0), + side: const BorderSide( + color: Color(0xffff9e64), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffff9e64), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildArcticFrostTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xff88c0d0), + secondary: Color(0xff81a1c1), + surface: Color(0xff2a2f36), + error: Color(0xffbf616a), + onSurface: Color(0xffeceff4), + background: Color(0xff1a1d21), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff2a2f36), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff2a2f36), + shadowColor: const Color(0xff2a2f36), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xff88c0d0), + canvasColor: const Color(0xff1a1d21), + scaffoldBackgroundColor: const Color(0xff1a1d21), + cardColor: const Color(0xff2a2f36), + dividerColor: const Color(0xff2a2f36), + highlightColor: const Color(0xff5e81ac), + splashColor: const Color(0xff5e81ac), + unselectedWidgetColor: const Color(0xffeceff4), + disabledColor: const Color(0x77aeb3bb), + secondaryHeaderColor: const Color(0xff2a2f36), + dialogBackgroundColor: const Color(0xff2a2f36), + indicatorColor: const Color(0xff5e81ac), + hintColor: const Color(0x80eceff4), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xffeceff4)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xffeceff4), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff2a2f36), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff88c0d0), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff2a2f36), + foregroundColor: const Color(0xffeceff4), + shadowColor: const Color(0xff2a2f36), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff2a2f36), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff88c0d0), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffeceff4), + side: const BorderSide( + color: Color(0xff88c0d0), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff88c0d0), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildNeonTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xfff75c1d), + secondary: Color(0xff7000ff), + surface: Color(0xff1a171e), + error: Color(0xffff5555), + onSurface: Color(0xff9F9DA1), + background: Color(0xff120e16), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff120e16), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff1a171e), + shadowColor: const Color(0xff303648), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xfff75c1d), + canvasColor: const Color(0xff120e16), + scaffoldBackgroundColor: const Color(0xff120e16), + cardColor: const Color(0xff1a171e), + dividerColor: const Color(0xff4a535e), + highlightColor: const Color(0xff7000ff), + splashColor: const Color(0xff7000ff), + unselectedWidgetColor: const Color(0xff9F9DA1), + disabledColor: const Color(0x774a535e), + secondaryHeaderColor: const Color(0xff120e16), + dialogBackgroundColor: const Color(0xff120e16), + indicatorColor: const Color(0xfff75c1d), + hintColor: const Color(0x809F9DA1), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xff9F9DA1)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff9F9DA1), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff4a535e), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xfff75c1d), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff120e16), + foregroundColor: const Color(0xff9F9DA1), + shadowColor: const Color(0xff120e16), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff120e16), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xfff75c1d), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff9F9DA1), + side: const BorderSide( + color: Color(0xfff75c1d), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xfff75c1d), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildKimbieTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xffca9858), + secondary: Color(0xfff6f5f4), + surface: Color(0xff362712), + error: Color(0xffff5555), + onSurface: Color(0xffB1AD86), + background: Color(0xff221a0f), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff131510), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff362712), + shadowColor: const Color(0xff65533c), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xffca9858), + canvasColor: const Color(0xff221a0f), + scaffoldBackgroundColor: const Color(0xff221a0f), + cardColor: const Color(0xff362712), + dividerColor: const Color(0xff4a535e), + highlightColor: const Color(0xffd3af86), + splashColor: const Color(0xffd3af86), + unselectedWidgetColor: const Color(0xffB1AD86), + disabledColor: const Color(0x774a535e), + secondaryHeaderColor: const Color(0xff131510), + dialogBackgroundColor: const Color(0xff131510), + indicatorColor: const Color(0xffca9858), + hintColor: const Color(0x80B1AD86), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xffB1AD86)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xffB1AD86), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff4a535e), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xffca9858), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff131510), + foregroundColor: const Color(0xffB1AD86), + shadowColor: const Color(0xff131510), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff131510), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xffca9858), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffB1AD86), + side: const BorderSide( + color: Color(0xffca9858), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffca9858), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildGruvboxLightTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xffd1ac0e), + secondary: Color(0xffa68738), + surface: Color(0xfffbf1c7), + error: Color(0xffcc241d), + onSurface: Color(0xff5f5750), + background: Color(0xfff9f5d7), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xfffbf1c7), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xfffbf1c7), + shadowColor: const Color(0xffaca289), + ), + brightness: Brightness.light, + primaryColor: const Color(0xffd1ac0e), + canvasColor: const Color(0xfff9f5d7), + scaffoldBackgroundColor: const Color(0xfff9f5d7), + cardColor: const Color(0xfffbf1c7), + dividerColor: const Color(0xffe0dbb2), + highlightColor: const Color(0xffcfd2a8), + splashColor: const Color(0xffcfd2a8), + unselectedWidgetColor: const Color(0xff5f5750), + disabledColor: const Color(0x77aca289), + secondaryHeaderColor: const Color(0xfffbf1c7), + dialogBackgroundColor: const Color(0xfffbf1c7), + indicatorColor: const Color(0xffd1ac0e), + hintColor: const Color(0x805f5750), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff5f5750)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff5f5750), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffe0dbb2), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xffd1ac0e), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xfffbf1c7), + foregroundColor: const Color(0xff5f5750), + shadowColor: const Color(0xfffbf1c7), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xfffbf1c7), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xffd1ac0e), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff5f5750), + side: const BorderSide( + color: Color(0xffd1ac0e), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffd1ac0e), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildGreenMeanieTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xff224e44), + secondary: Color(0xff6590fd), + surface: Color(0xff292A2E), + error: Color(0xffff5555), + onSurface: Color(0xff489D50), + background: Color(0xff142e28), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff292A2E), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff292A2E), + shadowColor: const Color(0xff489D50), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xff224e44), + canvasColor: const Color(0xff142e28), + scaffoldBackgroundColor: const Color(0xff142e28), + cardColor: const Color(0xff292A2E), + dividerColor: const Color(0xff446448), + highlightColor: const Color(0xff4b5563), + splashColor: const Color(0xff4b5563), + unselectedWidgetColor: const Color(0xff489D50), + disabledColor: const Color(0x77446448), + secondaryHeaderColor: const Color(0xff292A2E), + dialogBackgroundColor: const Color(0xff292A2E), + indicatorColor: const Color(0xff224e44), + hintColor: const Color(0x80489D50), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xff489D50)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff489D50), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff446448), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff224e44), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff292A2E), + foregroundColor: const Color(0xff489D50), + shadowColor: const Color(0xff292A2E), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff292A2E), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff224e44), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff489D50), + side: const BorderSide( + color: Color(0xff224e44), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff489D50), + foregroundColor: const Color(0xff000000), + ), + ), + ); +} + +ThemeData _buildWildberriesTheme() { + final base = ThemeData.dark(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xff4b246b), + secondary: Color(0xff5196B2), + surface: Color(0xff19002E), + error: Color(0xffff5555), + onSurface: Color(0xffCF8B3E), + background: Color(0xff240041), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xff19002E), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xff19002E), + shadowColor: const Color(0xff3a264a), + ), + brightness: Brightness.dark, + primaryColor: const Color(0xff4b246b), + canvasColor: const Color(0xff240041), + scaffoldBackgroundColor: const Color(0xff240041), + cardColor: const Color(0xff19002E), + dividerColor: const Color(0xffC79BFF), + highlightColor: const Color(0xff44433A), + splashColor: const Color(0xff44433A), + unselectedWidgetColor: const Color(0xffCF8B3E), + disabledColor: const Color(0x77C79BFF), + secondaryHeaderColor: const Color(0xff19002E), + dialogBackgroundColor: const Color(0xff19002E), + indicatorColor: const Color(0xff4b246b), + hintColor: const Color(0x80CF8B3E), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).white, + textTheme: Typography.material2021(platform: TargetPlatform.android).white, + primaryIconTheme: const IconThemeData(color: Color(0xffCF8B3E)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xffCF8B3E), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffC79BFF), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff4b246b), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xff19002E), + foregroundColor: const Color(0xffCF8B3E), + shadowColor: const Color(0xff19002E), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarColor: const Color(0xff19002E), + statusBarIconBrightness: Brightness.light, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff4b246b), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xffCF8B3E), + side: const BorderSide( + color: Color(0xff4b246b), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff4b246b), + foregroundColor: const Color(0xffCF8B3E), + ), + ), + ); +} + +ThemeData _buildSoftLavenderTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xff9b7cb6), + secondary: Color(0xffc8a8d8), + surface: Color(0xfff8f5ff), + error: Color(0xffb91c1c), + onSurface: Color(0xff3e2851), + background: Color(0xfff5f2ff), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xfff8f5ff), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xfff8f5ff), + shadowColor: const Color(0xffc8a8d8), + ), + brightness: Brightness.light, + primaryColor: const Color(0xff9b7cb6), + canvasColor: const Color(0xfff5f2ff), + scaffoldBackgroundColor: const Color(0xfff5f2ff), + cardColor: const Color(0xfff8f5ff), + dividerColor: const Color(0xffc8a8d8), + highlightColor: const Color(0xffc8a8d8), + splashColor: const Color(0xffc8a8d8), + unselectedWidgetColor: const Color(0xff3e2851), + disabledColor: const Color(0x77c8a8d8), + secondaryHeaderColor: const Color(0xfff8f5ff), + dialogBackgroundColor: const Color(0xfff8f5ff), + indicatorColor: const Color(0xff9b7cb6), + hintColor: const Color(0x803e2851), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff3e2851)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff3e2851), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffc8a8d8), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff9b7cb6), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xfff8f5ff), + foregroundColor: const Color(0xff3e2851), + shadowColor: const Color(0xfff8f5ff), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xfff8f5ff), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff9b7cb6), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff3e2851), + side: const BorderSide( + color: Color(0xff9b7cb6), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff9b7cb6), + foregroundColor: const Color(0xffffffff), + ), + ), + ); +} + +ThemeData _buildMintyFreshTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xff0d9488), + secondary: Color(0xff5eead4), + surface: Color(0xfff0fdfa), + error: Color(0xffdc2626), + onSurface: Color(0xff134e4a), + background: Color(0xffecfdf5), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xfff0fdfa), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xfff0fdfa), + shadowColor: const Color(0xff5eead4), + ), + brightness: Brightness.light, + primaryColor: const Color(0xff0d9488), + canvasColor: const Color(0xffecfdf5), + scaffoldBackgroundColor: const Color(0xffecfdf5), + cardColor: const Color(0xfff0fdfa), + dividerColor: const Color(0xff5eead4), + highlightColor: const Color(0xff5eead4), + splashColor: const Color(0xff5eead4), + unselectedWidgetColor: const Color(0xff134e4a), + disabledColor: const Color(0x775eead4), + secondaryHeaderColor: const Color(0xfff0fdfa), + dialogBackgroundColor: const Color(0xfff0fdfa), + indicatorColor: const Color(0xff0d9488), + hintColor: const Color(0x80134e4a), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff134e4a)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff134e4a), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff5eead4), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff0d9488), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xfff0fdfa), + foregroundColor: const Color(0xff134e4a), + shadowColor: const Color(0xfff0fdfa), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xfff0fdfa), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff0d9488), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff134e4a), + side: const BorderSide( + color: Color(0xff0d9488), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff0d9488), + foregroundColor: const Color(0xffffffff), + ), + ), + ); +} + +ThemeData _buildWarmVanillaTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xffd97706), + secondary: Color(0xfffbbf24), + surface: Color(0xfffffbeb), + error: Color(0xffdc2626), + onSurface: Color(0xff78350f), + background: Color(0xfffef3c7), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xfffffbeb), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xfffffbeb), + shadowColor: const Color(0xfffbbf24), + ), + brightness: Brightness.light, + primaryColor: const Color(0xffd97706), + canvasColor: const Color(0xfffef3c7), + scaffoldBackgroundColor: const Color(0xfffef3c7), + cardColor: const Color(0xfffffbeb), + dividerColor: const Color(0xfffbbf24), + highlightColor: const Color(0xfffbbf24), + splashColor: const Color(0xfffbbf24), + unselectedWidgetColor: const Color(0xff78350f), + disabledColor: const Color(0x77fbbf24), + secondaryHeaderColor: const Color(0xfffffbeb), + dialogBackgroundColor: const Color(0xfffffbeb), + indicatorColor: const Color(0xffd97706), + hintColor: const Color(0x8078350f), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff78350f)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff78350f), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xfffbbf24), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xffd97706), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xfffffbeb), + foregroundColor: const Color(0xff78350f), + shadowColor: const Color(0xfffffbeb), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xfffffbeb), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xffd97706), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff78350f), + side: const BorderSide( + color: Color(0xffd97706), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffd97706), + foregroundColor: const Color(0xffffffff), + ), + ), + ); +} + +ThemeData _buildCoastalBlueTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xff0369a1), + secondary: Color(0xff7dd3fc), + surface: Color(0xfff0f9ff), + error: Color(0xffdc2626), + onSurface: Color(0xff0c4a6e), + background: Color(0xffe0f2fe), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xfff0f9ff), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xfff0f9ff), + shadowColor: const Color(0xff7dd3fc), + ), + brightness: Brightness.light, + primaryColor: const Color(0xff0369a1), + canvasColor: const Color(0xffe0f2fe), + scaffoldBackgroundColor: const Color(0xffe0f2fe), + cardColor: const Color(0xfff0f9ff), + dividerColor: const Color(0xff7dd3fc), + highlightColor: const Color(0xff7dd3fc), + splashColor: const Color(0xff7dd3fc), + unselectedWidgetColor: const Color(0xff0c4a6e), + disabledColor: const Color(0x777dd3fc), + secondaryHeaderColor: const Color(0xfff0f9ff), + dialogBackgroundColor: const Color(0xfff0f9ff), + indicatorColor: const Color(0xff0369a1), + hintColor: const Color(0x800c4a6e), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff0c4a6e)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff0c4a6e), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xff7dd3fc), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff0369a1), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xfff0f9ff), + foregroundColor: const Color(0xff0c4a6e), + shadowColor: const Color(0xfff0f9ff), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xfff0f9ff), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff0369a1), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff0c4a6e), + side: const BorderSide( + color: Color(0xff0369a1), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff0369a1), + foregroundColor: const Color(0xffffffff), + ), + ), + ); +} + +ThemeData _buildPaperCreamTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xff8b5a3c), + secondary: Color(0xffd4af8c), + surface: Color(0xfff9f7f4), + error: Color(0xffdc2626), + onSurface: Color(0xff4a3728), + background: Color(0xfff5f2ef), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xfff9f7f4), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xfff9f7f4), + shadowColor: const Color(0xffd4af8c), + ), + brightness: Brightness.light, + primaryColor: const Color(0xff8b5a3c), + canvasColor: const Color(0xfff5f2ef), + scaffoldBackgroundColor: const Color(0xfff5f2ef), + cardColor: const Color(0xfff9f7f4), + dividerColor: const Color(0xffd4af8c), + highlightColor: const Color(0xffd4af8c), + splashColor: const Color(0xffd4af8c), + unselectedWidgetColor: const Color(0xff4a3728), + disabledColor: const Color(0x77d4af8c), + secondaryHeaderColor: const Color(0xfff9f7f4), + dialogBackgroundColor: const Color(0xfff9f7f4), + indicatorColor: const Color(0xff8b5a3c), + hintColor: const Color(0x804a3728), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff4a3728)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff4a3728), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffd4af8c), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff8b5a3c), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xfff9f7f4), + foregroundColor: const Color(0xff4a3728), + shadowColor: const Color(0xfff9f7f4), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xfff9f7f4), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff8b5a3c), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff4a3728), + side: const BorderSide( + color: Color(0xff8b5a3c), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff8b5a3c), + foregroundColor: const Color(0xffffffff), + ), + ), + ); +} + +ThemeData _buildGithubLightTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xff0969da), + secondary: Color(0xff54aeff), + surface: Color(0xffffffff), + error: Color(0xffcf222e), + onSurface: Color(0xff1f2328), + background: Color(0xfff6f8fa), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xffffffff), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xffffffff), + shadowColor: const Color(0xffd0d7de), + ), + brightness: Brightness.light, + primaryColor: const Color(0xff0969da), + canvasColor: const Color(0xfff6f8fa), + scaffoldBackgroundColor: const Color(0xfff6f8fa), + cardColor: const Color(0xffffffff), + dividerColor: const Color(0xffd0d7de), + highlightColor: const Color(0xffd0d7de), + splashColor: const Color(0xffd0d7de), + unselectedWidgetColor: const Color(0xff1f2328), + disabledColor: const Color(0x77d0d7de), + secondaryHeaderColor: const Color(0xffffffff), + dialogBackgroundColor: const Color(0xffffffff), + indicatorColor: const Color(0xff0969da), + hintColor: const Color(0x801f2328), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff1f2328)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff1f2328), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffd0d7de), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xff0969da), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xffffffff), + foregroundColor: const Color(0xff1f2328), + shadowColor: const Color(0xffffffff), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xffffffff), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xff0969da), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff1f2328), + side: const BorderSide( + color: Color(0xff0969da), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xff0969da), + foregroundColor: const Color(0xffffffff), + ), + ), + ); +} + +ThemeData _buildHotDogStandTheme() { + final base = ThemeData.light(useMaterial3: false); + + return base.copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xffff0000), + secondary: Color(0xffffff00), + surface: Color(0xffffff00), + error: Color(0xffffff00), + onSurface: Color(0xff000000), + background: Color(0xffffff00), + ), + bottomAppBarTheme: const BottomAppBarThemeData().copyWith( + color: const Color(0xffffff00), + ), + cardTheme: const CardThemeData().copyWith( + color: const Color(0xffffff00), + shadowColor: const Color(0xffffffff), + ), + brightness: Brightness.light, + primaryColor: const Color(0xffff0000), + canvasColor: const Color(0xffffff00), + scaffoldBackgroundColor: const Color(0xffffff00), + cardColor: const Color(0xffffff00), + dividerColor: const Color(0xffff0000), + highlightColor: const Color(0xffff0000), + splashColor: const Color(0xffff0000), + unselectedWidgetColor: const Color(0xff000000), + disabledColor: const Color(0x77ff0000), + secondaryHeaderColor: const Color(0xffffff00), + dialogBackgroundColor: const Color(0xffffff00), + indicatorColor: const Color(0xffff0000), + hintColor: const Color(0x80000000), + primaryTextTheme: Typography.material2021(platform: TargetPlatform.android).black, + textTheme: Typography.material2021(platform: TargetPlatform.android).black, + primaryIconTheme: const IconThemeData(color: Color(0xff000000)), + iconTheme: base.iconTheme.copyWith( + color: const Color(0xff000000), + ), + dividerTheme: base.dividerTheme.copyWith( + color: const Color(0xffff0000), + ), + sliderTheme: const SliderThemeData().copyWith( + valueIndicatorColor: const Color(0xffff0000), + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0, + disabledThumbRadius: 6.0, + ), + ), + appBarTheme: base.appBarTheme.copyWith( + backgroundColor: const Color(0xffffff00), + foregroundColor: const Color(0xff000000), + shadowColor: const Color(0xffffff00), + elevation: 1.0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: const Color(0xffffff00), + statusBarIconBrightness: Brightness.dark, + ), + ), + snackBarTheme: base.snackBarTheme.copyWith( + actionTextColor: const Color(0xffff0000), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xff000000), + side: const BorderSide( + color: Color(0xffff0000), + style: BorderStyle.solid, + ), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffff0000), + foregroundColor: const Color(0xffffff00), + ), + ), + ); +} diff --git a/PinePods-0.8.2/mobile/lib/ui/utils/local_download_utils.dart b/PinePods-0.8.2/mobile/lib/ui/utils/local_download_utils.dart new file mode 100644 index 0000000..ef20e32 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/utils/local_download_utils.dart @@ -0,0 +1,225 @@ +// lib/ui/utils/local_download_utils.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/services/logging/app_logger.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:provider/provider.dart'; + +/// Utility class for managing local downloads of PinePods episodes +class LocalDownloadUtils { + static final Map _localDownloadStatusCache = {}; + + /// Generate consistent GUID for PinePods episodes for local downloads + static String generateEpisodeGuid(PinepodsEpisode episode) { + return 'pinepods_${episode.episodeId}'; + } + + /// Clear the local download status cache (call on refresh) + static void clearCache() { + _localDownloadStatusCache.clear(); + } + + /// Check if episode is downloaded locally with caching + static Future isEpisodeDownloadedLocally( + BuildContext context, + PinepodsEpisode episode + ) async { + final guid = generateEpisodeGuid(episode); + final logger = AppLogger(); + logger.debug('LocalDownload', 'Checking download status for episode: ${episode.episodeTitle}, GUID: $guid'); + + // Check cache first + if (_localDownloadStatusCache.containsKey(guid)) { + logger.debug('LocalDownload', 'Found cached status for $guid: ${_localDownloadStatusCache[guid]}'); + return _localDownloadStatusCache[guid]!; + } + + try { + final podcastBloc = Provider.of(context, listen: false); + + // Get all episodes and find matches with both new and old GUID formats + final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes(); + final matchingEpisodes = allEpisodes.where((ep) => + ep.guid == guid || ep.guid.startsWith('${guid}_') + ).toList(); + + logger.debug('LocalDownload', 'Repository lookup for $guid: found ${matchingEpisodes.length} matching episodes'); + + // Found matching episodes + + // Consider downloaded if ANY matching episode is downloaded + final isDownloaded = matchingEpisodes.any((ep) => + ep.downloaded || ep.downloadState == DownloadState.downloaded + ); + + logger.debug('LocalDownload', 'Final download status for $guid: $isDownloaded'); + + // Cache the result + _localDownloadStatusCache[guid] = isDownloaded; + return isDownloaded; + } catch (e) { + final logger = AppLogger(); + logger.error('LocalDownload', 'Error checking local download status for episode: ${episode.episodeTitle}', e.toString()); + return false; + } + } + + /// Update local download status cache + static void updateLocalDownloadStatus(PinepodsEpisode episode, bool isDownloaded) { + final guid = generateEpisodeGuid(episode); + _localDownloadStatusCache[guid] = isDownloaded; + } + + /// Proactively load local download status for a list of episodes + static Future loadLocalDownloadStatuses( + BuildContext context, + List episodes + ) async { + final logger = AppLogger(); + logger.debug('LocalDownload', 'Loading local download statuses for ${episodes.length} episodes'); + + try { + final podcastBloc = Provider.of(context, listen: false); + + // Get all downloaded episodes from repository + final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes(); + logger.debug('LocalDownload', 'Found ${allEpisodes.length} total episodes in repository'); + + // Filter to PinePods episodes only and log them + final pinepodsEpisodes = allEpisodes.where((ep) => ep.guid.startsWith('pinepods_')).toList(); + logger.debug('LocalDownload', 'Found ${pinepodsEpisodes.length} PinePods episodes in repository'); + + // Found pinepods episodes in repository + + // Now check each episode against the repository + for (final episode in episodes) { + final guid = generateEpisodeGuid(episode); + + // Look for episodes with either new format (pinepods_123) or old format (pinepods_123_timestamp) + final matchingEpisodes = allEpisodes.where((ep) => + ep.guid == guid || ep.guid.startsWith('${guid}_') + ).toList(); + + // Checking for matching episodes + + // Consider downloaded if ANY matching episode is downloaded + final isDownloaded = matchingEpisodes.any((ep) => + ep.downloaded || ep.downloadState == DownloadState.downloaded + ); + + _localDownloadStatusCache[guid] = isDownloaded; + // Episode status checked + } + + // Download statuses cached + + } catch (e) { + logger.error('LocalDownload', 'Error loading local download statuses', e.toString()); + } + } + + /// Download episode locally + static Future localDownloadEpisode( + BuildContext context, + PinepodsEpisode episode + ) async { + final logger = AppLogger(); + + try { + // Convert PinepodsEpisode to Episode for local download + final localEpisode = Episode( + guid: generateEpisodeGuid(episode), + pguid: 'pinepods_${episode.podcastName.replaceAll(' ', '_').toLowerCase()}', + podcast: episode.podcastName, + title: episode.episodeTitle, + description: episode.episodeDescription, + imageUrl: episode.episodeArtwork, + contentUrl: episode.episodeUrl, + duration: episode.episodeDuration, + publicationDate: DateTime.tryParse(episode.episodePubDate), + author: episode.podcastName, + season: 0, + episode: 0, + position: episode.listenDuration ?? 0, + played: episode.completed, + chapters: [], + transcriptUrls: [], + ); + + logger.debug('LocalDownload', 'Created local episode with GUID: ${localEpisode.guid}'); + logger.debug('LocalDownload', 'Episode title: ${localEpisode.title}'); + logger.debug('LocalDownload', 'Episode URL: ${localEpisode.contentUrl}'); + + final podcastBloc = Provider.of(context, listen: false); + + // First save the episode to the repository so it can be tracked + await podcastBloc.podcastService.saveEpisode(localEpisode); + logger.debug('LocalDownload', 'Episode saved to repository'); + + // Use the download service from podcast bloc + final success = await podcastBloc.downloadService.downloadEpisode(localEpisode); + logger.debug('LocalDownload', 'Download service result: $success'); + + if (success) { + updateLocalDownloadStatus(episode, true); + } + + return success; + } catch (e) { + logger.error('LocalDownload', 'Error in local download for episode: ${episode.episodeTitle}', e.toString()); + return false; + } + } + + /// Delete local download(s) for episode + static Future deleteLocalDownload( + BuildContext context, + PinepodsEpisode episode + ) async { + final logger = AppLogger(); + + try { + final podcastBloc = Provider.of(context, listen: false); + final guid = generateEpisodeGuid(episode); + + // Get all episodes and find matches with both new and old GUID formats + final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes(); + final matchingEpisodes = allEpisodes.where((ep) => + ep.guid == guid || ep.guid.startsWith('${guid}_') + ).toList(); + + logger.debug('LocalDownload', 'Found ${matchingEpisodes.length} episodes to delete for $guid'); + + if (matchingEpisodes.isNotEmpty) { + // Delete ALL matching episodes (handles duplicates from old timestamp GUIDs) + for (final localEpisode in matchingEpisodes) { + logger.debug('LocalDownload', 'Deleting episode: ${localEpisode.guid}'); + await podcastBloc.podcastService.repository.deleteEpisode(localEpisode); + } + + // Update cache + updateLocalDownloadStatus(episode, false); + + return matchingEpisodes.length; + } else { + return 0; + } + } catch (e) { + logger.error('LocalDownload', 'Error deleting local download for episode: ${episode.episodeTitle}', e.toString()); + return 0; + } + } + + /// Show snackbar with message + static void showSnackBar(BuildContext context, String message, Color backgroundColor) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/utils/player_utils.dart b/PinePods-0.8.2/mobile/lib/ui/utils/player_utils.dart new file mode 100644 index 0000000..436dde4 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/utils/player_utils.dart @@ -0,0 +1,43 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/ui/podcast/now_playing.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; +import 'package:provider/provider.dart'; + +/// If we have the 'show now playing upon play' option set to true, launch +/// the [NowPlaying] widget automatically. +void optionalShowNowPlaying(BuildContext context, AppSettings settings) { + if (settings.autoOpenNowPlaying) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const NowPlaying(), + settings: const RouteSettings(name: 'nowplaying'), + fullscreenDialog: false, + ), + ); + } +} + +/// Helper function to play a PinePods episode and automatically show the full screen player if enabled +Future playPinepodsEpisodeWithOptionalFullScreen( + BuildContext context, + PinepodsAudioService audioService, + PinepodsEpisode episode, { + bool resume = true, +}) async { + await audioService.playPinepodsEpisode( + pinepodsEpisode: episode, + resume: resume, + ); + + // Show full screen player if setting is enabled + final settingsBloc = Provider.of(context, listen: false); + optionalShowNowPlaying(context, settingsBloc.currentSettings); +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/utils/position_utils.dart b/PinePods-0.8.2/mobile/lib/ui/utils/position_utils.dart new file mode 100644 index 0000000..a90cbca --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/utils/position_utils.dart @@ -0,0 +1,151 @@ +// lib/ui/utils/position_utils.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/services/logging/app_logger.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:provider/provider.dart'; + +/// Utility class for managing episode position synchronization and display +class PositionUtils { + static final AppLogger _logger = AppLogger(); + + /// Generate consistent GUID for PinePods episodes + static String generateEpisodeGuid(PinepodsEpisode episode) { + return 'pinepods_${episode.episodeId}'; + } + + /// Get local position for episode from repository + static Future getLocalPosition(BuildContext context, PinepodsEpisode episode) async { + try { + final podcastBloc = Provider.of(context, listen: false); + final guid = generateEpisodeGuid(episode); + + // Get all episodes and find matches with both new and old GUID formats + final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes(); + final matchingEpisodes = allEpisodes.where((ep) => + ep.guid == guid || ep.guid.startsWith('${guid}_') + ).toList(); + + if (matchingEpisodes.isNotEmpty) { + // Return the highest position from any matching episode (in case of duplicates) + final positions = matchingEpisodes.map((ep) => ep.position / 1000.0).toList(); + return positions.reduce((a, b) => a > b ? a : b); + } + + return null; + } catch (e) { + _logger.error('PositionUtils', 'Error getting local position for episode: ${episode.episodeTitle}', e.toString()); + return null; + } + } + + /// Get server position for episode (use existing data from feed) + static Future getServerPosition(PinepodsService pinepodsService, PinepodsEpisode episode, int userId) async { + return episode.listenDuration?.toDouble(); + } + + /// Get the best available position (furthest of local vs server) + static Future getBestPosition( + BuildContext context, + PinepodsService pinepodsService, + PinepodsEpisode episode, + int userId, + ) async { + // Get both positions in parallel + final futures = await Future.wait([ + getLocalPosition(context, episode), + getServerPosition(pinepodsService, episode, userId), + ]); + + final localPosition = futures[0] ?? 0.0; + final serverPosition = futures[1] ?? episode.listenDuration?.toDouble() ?? 0.0; + + final bestPosition = localPosition > serverPosition ? localPosition : serverPosition; + final isLocal = localPosition >= serverPosition; + + + return PositionInfo( + position: bestPosition, + isLocal: isLocal, + localPosition: localPosition, + serverPosition: serverPosition, + ); + } + + /// Enrich a single episode with the best available position + static Future enrichEpisodeWithBestPosition( + BuildContext context, + PinepodsService pinepodsService, + PinepodsEpisode episode, + int userId, + ) async { + final positionInfo = await getBestPosition(context, pinepodsService, episode, userId); + + // Create a new episode with updated position + return PinepodsEpisode( + podcastName: episode.podcastName, + episodeTitle: episode.episodeTitle, + episodePubDate: episode.episodePubDate, + episodeDescription: episode.episodeDescription, + episodeArtwork: episode.episodeArtwork, + episodeUrl: episode.episodeUrl, + episodeDuration: episode.episodeDuration, + listenDuration: positionInfo.position.round(), + episodeId: episode.episodeId, + completed: episode.completed, + saved: episode.saved, + queued: episode.queued, + downloaded: episode.downloaded, + isYoutube: episode.isYoutube, + podcastId: episode.podcastId, + ); + } + + /// Enrich a list of episodes with the best available positions + static Future> enrichEpisodesWithBestPositions( + BuildContext context, + PinepodsService pinepodsService, + List episodes, + int userId, + ) async { + _logger.info('PositionUtils', 'Enriching ${episodes.length} episodes with best positions'); + + final enrichedEpisodes = []; + + for (final episode in episodes) { + try { + final enrichedEpisode = await enrichEpisodeWithBestPosition( + context, + pinepodsService, + episode, + userId, + ); + enrichedEpisodes.add(enrichedEpisode); + } catch (e) { + _logger.warning('PositionUtils', 'Failed to enrich episode ${episode.episodeTitle}, using original: ${e.toString()}'); + enrichedEpisodes.add(episode); + } + } + + _logger.info('PositionUtils', 'Successfully enriched ${enrichedEpisodes.length} episodes'); + return enrichedEpisodes; + } +} + +/// Information about episode position +class PositionInfo { + final double position; + final bool isLocal; + final double localPosition; + final double serverPosition; + + PositionInfo({ + required this.position, + required this.isLocal, + required this.localPosition, + required this.serverPosition, + }); +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/action_text.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/action_text.dart new file mode 100644 index 0000000..095b0e9 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/action_text.dart @@ -0,0 +1,26 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:flutter/widgets.dart'; + +/// This is a simple wrapper for the [Text] widget that is intended to +/// be used with action dialogs. +/// +/// It should be supplied with a text value in sentence case. If running on +/// Android this will be shifted to all upper case to meet the Material Design +/// guidelines; otherwise it will be displayed as is to fit in the with iOS +/// developer guidelines. +class ActionText extends StatelessWidget { + /// The text to display which will be shifted to all upper-case on Android. + final String text; + + const ActionText(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + return Platform.isAndroid ? Text(text.toUpperCase()) : Text(text); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/decorated_icon_button.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/decorated_icon_button.dart new file mode 100644 index 0000000..6ec784b --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/decorated_icon_button.dart @@ -0,0 +1,46 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// An [IconButton] cannot have a background or border. +/// +/// This class wraps an IconButton in a shape so that it can have a background. +class DecoratedIconButton extends StatelessWidget { + final Color decorationColour; + final Color iconColour; + final IconData icon; + final VoidCallback onPressed; + + const DecoratedIconButton({ + super.key, + required this.iconColour, + required this.decorationColour, + required this.icon, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Center( + child: Ink( + width: 42.0, + height: 42.0, + decoration: ShapeDecoration( + color: decorationColour, + shape: const CircleBorder(), + ), + child: IconButton( + icon: Icon(icon), + padding: const EdgeInsets.all(0.0), + color: iconColour, + onPressed: onPressed, + ), + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/delayed_progress_indicator.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/delayed_progress_indicator.dart new file mode 100644 index 0000000..8652940 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/delayed_progress_indicator.dart @@ -0,0 +1,37 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:flutter/material.dart'; + +/// This class returns a platform-specific spinning indicator after a time specified +/// in milliseconds. +/// +/// Defaults to 1 second. This can be used as a place holder for cached images. By +/// delaying for several milliseconds it can reduce the occurrences of placeholders +/// flashing on screen as the cached image is loaded. Images that take longer to fetch +/// or process from the cache will result in a [PlatformProgressIndicator] indicator +/// being displayed. +class DelayedCircularProgressIndicator extends StatelessWidget { + final f = Future.delayed(const Duration(milliseconds: 1000), () => Container()); + + DelayedCircularProgressIndicator({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: f, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return const Center( + child: PlatformProgressIndicator(), + ); + } else { + return Container(); + } + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/download_button.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/download_button.dart new file mode 100644 index 0000000..486ae47 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/download_button.dart @@ -0,0 +1,62 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:percent_indicator/percent_indicator.dart'; + +/// Displays a download button for an episode. +/// +/// Can be passed a percentage representing the download progress which +/// the button will then animate to show progress. +class DownloadButton extends StatelessWidget { + final String label; + final String title; + final IconData icon; + final int percent; + final VoidCallback onPressed; + + const DownloadButton({ + super.key, + required this.label, + required this.title, + required this.icon, + required this.percent, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + var progress = percent.toDouble() / 100; + + return Semantics( + label: '$label $title', + child: InkWell( + onTap: onPressed, + child: CircularPercentIndicator( + radius: 19.0, + lineWidth: 1.5, + backgroundColor: Theme.of(context).primaryColor, + progressColor: Theme.of(context).indicatorColor, + animation: true, + animateFromLastPercent: true, + percent: progress, + center: percent > 0 + ? Text( + '$percent%', + style: const TextStyle( + fontSize: 12.0, + ), + ) + : Icon( + icon, + size: 22.0, + + /// Why is this not picking up the theme like other widgets?!?!?! + color: Theme.of(context).primaryColor, + ), + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/draggable_episode_tile.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/draggable_episode_tile.dart new file mode 100644 index 0000000..4900b9f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/draggable_episode_tile.dart @@ -0,0 +1,68 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Renders an episode within the queue which can be dragged to re-order the queue. +class DraggableEpisodeTile extends StatelessWidget { + final Episode episode; + final int index; + final bool draggable; + final bool playable; + + const DraggableEpisodeTile({ + super.key, + required this.episode, + this.index = 0, + this.draggable = true, + this.playable = false, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final audioBloc = Provider.of(context, listen: false); + + return ListTile( + key: Key('DT${episode.guid}'), + enabled: playable, + leading: TileImage( + url: episode.thumbImageUrl ?? episode.imageUrl ?? '', + size: 56.0, + highlight: episode.highlight, + ), + title: Text( + episode.title!, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: false, + style: textTheme.bodyMedium, + ), + subtitle: EpisodeSubtitle(episode), + trailing: draggable + ? ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ) + : const SizedBox( + width: 0.0, + height: 0.0, + ), + onTap: () { + if (playable) { + final settings = Provider.of(context, listen: false).currentSettings; + audioBloc.play(episode); + optionalShowNowPlaying(context, settings); + } + }, + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/draggable_queue_episode_card.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/draggable_queue_episode_card.dart new file mode 100644 index 0000000..50d92e6 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/draggable_queue_episode_card.dart @@ -0,0 +1,265 @@ +// lib/ui/widgets/draggable_queue_episode_card.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; + +class DraggableQueueEpisodeCard extends StatelessWidget { + final PinepodsEpisode episode; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onPlayPressed; + final int index; // Add index for drag listener + + const DraggableQueueEpisodeCard({ + Key? key, + required this.episode, + required this.index, + this.onTap, + this.onLongPress, + this.onPlayPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + elevation: 1, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + ReorderableDragStartListener( + index: index, + child: Container( + width: 24, + height: 50, + margin: const EdgeInsets.only(right: 12), + child: Center( + child: Icon( + Icons.drag_indicator, + color: Colors.grey[600], + size: 20, + ), + ), + ), + ), + + // Episode artwork + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: episode.episodeArtwork.isNotEmpty + ? Image.network( + episode.episodeArtwork, + width: 50, + height: 50, + fit: BoxFit.cover, + cacheWidth: 100, + cacheHeight: 100, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.podcasts, + color: Colors.grey[600], + size: 24, + ), + ); + }, + ) + : Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.podcasts, + color: Colors.grey[600], + size: 24, + ), + ), + ), + + const SizedBox(width: 12), + + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + episode.episodeTitle, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + episode.podcastName, + style: TextStyle( + color: Colors.grey[600], + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (episode.episodePubDate.isNotEmpty) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.calendar_today, + size: 12, + color: Colors.grey[500], + ), + const SizedBox(width: 4), + Text( + _formatDate(episode.episodePubDate), + style: TextStyle( + color: Colors.grey[500], + fontSize: 12, + ), + ), + if (episode.episodeDuration > 0) ...[ + const SizedBox(width: 12), + Icon( + Icons.access_time, + size: 12, + color: Colors.grey[500], + ), + const SizedBox(width: 4), + Text( + _formatDuration(episode.episodeDuration), + style: TextStyle( + color: Colors.grey[500], + fontSize: 12, + ), + ), + ], + ], + ), + ], + // Progress bar if episode has been started + if (episode.listenDuration != null && episode.listenDuration! > 0 && episode.episodeDuration > 0) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: episode.listenDuration! / episode.episodeDuration, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColor.withOpacity(0.7), + ), + ), + ], + ], + ), + ), + + // Status indicators and play button + Column( + children: [ + if (onPlayPressed != null) + IconButton( + onPressed: onPlayPressed, + icon: Icon( + episode.completed + ? Icons.check_circle + : ((episode.listenDuration != null && episode.listenDuration! > 0) ? Icons.play_circle_filled : Icons.play_circle_outline), + color: episode.completed + ? Colors.green + : Theme.of(context).primaryColor, + size: 28, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (episode.saved) + Icon( + Icons.bookmark, + size: 16, + color: Colors.orange[600], + ), + if (episode.downloaded) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.download_done, + size: 16, + color: Colors.green[600], + ), + ), + if (episode.queued) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.queue_music, + size: 16, + color: Colors.blue[600], + ), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } + + String _formatDate(String dateString) { + try { + final date = DateTime.parse(dateString); + final now = DateTime.now(); + final difference = now.difference(date).inDays; + + if (difference == 0) { + return 'Today'; + } else if (difference == 1) { + return 'Yesterday'; + } else if (difference < 7) { + return '${difference}d ago'; + } else if (difference < 30) { + return '${(difference / 7).floor()}w ago'; + } else { + return '${date.day}/${date.month}/${date.year}'; + } + } catch (e) { + return dateString; + } + } + + String _formatDuration(int seconds) { + if (seconds <= 0) return ''; + + final hours = seconds ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else { + return '${minutes}m'; + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/episode_context_menu.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_context_menu.dart new file mode 100644 index 0000000..ce20b9b --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_context_menu.dart @@ -0,0 +1,164 @@ +// lib/ui/widgets/episode_context_menu.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; + +class EpisodeContextMenu extends StatelessWidget { + final PinepodsEpisode episode; + final VoidCallback? onSave; + final VoidCallback? onRemoveSaved; + final VoidCallback? onDownload; + final VoidCallback? onLocalDownload; + final VoidCallback? onDeleteLocalDownload; + final VoidCallback? onQueue; + final VoidCallback? onMarkComplete; + final VoidCallback? onDismiss; + final bool isDownloadedLocally; + + const EpisodeContextMenu({ + Key? key, + required this.episode, + this.onSave, + this.onRemoveSaved, + this.onDownload, + this.onLocalDownload, + this.onDeleteLocalDownload, + this.onQueue, + this.onMarkComplete, + this.onDismiss, + this.isDownloadedLocally = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onDismiss, // Dismiss when tapping outside + child: Container( + color: Colors.black.withOpacity(0.3), // Semi-transparent overlay + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: GestureDetector( + onTap: () {}, // Prevent dismissal when tapping the menu itself + child: Material( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + elevation: 10, + child: Container( + padding: const EdgeInsets.all(16), + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 400, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode title + Text( + episode.episodeTitle, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + episode.podcastName, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).primaryColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 8), + + // Menu options + _buildMenuOption( + context, + icon: episode.saved ? Icons.bookmark_remove : Icons.bookmark_add, + text: episode.saved ? 'Remove from Saved' : 'Save Episode', + onTap: episode.saved ? onRemoveSaved : onSave, + ), + + _buildMenuOption( + context, + icon: episode.downloaded ? Icons.delete_outline : Icons.cloud_download_outlined, + text: episode.downloaded ? 'Delete from Server' : 'Download to Server', + onTap: onDownload, + ), + + _buildMenuOption( + context, + icon: isDownloadedLocally ? Icons.delete_forever_outlined : Icons.file_download_outlined, + text: isDownloadedLocally ? 'Delete Local Download' : 'Download Locally', + onTap: isDownloadedLocally ? onDeleteLocalDownload : onLocalDownload, + ), + + _buildMenuOption( + context, + icon: episode.queued ? Icons.queue_music : Icons.add_to_queue, + text: episode.queued ? 'Remove from Queue' : 'Add to Queue', + onTap: onQueue, + ), + + _buildMenuOption( + context, + icon: episode.completed ? Icons.check_circle : Icons.check_circle_outline, + text: episode.completed ? 'Mark as Incomplete' : 'Mark as Complete', + onTap: onMarkComplete, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildMenuOption( + BuildContext context, { + required IconData icon, + required String text, + VoidCallback? onTap, + bool enabled = true, + }) { + return InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: enabled + ? Theme.of(context).iconTheme.color + : Theme.of(context).disabledColor, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 14, + color: enabled + ? Theme.of(context).textTheme.bodyLarge?.color + : Theme.of(context).disabledColor, + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/episode_description.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_description.dart new file mode 100644 index 0000000..4c2118f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_description.dart @@ -0,0 +1,96 @@ +// lib/ui/widgets/episode_description.dart +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_html_svg/flutter_html_svg.dart'; +import 'package:flutter_html_table/flutter_html_table.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// A specialized widget for displaying episode descriptions with clickable timestamps. +/// +/// This widget extends the basic HTML display functionality to parse timestamp patterns +/// like "43:53" or "1:23:45" and make them clickable for navigation within the episode. +class EpisodeDescription extends StatelessWidget { + final String content; + final FontSize? fontSize; + final Function(Duration)? onTimestampTap; + + const EpisodeDescription({ + super.key, + required this.content, + this.fontSize, + this.onTimestampTap, + }); + + @override + Widget build(BuildContext context) { + // For now, let's use a simpler approach - just display the HTML with custom link handling + // We'll parse timestamps in the onLinkTap handler + return Html( + data: _processTimestamps(content), + extensions: const [ + SvgHtmlExtension(), + TableHtmlExtension(), + ], + style: { + 'html': Style( + fontSize: FontSize(16.25), + lineHeight: LineHeight.percent(110), + ), + 'p': Style( + margin: Margins.only( + top: 0, + bottom: 12, + ), + ), + '.timestamp': Style( + color: const Color(0xFF539e8a), + textDecoration: TextDecoration.underline, + ), + }, + onLinkTap: (url, _, __) { + if (url != null && url.startsWith('timestamp:') && onTimestampTap != null) { + // Handle timestamp links + final secondsStr = url.substring(10); // Remove 'timestamp:' prefix + final seconds = int.tryParse(secondsStr); + if (seconds != null) { + final duration = Duration(seconds: seconds); + onTimestampTap!(duration); + } + } else if (url != null) { + // Handle regular links + canLaunchUrl(Uri.parse(url)).then((value) => launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + )); + } + }, + ); + } + + /// Parses content and wraps timestamps with clickable links + String _processTimestamps(String htmlContent) { + // Regex pattern to match timestamp formats: + // - MM:SS (e.g., 43:53) + // - H:MM:SS (e.g., 1:23:45) + // - HH:MM:SS (e.g., 12:34:56) + final timestampRegex = RegExp(r'\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b'); + + return htmlContent.replaceAllMapped(timestampRegex, (match) { + final fullMatch = match.group(0)!; + final hours = match.group(1); + final minutes = match.group(2)!; + final seconds = match.group(3)!; + + // Calculate total seconds for the timestamp + int totalSeconds = int.parse(seconds); + totalSeconds += int.parse(minutes) * 60; + if (hours != null) { + totalSeconds += int.parse(hours) * 3600; + } + + // Return the timestamp wrapped in a clickable link + return '$fullMatch'; + }); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/episode_filter_selector.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_filter_selector.dart new file mode 100644 index 0000000..d319ca2 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_filter_selector.dart @@ -0,0 +1,212 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:pinepods_mobile/ui/widgets/slider_handle.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// This widget allows the user to filter the episodes. +class EpisodeFilterSelectorWidget extends StatefulWidget { + final Podcast? podcast; + + const EpisodeFilterSelectorWidget({ + required this.podcast, + super.key, + }); + + @override + State createState() => _EpisodeFilterSelectorWidgetState(); +} + +class _EpisodeFilterSelectorWidgetState extends State { + @override + Widget build(BuildContext context) { + var podcastBloc = Provider.of(context); + var theme = Theme.of(context); + + return StreamBuilder>( + stream: podcastBloc.details, + initialData: BlocEmptyState(), + builder: (context, snapshot) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 48.0, + width: 48.0, + child: Center( + child: IconButton( + icon: Icon( + widget.podcast == null || widget.podcast!.filter == PodcastEpisodeFilter.none + ? Icons.filter_alt_outlined + : Icons.filter_alt_off_outlined, + semanticLabel: L.of(context)!.episode_filter_semantic_label, + ), + visualDensity: VisualDensity.compact, + onPressed: widget.podcast != null && widget.podcast!.subscribed + ? () { + showModalBottomSheet( + isScrollControlled: true, + barrierLabel: L.of(context)!.scrim_episode_filter_selector, + context: context, + backgroundColor: theme.secondaryHeaderColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + builder: (context) { + return EpisodeFilterSlider( + podcast: widget.podcast!, + ); + }); + } + : null, + ), + ), + ), + ], + ); + }); + } +} + +class EpisodeFilterSlider extends StatefulWidget { + final Podcast podcast; + + const EpisodeFilterSlider({ + required this.podcast, + super.key, + }); + + @override + State createState() => _EpisodeFilterSliderState(); +} + +class _EpisodeFilterSliderState extends State { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SliderHandle(), + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Semantics( + header: true, + child: Text( + 'Episode Filter', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + shrinkWrap: true, + children: [ + const Divider(), + EpisodeFilterSelectorEntry( + label: L.of(context)!.episode_filter_none_label, + filter: PodcastEpisodeFilter.none, + selectedFilter: widget.podcast.filter, + ), + const Divider(), + EpisodeFilterSelectorEntry( + label: L.of(context)!.episode_filter_started_label, + filter: PodcastEpisodeFilter.started, + selectedFilter: widget.podcast.filter, + ), + const Divider(), + EpisodeFilterSelectorEntry( + label: L.of(context)!.episode_filter_played_label, + filter: PodcastEpisodeFilter.played, + selectedFilter: widget.podcast.filter, + ), + const Divider(), + EpisodeFilterSelectorEntry( + label: L.of(context)!.episode_filter_unplayed_label, + filter: PodcastEpisodeFilter.notPlayed, + selectedFilter: widget.podcast.filter, + ), + const Divider(), + ], + ), + ) + ]); + } +} + +class EpisodeFilterSelectorEntry extends StatelessWidget { + const EpisodeFilterSelectorEntry({ + super.key, + required this.label, + required this.filter, + required this.selectedFilter, + }); + + final String label; + final PodcastEpisodeFilter filter; + final PodcastEpisodeFilter selectedFilter; + + @override + Widget build(BuildContext context) { + final podcastBloc = Provider.of(context); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + switch (filter) { + case PodcastEpisodeFilter.none: + podcastBloc.podcastEvent(PodcastEvent.episodeFilterNone); + break; + case PodcastEpisodeFilter.started: + podcastBloc.podcastEvent(PodcastEvent.episodeFilterStarted); + break; + case PodcastEpisodeFilter.played: + podcastBloc.podcastEvent(PodcastEvent.episodeFilterFinished); + break; + case PodcastEpisodeFilter.notPlayed: + podcastBloc.podcastEvent(PodcastEvent.episodeFilterNotFinished); + break; + } + + Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.only( + top: 4.0, + bottom: 4.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Semantics( + selected: filter == selectedFilter, + child: Text( + label, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + if (filter == selectedFilter) + const Icon( + Icons.check, + size: 18.0, + ), + ], + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/episode_sort_selector.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_sort_selector.dart new file mode 100644 index 0000000..61a839b --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_sort_selector.dart @@ -0,0 +1,219 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:pinepods_mobile/ui/widgets/slider_handle.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// This widget allows the user to filter the episodes. +class EpisodeSortSelectorWidget extends StatefulWidget { + final Podcast? podcast; + + const EpisodeSortSelectorWidget({ + required this.podcast, + super.key, + }); + + @override + State createState() => _EpisodeSortSelectorWidgetState(); +} + +class _EpisodeSortSelectorWidgetState extends State { + @override + Widget build(BuildContext context) { + var podcastBloc = Provider.of(context); + var theme = Theme.of(context); + + return StreamBuilder>( + stream: podcastBloc.details, + initialData: BlocEmptyState(), + builder: (context, snapshot) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 48.0, + width: 48.0, + child: Center( + child: IconButton( + icon: Icon( + Icons.sort, + semanticLabel: L.of(context)!.episode_sort_semantic_label, + ), + visualDensity: VisualDensity.compact, + onPressed: widget.podcast != null && widget.podcast!.subscribed + ? () { + showModalBottomSheet( + barrierLabel: L.of(context)!.scrim_episode_sort_selector, + isScrollControlled: true, + context: context, + backgroundColor: theme.secondaryHeaderColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + builder: (context) { + return EpisodeSortSlider( + podcast: widget.podcast!, + ); + }); + } + : null, + ), + ), + ), + ], + ); + }); + } +} + +class EpisodeSortSlider extends StatefulWidget { + final Podcast podcast; + + const EpisodeSortSlider({ + required this.podcast, + super.key, + }); + + @override + State createState() => _EpisodeSortSliderState(); +} + +class _EpisodeSortSliderState extends State { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SliderHandle(), + Semantics( + header: true, + child: Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Text( + L.of(context)!.episode_sort_semantic_label, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + shrinkWrap: true, + children: [ + const Divider(), + EpisodeSortSelectorEntry( + label: L.of(context)!.episode_sort_none_label, + sort: PodcastEpisodeSort.none, + selectedSort: widget.podcast.sort, + ), + const Divider(), + EpisodeSortSelectorEntry( + label: L.of(context)!.episode_sort_latest_first_label, + sort: PodcastEpisodeSort.latestFirst, + selectedSort: widget.podcast.sort, + ), + const Divider(), + EpisodeSortSelectorEntry( + label: L.of(context)!.episode_sort_earliest_first_label, + sort: PodcastEpisodeSort.earliestFirst, + selectedSort: widget.podcast.sort, + ), + const Divider(), + EpisodeSortSelectorEntry( + label: L.of(context)!.episode_sort_alphabetical_ascending_label, + sort: PodcastEpisodeSort.alphabeticalAscending, + selectedSort: widget.podcast.sort, + ), + const Divider(), + EpisodeSortSelectorEntry( + label: L.of(context)!.episode_sort_alphabetical_descending_label, + sort: PodcastEpisodeSort.alphabeticalDescending, + selectedSort: widget.podcast.sort, + ), + const Divider(), + ], + ), + ) + ]); + } +} + +class EpisodeSortSelectorEntry extends StatelessWidget { + const EpisodeSortSelectorEntry({ + super.key, + required this.label, + required this.sort, + required this.selectedSort, + }); + + final String label; + final PodcastEpisodeSort sort; + final PodcastEpisodeSort selectedSort; + + @override + Widget build(BuildContext context) { + final podcastBloc = Provider.of(context); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + switch (sort) { + case PodcastEpisodeSort.none: + podcastBloc.podcastEvent(PodcastEvent.episodeSortDefault); + break; + case PodcastEpisodeSort.latestFirst: + podcastBloc.podcastEvent(PodcastEvent.episodeSortLatest); + break; + case PodcastEpisodeSort.earliestFirst: + podcastBloc.podcastEvent(PodcastEvent.episodeSortEarliest); + break; + case PodcastEpisodeSort.alphabeticalAscending: + podcastBloc.podcastEvent(PodcastEvent.episodeSortAlphabeticalAscending); + break; + case PodcastEpisodeSort.alphabeticalDescending: + podcastBloc.podcastEvent(PodcastEvent.episodeSortAlphabeticalDescending); + break; + } + + Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.only( + top: 4.0, + bottom: 4.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Semantics( + selected: sort == selectedSort, + child: Text( + label, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + if (sort == selectedSort) + const Icon( + Icons.check, + size: 18.0, + ), + ], + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/episode_tile.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_tile.dart new file mode 100644 index 0000000..2061935 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/episode_tile.dart @@ -0,0 +1,957 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/state/queue_event_state.dart'; +import 'package:pinepods_mobile/ui/podcast/episode_details.dart'; +import 'package:pinepods_mobile/ui/podcast/transport_controls.dart'; +import 'package:pinepods_mobile/ui/widgets/action_text.dart'; +import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dialogs/flutter_dialogs.dart'; +import 'package:intl/intl.dart' show DateFormat; +import 'package:provider/provider.dart'; +import 'package:rxdart/rxdart.dart'; + +/// This class builds a tile for each episode in the podcast feed. +class EpisodeTile extends StatelessWidget { + final Episode episode; + final bool download; + final bool play; + final bool playing; + final bool queued; + + const EpisodeTile({ + super.key, + required this.episode, + required this.download, + required this.play, + this.playing = false, + this.queued = false, + }); + + @override + Widget build(BuildContext context) { + final mediaQueryData = MediaQuery.of(context); + + if (mediaQueryData.accessibleNavigation) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return _CupertinoAccessibleEpisodeTile( + episode: episode, + download: download, + play: play, + playing: playing, + queued: queued, + ); + } else { + return _AccessibleEpisodeTile( + episode: episode, + download: download, + play: play, + playing: playing, + queued: queued, + ); + } + } else { + return ExpandableEpisodeTile( + episode: episode, + download: download, + play: play, + playing: playing, + queued: queued, + ); + } + } +} + +/// An EpisodeTitle is built with an [ExpansionTile] widget and displays the episode's +/// basic details, thumbnail and play button. +/// +/// It can then be expanded to present addition information about the episode and further +/// controls. +/// +/// TODO: Replace [Opacity] with [Container] with a transparent colour. +class ExpandableEpisodeTile extends StatefulWidget { + final Episode episode; + final bool download; + final bool play; + final bool playing; + final bool queued; + + const ExpandableEpisodeTile({ + super.key, + required this.episode, + required this.download, + required this.play, + this.playing = false, + this.queued = false, + }); + + @override + State createState() => _ExpandableEpisodeTileState(); +} + +class _ExpandableEpisodeTileState extends State { + bool expanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = Theme.of(context).textTheme; + final episodeBloc = Provider.of(context); + final queueBloc = Provider.of(context); + + return ExpansionTile( + tilePadding: const EdgeInsets.fromLTRB(16.0, 0.0, 8.0, 0.0), + key: Key('PT${widget.episode.guid}'), + onExpansionChanged: (isExpanded) { + setState(() { + expanded = isExpanded; + }); + }, + trailing: Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: EpisodeTransportControls( + episode: widget.episode, + download: widget.download, + play: widget.play, + ), + ), + leading: ExcludeSemantics( + child: Stack( + alignment: Alignment.bottomLeft, + fit: StackFit.passthrough, + children: [ + Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: TileImage( + url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!, + size: 56.0, + highlight: widget.episode.highlight, + ), + ), + SizedBox( + height: 5.0, + width: 56.0 * (widget.episode.percentagePlayed / 100), + child: Container( + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + subtitle: Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: EpisodeSubtitle(widget.episode), + ), + title: Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: Text( + widget.episode.title!, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: false, + style: textTheme.bodyMedium, + ), + ), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + child: Text( + widget.episode.descriptionText!, + overflow: TextOverflow.ellipsis, + softWrap: false, + maxLines: 5, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: 14, + fontWeight: FontWeight.normal, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)), + ), + onPressed: widget.episode.downloaded + ? () { + showPlatformDialog( + context: context, + useRootNavigator: false, + builder: (_) => BasicDialogAlert( + title: Text( + L.of(context)!.delete_episode_title, + ), + content: Text(L.of(context)!.delete_episode_confirmation), + actions: [ + BasicDialogAction( + title: ActionText( + L.of(context)!.cancel_button_label, + ), + onPressed: () { + Navigator.pop(context); + }, + ), + BasicDialogAction( + title: ActionText( + L.of(context)!.delete_button_label, + ), + iosIsDefaultAction: true, + iosIsDestructiveAction: true, + onPressed: () { + episodeBloc.deleteDownload(widget.episode); + Navigator.pop(context); + }, + ), + ], + ), + ); + } + : null, + child: Column( + children: [ + Icon( + Icons.delete_outline, + semanticLabel: L.of(context)!.delete_episode_button_label, + size: 22, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 2.0), + ), + ExcludeSemantics( + child: Text( + L.of(context)!.delete_label, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + ), + ), + ], + ), + ), + ), + Expanded( + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)), + ), + onPressed: widget.playing + ? null + : () { + if (widget.queued) { + queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode)); + } else { + queueBloc.queueEvent(QueueAddEvent(episode: widget.episode)); + } + }, + child: Column( + children: [ + Icon( + widget.queued ? Icons.playlist_add_check_outlined : Icons.playlist_add_outlined, + semanticLabel: widget.queued + ? L.of(context)!.semantics_remove_from_queue + : L.of(context)!.semantics_add_to_queue, + size: 22, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 2.0), + ), + ExcludeSemantics( + child: Text( + widget.queued ? 'Remove' : 'Add', + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + ), + ), + ], + ), + ), + ), + Expanded( + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)), + ), + onPressed: () { + episodeBloc.togglePlayed(widget.episode); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + widget.episode.played ? Icons.unpublished_outlined : Icons.check_circle_outline, + size: 22, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 2.0), + ), + Text( + widget.episode.played ? L.of(context)!.mark_unplayed_label : L.of(context)!.mark_played_label, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ), + ), + Expanded( + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)), + ), + onPressed: () { + showModalBottomSheet( + barrierLabel: L.of(context)!.scrim_episode_details_selector, + context: context, + backgroundColor: theme.bottomAppBarTheme.color, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10.0), + topRight: Radius.circular(10.0), + ), + ), + builder: (context) { + return EpisodeDetails( + episode: widget.episode, + ); + }); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.unfold_more_outlined, + size: 22, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 2.0), + ), + Text( + L.of(context)!.more_label, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +/// This is an accessible version of the episode tile that uses Apple theming. +/// When the tile is tapped, an iOS menu will appear with the relevant options. +class _CupertinoAccessibleEpisodeTile extends StatefulWidget { + final Episode episode; + final bool download; + final bool play; + final bool playing; + final bool queued; + + const _CupertinoAccessibleEpisodeTile({ + required this.episode, + required this.download, + required this.play, + this.playing = false, + this.queued = false, + }); + + @override + State<_CupertinoAccessibleEpisodeTile> createState() => _CupertinoAccessibleEpisodeTileState(); +} + +class _CupertinoAccessibleEpisodeTileState extends State<_CupertinoAccessibleEpisodeTile> { + bool expanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = Theme.of(context).textTheme; + final audioBloc = Provider.of(context, listen: false); + final episodeBloc = Provider.of(context); + final podcastBloc = Provider.of(context); + final queueBloc = Provider.of(context); + + return StreamBuilder<_PlayerControlState>( + stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!, + (AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Container(); + } + + final audioState = snapshot.data!.audioState; + final nowPlaying = snapshot.data!.episode; + final currentlyPlaying = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.playing; + final currentlyPaused = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.pausing; + + return Semantics( + button: true, + child: ListTile( + key: Key('PT${widget.episode.guid}'), + leading: ExcludeSemantics( + child: Stack( + alignment: Alignment.bottomLeft, + fit: StackFit.passthrough, + children: [ + Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: TileImage( + url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!, + size: 56.0, + highlight: widget.episode.highlight, + ), + ), + SizedBox( + height: 5.0, + width: 56.0 * (widget.episode.percentagePlayed / 100), + child: Container( + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + subtitle: Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: EpisodeSubtitle(widget.episode), + ), + title: Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: Text( + widget.episode.title!, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: false, + style: textTheme.bodyMedium, + ), + ), + onTap: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + if (currentlyPlaying) + CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + audioBloc.transitionState(TransitionState.pause); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.pause_button_label), + ), + if (currentlyPaused) + CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + audioBloc.transitionState(TransitionState.play); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.resume_button_label), + ), + if (!currentlyPlaying && !currentlyPaused) + CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + final settings = Provider.of(context, listen: false).currentSettings; + audioBloc.play(widget.episode); + optionalShowNowPlaying(context, settings); + Navigator.pop(context, 'Cancel'); + }, + child: widget.episode.downloaded + ? Text(L.of(context)!.play_download_button_label) + : Text(L.of(context)!.play_button_label), + ), + if (widget.episode.downloadState == DownloadState.queued || + widget.episode.downloadState == DownloadState.downloading) + CupertinoActionSheetAction( + isDefaultAction: false, + onPressed: () { + episodeBloc.deleteDownload(widget.episode); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.cancel_download_button_label), + ), + if (widget.episode.downloadState != DownloadState.downloading) + CupertinoActionSheetAction( + isDefaultAction: false, + onPressed: () { + if (widget.episode.downloaded) { + episodeBloc.deleteDownload(widget.episode); + Navigator.pop(context, 'Cancel'); + } else { + podcastBloc.downloadEpisode(widget.episode); + Navigator.pop(context, 'Cancel'); + } + }, + child: widget.episode.downloaded + ? Text(L.of(context)!.delete_episode_button_label) + : Text(L.of(context)!.download_episode_button_label), + ), + if (!currentlyPlaying && !widget.queued) + CupertinoActionSheetAction( + isDefaultAction: false, + onPressed: () { + queueBloc.queueEvent(QueueAddEvent(episode: widget.episode)); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.semantics_add_to_queue), + ), + if (!currentlyPlaying && widget.queued) + CupertinoActionSheetAction( + isDefaultAction: false, + onPressed: () { + queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode)); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.semantics_remove_from_queue), + ), + if (widget.episode.played) + CupertinoActionSheetAction( + isDefaultAction: false, + onPressed: () { + episodeBloc.togglePlayed(widget.episode); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.semantics_mark_episode_unplayed), + ), + if (!widget.episode.played) + CupertinoActionSheetAction( + isDefaultAction: false, + onPressed: () { + episodeBloc.togglePlayed(widget.episode); + Navigator.pop(context, 'Cancel'); + }, + child: Text(L.of(context)!.semantics_mark_episode_played), + ), + CupertinoActionSheetAction( + isDefaultAction: false, + onPressed: () { + Navigator.pop(context, 'Cancel'); + showModalBottomSheet( + context: context, + barrierLabel: L.of(context)!.scrim_episode_details_selector, + backgroundColor: theme.bottomAppBarTheme.color, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10.0), + topRight: Radius.circular(10.0), + ), + ), + builder: (context) { + return EpisodeDetails( + episode: widget.episode, + ); + }); + }, + child: Text(L.of(context)!.episode_details_button_label), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: false, + onPressed: () { + Navigator.pop(context, 'Close'); + }, + child: Text(L.of(context)!.close_button_label), + ), + ); + }, + ); + }, + ), + ); + }); + } +} + +/// This is an accessible version of the episode tile that uses Android theming. +/// When the tile is tapped, an Android dialog menu will appear with the relevant +/// options. +class _AccessibleEpisodeTile extends StatefulWidget { + final Episode episode; + final bool download; + final bool play; + final bool playing; + final bool queued; + + const _AccessibleEpisodeTile({ + required this.episode, + required this.download, + required this.play, + this.playing = false, + this.queued = false, + }); + + @override + State<_AccessibleEpisodeTile> createState() => _AccessibleEpisodeTileState(); +} + +class _AccessibleEpisodeTileState extends State<_AccessibleEpisodeTile> { + bool expanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = Theme.of(context).textTheme; + final audioBloc = Provider.of(context, listen: false); + final episodeBloc = Provider.of(context); + final podcastBloc = Provider.of(context); + final queueBloc = Provider.of(context); + + return StreamBuilder<_PlayerControlState>( + stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!, + (AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Container(); + } + + final audioState = snapshot.data!.audioState; + final nowPlaying = snapshot.data!.episode; + final currentlyPlaying = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.playing; + final currentlyPaused = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.pausing; + + return ListTile( + key: Key('PT${widget.episode.guid}'), + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return Semantics( + header: true, + child: SimpleDialog( + //TODO: Fix this - should not be hardcoded text + title: const Text('Episode Actions'), + children: [ + if (currentlyPlaying) + SimpleDialogOption( + onPressed: () { + audioBloc.transitionState(TransitionState.pause); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.pause_button_label), + ), + if (currentlyPaused) + SimpleDialogOption( + onPressed: () { + audioBloc.transitionState(TransitionState.play); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.resume_button_label), + ), + if (!currentlyPlaying && !currentlyPaused && widget.episode.downloaded) + SimpleDialogOption( + onPressed: () { + final settings = Provider.of(context, listen: false).currentSettings; + audioBloc.play(widget.episode); + optionalShowNowPlaying(context, settings); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.play_download_button_label), + ), + if (!currentlyPlaying && !currentlyPaused && !widget.episode.downloaded) + SimpleDialogOption( + onPressed: () { + final settings = Provider.of(context, listen: false).currentSettings; + audioBloc.play(widget.episode); + optionalShowNowPlaying(context, settings); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.play_button_label), + ), + if (widget.episode.downloadState == DownloadState.queued || + widget.episode.downloadState == DownloadState.downloading) + SimpleDialogOption( + onPressed: () { + episodeBloc.deleteDownload(widget.episode); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.cancel_download_button_label), + ), + if (widget.episode.downloadState != DownloadState.downloading && widget.episode.downloaded) + SimpleDialogOption( + onPressed: () { + episodeBloc.deleteDownload(widget.episode); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.delete_episode_button_label), + ), + if (widget.episode.downloadState != DownloadState.downloading && !widget.episode.downloaded) + SimpleDialogOption( + onPressed: () { + podcastBloc.downloadEpisode(widget.episode); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.download_episode_button_label), + ), + if (!currentlyPlaying && !widget.queued) + SimpleDialogOption( + onPressed: () { + queueBloc.queueEvent(QueueAddEvent(episode: widget.episode)); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.semantics_add_to_queue), + ), + if (!currentlyPlaying && widget.queued) + SimpleDialogOption( + onPressed: () { + queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode)); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.semantics_remove_from_queue), + ), + if (widget.episode.played) + SimpleDialogOption( + onPressed: () { + episodeBloc.togglePlayed(widget.episode); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.semantics_mark_episode_unplayed), + ), + if (!widget.episode.played) + SimpleDialogOption( + onPressed: () { + episodeBloc.togglePlayed(widget.episode); + Navigator.pop(context, ''); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.semantics_mark_episode_played), + ), + SimpleDialogOption( + onPressed: () { + Navigator.pop(context, ''); + showModalBottomSheet( + context: context, + barrierLabel: L.of(context)!.scrim_episode_details_selector, + backgroundColor: theme.bottomAppBarTheme.color, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10.0), + topRight: Radius.circular(10.0), + ), + ), + builder: (context) { + return EpisodeDetails( + episode: widget.episode, + ); + }); + }, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: Text(L.of(context)!.episode_details_button_label), + ), + SimpleDialogOption( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + // child: Text(L.of(context)!.close_button_label), + child: Align( + alignment: Alignment.centerRight, + child: TextButton( + child: ActionText(L.of(context)!.close_button_label), + onPressed: () { + Navigator.pop(context, ''); + }, + ), + ), + ), + ], + ), + ); + }, + ); + }, + leading: ExcludeSemantics( + child: Stack( + alignment: Alignment.bottomLeft, + fit: StackFit.passthrough, + children: [ + Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: TileImage( + url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!, + size: 56.0, + highlight: widget.episode.highlight, + ), + ), + SizedBox( + height: 5.0, + width: 56.0 * (widget.episode.percentagePlayed / 100), + child: Container( + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + subtitle: Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: EpisodeSubtitle(widget.episode), + ), + title: Opacity( + opacity: widget.episode.played ? 0.5 : 1.0, + child: Text( + widget.episode.title!, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: false, + style: textTheme.bodyMedium, + ), + ), + ); + }); + } +} + +class EpisodeTransportControls extends StatelessWidget { + final Episode episode; + final bool download; + final bool play; + + const EpisodeTransportControls({ + super.key, + required this.episode, + required this.download, + required this.play, + }); + + @override + Widget build(BuildContext context) { + final buttons = []; + + if (download) { + buttons.add(Semantics( + container: true, + child: DownloadControl( + episode: episode, + ), + )); + } + + if (play) { + buttons.add(Semantics( + container: true, + child: PlayControl( + episode: episode, + ), + )); + } + + return SizedBox( + width: (buttons.length * 48.0), + child: Row( + children: [...buttons], + ), + ); + } +} + +class EpisodeSubtitle extends StatelessWidget { + final Episode episode; + final String date; + final Duration length; + + EpisodeSubtitle(this.episode, {super.key}) + : date = episode.publicationDate == null + ? '' + : DateFormat(episode.publicationDate!.year == DateTime.now().year ? 'd MMM' : 'd MMM yyyy') + .format(episode.publicationDate!), + length = Duration(seconds: episode.duration); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + var timeRemaining = episode.timeRemaining; + + String title; + + if (length.inSeconds > 0) { + if (length.inSeconds < 60) { + title = '$date • ${length.inSeconds} sec'; + } else { + title = '$date • ${length.inMinutes} min'; + } + } else { + title = date; + } + + if (timeRemaining.inSeconds > 0) { + if (timeRemaining.inSeconds < 60) { + title = '$title / ${timeRemaining.inSeconds} sec left'; + } else { + title = '$title / ${timeRemaining.inMinutes} min left'; + } + } + + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + title, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: textTheme.bodySmall, + ), + ); + } +} + +/// This class acts as a wrapper between the current audio state and +/// downloadables. Saves all that nesting of StreamBuilders. +class _PlayerControlState { + final AudioState audioState; + final Episode? episode; + + _PlayerControlState(this.audioState, this.episode); +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/layout_selector.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/layout_selector.dart new file mode 100644 index 0000000..26b08ec --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/layout_selector.dart @@ -0,0 +1,148 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/slider_handle.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Allows the user to select the layout for the library and discovery panes. +/// Can select from a list or different sized grids. +class LayoutSelectorWidget extends StatefulWidget { + const LayoutSelectorWidget({ + super.key, + }); + + @override + State createState() => _LayoutSelectorWidgetState(); +} + +class _LayoutSelectorWidgetState extends State { + var speed = 1.0; + + @override + void initState() { + var settingsBloc = Provider.of(context, listen: false); + + speed = settingsBloc.currentSettings.playbackSpeed; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final settingsBloc = Provider.of(context, listen: false); + + return StreamBuilder( + stream: settingsBloc.settings, + initialData: AppSettings.sensibleDefaults(), + builder: (context, snapshot) { + final mode = snapshot.data!.layout; + + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SliderHandle(), + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + children: [ + const Padding( + padding: EdgeInsets.only(left: 8.0, right: 8.0), + child: Icon( + Icons.grid_view, + size: 18, + ), + ), + ExcludeSemantics( + child: Text( + L.of(context)!.layout_label, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + )), + Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + ), + child: OutlinedButton( + onPressed: () { + settingsBloc.layoutMode(0); + }, + style: OutlinedButton.styleFrom( + backgroundColor: mode == 0 ? Theme.of(context).primaryColor : null, + ), + child: Semantics( + selected: mode == 0, + label: L.of(context)!.semantics_layout_option_list, + child: Icon( + Icons.list, + color: mode == 0 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + right: 8.0, + ), + child: OutlinedButton( + onPressed: () { + settingsBloc.layoutMode(1); + }, + style: OutlinedButton.styleFrom( + backgroundColor: mode == 1 ? Theme.of(context).primaryColor : null, + ), + child: Semantics( + selected: mode == 1, + label: L.of(context)!.semantics_layout_option_compact_grid, + child: Icon( + Icons.grid_on, + color: mode == 1 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + right: 8.0, + ), + child: OutlinedButton( + onPressed: () { + settingsBloc.layoutMode(2); + }, + style: OutlinedButton.styleFrom( + backgroundColor: mode == 2 ? Theme.of(context).primaryColor : null, + ), + child: Semantics( + selected: mode == 2, + label: L.of(context)!.semantics_layout_option_grid, + child: Icon( + Icons.grid_view, + color: mode == 2 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor, + ), + ), + ), + ), + ]), + ), + const SizedBox( + height: 8.0, + ), + ], + ); + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/lazy_network_image.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/lazy_network_image.dart new file mode 100644 index 0000000..93bf186 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/lazy_network_image.dart @@ -0,0 +1,116 @@ +// lib/ui/widgets/lazy_network_image.dart +import 'package:flutter/material.dart'; + +class LazyNetworkImage extends StatefulWidget { + final String imageUrl; + final double width; + final double height; + final BoxFit fit; + final Widget? placeholder; + final Widget? errorWidget; + final BorderRadius? borderRadius; + + const LazyNetworkImage({ + super.key, + required this.imageUrl, + required this.width, + required this.height, + this.fit = BoxFit.cover, + this.placeholder, + this.errorWidget, + this.borderRadius, + }); + + @override + State createState() => _LazyNetworkImageState(); +} + +class _LazyNetworkImageState extends State { + bool _shouldLoad = false; + + Widget get _defaultPlaceholder => Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: widget.borderRadius, + ), + child: const Icon( + Icons.music_note, + color: Colors.grey, + size: 24, + ), + ); + + Widget get _defaultErrorWidget => Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: widget.borderRadius, + ), + child: const Icon( + Icons.broken_image, + color: Colors.grey, + size: 24, + ), + ); + + @override + void initState() { + super.initState(); + // Delay loading slightly to allow the widget to be built first + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _shouldLoad = true; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: widget.borderRadius ?? BorderRadius.zero, + child: _shouldLoad && widget.imageUrl.isNotEmpty + ? Image.network( + widget.imageUrl, + width: widget.width, + height: widget.height, + fit: widget.fit, + cacheWidth: (widget.width * 2).round(), // 2x for better quality on high-DPI + cacheHeight: (widget.height * 2).round(), + errorBuilder: (context, error, stackTrace) { + return widget.errorWidget ?? _defaultErrorWidget; + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: widget.borderRadius, + ), + child: Center( + child: SizedBox( + width: widget.width * 0.4, + height: widget.height * 0.4, + child: CircularProgressIndicator( + strokeWidth: 2, + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ), + ); + }, + ) + : widget.placeholder ?? _defaultPlaceholder, + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/offline_episode_tile.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/offline_episode_tile.dart new file mode 100644 index 0000000..d0d43a7 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/offline_episode_tile.dart @@ -0,0 +1,162 @@ +// lib/ui/widgets/offline_episode_tile.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:intl/intl.dart' show DateFormat; + +/// A custom episode tile specifically for offline downloaded episodes. +/// This bypasses the legacy PlayControl system and uses a custom play callback. +class OfflineEpisodeTile extends StatelessWidget { + final Episode episode; + final VoidCallback? onPlayPressed; + final VoidCallback? onTap; + + const OfflineEpisodeTile({ + super.key, + required this.episode, + this.onPlayPressed, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: ListTile( + onTap: onTap, + leading: Stack( + alignment: Alignment.bottomLeft, + children: [ + Opacity( + opacity: episode.played ? 0.5 : 1.0, + child: TileImage( + url: episode.thumbImageUrl ?? episode.imageUrl!, + size: 56.0, + highlight: episode.highlight, + ), + ), + // Progress indicator + SizedBox( + height: 5.0, + width: 56.0 * (episode.percentagePlayed / 100), + child: Container( + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + title: Opacity( + opacity: episode.played ? 0.5 : 1.0, + child: Text( + episode.title!, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: textTheme.bodyMedium, + ), + ), + subtitle: Opacity( + opacity: episode.played ? 0.5 : 1.0, + child: _EpisodeSubtitle(episode), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Offline indicator + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green[100], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.offline_pin, + size: 12, + color: Colors.green[700], + ), + const SizedBox(width: 4), + Text( + 'Offline', + style: TextStyle( + fontSize: 10, + color: Colors.green[700], + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Custom play button that bypasses legacy audio system + SizedBox( + width: 48, + height: 48, + child: IconButton( + onPressed: onPlayPressed, + icon: Icon( + Icons.play_arrow, + color: Theme.of(context).primaryColor, + ), + tooltip: L.of(context)?.play_button_label ?? 'Play', + ), + ), + ], + ), + ), + ); + } +} + +class _EpisodeSubtitle extends StatelessWidget { + final Episode episode; + final String date; + final Duration length; + + _EpisodeSubtitle(this.episode) + : date = episode.publicationDate == null + ? '' + : DateFormat(episode.publicationDate!.year == DateTime.now().year ? 'd MMM' : 'd MMM yyyy') + .format(episode.publicationDate!), + length = Duration(seconds: episode.duration); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + var timeRemaining = episode.timeRemaining; + + String title; + + if (length.inSeconds > 0) { + if (length.inSeconds < 60) { + title = '$date • ${length.inSeconds} sec'; + } else { + title = '$date • ${length.inMinutes} min'; + } + } else { + title = date; + } + + if (timeRemaining.inSeconds > 0) { + if (timeRemaining.inSeconds < 60) { + title = '$title / ${timeRemaining.inSeconds} sec left'; + } else { + title = '$title / ${timeRemaining.inMinutes} min left'; + } + } + + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + title, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: textTheme.bodySmall, + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/paginated_episode_list.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/paginated_episode_list.dart new file mode 100644 index 0000000..6affd30 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/paginated_episode_list.dart @@ -0,0 +1,174 @@ +// lib/ui/widgets/paginated_episode_list.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; +import 'package:pinepods_mobile/ui/widgets/episode_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/offline_episode_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/shimmer_episode_tile.dart'; + +class PaginatedEpisodeList extends StatefulWidget { + final List episodes; // Can be PinepodsEpisode or Episode + final bool isServerEpisodes; + final bool isOfflineMode; // New flag for offline mode + final Function(dynamic episode)? onEpisodeTap; + final Function(dynamic episode, int globalIndex)? onEpisodeLongPress; + final Function(dynamic episode)? onPlayPressed; + final int pageSize; + + const PaginatedEpisodeList({ + super.key, + required this.episodes, + required this.isServerEpisodes, + this.isOfflineMode = false, + this.onEpisodeTap, + this.onEpisodeLongPress, + this.onPlayPressed, + this.pageSize = 20, // Show 20 episodes at a time + }); + + @override + State createState() => _PaginatedEpisodeListState(); +} + +class _PaginatedEpisodeListState extends State { + int _currentPage = 0; + bool _isLoadingMore = false; + + int get _totalPages => (widget.episodes.length / widget.pageSize).ceil(); + int get _currentEndIndex => (_currentPage + 1) * widget.pageSize; + int get _displayedCount => _currentEndIndex.clamp(0, widget.episodes.length); + + List get _displayedEpisodes => + widget.episodes.take(_displayedCount).toList(); + + Future _loadMoreEpisodes() async { + if (_isLoadingMore || _currentPage + 1 >= _totalPages) return; + + setState(() { + _isLoadingMore = true; + }); + + // Simulate a small delay to show loading state + await Future.delayed(const Duration(milliseconds: 500)); + + setState(() { + _currentPage++; + _isLoadingMore = false; + }); + } + + Widget _buildEpisodeWidget(dynamic episode, int globalIndex) { + if (widget.isServerEpisodes && episode is PinepodsEpisode) { + return PinepodsEpisodeCard( + episode: episode, + onTap: widget.onEpisodeTap != null + ? () => widget.onEpisodeTap!(episode) + : null, + onLongPress: widget.onEpisodeLongPress != null + ? () => widget.onEpisodeLongPress!(episode, globalIndex) + : null, + onPlayPressed: widget.onPlayPressed != null + ? () => widget.onPlayPressed!(episode) + : null, + ); + } else if (!widget.isServerEpisodes && episode is Episode) { + // Use offline episode tile when in offline mode to bypass legacy audio system + if (widget.isOfflineMode) { + return OfflineEpisodeTile( + episode: episode, + onTap: widget.onEpisodeTap != null + ? () => widget.onEpisodeTap!(episode) + : null, + onPlayPressed: widget.onPlayPressed != null + ? () => widget.onPlayPressed!(episode) + : null, + ); + } else { + return EpisodeTile( + episode: episode, + download: false, + play: true, + ); + } + } + + return const SizedBox.shrink(); // Fallback + } + + @override + Widget build(BuildContext context) { + if (widget.episodes.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + // Display current episodes + ..._displayedEpisodes.asMap().entries.map((entry) { + final index = entry.key; + final episode = entry.value; + final globalIndex = widget.episodes.indexOf(episode); + + return _buildEpisodeWidget(episode, globalIndex); + }).toList(), + + // Loading shimmer for more episodes + if (_isLoadingMore) ...[ + ...List.generate(3, (index) => const ShimmerEpisodeTile()), + ], + + // Load more button or loading indicator + if (_currentPage + 1 < _totalPages && !_isLoadingMore) ...[ + const SizedBox(height: 8), + if (_isLoadingMore) + const Padding( + padding: EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Loading more episodes...'), + ], + ), + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _loadMoreEpisodes, + icon: const Icon(Icons.expand_more), + label: Text( + 'Load ${(_displayedCount + widget.pageSize).clamp(0, widget.episodes.length) - _displayedCount} more episodes ' + '(${widget.episodes.length - _displayedCount} remaining)', + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ), + ] else if (widget.episodes.length > widget.pageSize) ...[ + // Show completion message for large lists + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'All ${widget.episodes.length} episodes loaded', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ), + ], + ], + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_episode_card.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_episode_card.dart new file mode 100644 index 0000000..46611ed --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_episode_card.dart @@ -0,0 +1,168 @@ +// lib/ui/widgets/pinepods_episode_card.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/ui/widgets/lazy_network_image.dart'; + +class PinepodsEpisodeCard extends StatelessWidget { + final PinepodsEpisode episode; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onPlayPressed; + + const PinepodsEpisodeCard({ + Key? key, + required this.episode, + this.onTap, + this.onLongPress, + this.onPlayPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + elevation: 1, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode artwork with lazy loading + LazyNetworkImage( + imageUrl: episode.episodeArtwork, + width: 50, + height: 50, + fit: BoxFit.cover, + borderRadius: BorderRadius.circular(6), + ), + const SizedBox(width: 12), + + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + episode.episodeTitle, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + episode.podcastName, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + episode.formattedPubDate, + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 8), + Text( + episode.formattedDuration, + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + ], + ), + + // Progress bar if episode has been started + if (episode.isStarted) ...[ + const SizedBox(height: 6), + LinearProgressIndicator( + value: episode.progressPercentage / 100, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColor, + ), + minHeight: 2, + ), + ], + ], + ), + ), + + // Status indicators and play button + Column( + children: [ + if (onPlayPressed != null) + IconButton( + onPressed: onPlayPressed, + icon: Icon( + episode.completed + ? Icons.check_circle + : ((episode.listenDuration != null && episode.listenDuration! > 0) + ? Icons.play_circle_filled + : Icons.play_circle_outline), + color: episode.completed + ? Colors.green + : Theme.of(context).primaryColor, + size: 28, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (episode.saved) + Icon( + Icons.bookmark, + size: 16, + color: Colors.orange[600], + ), + if (episode.downloaded) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.download_done, + size: 16, + color: Colors.green[600], + ), + ), + if (episode.queued) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.queue_music, + size: 16, + color: Colors.blue[600], + ), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_podcast_grid_tile.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_podcast_grid_tile.dart new file mode 100644 index 0000000..7c364bf --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_podcast_grid_tile.dart @@ -0,0 +1,146 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; +import 'package:flutter/material.dart'; + +class PinepodsPodcastGridTile extends StatelessWidget { + final Podcast podcast; + + const PinepodsPodcastGridTile({ + super.key, + required this.podcast, + }); + + UnifiedPinepodsPodcast _convertToUnifiedPodcast() { + return UnifiedPinepodsPodcast( + id: podcast.id ?? 0, + indexId: 0, // Default value for subscribed podcasts + title: podcast.title, + url: podcast.url, + originalUrl: podcast.url, + link: podcast.link ?? '', + description: podcast.description ?? '', + author: podcast.copyright ?? '', + ownerName: podcast.copyright ?? '', + image: podcast.imageUrl ?? '', + artwork: podcast.imageUrl ?? '', + lastUpdateTime: 0, // Default value + categories: null, + explicit: false, // Default value + episodeCount: podcast.episodes.length, // Use actual episode count + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + final unifiedPodcast = _convertToUnifiedPodcast(); + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepods_podcast_details'), + builder: (context) => PinepodsPodcastDetails( + podcast: unifiedPodcast, + isFollowing: true, // These are subscribed podcasts + ), + ), + ); + }, + child: Semantics( + label: podcast.title, + child: GridTile( + child: Hero( + key: Key('tilehero${podcast.imageUrl}:${podcast.link}'), + tag: '${podcast.imageUrl}:${podcast.link}', + child: TileImage( + url: podcast.imageUrl!, + size: 18.0, + ), + ), + ), + ), + ); + } +} + +class PinepodsPodcastTitledGridTile extends StatelessWidget { + final Podcast podcast; + + const PinepodsPodcastTitledGridTile({ + super.key, + required this.podcast, + }); + + UnifiedPinepodsPodcast _convertToUnifiedPodcast() { + return UnifiedPinepodsPodcast( + id: podcast.id ?? 0, + indexId: 0, // Default value for subscribed podcasts + title: podcast.title, + url: podcast.url, + originalUrl: podcast.url, + link: podcast.link ?? '', + description: podcast.description ?? '', + author: podcast.copyright ?? '', + ownerName: podcast.copyright ?? '', + image: podcast.imageUrl ?? '', + artwork: podcast.imageUrl ?? '', + lastUpdateTime: 0, // Default value + categories: null, + explicit: false, // Default value + episodeCount: podcast.episodes.length, // Use actual episode count + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return GestureDetector( + onTap: () { + final unifiedPodcast = _convertToUnifiedPodcast(); + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepods_podcast_details'), + builder: (context) => PinepodsPodcastDetails( + podcast: unifiedPodcast, + isFollowing: true, // These are subscribed podcasts + ), + ), + ); + }, + child: GridTile( + child: Hero( + key: Key('tilehero${podcast.imageUrl}:${podcast.link}'), + tag: '${podcast.imageUrl}:${podcast.link}', + child: Column( + children: [ + TileImage( + url: podcast.imageUrl!, + size: 128.0, + ), + Padding( + padding: const EdgeInsets.only( + top: 4.0, + ), + child: Text( + podcast.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_podcast_tile.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_podcast_tile.dart new file mode 100644 index 0000000..5e84271 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_podcast_tile.dart @@ -0,0 +1,77 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; +import 'package:flutter/material.dart'; + +class PinepodsPodcastTile extends StatelessWidget { + final Podcast podcast; + + const PinepodsPodcastTile({ + super.key, + required this.podcast, + }); + + UnifiedPinepodsPodcast _convertToUnifiedPodcast() { + return UnifiedPinepodsPodcast( + id: podcast.id ?? 0, + indexId: 0, // Default value for subscribed podcasts + title: podcast.title, + url: podcast.url, + originalUrl: podcast.url, + link: podcast.link ?? '', + description: podcast.description ?? '', + author: podcast.copyright ?? '', + ownerName: podcast.copyright ?? '', + image: podcast.imageUrl ?? '', + artwork: podcast.imageUrl ?? '', + lastUpdateTime: 0, // Default value + categories: null, + explicit: false, // Default value + episodeCount: podcast.episodes.length, // Use actual episode count + ); + } + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: () { + final unifiedPodcast = _convertToUnifiedPodcast(); + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepods_podcast_details'), + builder: (context) => PinepodsPodcastDetails( + podcast: unifiedPodcast, + isFollowing: true, // These are subscribed podcasts + ), + ), + ); + }, + minVerticalPadding: 9, + leading: ExcludeSemantics( + child: Hero( + key: Key('tilehero${podcast.imageUrl}:${podcast.link}'), + tag: '${podcast.imageUrl}:${podcast.link}', + child: TileImage( + url: podcast.imageUrl!, + size: 60, + ), + ), + ), + title: Text( + podcast.title, + maxLines: 1, + ), + subtitle: Text( + '${podcast.copyright ?? ''}\n', + maxLines: 2, + ), + isThreeLine: false, + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/placeholder_builder.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/placeholder_builder.dart new file mode 100644 index 0000000..c7ab5a5 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/placeholder_builder.dart @@ -0,0 +1,25 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'package:flutter/material.dart'; + +class PlaceholderBuilder extends InheritedWidget { + final WidgetBuilder Function() builder; + final WidgetBuilder Function() errorBuilder; + + const PlaceholderBuilder({ + super.key, + required this.builder, + required this.errorBuilder, + required super.child, + }); + + static PlaceholderBuilder? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(PlaceholderBuilder oldWidget) { + return builder != oldWidget.builder || errorBuilder != oldWidget.errorBuilder; + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/platform_back_button.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/platform_back_button.dart new file mode 100644 index 0000000..d9cc543 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/platform_back_button.dart @@ -0,0 +1,58 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:flutter/material.dart'; + +/// Simple widget for rendering either the standard Android close or iOS Back button. +class PlatformBackButton extends StatelessWidget { + final Color decorationColour; + final Color iconColour; + final VoidCallback onPressed; + + const PlatformBackButton({ + super.key, + required this.iconColour, + required this.decorationColour, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Semantics( + button: true, + child: Center( + child: SizedBox( + height: 48.0, + width: 48.0, + child: InkWell( + onTap: onPressed, + child: Container( + margin: const EdgeInsets.all(6.0), + height: 48.0, + width: 48.0, + decoration: ShapeDecoration( + color: decorationColour, + shape: const CircleBorder(), + ), + child: Padding( + padding: EdgeInsets.only(left: Platform.isIOS ? 8.0 : 0.0), + child: Icon( + Platform.isIOS ? Icons.arrow_back_ios : Icons.close, + size: Platform.isIOS ? 20.0 : 26.0, + semanticLabel: L.of(context)?.go_back_button_label, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/platform_progress_indicator.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/platform_progress_indicator.dart new file mode 100644 index 0000000..95a4920 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/platform_progress_indicator.dart @@ -0,0 +1,32 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// The class returns a circular progress indicator that is appropriate for the platform +/// it is running on. +/// +/// This boils down to a [CupertinoActivityIndicator] when running on iOS or MacOS +/// and a [CircularProgressIndicator] for everything else. +class PlatformProgressIndicator extends StatelessWidget { + const PlatformProgressIndicator({ + super.key, + }); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return const CircularProgressIndicator(); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return const CupertinoActivityIndicator(); + } + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/play_pause_button.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/play_pause_button.dart new file mode 100644 index 0000000..b63c47f --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/play_pause_button.dart @@ -0,0 +1,77 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:percent_indicator/percent_indicator.dart'; + +class PlayPauseButton extends StatelessWidget { + final IconData icon; + final String label; + final String title; + + const PlayPauseButton({ + super.key, + required this.icon, + required this.label, + required this.title, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + label: '$label $title', + child: CircularPercentIndicator( + radius: 19.0, + lineWidth: 1.5, + backgroundColor: Theme.of(context).primaryColor, + percent: 0.0, + center: Icon( + icon, + size: 22.0, + + /// Why is this not picking up the theme like other widgets?!?!?! + color: Theme.of(context).primaryColor, + ), + ), + ); + } +} + +class PlayPauseBusyButton extends StatelessWidget { + final IconData icon; + final String label; + final String title; + + const PlayPauseBusyButton({ + super.key, + required this.icon, + required this.label, + required this.title, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + label: '$label $title', + child: Stack( + children: [ + SizedBox( + height: 48.0, + width: 48.0, + child: Icon( + icon, + size: 22.0, + color: Theme.of(context).primaryColor, + ), + ), + SpinKitRing( + lineWidth: 1.5, + color: Theme.of(context).primaryColor, + size: 38.0, + ), + ], + )); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_grid_tile.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_grid_tile.dart new file mode 100644 index 0000000..eed15cf --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_grid_tile.dart @@ -0,0 +1,250 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/ui/podcast/podcast_details.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PodcastGridTile extends StatelessWidget { + final Podcast podcast; + + const PodcastGridTile({ + super.key, + required this.podcast, + }); + + @override + Widget build(BuildContext context) { + final podcastBloc = Provider.of(context); + + return GestureDetector( + onTap: () async { + await _navigateToPodcastDetails(context, podcastBloc); + }, + child: Semantics( + label: podcast.title, + child: GridTile( + child: Hero( + key: Key('tilehero${podcast.imageUrl}:${podcast.link}'), + tag: '${podcast.imageUrl}:${podcast.link}', + child: TileImage( + url: podcast.imageUrl!, + size: 18.0, + ), + ), + ), + ), + ); + } + + Future _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async { + // Check if this is a PinePods setup and if the podcast is already subscribed + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && + settings.pinepodsApiKey != null && + settings.pinepodsUserId != null) { + + // Check if podcast is already subscribed + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final isSubscribed = await pinepodsService.checkPodcastExists( + podcast.title, + podcast.url!, + settings.pinepodsUserId! + ); + + if (isSubscribed) { + // Get the internal PinePods database ID + final internalPodcastId = await pinepodsService.getPodcastId( + settings.pinepodsUserId!, + podcast.url!, + podcast.title + ); + + // Use PinePods podcast details for subscribed podcasts + final unifiedPodcast = UnifiedPinepodsPodcast( + id: internalPodcastId ?? 0, + indexId: 0, // Default for subscribed podcasts + title: podcast.title, + url: podcast.url ?? '', + originalUrl: podcast.url ?? '', + link: podcast.link ?? '', + description: podcast.description ?? '', + author: podcast.copyright ?? '', + ownerName: podcast.copyright ?? '', + image: podcast.imageUrl ?? '', + artwork: podcast.imageUrl ?? '', + lastUpdateTime: 0, + explicit: false, + episodeCount: 0, // Will be loaded + ); + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepodspodcastdetails'), + builder: (context) => PinepodsPodcastDetails( + podcast: unifiedPodcast, + isFollowing: true, + ), + ), + ); + } + return; + } + } catch (e) { + // If check fails, fall through to standard podcast details + print('Error checking subscription status: $e'); + } + } + + // Use standard podcast details for non-subscribed or non-PinePods setups + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'podcastdetails'), + builder: (context) => PodcastDetails(podcast, podcastBloc), + ), + ); + } + } +} + +class PodcastTitledGridTile extends StatelessWidget { + final Podcast podcast; + + const PodcastTitledGridTile({ + super.key, + required this.podcast, + }); + + @override + Widget build(BuildContext context) { + final podcastBloc = Provider.of(context); + final theme = Theme.of(context); + + return GestureDetector( + onTap: () async { + await _navigateToPodcastDetails(context, podcastBloc); + }, + child: GridTile( + child: Hero( + key: Key('tilehero${podcast.imageUrl}:${podcast.link}'), + tag: '${podcast.imageUrl}:${podcast.link}', + child: Column( + children: [ + TileImage( + url: podcast.imageUrl!, + size: 128.0, + ), + Padding( + padding: const EdgeInsets.only( + top: 4.0, + ), + child: Text( + podcast.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + ), + ), + ], + ), + ), + ), + ); + } + + Future _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async { + // Check if this is a PinePods setup and if the podcast is already subscribed + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && + settings.pinepodsApiKey != null && + settings.pinepodsUserId != null) { + + // Check if podcast is already subscribed + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final isSubscribed = await pinepodsService.checkPodcastExists( + podcast.title, + podcast.url!, + settings.pinepodsUserId! + ); + + if (isSubscribed) { + // Get the internal PinePods database ID + final internalPodcastId = await pinepodsService.getPodcastId( + settings.pinepodsUserId!, + podcast.url!, + podcast.title + ); + + // Use PinePods podcast details for subscribed podcasts + final unifiedPodcast = UnifiedPinepodsPodcast( + id: internalPodcastId ?? 0, + indexId: 0, // Default for subscribed podcasts + title: podcast.title, + url: podcast.url ?? '', + originalUrl: podcast.url ?? '', + link: podcast.link ?? '', + description: podcast.description ?? '', + author: podcast.copyright ?? '', + ownerName: podcast.copyright ?? '', + image: podcast.imageUrl ?? '', + artwork: podcast.imageUrl ?? '', + lastUpdateTime: 0, + explicit: false, + episodeCount: 0, // Will be loaded + ); + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepodspodcastdetails'), + builder: (context) => PinepodsPodcastDetails( + podcast: unifiedPodcast, + isFollowing: true, + ), + ), + ); + } + return; + } + } catch (e) { + // If check fails, fall through to standard podcast details + print('Error checking subscription status: $e'); + } + } + + // Use standard podcast details for non-subscribed or non-PinePods setups + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'podcastdetails'), + builder: (context) => PodcastDetails(podcast, podcastBloc), + ), + ); + } + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_html.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_html.dart new file mode 100644 index 0000000..fb1ef87 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_html.dart @@ -0,0 +1,50 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_html_svg/flutter_html_svg.dart'; +import 'package:flutter_html_table/flutter_html_table.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// This class is a simple, common wrapper around the flutter_html Html widget. +/// +/// This wrapper allows us to remove some of the HTML tags which can cause rendering +/// issues when viewing podcast descriptions on a mobile device. +class PodcastHtml extends StatelessWidget { + final String content; + final FontSize? fontSize; + + const PodcastHtml({ + super.key, + required this.content, + this.fontSize, + }); + + @override + Widget build(BuildContext context) { + return Html( + data: content, + extensions: const [ + SvgHtmlExtension(), + TableHtmlExtension(), + ], + style: { + 'html': Style( + fontSize: FontSize(16.25), + lineHeight: LineHeight.percent(110), + ), + 'p': Style( + margin: Margins.only( + top: 0, + bottom: 12, + ), + ), + }, + onLinkTap: (url, _, __) => canLaunchUrl(Uri.parse(url!)).then((value) => launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + )), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_image.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_image.dart new file mode 100644 index 0000000..d526ccb --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_image.dart @@ -0,0 +1,263 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/core/environment.dart'; +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; + +/// This class handles rendering of podcast images from a url. +/// Images will be cached for quicker fetching on subsequent requests. An optional placeholder +/// and error placeholder can be specified which will be rendered whilst the image is loading +/// or has failed to load. +/// +/// We cache the image at a fixed sized of 480 regardless of render size. By doing this, large +/// podcast artwork will not slow the application down and the same image rendered at different +/// sizes will return the same cache hit reducing the need for fetching the image several times +/// for differing render sizes. +// ignore: must_be_immutable +class PodcastImage extends StatefulWidget { + final String url; + final double height; + final double width; + final BoxFit fit; + final bool highlight; + final double borderRadius; + final Widget? placeholder; + final Widget? errorPlaceholder; + + const PodcastImage({ + super.key, + required this.url, + this.height = double.infinity, + this.width = double.infinity, + this.fit = BoxFit.cover, + this.placeholder, + this.errorPlaceholder, + this.highlight = false, + this.borderRadius = 0.0, + }); + + @override + State createState() => _PodcastImageState(); +} + +class _PodcastImageState extends State with TickerProviderStateMixin { + static const cacheWidth = 480; + + /// There appears to be a bug in extended image that causes images to + /// be re-fetched if headers have been set. We'll leave headers for now. + final headers = {'User-Agent': Environment.userAgent()}; + + @override + Widget build(BuildContext context) { + return ExtendedImage.network( + widget.url, + key: widget.key, + width: widget.height, + height: widget.width, + cacheWidth: cacheWidth, + fit: widget.fit, + cache: true, + loadStateChanged: (ExtendedImageState state) { + Widget renderWidget; + + if (state.extendedImageLoadState == LoadState.failed) { + renderWidget = ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)), + child: widget.errorPlaceholder ?? + SizedBox( + width: widget.width, + height: widget.height, + ), + ); + } else { + renderWidget = AnimatedCrossFade( + crossFadeState: state.wasSynchronouslyLoaded || state.extendedImageLoadState == LoadState.completed + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + firstChild: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)), + child: widget.placeholder ?? + SizedBox( + width: widget.width, + height: widget.height, + ), + ), + secondChild: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)), + child: ExtendedRawImage( + image: state.extendedImageInfo?.image, + fit: widget.fit, + ), + ), + layoutBuilder: ( + Widget topChild, + Key topChildKey, + Widget bottomChild, + Key bottomChildKey, + ) { + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: widget.highlight + ? [ + PositionedDirectional( + key: bottomChildKey, + child: bottomChild, + ), + PositionedDirectional( + key: topChildKey, + child: topChild, + ), + Positioned( + top: -1.5, + right: -1.5, + child: Container( + width: 13, + height: 13, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).canvasColor, + ), + ), + ), + Positioned( + top: 0.0, + right: 0.0, + child: Container( + width: 10.0, + height: 10.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).indicatorColor, + ), + ), + ), + ] + : [ + PositionedDirectional( + key: bottomChildKey, + child: bottomChild, + ), + PositionedDirectional( + key: topChildKey, + child: topChild, + ), + ], + ); + }, + ); + } + + return renderWidget; + }, + ); + } +} + +class PodcastBannerImage extends StatefulWidget { + final String url; + final double height; + final double width; + final BoxFit fit; + final double borderRadius; + final Widget? placeholder; + final Widget? errorPlaceholder; + + const PodcastBannerImage({ + super.key, + required this.url, + this.height = double.infinity, + this.width = double.infinity, + this.fit = BoxFit.cover, + this.placeholder, + this.errorPlaceholder, + this.borderRadius = 0.0, + }); + + @override + State createState() => _PodcastBannerImageState(); +} + +class _PodcastBannerImageState extends State with TickerProviderStateMixin { + static const cacheWidth = 480; + + /// There appears to be a bug in extended image that causes images to + /// be re-fetched if headers have been set. We'll leave headers for now. + final headers = {'User-Agent': Environment.userAgent()}; + + @override + Widget build(BuildContext context) { + return ExtendedImage.network( + widget.url, + key: widget.key, + width: widget.height, + height: widget.width, + cacheWidth: cacheWidth, + fit: widget.fit, + cache: true, + loadStateChanged: (ExtendedImageState state) { + Widget renderWidget; + + if (state.extendedImageLoadState == LoadState.failed) { + renderWidget = Container( + alignment: Alignment.topCenter, + width: widget.width - 2.0, + height: widget.height - 2.0, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)), + child: widget.errorPlaceholder ?? + SizedBox( + width: widget.width - 2.0, + height: widget.height - 2.0, + ), + ), + ); + } else { + renderWidget = AnimatedCrossFade( + crossFadeState: state.wasSynchronouslyLoaded || state.extendedImageLoadState == LoadState.completed + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(seconds: 1), + firstChild: widget.placeholder ?? + SizedBox( + width: widget.width, + height: widget.height, + ), + secondChild: ExtendedRawImage( + width: widget.width, + height: widget.height, + image: state.extendedImageInfo?.image, + fit: widget.fit, + ), + layoutBuilder: ( + Widget topChild, + Key topChildKey, + Widget bottomChild, + Key bottomChildKey, + ) { + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + PositionedDirectional( + key: bottomChildKey, + child: bottomChild, + ), + PositionedDirectional( + key: topChildKey, + child: topChild, + ), + ], + ); + }, + ); + } + + return renderWidget; + }, + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_list.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_list.dart new file mode 100644 index 0000000..67ff262 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_list.dart @@ -0,0 +1,99 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_grid_tile.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:podcast_search/podcast_search.dart' as search; +import 'package:provider/provider.dart'; + +class PodcastList extends StatelessWidget { + const PodcastList({ + super.key, + required this.results, + }); + + final search.SearchResult results; + + @override + Widget build(BuildContext context) { + final settingsBloc = Provider.of(context); + + if (results.items.isNotEmpty) { + return StreamBuilder( + stream: settingsBloc.settings, + builder: (context, settingsSnapshot) { + if (settingsSnapshot.hasData) { + var mode = settingsSnapshot.data!.layout; + var size = mode == 1 ? 100.0 : 160.0; + + if (mode == 0) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final i = results.items[index]; + final p = Podcast.fromSearchResultItem(i); + + return PodcastTile(podcast: p); + }, + childCount: results.items.length, + addAutomaticKeepAlives: false, + )); + } + return SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: size, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final i = results.items[index]; + final p = Podcast.fromSearchResultItem(i); + + return PodcastGridTile(podcast: p); + }, + childCount: results.items.length, + ), + ); + } else { + return const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox( + height: 0, + width: 0, + ), + ); + } + }); + } else { + return SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 75, + color: Theme.of(context).primaryColor, + ), + Text( + L.of(context)!.no_search_results_message, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_tile.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_tile.dart new file mode 100644 index 0000000..e83a1e2 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/podcast_tile.dart @@ -0,0 +1,136 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/podcast.dart'; +import 'package:pinepods_mobile/entities/pinepods_search.dart'; +import 'package:pinepods_mobile/ui/podcast/podcast_details.dart'; +import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart'; +import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PodcastTile extends StatelessWidget { + final Podcast podcast; + + const PodcastTile({ + super.key, + required this.podcast, + }); + + @override + Widget build(BuildContext context) { + final podcastBloc = Provider.of(context); + + return ListTile( + onTap: () async { + await _navigateToPodcastDetails(context, podcastBloc); + }, + minVerticalPadding: 9, + leading: ExcludeSemantics( + child: Hero( + key: Key('tilehero${podcast.imageUrl}:${podcast.link}'), + tag: '${podcast.imageUrl}:${podcast.link}', + child: TileImage( + url: podcast.imageUrl!, + size: 60, + ), + ), + ), + title: Text( + podcast.title, + maxLines: 1, + ), + + /// A ListTile's density changes depending upon whether we have 2 or more lines of text. We + /// manually add a newline character here to ensure the density is consistent whether the + /// podcast subtitle spans 1 or more lines. Bit of a hack, but a simple solution. + subtitle: Text( + '${podcast.copyright ?? ''}\n', + maxLines: 2, + ), + isThreeLine: false, + ); + } + + Future _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async { + // Check if this is a PinePods setup and if the podcast is already subscribed + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + if (settings.pinepodsServer != null && + settings.pinepodsApiKey != null && + settings.pinepodsUserId != null) { + + // Check if podcast is already subscribed + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + + try { + final isSubscribed = await pinepodsService.checkPodcastExists( + podcast.title, + podcast.url!, + settings.pinepodsUserId! + ); + + if (isSubscribed) { + // Get the internal PinePods database ID + final internalPodcastId = await pinepodsService.getPodcastId( + settings.pinepodsUserId!, + podcast.url!, + podcast.title + ); + + // Use PinePods podcast details for subscribed podcasts + final unifiedPodcast = UnifiedPinepodsPodcast( + id: internalPodcastId ?? 0, + indexId: 0, // Default for subscribed podcasts + title: podcast.title, + url: podcast.url ?? '', + originalUrl: podcast.url ?? '', + link: podcast.link ?? '', + description: podcast.description ?? '', + author: podcast.copyright ?? '', + ownerName: podcast.copyright ?? '', + image: podcast.imageUrl ?? '', + artwork: podcast.imageUrl ?? '', + lastUpdateTime: 0, + explicit: false, + episodeCount: 0, // Will be loaded + ); + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'pinepodspodcastdetails'), + builder: (context) => PinepodsPodcastDetails( + podcast: unifiedPodcast, + isFollowing: true, + ), + ), + ); + } + return; + } + } catch (e) { + // If check fails, fall through to standard podcast details + print('Error checking subscription status: $e'); + } + } + + // Use standard podcast details for non-subscribed or non-PinePods setups + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'podcastdetails'), + builder: (context) => PodcastDetails(podcast, podcastBloc), + ), + ); + } + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/restart_widget.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/restart_widget.dart new file mode 100644 index 0000000..9ccd1d1 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/restart_widget.dart @@ -0,0 +1,34 @@ +// lib/ui/widgets/restart_widget.dart + +import 'package:flutter/material.dart'; + +class RestartWidget extends StatefulWidget { + final Widget child; + + const RestartWidget({Key? key, required this.child}) : super(key: key); + + static void restartApp(BuildContext context) { + context.findAncestorStateOfType<_RestartWidgetState>()?.restartApp(); + } + + @override + _RestartWidgetState createState() => _RestartWidgetState(); +} + +class _RestartWidgetState extends State { + Key key = UniqueKey(); + + void restartApp() { + setState(() { + key = UniqueKey(); + }); + } + + @override + Widget build(BuildContext context) { + return KeyedSubtree( + key: key, + child: widget.child, + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/search_slide_route.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/search_slide_route.dart new file mode 100644 index 0000000..3e1fdb1 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/search_slide_route.dart @@ -0,0 +1,44 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// A transitioning route that slides the child in from the +/// right. +class SlideRightRoute extends PageRouteBuilder { + final Widget widget; + + @override + final RouteSettings settings; + + SlideRightRoute({ + required this.widget, + required this.settings, + }) : super( + pageBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return widget; + }, + settings: settings, + transitionsBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return SlideTransition( + position: Tween( + begin: const Offset( + 1.0, + 0.0, + ), + end: Offset.zero, + ).animate(animation), + child: child, + ); + }); +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/server_error_page.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/server_error_page.dart new file mode 100644 index 0000000..1c8d12a --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/server_error_page.dart @@ -0,0 +1,242 @@ +// lib/ui/widgets/server_error_page.dart +import 'package:flutter/material.dart'; + +class ServerErrorPage extends StatelessWidget { + final String? errorMessage; + final VoidCallback? onRetry; + final String? title; + final String? subtitle; + final bool showLogo; + + const ServerErrorPage({ + Key? key, + this.errorMessage, + this.onRetry, + this.title, + this.subtitle, + this.showLogo = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 48.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // PinePods Logo + if (showLogo) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.asset( + 'assets/images/pinepods-logo.png', + width: 120, + height: 120, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + // Fallback if logo image fails to load + return Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.podcasts, + size: 64, + color: Theme.of(context).primaryColor, + ), + ); + }, + ), + ), + const SizedBox(height: 32), + ], + + // Error Icon + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.cloud_off_rounded, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + ), + const SizedBox(height: 24), + + // Title + Text( + title ?? 'Server Unavailable', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + + // Subtitle + Text( + subtitle ?? 'Unable to connect to the PinePods server', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + + // Error Message (if provided) + if (errorMessage != null && errorMessage!.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.error.withOpacity(0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 6), + Text( + 'Error Details', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + errorMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + + // Troubleshooting suggestions + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withOpacity(0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.lightbulb_outline, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + 'Troubleshooting Tips', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + _buildTroubleshootingTip(context, '• Check your internet connection'), + _buildTroubleshootingTip(context, '• Verify server settings in the app'), + _buildTroubleshootingTip(context, '• Ensure the PinePods server is running'), + _buildTroubleshootingTip(context, '• Contact your administrator if the issue persists'), + ], + ), + ), + + const SizedBox(height: 32), + + // Action Buttons + if (onRetry != null) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTroubleshootingTip(BuildContext context, String tip) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + tip, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } +} + +/// A specialized server error page for SliverFillRemaining usage +class SliverServerErrorPage extends StatelessWidget { + final String? errorMessage; + final VoidCallback? onRetry; + final String? title; + final String? subtitle; + final bool showLogo; + + const SliverServerErrorPage({ + Key? key, + this.errorMessage, + this.onRetry, + this.title, + this.subtitle, + this.showLogo = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SliverFillRemaining( + hasScrollBody: false, + child: ServerErrorPage( + errorMessage: errorMessage, + onRetry: onRetry, + title: title, + subtitle: subtitle, + showLogo: showLogo, + ), + ); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/shimmer_episode_tile.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/shimmer_episode_tile.dart new file mode 100644 index 0000000..9b50971 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/shimmer_episode_tile.dart @@ -0,0 +1,138 @@ +// lib/ui/widgets/shimmer_episode_tile.dart +import 'package:flutter/material.dart'; + +class ShimmerEpisodeTile extends StatefulWidget { + const ShimmerEpisodeTile({super.key}); + + @override + State createState() => _ShimmerEpisodeTileState(); +} + +class _ShimmerEpisodeTileState extends State + with SingleTickerProviderStateMixin { + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController.unbounded(vsync: this) + ..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000)); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + elevation: 1, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Shimmer image placeholder + AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + return Container( + width: 50, + height: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + gradient: LinearGradient( + colors: [ + Colors.grey[300]!, + Colors.grey[100]!, + Colors.grey[300]!, + ], + stops: const [0.1, 0.3, 0.4], + begin: const Alignment(-1.0, -0.3), + end: const Alignment(1.0, 0.3), + transform: _SlidingGradientTransform(_shimmerController.value), + ), + ), + ); + }, + ), + const SizedBox(width: 12), + + // Shimmer text content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title placeholder + AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + return Container( + width: double.infinity, + height: 16, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: LinearGradient( + colors: [ + Colors.grey[300]!, + Colors.grey[100]!, + Colors.grey[300]!, + ], + stops: const [0.1, 0.3, 0.4], + begin: const Alignment(-1.0, -0.3), + end: const Alignment(1.0, 0.3), + transform: _SlidingGradientTransform(_shimmerController.value), + ), + ), + ); + }, + ), + const SizedBox(height: 8), + + // Subtitle placeholder + AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + return Container( + width: 120, + height: 12, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: LinearGradient( + colors: [ + Colors.grey[300]!, + Colors.grey[100]!, + Colors.grey[300]!, + ], + stops: const [0.1, 0.3, 0.4], + begin: const Alignment(-1.0, -0.3), + end: const Alignment(1.0, 0.3), + transform: _SlidingGradientTransform(_shimmerController.value), + ), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _SlidingGradientTransform extends GradientTransform { + const _SlidingGradientTransform(this.slidePercent); + + final double slidePercent; + + @override + Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { + return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0); + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/sleep_selector.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/sleep_selector.dart new file mode 100644 index 0000000..d2125f7 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/sleep_selector.dart @@ -0,0 +1,298 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/entities/sleep.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/slider_handle.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// This widget allows the user to change the playback speed and toggle audio effects. +/// +/// The two audio effects, trim silence and volume boost, are currently Android only. +class SleepSelectorWidget extends StatefulWidget { + const SleepSelectorWidget({ + super.key, + }); + + @override + State createState() => _SleepSelectorWidgetState(); +} + +class _SleepSelectorWidgetState extends State { + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + final settingsBloc = Provider.of(context); + var theme = Theme.of(context); + + return StreamBuilder( + stream: settingsBloc.settings, + initialData: AppSettings.sensibleDefaults(), + builder: (context, snapshot) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 48.0, + width: 48.0, + child: Center( + child: StreamBuilder( + stream: audioBloc.sleepStream, + initialData: Sleep(type: SleepType.none), + builder: (context, sleepSnapshot) { + var sl = ''; + + if (sleepSnapshot.hasData) { + var s = sleepSnapshot.data!; + + switch(s.type) { + case SleepType.none: + sl = ''; + case SleepType.time: + sl = '${L.of(context)!.now_playing_episode_time_remaining} ${SleepSlider.formatDuration(s.timeRemaining)}'; + case SleepType.episode: + sl = '${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_episode_label}'; + } + } + + return IconButton( + icon: sleepSnapshot.data?.type != SleepType.none ? Icon( + Icons.bedtime, + semanticLabel: '${L.of(context)!.sleep_timer_label}. $sl', + size: 20.0, + ) : Icon( + Icons.bedtime_outlined, + semanticLabel: L.of(context)!.sleep_timer_label, + size: 20.0, + ), + onPressed: () { + showModalBottomSheet( + isScrollControlled: true, + context: context, + backgroundColor: theme.secondaryHeaderColor, + barrierLabel: L.of(context)!.scrim_sleep_timer_selector, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + builder: (context) { + return const SleepSlider(); + }); + }, + ); + } + ), + ), + ), + ], + ); + }); + } +} + +class SleepSlider extends StatefulWidget { + const SleepSlider({super.key}); + + static String formatDuration(Duration duration) { + String twoDigits(int n) { + if (n >= 10) return '$n'; + return '0$n'; + } + + var twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).toInt()); + var twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).toInt()); + + return '${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds'; + } + + @override + State createState() => _SleepSliderState(); +} + +class _SleepSliderState extends State { + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + + return StreamBuilder( + stream: audioBloc.sleepStream, + initialData: Sleep(type: SleepType.none), + builder: (context, snapshot) { + var s = snapshot.data; + + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SliderHandle(), + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Semantics( + header: true, + child: Text( + L.of(context)!.sleep_timer_label, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + if (s != null && s.type == SleepType.none) + Text( + '(${L.of(context)!.sleep_off_label})', + semanticsLabel: '${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_off_label}', + style: Theme.of(context).textTheme.bodyLarge, + ), + if (s != null && s.type == SleepType.time) + Text( + '(${SleepSlider.formatDuration(s.timeRemaining)})', + semanticsLabel: + '${L.of(context)!.semantic_current_value_label} ${SleepSlider.formatDuration(s.timeRemaining)}', + style: Theme.of(context).textTheme.bodyLarge, + ), + if (s != null && s.type == SleepType.episode) + Text( + '(${L.of(context)!.sleep_episode_label})', + semanticsLabel: + '${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_episode_label}', + style: Theme.of(context).textTheme.bodyLarge, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + shrinkWrap: true, + children: [ + SleepSelectorEntry( + sleep: Sleep(type: SleepType.none), + current: s, + ), + const Divider(), + SleepSelectorEntry( + sleep: Sleep( + type: SleepType.time, + duration: const Duration(minutes: 5), + ), + current: s, + ), + const Divider(), + SleepSelectorEntry( + sleep: Sleep( + type: SleepType.time, + duration: const Duration(minutes: 10), + ), + current: s, + ), + const Divider(), + SleepSelectorEntry( + sleep: Sleep( + type: SleepType.time, + duration: const Duration(minutes: 15), + ), + current: s, + ), + const Divider(), + SleepSelectorEntry( + sleep: Sleep( + type: SleepType.time, + duration: const Duration(minutes: 30), + ), + current: s, + ), + const Divider(), + SleepSelectorEntry( + sleep: Sleep( + type: SleepType.time, + duration: const Duration(minutes: 45), + ), + current: s, + ), + const Divider(), + SleepSelectorEntry( + sleep: Sleep( + type: SleepType.time, + duration: const Duration(minutes: 60), + ), + current: s, + ), + const Divider(), + SleepSelectorEntry( + sleep: Sleep( + type: SleepType.episode, + ), + current: s, + ), + ], + ), + ) + ]); + }); + } +} + +class SleepSelectorEntry extends StatelessWidget { + const SleepSelectorEntry({ + super.key, + required this.sleep, + required this.current, + }); + + final Sleep sleep; + final Sleep? current; + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + audioBloc.sleep(Sleep( + type: sleep.type, + duration: sleep.duration, + )); + + Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.only( + top: 4.0, + bottom: 4.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + if (sleep.type == SleepType.none) + Text( + L.of(context)!.sleep_off_label, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (sleep.type == SleepType.time) + Text( + L.of(context)!.sleep_minute_label(sleep.duration.inMinutes.toString()), + style: Theme.of(context).textTheme.bodyLarge, + ), + if (sleep.type == SleepType.episode) + Text( + L.of(context)!.sleep_episode_label, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (sleep == current) + const Icon( + Icons.check, + size: 18.0, + ), + ], + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/slider_handle.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/slider_handle.dart new file mode 100644 index 0000000..64ce613 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/slider_handle.dart @@ -0,0 +1,43 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// This class generates a simple 'handle' icon that can be added on widgets such as +/// scrollable sheets and bottom dialogs. +/// +/// When running with a screen reader, the handle icon becomes selectable with an +/// optional label and tap callback. This makes it easier to open/close. +class SliderHandle extends StatelessWidget { + final GestureTapCallback? onTap; + final String label; + + const SliderHandle({ + super.key, + this.onTap, + this.label = '', + }); + + @override + Widget build(BuildContext context) { + return Semantics( + liveRegion: true, + label: label, + child: GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).hintColor, + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + ), + ), + ), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/speed_selector.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/speed_selector.dart new file mode 100644 index 0000000..b8e03fc --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/speed_selector.dart @@ -0,0 +1,241 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; +import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/core/extensions.dart'; +import 'package:pinepods_mobile/entities/app_settings.dart'; +import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/ui/widgets/slider_handle.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// This widget allows the user to change the playback speed and toggle audio effects. +/// +/// The two audio effects, trim silence and volume boost, are currently Android only. +class SpeedSelectorWidget extends StatefulWidget { + const SpeedSelectorWidget({ + super.key, + }); + + @override + State createState() => _SpeedSelectorWidgetState(); +} + +class _SpeedSelectorWidgetState extends State { + var speed = 1.0; + + @override + void initState() { + var settingsBloc = Provider.of(context, listen: false); + + speed = settingsBloc.currentSettings.playbackSpeed; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + var settingsBloc = Provider.of(context); + var theme = Theme.of(context); + + return StreamBuilder( + stream: settingsBloc.settings, + initialData: AppSettings.sensibleDefaults(), + builder: (context, snapshot) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () { + showModalBottomSheet( + context: context, + backgroundColor: theme.secondaryHeaderColor, + barrierLabel: L.of(context)!.scrim_speed_selector, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + builder: (context) { + return const SpeedSlider(); + }); + }, + child: SizedBox( + height: 48.0, + width: 48.0, + child: Center( + child: Semantics( + button: true, + child: Text( + semanticsLabel: '${L.of(context)!.playback_speed_label} ${snapshot.data!.playbackSpeed.toTenth}', + snapshot.data!.playbackSpeed == 1.0 ? 'x1' : 'x${snapshot.data!.playbackSpeed.toTenth}', + style: TextStyle( + fontSize: 16.0, + color: Theme.of(context).iconTheme.color, + ), + ), + ), + ), + ), + ), + ], + ); + }); + } +} + +class SpeedSlider extends StatefulWidget { + const SpeedSlider({super.key}); + + @override + State createState() => _SpeedSliderState(); +} + +class _SpeedSliderState extends State { + var speed = 1.0; + var trimSilence = false; + var volumeBoost = false; + + @override + void initState() { + final settingsBloc = Provider.of(context, listen: false); + + speed = settingsBloc.currentSettings.playbackSpeed; + trimSilence = settingsBloc.currentSettings.trimSilence; + volumeBoost = settingsBloc.currentSettings.volumeBoost; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final audioBloc = Provider.of(context, listen: false); + final settingsBloc = Provider.of(context, listen: false); + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SliderHandle(), + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Text( + L.of(context)!.audio_settings_playback_speed_label, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const Divider(), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text( + '${speed.toStringAsFixed(1)}x', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: IconButton( + tooltip: L.of(context)!.semantics_decrease_playback_speed, + iconSize: 28.0, + icon: const Icon(Icons.remove_circle_outline), + onPressed: (speed <= 0.5) + ? null + : () { + setState(() { + speed -= 0.1; + speed = speed.toTenth; + audioBloc.playbackSpeed(speed); + settingsBloc.setPlaybackSpeed(speed); + }); + }, + ), + ), + Expanded( + flex: 4, + child: Slider( + value: speed.toTenth, + min: 0.5, + max: 2.0, + divisions: 15, + onChanged: (value) { + setState(() { + speed = value; + }); + }, + onChangeEnd: (value) { + audioBloc.playbackSpeed(speed); + settingsBloc.setPlaybackSpeed(value); + }, + ), + ), + Expanded( + child: IconButton( + tooltip: L.of(context)!.semantics_increase_playback_speed, + iconSize: 28.0, + icon: const Icon(Icons.add_circle_outline), + onPressed: (speed > 1.9) + ? null + : () { + setState(() { + speed += 0.1; + speed = speed.toTenth; + audioBloc.playbackSpeed(speed); + settingsBloc.setPlaybackSpeed(speed); + }); + }, + ), + ), + ], + ), + const SizedBox( + height: 8.0, + ), + const Divider(), + if (theme.platform == TargetPlatform.android) ...[ + /// Disable the trim silence option for now until the positioning bug + /// in just_audio is resolved. + // ListTile( + // title: Text(L.of(context).audio_effect_trim_silence_label), + // trailing: Switch.adaptive( + // value: trimSilence, + // onChanged: (value) { + // setState(() { + // trimSilence = value; + // audioBloc.trimSilence(value); + // settingsBloc.trimSilence(value); + // }); + // }, + // ), + // ), + ListTile( + title: Text(L.of(context)!.audio_effect_volume_boost_label), + trailing: Switch.adaptive( + value: volumeBoost, + onChanged: (boost) { + setState(() { + volumeBoost = boost; + audioBloc.volumeBoost(boost); + settingsBloc.volumeBoost(boost); + }); + }, + ), + ), + ] else + const SizedBox( + width: 0.0, + height: 0.0, + ), + ], + ); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/sync_spinner.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/sync_spinner.dart new file mode 100644 index 0000000..06a1aa0 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/sync_spinner.dart @@ -0,0 +1,76 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/state/bloc_state.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SyncSpinner extends StatefulWidget { + const SyncSpinner({super.key}); + + @override + State createState() => _SyncSpinnerState(); +} + +class _SyncSpinnerState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + StreamSubscription>? subscription; + Widget? _child; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + + _child = const Icon( + Icons.refresh, + size: 16.0, + ); + + final podcastBloc = Provider.of(context, listen: false); + + subscription = podcastBloc.backgroundLoading.listen((event) { + if (event is BlocSuccessfulState || event is BlocErrorState) { + _controller.stop(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + subscription?.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final podcastBloc = Provider.of(context, listen: false); + + return StreamBuilder>( + initialData: BlocEmptyState(), + stream: podcastBloc.backgroundLoading, + builder: (context, snapshot) { + final state = snapshot.data; + + return state is BlocLoadingState + ? RotationTransition( + turns: _controller, + child: _child, + ) + : const SizedBox( + width: 0.0, + height: 0.0, + ); + }); + } +} diff --git a/PinePods-0.8.2/mobile/lib/ui/widgets/tile_image.dart b/PinePods-0.8.2/mobile/lib/ui/widgets/tile_image.dart new file mode 100644 index 0000000..31ec826 --- /dev/null +++ b/PinePods-0.8.2/mobile/lib/ui/widgets/tile_image.dart @@ -0,0 +1,51 @@ +// Copyright 2020 Ben Hills and the project contributors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart'; +import 'package:pinepods_mobile/ui/widgets/podcast_image.dart'; +import 'package:flutter/material.dart'; + +class TileImage extends StatelessWidget { + const TileImage({ + super.key, + required this.url, + required this.size, + this.highlight = false, + }); + + /// The URL of the image to display. + final String url; + + /// The size of the image container; both height and width. + final double size; + + final bool highlight; + + @override + Widget build(BuildContext context) { + final placeholderBuilder = PlaceholderBuilder.of(context); + + return PodcastImage( + key: Key('tile$url'), + highlight: highlight, + url: url, + height: size, + width: size, + borderRadius: 4.0, + fit: BoxFit.contain, + placeholder: placeholderBuilder != null + ? placeholderBuilder.builder()(context) + : const Image( + fit: BoxFit.contain, + image: AssetImage('assets/images/favicon.png'), + ), + errorPlaceholder: placeholderBuilder != null + ? placeholderBuilder.errorBuilder()(context) + : const Image( + fit: BoxFit.contain, + image: AssetImage('assets/images/favicon.png'), + ), + ); + } +} diff --git a/PinePods-0.8.2/mobile/metadata/en-US/full_description.txt b/PinePods-0.8.2/mobile/metadata/en-US/full_description.txt new file mode 100644 index 0000000..a0690f2 --- /dev/null +++ b/PinePods-0.8.2/mobile/metadata/en-US/full_description.txt @@ -0,0 +1,22 @@ +PinePods is a complete podcast management solution that allows you to host your own podcast server and enjoy a beautiful mobile experience. + +This is the official Pinepods companion app for Android. + +Features of Pinepods: + +• Self-hosted podcast server synchronization that syncs everything between your devices +• Beautiful, intuitive mobile interface with loads of themes that sync between devices and platforms +• Download episodes for offline listening or archiving on your server +• Chapter support with navigation +• Playlist management +• User statistics and listening history +• Multi-device synchronization +• Search and discovery +• Background audio playback +• Sleep timer and playback speed controls + +PinePods gives you complete control over your podcast experience while providing the convenience of modern podcast apps. Perfect for users who want privacy, control, and a great listening experience. + +Note: This app requires a PinePods server to be set up. Visit the PinePods GitHub repository for server installation instructions. + +Thanks for using Pinepods! diff --git a/PinePods-0.8.2/mobile/metadata/en-US/images/featureGraphic.png b/PinePods-0.8.2/mobile/metadata/en-US/images/featureGraphic.png new file mode 100644 index 0000000..e7aab93 Binary files /dev/null and b/PinePods-0.8.2/mobile/metadata/en-US/images/featureGraphic.png differ diff --git a/PinePods-0.8.2/mobile/metadata/en-US/images/icon.png b/PinePods-0.8.2/mobile/metadata/en-US/images/icon.png new file mode 100644 index 0000000..4fe781c Binary files /dev/null and b/PinePods-0.8.2/mobile/metadata/en-US/images/icon.png differ diff --git a/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/1.png b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..a5c21ee Binary files /dev/null and b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/1.png differ diff --git a/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/2.png b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..668c407 Binary files /dev/null and b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/2.png differ diff --git a/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/3.png b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..6182ba2 Binary files /dev/null and b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/3.png differ diff --git a/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/4.png b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..1d45d00 Binary files /dev/null and b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/4.png differ diff --git a/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/5.png b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..4c00442 Binary files /dev/null and b/PinePods-0.8.2/mobile/metadata/en-US/images/phoneScreenshots/5.png differ diff --git a/PinePods-0.8.2/mobile/metadata/en-US/short_description.txt b/PinePods-0.8.2/mobile/metadata/en-US/short_description.txt new file mode 100644 index 0000000..640972e --- /dev/null +++ b/PinePods-0.8.2/mobile/metadata/en-US/short_description.txt @@ -0,0 +1 @@ +A beautiful, open-source self-hosted podcast app with powerful server synchronization diff --git a/PinePods-0.8.2/mobile/metadata/en-US/title.txt b/PinePods-0.8.2/mobile/metadata/en-US/title.txt new file mode 100644 index 0000000..50437f6 --- /dev/null +++ b/PinePods-0.8.2/mobile/metadata/en-US/title.txt @@ -0,0 +1 @@ +PinePods \ No newline at end of file diff --git a/PinePods-0.8.2/mobile/pubspec.yaml b/PinePods-0.8.2/mobile/pubspec.yaml new file mode 100644 index 0000000..f8c2d14 --- /dev/null +++ b/PinePods-0.8.2/mobile/pubspec.yaml @@ -0,0 +1,130 @@ +name: pinepods_mobile +description: Pinepods Podcast Server + +version: 0.8.1+20252203 + +environment: + sdk: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" + +dependencies: + accessibility_tools: ^2.3.0 + app_links: ^6.3.0 + auto_size_text: ^3.0.0 + audio_service: ^0.18.17 + audio_session: ^0.2.2 + collection: ^1.18.0 + connectivity_plus: ^6.0.11 + crypto: ^3.0.3 + cupertino_icons: ^1.0.6 + device_info_plus: ^11.2.0 + extended_image: ^10.0.1 + file_picker: ^10.2.0 + flutter_dialogs: ^3.0.0 + flutter_downloader: ^1.11.8 + flutter_html: ^3.0.0-beta.2 + flutter_html_svg: ^3.0.0-beta.2 + flutter_html_table: ^3.0.0-beta.2 + flutter_launcher_icons: ^0.14.2 + flutter_spinkit: ^5.0.0 + html: ^0.15.0 + http: ^1.2.2 + intl: ^0.20.2 + intl_translation: ^0.20.0 + just_audio: ^0.10.4 + logging: ^1.0.2 + meta: ^1.17.0 + mp3_info: ^0.2.0 + package_info_plus: ^8.1.2 + path: ^1.8.3 + path_provider: ^2.1.4 + path_provider_platform_interface: ^2.0.4 + percent_indicator: ^4.2.5 + permission_handler: ^12.0.1 + podcast_search: ^0.7.11 + provider: ^6.0.3 + rxdart: ^0.28.0 + scrollable_positioned_list: ^0.3.7 + sembast: ^3.8.3 + share_plus: ^11.0.0 + shared_preferences: ^2.3.4 + sliver_tools: ^0.2.12 + url_launcher: ^6.3.1 + webview_flutter: ^4.9.0 + xml: ^6.5.0 + + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + +dev_dependencies: + mockito: ^5.4.5 + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + +dependency_overrides: + # Override layout grid to fix dependency issue with flutter_html_table + flutter_layout_grid: ^2.0.5 + # Override meta to use latest version + meta: ^1.17.0 + # Force latest versions for release prep + analyzer: ^7.5.6 + _fe_analyzer_shared: ^85.0.0 + dart_style: ^3.1.0 + mockito: ^5.4.6 + vector_math: ^2.2.0 + characters: ^1.4.1 + flutter_svg: ^2.2.0 + material_color_utilities: ^0.13.0 + leak_tracker: ^11.0.1 + test_api: ^0.7.6 + vm_service: ^15.0.2 + # Override vector graphics to support latest xml + vector_graphics_compiler: ^1.1.17 + vector_graphics_codec: ^1.1.13 + # Force remaining packages to latest + leak_tracker_flutter_testing: ^3.0.10 + leak_tracker_testing: ^3.0.2 + petitparser: ^7.0.0 + xml: ^6.6.0 + +flutter_icons: + image_path_android: "assets/images/favicon.png" + image_path_ios: "assets/images/icon-192.png" + remove_alpha_ios: true + android: true + ios: true + +flutter: + uses-material-design: true + + assets: + - assets/images/favicon.png + - assets/images/icon-192.png + - assets/images/pinepods-logo.png + - assets/images/1.webp + - assets/images/2.webp + - assets/images/3.webp + - assets/images/4.webp + - assets/images/5.webp + - assets/images/6.webp + - assets/images/7.webp + - assets/images/8.webp + - assets/images/9.webp + + # Certificate authorities + - assets/ca/lets-encrypt-r3.pem + - assets/ca/globalsign-gcc-r6-alphassl-ca-2023.pem + + fonts: + - family: MontserratMedium + fonts: + - asset: assets/fonts/Montserrat-Medium.otf + - family: MontserratRegular + fonts: + - asset: assets/fonts/Montserrat-Regular.otf + - family: MontserratBold + fonts: + - asset: assets/fonts/Montserrat-Bold.otf diff --git a/PinePods-0.8.2/mobile/todos b/PinePods-0.8.2/mobile/todos new file mode 100644 index 0000000..18ffb5e --- /dev/null +++ b/PinePods-0.8.2/mobile/todos @@ -0,0 +1,29 @@ +Searching functionality + - Allow playing even if pod isn't added +setting to go full screen default still doesn't work + +Clicking to a podcast from episode details page results in 0 episode count +Host links don't work in podcast detail page or episode detail page, currenlty they only work on full player description page + +print statements +pay apples developer tax + +pre-release +fix search on web +return saved, downloaded, queued status on user_history call - double check app hsitory page + +post beta +--- +oidc login +logout page not always working after logout. Just navigates back to home screen +on episode page player goes too high. Needs padding +Fix android homescreen icon +full screen player via scroll up, not just tap + + +app stores + fdroid + apple + google + +android auto diff --git a/PinePods-0.8.2/mobile/xl10n.yaml b/PinePods-0.8.2/mobile/xl10n.yaml new file mode 100644 index 0000000..ed3a978 --- /dev/null +++ b/PinePods-0.8.2/mobile/xl10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: intl_en.arb +output-localization-file: messages_all.dart diff --git a/PinePods-0.8.2/requirements.txt b/PinePods-0.8.2/requirements.txt new file mode 100644 index 0000000..eb47f40 --- /dev/null +++ b/PinePods-0.8.2/requirements.txt @@ -0,0 +1,17 @@ +mariadb +python-dateutil +python-dateutil +passlib +itsdangerous +keyring +cryptography +python-multipart +fernet +asyncio +pytz +psycopg[binary] +mygpoclient +appdirs +argon2_cffi +httpx +psycopg[pool] diff --git a/PinePods-0.8.2/run-tests.sh b/PinePods-0.8.2/run-tests.sh new file mode 100755 index 0000000..2f1d314 --- /dev/null +++ b/PinePods-0.8.2/run-tests.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +# Get the directory where the script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + + +check_postgres() { + if docker logs pinepods-test-db 2>&1 | grep -q "database system is ready to accept connections"; then + echo "PostgreSQL is ready!" + return 0 + else + echo "PostgreSQL is not ready" + return 1 + fi +} + +# Function to start MySQL container +start_mysql() { + if ! docker ps | grep -q pinepods-mysql-test; then + if docker ps -a | grep -q pinepods-mysql-test; then + docker start pinepods-mysql-test + else + docker run --name pinepods-mysql-test \ + -e MYSQL_USER=test_user \ + -e MYSQL_PASSWORD=test_password \ + -e MYSQL_DATABASE=test_db \ + -e MYSQL_RANDOM_ROOT_PASSWORD=yes \ + -p 3306:3306 \ + -d mariadb:latest + + # Wait for MySQL to be ready + echo "Waiting for MySQL to be ready..." + sleep 10 + fi + fi +} + +# Function to start PostgreSQL container +start_postgres() { + if ! docker ps | grep -q pinepods-test-db; then + if docker ps -a | grep -q pinepods-test-db; then + docker start pinepods-test-db + else + docker run --name pinepods-test-db \ + -e POSTGRES_USER=test_user \ + -e POSTGRES_PASSWORD=test_password \ + -e POSTGRES_DB=test_db \ + -p 5432:5432 \ + -d postgres:latest + + # Wait for PostgreSQL to be ready + echo "Waiting for PostgreSQL to be ready..." + sleep 10 + check_postgres + fi + fi +} + +# Function to setup database schema +setup_database() { + local db_type=$1 + + # Export necessary environment variables for the setup script + export DB_HOST=localhost + export DB_PORT=5432 + export DB_USER=test_user + export DB_PASSWORD=test_password + export DB_NAME=test_db + export DB_TYPE=$db_type + + echo "Setting up $db_type schema using new migration system..." + python3 "${SCRIPT_DIR}/startup/setup_database_new.py" +} + + + +# Activate virtual environment +source "${SCRIPT_DIR}/venv/bin/activate" + +# Set PYTHONPATH to include the project root +export PYTHONPATH="${SCRIPT_DIR}:${PYTHONPATH}" + +# Parse command line arguments +DB_TYPE=${1:-"postgresql"} # Default to PostgreSQL if no argument provided + +case $DB_TYPE in + "postgresql") + echo "Running tests with PostgreSQL..." + start_postgres + export TEST_DB_TYPE=postgresql + setup_database postgresql + ;; + "mariadb") + echo "Running tests with MariaDB..." + start_mysql + export TEST_DB_TYPE=mariadb + setup_database mariadb + ;; + *) + echo "Invalid database type. Use 'postgresql' or 'mariadb'" + exit 1 + ;; +esac + +# Function to cleanup containers +cleanup_containers() { + local db_type=$1 + if [ "$db_type" == "postgresql" ]; then + echo "Cleaning up PostgreSQL container..." + docker stop pinepods-test-db >/dev/null 2>&1 + docker rm pinepods-test-db >/dev/null 2>&1 + else + echo "Cleaning up MariaDB container..." + docker stop pinepods-mysql-test >/dev/null 2>&1 + docker rm pinepods-mysql-test >/dev/null 2>&1 + fi +} + +# Add trap to ensure cleanup happens even if script fails +trap 'cleanup_containers "$DB_TYPE"' EXIT + +# Rest of your script remains the same... + +# Run tests with verbose output +pytest -v tests/ + +# Cleanup will happen automatically due to trap, but you can also call it explicitly +cleanup_containers "$DB_TYPE" diff --git a/PinePods-0.8.2/rust-api/Cargo.lock b/PinePods-0.8.2/rust-api/Cargo.lock new file mode 100644 index 0000000..e61fd1e --- /dev/null +++ b/PinePods-0.8.2/rust-api/Cargo.lock @@ -0,0 +1,5098 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "argon2" +version = "0.6.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d911686206fdd816a61ed5226535997149b0fc7726e37fee46f407c9ff82ed87" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-compression" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40f6024f3f856663b45fd0c9b6f2024034a702f453549449e0d84a305900dad4" +dependencies = [ + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av1-grain" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 7.1.3", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea8ef51aced2b9191c08197f55450d830876d9933f8f48a429b354f1d496b42" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b1d86e7705efe1be1b569bab41d4fa1e14e220b60a160f78de2db687add079" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "axum-macros", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bigdecimal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560f42649de9fa436b73517378a147ec21f6c997a546581df4b4b31677828934" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", + "which", +] + +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +dependencies = [ + "serde", +] + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "blake2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edac47499deef695d9431bf241c75ea29f4cf3dcb78d39e19b31515e4ad3b08" +dependencies = [ + "digest 0.11.0-rc.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.11.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e549344080374f9b32ed41bf3b6b57885ff6a289367b3dbc10eea8acc1918" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml 0.9.8", + "winnow", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "croner" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +dependencies = [ + "chrono", + "derive_builder", + "strum", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac89f8a64533a9b0eaa73a68e424db0fb1fd6271c74cc0125336a05f090568d" +dependencies = [ + "block-buffer 0.11.0-rc.5", + "crypto-common 0.2.0-rc.4", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "feed-rs" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c0591d23efd0d595099af69a31863ac1823046b1b021e3b06ba3aae7e00991" +dependencies = [ + "chrono", + "mediatype", + "quick-xml 0.37.5", + "regex", + "serde", + "serde_json", + "siphasher", + "url", + "uuid", +] + +[[package]] +name = "fernet" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66b725fe9483b9ee72ccaec072b15eb8ad95a3ae63a8c798d5748883b72fd33" +dependencies = [ + "base64 0.22.1", + "byteorder", + "getrandom 0.2.16", + "openssl", + "zeroize", +] + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.4", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f471e0a81b2f90ffc0cb2f951ae04da57de8baa46fa99112b062a5173a5088d0" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.1", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.5.10", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id3" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aadb14a5ba1a0d58ecd4a29bfc9b8f1d119eee24aa01a62c1ec93eb9630a1d86" +dependencies = [ + "bitflags", + "byteorder", + "flate2", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6970fe7a5300b4b42e62c52efa0187540a5bef546c60edaf554ef595d2e6f0b" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown 0.15.4", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d119c6924272d16f0ab9ce41f7aa0bfef9340c00b0bb7ca3dd3b263d4a9150b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.16", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + +[[package]] +name = "lettre" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb54db6ff7a89efac87dba5baeac57bb9ccd726b49a9b6f21fb92b3966aaf56" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2 0.6.0", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.1", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.48.5", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "litrs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "mediatype" +version = "0.19.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" +dependencies = [ + "serde", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "moxcms" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mp3-metadata" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e462514acb482a039cf915999d6cca0474dfa9d711f040025cf394d54f0f92e4" + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[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 = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "password-hash" +version = "0.6.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee14c44aa1c04c22c4d4532c4fa2cdd5b6d31c2514a5898530d889fc2fc2737" +dependencies = [ + "base64ct", + "rand_core 0.9.3", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +dependencies = [ + "memchr", + "thiserror 2.0.17", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinepods-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "async-trait", + "axum", + "base64 0.22.1", + "bigdecimal", + "chrono", + "chrono-tz", + "config", + "dotenvy", + "feed-rs", + "fernet", + "futures", + "hyper", + "id3", + "image", + "jsonwebtoken", + "lazy_static", + "lettre", + "mime_guess", + "mp3-metadata", + "qrcode", + "quick-xml 0.38.3", + "rand 0.9.2", + "redis", + "regex", + "reqwest", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.17", + "tokio", + "tokio-cron-scheduler", + "tokio-stream", + "tokio-util", + "totp-rs", + "tower", + "tower-http", + "tower-test", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "psm" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" +dependencies = [ + "cc", +] + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pxfm" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55f4fedc84ed39cb7a489322318976425e42a147e2be79d8f878e2884f94e84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.5", + "rand_chacha 0.3.1", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redis" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44" +dependencies = [ + "bytes", + "cfg-if", + "combine", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.6.0", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.1", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags", + "serde", + "serde_derive", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[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.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bigdecimal", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.4", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bigdecimal", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bigdecimal", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.17", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stacker" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.0.7", + "windows-sys 0.59.0", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.0", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-cron-scheduler" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a" +dependencies = [ + "chrono", + "chrono-tz", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "totp-rs" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72" +dependencies = [ + "base32", + "constant_time_eq", + "hmac", + "sha1", + "sha2", + "url", + "urlencoding", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower-test" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4546773ffeab9e4ea02b8872faa49bb616a80a7da66afc2f32688943f97efa7" +dependencies = [ + "futures-util", + "pin-project", + "tokio", + "tokio-test", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.1", +] + +[[package]] +name = "webpki-roots" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "whoami" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +dependencies = [ + "redox_syscall", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9e525af0a6a658e031e95f14b7f889976b74a11ba0eca5a5fc9ac8a1c43a6a" +dependencies = [ + "zune-core", +] diff --git a/PinePods-0.8.2/rust-api/Cargo.toml b/PinePods-0.8.2/rust-api/Cargo.toml new file mode 100644 index 0000000..69c13e5 --- /dev/null +++ b/PinePods-0.8.2/rust-api/Cargo.toml @@ -0,0 +1,88 @@ +[package] +name = "pinepods-api" +version = "0.1.0" +edition = "2021" +rust-version = "1.89" + +[dependencies] +# Web Framework +axum = { version = "0.8.6", features = ["macros", "multipart", "ws"] } +tokio = { version = "1.48.0", features = ["full"] } +tower = { version = "0.5.2", features = ["util", "timeout", "load-shed", "limit"] } +tower-http = { version = "0.6.6", features = ["fs", "trace", "cors", "compression-gzip"] } + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" + +# Database +sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "mysql", "uuid", "chrono", "json", "bigdecimal"] } +bigdecimal = "0.4.9" + +# Redis/Valkey +redis = { version = "0.32.7", features = ["aio", "tokio-comp"] } + +# HTTP Client +reqwest = { version = "0.12.24", features = ["json", "rustls-tls", "stream", "cookies"] } + +# Configuration and Environment +config = "0.15.18" +dotenvy = "0.15.7" + +# Logging +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } + +# Utilities +uuid = { version = "1.18.1", features = ["v4", "serde"] } +chrono = { version = "0.4.42", features = ["serde"] } +chrono-tz = "0.10.0" +anyhow = "1.0.100" +thiserror = "2.0.17" +async-trait = "0.1.89" +base64 = "0.22.1" +lazy_static = "1.5.0" +urlencoding = "2.1.3" + +# Authentication and Crypto +argon2 = "0.6.0-rc.1" +jsonwebtoken = { version = "10.1.0", features = ["aws_lc_rs"] } +rand = "0.9.2" + +# MFA/TOTP Support +totp-rs = { version = "5.7.0", features = ["otpauth"] } +qrcode = "0.14.1" +image = "0.25.8" + +# Encryption for sync credentials +fernet = "0.2.2" + +# RSS/Feed Processing +feed-rs = "2.3.1" +url = "2.5.7" +regex = "1.12.2" + +# Audio metadata tagging +id3 = "1.16.3" +mp3-metadata = "0.4.0" +quick-xml = "0.38.3" + +# Email +lettre = { version = "0.11.18", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] } + +# CORS and Security +hyper = "1.7.0" + +# Background Tasks and Task Management +tokio-cron-scheduler = "0.15.1" +tokio-stream = "0.1.17" +futures = "0.3.31" + +# WebSocket Support (already in axum features) + +# File handling +tokio-util = { version = "0.7.16", features = ["io"] } +mime_guess = "2.0.5" + +[dev-dependencies] +tower-test = "0.4" \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/config.rs b/PinePods-0.8.2/rust-api/src/config.rs new file mode 100644 index 0000000..9fc70f2 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/config.rs @@ -0,0 +1,405 @@ +use serde::{Deserialize, Serialize}; +use std::env; +use crate::error::{AppError, AppResult}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub database: DatabaseConfig, + pub redis: RedisConfig, + pub server: ServerConfig, + pub security: SecurityConfig, + pub email: EmailConfig, + pub oidc: OIDCConfig, + pub api: ApiConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiConfig { + pub search_api_url: String, + pub people_api_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseConfig { + pub db_type: String, + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + pub name: String, + pub max_connections: u32, + pub min_connections: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedisConfig { + pub host: String, + pub port: u16, + pub max_connections: u32, + pub password: Option, + pub username: Option, + pub database: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub port: u16, + pub host: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityConfig { + pub api_key_header: String, + pub jwt_secret: String, + pub password_salt_rounds: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailConfig { + pub smtp_server: Option, + pub smtp_port: Option, + pub smtp_username: Option, + pub smtp_password: Option, + pub from_email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OIDCConfig { + pub disable_standard_login: bool, + pub provider_name: Option, + pub client_id: Option, + pub client_secret: Option, + pub authorization_url: Option, + pub token_url: Option, + pub user_info_url: Option, + pub button_text: Option, + pub scope: Option, + pub button_color: Option, + pub button_text_color: Option, + pub icon_svg: Option, + pub name_claim: Option, + pub email_claim: Option, + pub username_claim: Option, + pub roles_claim: Option, + pub user_role: Option, + pub admin_role: Option, +} + +impl OIDCConfig { + pub fn is_configured(&self) -> bool { + self.provider_name.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.client_id.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.client_secret.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.authorization_url.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.token_url.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.user_info_url.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.button_text.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.scope.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.button_color.as_ref().map_or(false, |s| !s.trim().is_empty()) && + self.button_text_color.as_ref().map_or(false, |s| !s.trim().is_empty()) + } + + pub fn validate(&self) -> Result<(), String> { + let required_fields = [ + (&self.provider_name, "OIDC_PROVIDER_NAME"), + (&self.client_id, "OIDC_CLIENT_ID"), + (&self.client_secret, "OIDC_CLIENT_SECRET"), + (&self.authorization_url, "OIDC_AUTHORIZATION_URL"), + (&self.token_url, "OIDC_TOKEN_URL"), + (&self.user_info_url, "OIDC_USER_INFO_URL"), + (&self.button_text, "OIDC_BUTTON_TEXT"), + (&self.scope, "OIDC_SCOPE"), + (&self.button_color, "OIDC_BUTTON_COLOR"), + (&self.button_text_color, "OIDC_BUTTON_TEXT_COLOR"), + ]; + + let missing_fields: Vec<&str> = required_fields + .iter() + .filter_map(|(field, name)| if field.is_none() { Some(*name) } else { None }) + .collect(); + + // Check if any OIDC fields are set + let any_oidc_set = required_fields.iter().any(|(field, _)| field.is_some()); + + if any_oidc_set && !missing_fields.is_empty() { + return Err(format!( + "Incomplete OIDC configuration. When setting up OIDC, all required environment variables must be provided. Missing: {}", + missing_fields.join(", ") + )); + } + + if self.disable_standard_login && !self.is_configured() { + return Err("OIDC_DISABLE_STANDARD_LOGIN is set to true, but OIDC is not properly configured. All OIDC environment variables must be set when disabling standard login.".to_string()); + } + + Ok(()) + } +} + +impl Config { + pub fn new() -> AppResult { + // Load environment variables + dotenvy::dotenv().ok(); + + // Validate required database environment variables + let db_required_vars = [ + ("DB_TYPE", "Database type (e.g., postgresql, mariadb)"), + ("DB_HOST", "Database host (e.g., localhost, db)"), + ("DB_PORT", "Database port (e.g., 5432 for PostgreSQL, 3306 for MariaDB)"), + ("DB_USER", "Database username"), + ("DB_PASSWORD", "Database password"), + ("DB_NAME", "Database name"), + ]; + + let mut missing_db_vars = Vec::new(); + for (var_name, description) in &db_required_vars { + if env::var(var_name).is_err() { + missing_db_vars.push(format!(" {} - {}", var_name, description)); + } + } + + if !missing_db_vars.is_empty() { + return Err(AppError::Config(format!( + "Missing required database environment variables:\n{}\n\nPlease set these variables in your docker-compose.yml or environment.", + missing_db_vars.join("\n") + ))); + } + + // Validate required API URLs + let api_required_vars = [ + ("SEARCH_API_URL", "Search API URL (e.g., https://search.pinepods.online/api/search)"), + ("PEOPLE_API_URL", "People API URL (e.g., https://people.pinepods.online)"), + ]; + + let mut missing_api_vars = Vec::new(); + for (var_name, description) in &api_required_vars { + if env::var(var_name).is_err() { + missing_api_vars.push(format!(" {} - {}", var_name, description)); + } + } + + if !missing_api_vars.is_empty() { + return Err(AppError::Config(format!( + "Missing required API environment variables:\n{}\n\nPlease set these variables in your docker-compose.yml or environment.", + missing_api_vars.join("\n") + ))); + } + + // Validate Valkey/Redis configuration - either URL or individual variables (support both VALKEY_* and REDIS_* naming) + let has_valkey_url = env::var("VALKEY_URL").is_ok(); + let has_redis_url = env::var("REDIS_URL").is_ok(); + let has_valkey_vars = env::var("VALKEY_HOST").is_ok() && env::var("VALKEY_PORT").is_ok(); + let has_redis_vars = env::var("REDIS_HOST").is_ok() && env::var("REDIS_PORT").is_ok(); + + if !has_valkey_url && !has_redis_url && !has_valkey_vars && !has_redis_vars { + return Err(AppError::Config(format!( + "Missing required Valkey/Redis configuration. Please provide either:\n Option 1: VALKEY_URL or REDIS_URL - Complete connection URL\n Option 2: VALKEY_HOST/VALKEY_PORT or REDIS_HOST/REDIS_PORT - Individual connection parameters\n\nExample URL: VALKEY_URL=redis://localhost:6379\nExample individual: VALKEY_HOST=localhost, VALKEY_PORT=6379" + ))); + } + + let database = DatabaseConfig { + db_type: env::var("DB_TYPE").unwrap(), + host: env::var("DB_HOST").unwrap(), + port: { + let port_str = env::var("DB_PORT").unwrap(); + port_str.trim().parse() + .map_err(|e| AppError::Config(format!("Invalid DB_PORT '{}': Must be a valid port number (e.g., 5432 for PostgreSQL, 3306 for MariaDB)", port_str)))? + }, + username: env::var("DB_USER").unwrap(), + password: env::var("DB_PASSWORD").unwrap(), + name: env::var("DB_NAME").unwrap(), + max_connections: 32, + min_connections: 1, + }; + + let redis = if let Some(url) = env::var("VALKEY_URL").ok().or_else(|| env::var("REDIS_URL").ok()) { + // Parse VALKEY_URL or REDIS_URL + match url::Url::parse(&url) { + Ok(parsed_url) => { + let host = parsed_url.host_str().unwrap_or("localhost").to_string(); + let port = parsed_url.port().unwrap_or(6379); + let username = if parsed_url.username().is_empty() { + None + } else { + Some(parsed_url.username().to_string()) + }; + let password = parsed_url.password().map(|p| p.to_string()); + let database = if parsed_url.path().len() > 1 { + parsed_url.path().trim_start_matches('/').parse().ok() + } else { + None + }; + + RedisConfig { + host, + port, + max_connections: 32, + password, + username, + database, + } + } + Err(e) => { + return Err(AppError::Config(format!("Invalid URL format: {}", e))); + } + } + } else { + // Use individual variables - support both VALKEY_* and REDIS_* (VALKEY_* takes precedence) + let host = env::var("VALKEY_HOST").or_else(|_| env::var("REDIS_HOST")).unwrap(); + let port_str = env::var("VALKEY_PORT").or_else(|_| env::var("REDIS_PORT")).unwrap(); + let port = port_str.trim().parse() + .map_err(|e| AppError::Config(format!("Invalid port '{}': Must be a valid port number (e.g., 6379)", port_str)))?; + let password = env::var("VALKEY_PASSWORD").ok().or_else(|| env::var("REDIS_PASSWORD").ok()); + let username = env::var("VALKEY_USERNAME").ok().or_else(|| env::var("REDIS_USERNAME").ok()); + let database = env::var("VALKEY_DATABASE").ok() + .or_else(|| env::var("REDIS_DATABASE").ok()) + .and_then(|d| d.parse().ok()); + + RedisConfig { + host, + port, + max_connections: 32, + password, + username, + database, + } + }; + + let server = ServerConfig { + port: 8032, // Fixed port for internal API + host: "0.0.0.0".to_string(), + }; + + let security = SecurityConfig { + api_key_header: "pinepods_api".to_string(), + jwt_secret: "pinepods-default-secret".to_string(), + password_salt_rounds: 12, + }; + + let email = EmailConfig { + smtp_server: env::var("SMTP_SERVER").ok(), + smtp_port: env::var("SMTP_PORT").ok().and_then(|p| p.parse().ok()), + smtp_username: env::var("SMTP_USERNAME").ok(), + smtp_password: env::var("SMTP_PASSWORD").ok(), + from_email: env::var("FROM_EMAIL").ok(), + }; + + // Check if essential OIDC fields are present and non-empty before setting any defaults + let oidc_essentials_present = env::var("OIDC_PROVIDER_NAME").map_or(false, |s| !s.trim().is_empty()) && + env::var("OIDC_CLIENT_ID").map_or(false, |s| !s.trim().is_empty()) && + env::var("OIDC_CLIENT_SECRET").map_or(false, |s| !s.trim().is_empty()) && + env::var("OIDC_AUTHORIZATION_URL").map_or(false, |s| !s.trim().is_empty()) && + env::var("OIDC_TOKEN_URL").map_or(false, |s| !s.trim().is_empty()) && + env::var("OIDC_USER_INFO_URL").map_or(false, |s| !s.trim().is_empty()); + + let oidc = OIDCConfig { + disable_standard_login: env::var("OIDC_DISABLE_STANDARD_LOGIN") + .unwrap_or_else(|_| "false".to_string()) + .parse() + .unwrap_or(false), + provider_name: env::var("OIDC_PROVIDER_NAME").ok(), + client_id: env::var("OIDC_CLIENT_ID").ok(), + client_secret: env::var("OIDC_CLIENT_SECRET").ok(), + authorization_url: env::var("OIDC_AUTHORIZATION_URL").ok(), + token_url: env::var("OIDC_TOKEN_URL").ok(), + user_info_url: env::var("OIDC_USER_INFO_URL").ok(), + button_text: if oidc_essentials_present { + env::var("OIDC_BUTTON_TEXT").ok().or_else(|| Some("Login with OIDC".to_string())) + } else { + env::var("OIDC_BUTTON_TEXT").ok() + }, + scope: if oidc_essentials_present { + env::var("OIDC_SCOPE").ok().or_else(|| Some("openid email profile".to_string())) + } else { + env::var("OIDC_SCOPE").ok() + }, + button_color: if oidc_essentials_present { + env::var("OIDC_BUTTON_COLOR").ok().or_else(|| Some("#000000".to_string())) + } else { + env::var("OIDC_BUTTON_COLOR").ok() + }, + button_text_color: if oidc_essentials_present { + env::var("OIDC_BUTTON_TEXT_COLOR").ok().or_else(|| Some("#FFFFFF".to_string())) + } else { + env::var("OIDC_BUTTON_TEXT_COLOR").ok() + }, + icon_svg: env::var("OIDC_ICON_SVG").ok(), + name_claim: env::var("OIDC_NAME_CLAIM").ok(), + email_claim: env::var("OIDC_EMAIL_CLAIM").ok(), + username_claim: env::var("OIDC_USERNAME_CLAIM").ok(), + roles_claim: env::var("OIDC_ROLES_CLAIM").ok(), + user_role: env::var("OIDC_USER_ROLE").ok(), + admin_role: env::var("OIDC_ADMIN_ROLE").ok(), + }; + + let api = ApiConfig { + search_api_url: env::var("SEARCH_API_URL").unwrap(), + people_api_url: env::var("PEOPLE_API_URL").unwrap(), + }; + + // Validate OIDC configuration + if let Err(validation_error) = oidc.validate() { + return Err(AppError::Config(validation_error)); + } + + Ok(Config { + database, + redis, + server, + security, + email, + oidc, + api, + }) + } + + pub fn database_url(&self) -> String { + // URL encode username and password to handle special characters + let encoded_username = urlencoding::encode(&self.database.username); + let encoded_password = urlencoding::encode(&self.database.password); + + let url = match self.database.db_type.as_str() { + "postgresql" => format!( + "postgresql://{}:{}@{}:{}/{}", + encoded_username, + encoded_password, + self.database.host, + self.database.port, + self.database.name + ), + _ => format!( + "mysql://{}:{}@{}:{}/{}", + encoded_username, + encoded_password, + self.database.host, + self.database.port, + self.database.name + ), + }; + url + } + + pub fn redis_url(&self) -> String { + let mut url = String::from("redis://"); + + // Add authentication if provided + if let (Some(username), Some(password)) = (&self.redis.username, &self.redis.password) { + url.push_str(&format!("{}:{}@", + urlencoding::encode(username), + urlencoding::encode(password) + )); + } else if let Some(password) = &self.redis.password { + url.push_str(&format!(":{}@", urlencoding::encode(password))); + } + + // Add host and port + url.push_str(&format!("{}:{}", self.redis.host, self.redis.port)); + + // Add database if specified + if let Some(database) = self.redis.database { + url.push_str(&format!("/{}", database)); + } + + url + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/database.rs b/PinePods-0.8.2/rust-api/src/database.rs new file mode 100644 index 0000000..eef49eb --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/database.rs @@ -0,0 +1,24855 @@ +use sqlx::{MySql, Pool, Postgres, Row}; +use std::time::Duration; +use crate::{config::{Config, OIDCConfig}, error::{AppError, AppResult}}; +use chrono::{DateTime, Utc}; +use chrono_tz::Tz; +use std::collections::HashMap; +use bigdecimal::ToPrimitive; +use std::sync::{Arc, Mutex}; +use lazy_static::lazy_static; +use base64; + +// Global temporary MFA secrets storage (matches Python temp_mfa_secrets) +lazy_static! { + static ref TEMP_MFA_SECRETS: Arc>> = Arc::new(Mutex::new(HashMap::new())); +} + +#[derive(Clone)] +pub enum DatabasePool { + Postgres(Pool), + MySQL(Pool), +} + +impl DatabasePool { + pub async fn new(config: &Config) -> AppResult { + let database_url = config.database_url(); + + match config.database.db_type.as_str() { + "postgresql" => { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(config.database.max_connections) + .min_connections(config.database.min_connections) + .acquire_timeout(Duration::from_secs(30)) + .connect(&database_url) + .await?; + + // Skip migrations for now - database already exists + + Ok(DatabasePool::Postgres(pool)) + } + _ => { + let pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(config.database.max_connections) + .min_connections(config.database.min_connections) + .acquire_timeout(Duration::from_secs(30)) + .connect(&database_url) + .await?; + + // Skip migrations for now - database already exists + + Ok(DatabasePool::MySQL(pool)) + } + } + } + + pub async fn health_check(&self) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query("SELECT 1 as health") + .fetch_one(pool) + .await?; + let health: i32 = row.try_get("health")?; + Ok(health == 1) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT 1 as health") + .fetch_one(pool) + .await?; + let health: i32 = row.try_get("health")?; + Ok(health == 1) + } + } + } + + // Helper methods for database operations + + // Verify API key - matches Python verify_api_key function + pub async fn verify_api_key(&self, api_key: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT * FROM "APIKeys" WHERE apikey = $1"#) + .bind(api_key) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT * FROM APIKeys WHERE APIKey = ?") + .bind(api_key) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + } + } + + // Verify password - matches Python verify_password function + pub async fn verify_password(&self, username: &str, password: &str) -> AppResult { + use crate::services::auth::verify_password; + + let stored_hash = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT hashed_pw FROM "Users" WHERE username = $1"#) + .bind(username) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::("hashed_pw")? + } else { + return Ok(false); + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT Hashed_PW FROM Users WHERE Username = ?") + .bind(username) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::("Hashed_PW")? + } else { + return Ok(false); + } + } + }; + + verify_password(password, &stored_hash) + } + + // Get API key for user - matches Python get_api_key function + pub async fn get_api_key(&self, username: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // First get UserID + let user_row = sqlx::query(r#"SELECT userid FROM "Users" WHERE username = $1"#) + .bind(username) + .fetch_optional(pool) + .await?; + + let user_id: i32 = match user_row { + Some(row) => row.try_get("userid")?, + None => return Err(AppError::not_found("User not found")), + }; + + // Then get API key + let api_row = sqlx::query(r#"SELECT apikey FROM "APIKeys" WHERE userid = $1 LIMIT 1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + match api_row { + Some(row) => Ok(row.try_get("apikey")?), + None => Err(AppError::not_found("API key not found for user")), + } + } + DatabasePool::MySQL(pool) => { + // First get UserID + let user_row = sqlx::query("SELECT UserID FROM Users WHERE Username = ?") + .bind(username) + .fetch_optional(pool) + .await?; + + let user_id: i32 = match user_row { + Some(row) => row.try_get("UserID")?, + None => return Err(AppError::not_found("User not found")), + }; + + // Then get API key + let api_row = sqlx::query("SELECT APIKey FROM APIKeys WHERE UserID = ? LIMIT 1") + .bind(user_id) + .fetch_optional(pool) + .await?; + + match api_row { + Some(row) => Ok(row.try_get("APIKey")?), + None => Err(AppError::not_found("API key not found for user")), + } + } + } + } + + // Get user ID from API key - matches Python get_api_user function + pub async fn get_user_id_from_api_key(&self, api_key: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT userid FROM "APIKeys" WHERE apikey = $1 LIMIT 1"#) + .bind(api_key) + .fetch_one(pool) + .await?; + + Ok(row.try_get("userid")?) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT UserID FROM APIKeys WHERE APIKey = ? LIMIT 1") + .bind(api_key) + .fetch_one(pool) + .await?; + + Ok(row.try_get("UserID")?) + } + } + } + + // Get user ID from username - for login and key creation + pub async fn get_user_id_from_username(&self, username: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT userid FROM "Users" WHERE username = $1"#) + .bind(username) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => Ok(row.try_get("userid")?), + None => Err(AppError::not_found("User not found")), + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT UserID FROM Users WHERE Username = ?") + .bind(username) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => Ok(row.try_get("UserID")?), + None => Err(AppError::not_found("User not found")), + } + } + } + } + + // Get user details by ID - matches Python get_user_details_id function + pub async fn get_user_details_by_id(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT * FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(crate::handlers::auth::UserDetails { + UserID: row.try_get("userid")?, + Fullname: row.try_get("fullname").ok(), + Username: row.try_get("username").ok(), + Email: row.try_get("email").ok(), + Hashed_PW: row.try_get("hashed_pw").ok(), + Salt: row.try_get("salt").ok(), + }) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT * FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(crate::handlers::auth::UserDetails { + UserID: row.try_get("UserID")?, + Fullname: row.try_get("Fullname").ok(), + Username: row.try_get("Username").ok(), + Email: row.try_get("Email").ok(), + Hashed_PW: row.try_get("Hashed_PW").ok(), + Salt: row.try_get("Salt").ok(), + }) + } + } + } + + pub async fn get_user_by_credentials(&self, username: &str) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT userid as user_id, username as username, hashed_pw as hashed_password, email as email + FROM "Users" WHERE username = $1"# + ) + .bind(username) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(UserCredentials { + user_id: row.try_get("user_id")?, + username: row.try_get("username")?, + hashed_password: row.try_get("hashed_password")?, + email: row.try_get("email")?, + })) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT UserID as user_id, Username as username, Hashed_PW as hashed_password, Email as email + FROM Users WHERE Username = ?" + ) + .bind(username) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(UserCredentials { + user_id: row.try_get("user_id")?, + username: row.try_get("username")?, + hashed_password: row.try_get("hashed_password")?, + email: row.try_get("email")?, + })) + } else { + Ok(None) + } + } + } + } + + pub async fn get_user_settings_by_user_id(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT us.userid, ak.apikey as api_key, us.theme, + COALESCE(us.auto_download_episodes, false) as auto_download_episodes, + COALESCE(us.auto_delete_episodes, false) as auto_delete_episodes + FROM "UserSettings" us + LEFT JOIN "APIKeys" ak ON us.userid = ak.userid + WHERE us.userid = $1"# + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(UserSettings { + user_id: row.try_get("userid")?, + api_key: row.try_get("api_key")?, + theme: row.try_get("theme")?, + auto_download_episodes: row.try_get("auto_download_episodes")?, + auto_delete_episodes: row.try_get("auto_delete_episodes")?, + })) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT user_id, api_key, theme, auto_download_episodes, auto_delete_episodes + FROM UserSettings WHERE user_id = ?" + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(UserSettings { + user_id: row.try_get("user_id")?, + api_key: row.try_get("api_key")?, + theme: row.try_get("theme")?, + auto_download_episodes: row.try_get("auto_download_episodes")?, + auto_delete_episodes: row.try_get("auto_delete_episodes")?, + })) + } else { + Ok(None) + } + } + } + } + + // Get user ID by API key - matches Python get_api_user function + pub async fn get_api_user(&self, api_key: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT userid FROM "APIKeys" WHERE apikey = $1"#) + .bind(api_key) + .fetch_one(pool) + .await?; + + Ok(row.try_get("userid")?) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT UserID FROM APIKeys WHERE APIKey = ?") + .bind(api_key) + .fetch_one(pool) + .await?; + + Ok(row.try_get("UserID")?) + } + } + } + + // Get episodes for user - matches Python return_episodes function + pub async fn return_episodes(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT * FROM ( + SELECT + "Podcasts".podcastname as podcastname, + "Episodes".episodetitle as episodetitle, + "Episodes".episodepubdate as episodepubdate, + "Episodes".episodedescription as episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl as episodeurl, + "Episodes".episodeduration as episodeduration, + "UserEpisodeHistory".listenduration as listenduration, + "Episodes".episodeid as episodeid, + "Episodes".completed as completed, + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube + FROM "Episodes" + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON + "Episodes".episodeid = "UserEpisodeHistory".episodeid + AND "UserEpisodeHistory".userid = $1 + LEFT JOIN "SavedEpisodes" ON + "Episodes".episodeid = "SavedEpisodes".episodeid + AND "SavedEpisodes".userid = $1 + LEFT JOIN "EpisodeQueue" ON + "Episodes".episodeid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $1 + AND "EpisodeQueue".is_youtube = FALSE + LEFT JOIN "DownloadedEpisodes" ON + "Episodes".episodeid = "DownloadedEpisodes".episodeid + AND "DownloadedEpisodes".userid = $1 + WHERE "Episodes".episodepubdate >= NOW() - INTERVAL '30 days' + AND "Podcasts".userid = $1 + + UNION ALL + + SELECT + "Podcasts".podcastname as podcastname, + "YouTubeVideos".videotitle as episodetitle, + "YouTubeVideos".publishedat as episodepubdate, + "YouTubeVideos".videodescription as episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "YouTubeVideos".thumbnailurl + END as episodeartwork, + "YouTubeVideos".videourl as episodeurl, + "YouTubeVideos".duration as episodeduration, + "YouTubeVideos".listenposition as listenduration, + "YouTubeVideos".videoid as episodeid, + "YouTubeVideos".completed as completed, + CASE WHEN "SavedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL AND "EpisodeQueue".is_youtube = TRUE THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube + FROM "YouTubeVideos" + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "SavedVideos" ON + "YouTubeVideos".videoid = "SavedVideos".videoid + AND "SavedVideos".userid = $2 + LEFT JOIN "EpisodeQueue" ON + "YouTubeVideos".videoid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $3 + AND "EpisodeQueue".is_youtube = TRUE + LEFT JOIN "DownloadedVideos" ON + "YouTubeVideos".videoid = "DownloadedVideos".videoid + AND "DownloadedVideos".userid = $4 + WHERE "YouTubeVideos".publishedat >= NOW() - INTERVAL '30 days' + AND "Podcasts".userid = $5 + ) combined + ORDER BY episodepubdate DESC"# + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::handlers::podcasts::Episode { + podcastname: row.try_get("podcastname")?, + episodetitle: row.try_get("episodetitle")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + }); + } + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT * FROM ( + SELECT + Podcasts.PodcastName as podcastname, + Episodes.EpisodeTitle as episodetitle, + Episodes.EpisodePubDate as episodepubdate, + Episodes.EpisodeDescription as episodedescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = 1 AND Podcasts.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + ELSE Episodes.EpisodeArtwork + END as episodeartwork, + Episodes.EpisodeURL as episodeurl, + Episodes.EpisodeDuration as episodeduration, + UserEpisodeHistory.ListenDuration as listenduration, + Episodes.EpisodeID as episodeid, + Episodes.Completed as completed, + CASE WHEN SavedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube + FROM Episodes + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN UserEpisodeHistory ON + Episodes.EpisodeID = UserEpisodeHistory.EpisodeID + AND UserEpisodeHistory.UserID = ? + LEFT JOIN SavedEpisodes ON + Episodes.EpisodeID = SavedEpisodes.EpisodeID + AND SavedEpisodes.UserID = ? + LEFT JOIN EpisodeQueue ON + Episodes.EpisodeID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + AND EpisodeQueue.is_youtube = FALSE + LEFT JOIN DownloadedEpisodes ON + Episodes.EpisodeID = DownloadedEpisodes.EpisodeID + AND DownloadedEpisodes.UserID = ? + WHERE Episodes.EpisodePubDate >= DATE_SUB(NOW(), INTERVAL 30 DAY) + AND Podcasts.UserID = ? + + UNION ALL + + SELECT + Podcasts.PodcastName as podcastname, + YouTubeVideos.VideoTitle as episodetitle, + YouTubeVideos.PublishedAt as episodepubdate, + YouTubeVideos.VideoDescription as episodedescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = 1 AND Podcasts.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + ELSE YouTubeVideos.ThumbnailURL + END as episodeartwork, + YouTubeVideos.VideoURL as episodeurl, + YouTubeVideos.Duration as episodeduration, + YouTubeVideos.ListenPosition as listenduration, + YouTubeVideos.VideoID as episodeid, + YouTubeVideos.Completed as completed, + CASE WHEN SavedVideos.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL AND EpisodeQueue.is_youtube = TRUE THEN TRUE ELSE FALSE END AS queued, + CASE WHEN DownloadedVideos.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube + FROM YouTubeVideos + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN SavedVideos ON + YouTubeVideos.VideoID = SavedVideos.VideoID + AND SavedVideos.UserID = ? + LEFT JOIN EpisodeQueue ON + YouTubeVideos.VideoID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + AND EpisodeQueue.is_youtube = TRUE + LEFT JOIN DownloadedVideos ON + YouTubeVideos.VideoID = DownloadedVideos.VideoID + AND DownloadedVideos.UserID = ? + WHERE YouTubeVideos.PublishedAt >= DATE_SUB(NOW(), INTERVAL 30 DAY) + AND Podcasts.UserID = ? + ) combined + ORDER BY episodepubdate DESC" + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::handlers::podcasts::Episode { + podcastname: row.try_get("podcastname")?, + episodetitle: row.try_get("episodetitle")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + }); + } + Ok(episodes) + } + } + } + + // Add podcast - matches Python add_podcast function exactly + pub async fn add_podcast( + &self, + podcast_values: &crate::handlers::podcasts::PodcastValues, + podcast_index_id: i64, + username: Option<&str>, + password: Option<&str>, + ) -> AppResult<(i32, Option)> { + match self { + DatabasePool::Postgres(pool) => { + // Check if podcast already exists + let existing = sqlx::query(r#"SELECT podcastid, podcastname, feedurl FROM "Podcasts" WHERE feedurl = $1 AND userid = $2"#) + .bind(&podcast_values.pod_feed_url) + .bind(podcast_values.user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing { + let podcast_id: i32 = row.try_get("podcastid")?; + // Check if there are episodes + let episode_count = sqlx::query(r#"SELECT COUNT(*) as count FROM "Episodes" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_one(pool) + .await?; + + let count: i64 = episode_count.try_get("count")?; + if count == 0 { + // No episodes, add them + let first_episode_id = self.add_episodes(podcast_id, &podcast_values.pod_feed_url, + &podcast_values.pod_artwork, false, + username, password).await?; + return Ok((podcast_id, first_episode_id)); + } else { + return Ok((podcast_id, None)); + } + } + + // Convert categories to string + let category_list = serde_json::to_string(&podcast_values.categories)?; + + // Insert new podcast + let row = sqlx::query( + r#"INSERT INTO "Podcasts" + (podcastname, artworkurl, author, categories, description, episodecount, + feedurl, websiteurl, explicit, userid, feedcutoffdays, username, password, podcastindexid) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING podcastid"# + ) + .bind(&podcast_values.pod_title) + .bind(&podcast_values.pod_artwork) + .bind(&podcast_values.pod_author) + .bind(&category_list) + .bind(&podcast_values.pod_description) + .bind(0) // EpisodeCount starts at 0 + .bind(&podcast_values.pod_feed_url) + .bind(&podcast_values.pod_website) + .bind(podcast_values.pod_explicit) + .bind(podcast_values.user_id) + .bind(30) // Default feed cutoff days + .bind(username) + .bind(password) + .bind(podcast_index_id) + .fetch_one(pool) + .await?; + + let podcast_id: i32 = row.try_get("podcastid")?; + + // Update UserStats table + sqlx::query(r#"UPDATE "UserStats" SET podcastsadded = podcastsadded + 1 WHERE userid = $1"#) + .bind(podcast_values.user_id) + .execute(pool) + .await?; + + // Add episodes + let first_episode_id = self.add_episodes(podcast_id, &podcast_values.pod_feed_url, + &podcast_values.pod_artwork, false, + username, password).await?; + + // Count episodes for logging + let episode_count: i64 = sqlx::query_scalar(r#"SELECT COUNT(*) FROM "Episodes" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_one(pool) + .await?; + + println!("✅ Added podcast '{}' for user {} with {} episodes", + podcast_values.pod_title, podcast_values.user_id, episode_count); + + Ok((podcast_id, first_episode_id)) + } + DatabasePool::MySQL(pool) => { + // Check if podcast already exists + let existing = sqlx::query("SELECT PodcastID, PodcastName, FeedURL FROM Podcasts WHERE FeedURL = ? AND UserID = ?") + .bind(&podcast_values.pod_feed_url) + .bind(podcast_values.user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing { + let podcast_id: i32 = row.try_get("PodcastID")?; + // Check if there are episodes + let episode_count = sqlx::query("SELECT COUNT(*) as count FROM Episodes WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_one(pool) + .await?; + + let count: i64 = episode_count.try_get("count")?; + if count == 0 { + // No episodes, add them + let first_episode_id = self.add_episodes(podcast_id, &podcast_values.pod_feed_url, + &podcast_values.pod_artwork, false, + username, password).await?; + return Ok((podcast_id, first_episode_id)); + } else { + return Ok((podcast_id, None)); + } + } + + // Convert categories to string + let category_list = serde_json::to_string(&podcast_values.categories)?; + + // Insert new podcast + let result = sqlx::query( + "INSERT INTO Podcasts + (PodcastName, ArtworkURL, Author, Categories, Description, EpisodeCount, + FeedURL, WebsiteURL, Explicit, UserID, FeedCutoffDays, Username, Password, PodcastIndexID) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(&podcast_values.pod_title) + .bind(&podcast_values.pod_artwork) + .bind(&podcast_values.pod_author) + .bind(&category_list) + .bind(&podcast_values.pod_description) + .bind(0) // EpisodeCount starts at 0 + .bind(&podcast_values.pod_feed_url) + .bind(&podcast_values.pod_website) + .bind(if podcast_values.pod_explicit { 1 } else { 0 }) + .bind(podcast_values.user_id) + .bind(30) // Default feed cutoff days + .bind(username) + .bind(password) + .bind(podcast_index_id) + .execute(pool) + .await?; + + let podcast_id = result.last_insert_id() as i32; + + // Update UserStats table + sqlx::query("UPDATE UserStats SET PodcastsAdded = PodcastsAdded + 1 WHERE UserID = ?") + .bind(podcast_values.user_id) + .execute(pool) + .await?; + + // Add episodes + let first_episode_id = self.add_episodes(podcast_id, &podcast_values.pod_feed_url, + &podcast_values.pod_artwork, false, + username, password).await?; + + // Count episodes for logging + let episode_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM Episodes WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_one(pool) + .await?; + + println!("✅ Added podcast '{}' for user {} with {} episodes", + podcast_values.pod_title, podcast_values.user_id, episode_count); + + Ok((podcast_id, first_episode_id)) + } + } + } + + // Add podcast without episodes - for background episode processing + pub async fn add_podcast_without_episodes( + &self, + podcast_values: &crate::handlers::podcasts::PodcastValues, + podcast_index_id: i64, + username: Option<&str>, + password: Option<&str>, + ) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Check if podcast already exists + let existing = sqlx::query(r#"SELECT podcastid, podcastname, feedurl FROM "Podcasts" WHERE feedurl = $1 AND userid = $2"#) + .bind(&podcast_values.pod_feed_url) + .bind(podcast_values.user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing { + let podcast_id: i32 = row.try_get("podcastid")?; + return Ok(podcast_id); + } + + // Convert categories to string + let category_list = serde_json::to_string(&podcast_values.categories)?; + + // Insert new podcast without episodes + let row = sqlx::query( + r#"INSERT INTO "Podcasts" + (podcastname, artworkurl, author, categories, description, episodecount, + feedurl, websiteurl, explicit, userid, feedcutoffdays, username, password, podcastindexid) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING podcastid"# + ) + .bind(&podcast_values.pod_title) + .bind(&podcast_values.pod_artwork) + .bind(&podcast_values.pod_author) + .bind(&category_list) + .bind(&podcast_values.pod_description) + .bind(0) // EpisodeCount starts at 0 + .bind(&podcast_values.pod_feed_url) + .bind(&podcast_values.pod_website) + .bind(podcast_values.pod_explicit) + .bind(podcast_values.user_id) + .bind(30) // Default feed cutoff days + .bind(username) + .bind(password) + .bind(podcast_index_id) + .fetch_one(pool) + .await?; + + let podcast_id: i32 = row.try_get("podcastid")?; + + // Update UserStats table + sqlx::query(r#"UPDATE "UserStats" SET podcastsadded = podcastsadded + 1 WHERE userid = $1"#) + .bind(podcast_values.user_id) + .execute(pool) + .await?; + + println!("✅ Added podcast '{}' for user {} (episodes will be processed in background)", + podcast_values.pod_title, podcast_values.user_id); + + Ok(podcast_id) + } + DatabasePool::MySQL(pool) => { + // Check if podcast already exists + let existing = sqlx::query("SELECT PodcastID, PodcastName, FeedURL FROM Podcasts WHERE FeedURL = ? AND UserID = ?") + .bind(&podcast_values.pod_feed_url) + .bind(podcast_values.user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing { + let podcast_id: i32 = row.try_get("PodcastID")?; + return Ok(podcast_id); + } + + // Convert categories to string + let category_list = serde_json::to_string(&podcast_values.categories)?; + + // Insert new podcast without episodes + let result = sqlx::query( + "INSERT INTO Podcasts + (PodcastName, ArtworkURL, Author, Categories, Description, EpisodeCount, + FeedURL, WebsiteURL, Explicit, UserID, FeedCutoffDays, Username, Password, PodcastIndexID) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(&podcast_values.pod_title) + .bind(&podcast_values.pod_artwork) + .bind(&podcast_values.pod_author) + .bind(&category_list) + .bind(&podcast_values.pod_description) + .bind(0) // EpisodeCount starts at 0 + .bind(&podcast_values.pod_feed_url) + .bind(&podcast_values.pod_website) + .bind(podcast_values.pod_explicit) + .bind(podcast_values.user_id) + .bind(30) // Default feed cutoff days + .bind(username) + .bind(password) + .bind(podcast_index_id) + .execute(pool) + .await?; + + let podcast_id = result.last_insert_id() as i32; + + // Update UserStats table + sqlx::query("UPDATE UserStats SET PodcastsAdded = PodcastsAdded + 1 WHERE UserID = ?") + .bind(podcast_values.user_id) + .execute(pool) + .await?; + + println!("✅ Added podcast '{}' for user {} (episodes will be processed in background)", + podcast_values.pod_title, podcast_values.user_id); + + Ok(podcast_id) + } + } + } + + // Remove podcast - matches Python remove_podcast function + pub async fn remove_podcast( + &self, + podcast_name: &str, + podcast_url: &str, + user_id: i32, + ) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // First get the podcast ID to cascade delete properly + let podcast_row = sqlx::query( + r#"SELECT podcastid FROM "Podcasts" + WHERE podcastname = $1 AND feedurl = $2 AND userid = $3"# + ) + .bind(podcast_name) + .bind(podcast_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = podcast_row { + let podcast_id: i32 = row.try_get("podcastid")?; + + // Delete in the proper order to handle foreign key constraints + // 1. PlaylistContents first + sqlx::query(r#"DELETE FROM "PlaylistContents" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 2. UserEpisodeHistory + sqlx::query(r#"DELETE FROM "UserEpisodeHistory" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 3. DownloadedEpisodes + sqlx::query(r#"DELETE FROM "DownloadedEpisodes" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 4. SavedEpisodes + sqlx::query(r#"DELETE FROM "SavedEpisodes" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 5. EpisodeQueue + sqlx::query(r#"DELETE FROM "EpisodeQueue" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 6. Episodes + sqlx::query(r#"DELETE FROM "Episodes" WHERE podcastid = $1"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 7. Finally delete the podcast itself + sqlx::query(r#"DELETE FROM "Podcasts" WHERE podcastid = $1"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // Update user stats + sqlx::query(r#"UPDATE "UserStats" SET podcastsadded = podcastsadded - 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + Ok(()) + } + DatabasePool::MySQL(pool) => { + // First get the podcast ID to cascade delete properly + let podcast_row = sqlx::query( + "SELECT PodcastID FROM Podcasts + WHERE PodcastName = ? AND FeedURL = ? AND UserID = ?" + ) + .bind(podcast_name) + .bind(podcast_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = podcast_row { + let podcast_id: i32 = row.try_get("PodcastID")?; + + // Delete in the proper order to handle foreign key constraints + // 1. PlaylistContents first + sqlx::query("DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 2. UserEpisodeHistory + sqlx::query("DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 3. DownloadedEpisodes + sqlx::query("DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 4. SavedEpisodes + sqlx::query("DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 5. EpisodeQueue + sqlx::query("DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 6. Episodes + sqlx::query("DELETE FROM Episodes WHERE PodcastID = ?") + .bind(podcast_id) + .execute(pool) + .await?; + + // 7. Finally delete the podcast itself + sqlx::query("DELETE FROM Podcasts WHERE PodcastID = ?") + .bind(podcast_id) + .execute(pool) + .await?; + + // Update user stats + sqlx::query("UPDATE UserStats SET PodcastsAdded = PodcastsAdded - 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + Ok(()) + } + } + } + + // Remove podcast by ID - matches Python remove_podcast_id function + pub async fn remove_podcast_id(&self, podcast_id: i32, user_id: i32) -> AppResult<()> { + if podcast_id == 0 { + return Err(AppError::bad_request("Invalid podcast ID")); + } + + match self { + DatabasePool::Postgres(pool) => { + // Delete in the proper order to handle foreign key constraints + // 1. PlaylistContents first + sqlx::query(r#"DELETE FROM "PlaylistContents" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 2. UserEpisodeHistory + sqlx::query(r#"DELETE FROM "UserEpisodeHistory" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 3. DownloadedEpisodes + sqlx::query(r#"DELETE FROM "DownloadedEpisodes" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 4. SavedEpisodes + sqlx::query(r#"DELETE FROM "SavedEpisodes" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 5. EpisodeQueue + sqlx::query(r#"DELETE FROM "EpisodeQueue" WHERE episodeid IN (SELECT episodeid FROM "Episodes" WHERE podcastid = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 6. Episodes + sqlx::query(r#"DELETE FROM "Episodes" WHERE podcastid = $1"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 7. Finally delete the podcast itself + sqlx::query(r#"DELETE FROM "Podcasts" WHERE podcastid = $1"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // Update user stats + sqlx::query(r#"UPDATE "UserStats" SET podcastsadded = podcastsadded - 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) + } + DatabasePool::MySQL(pool) => { + // Delete in the proper order to handle foreign key constraints + // 1. PlaylistContents first + sqlx::query("DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 2. UserEpisodeHistory + sqlx::query("DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 3. DownloadedEpisodes + sqlx::query("DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 4. SavedEpisodes + sqlx::query("DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 5. EpisodeQueue + sqlx::query("DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + // 6. Episodes + sqlx::query("DELETE FROM Episodes WHERE PodcastID = ?") + .bind(podcast_id) + .execute(pool) + .await?; + + // 7. Finally delete the podcast itself + sqlx::query("DELETE FROM Podcasts WHERE PodcastID = ?") + .bind(podcast_id) + .execute(pool) + .await?; + + // Update user stats + sqlx::query("UPDATE UserStats SET PodcastsAdded = PodcastsAdded - 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) + } + } + } + + // Get user podcast count - for refresh progress tracking + pub async fn get_user_podcast_count(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT COUNT(*) as count FROM "Podcasts" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(row.try_get::("count")? as u32) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT COUNT(*) as count FROM Podcasts WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(row.try_get::("count")? as u32) + } + } + } + + // Get user podcasts for refresh - matches Python refresh logic + pub async fn get_user_podcasts_for_refresh(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT + podcastid as id, + podcastname as name, + feedurl as feed_url, + artworkurl as artwork_url, + isyoutubechannel as is_youtube, + autodownload as auto_download, + username as username, + password as password, + feedcutoffdays as feed_cutoff_days, + userid as user_id + FROM "Podcasts" + WHERE userid = $1"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + podcasts.push(crate::handlers::refresh::PodcastForRefresh { + id: row.try_get("id")?, + name: row.try_get("name")?, + feed_url: row.try_get("feed_url")?, + artwork_url: row.try_get("artwork_url").unwrap_or_default(), + is_youtube: row.try_get("is_youtube")?, + auto_download: row.try_get("auto_download")?, + username: row.try_get("username").ok(), + password: row.try_get("password").ok(), + feed_cutoff_days: row.try_get("feed_cutoff_days").ok(), + user_id: row.try_get("user_id")?, + }); + } + Ok(podcasts) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT + PodcastID as id, + PodcastName as name, + FeedURL as feed_url, + ArtworkURL as artwork_url, + IsYouTubeChannel as is_youtube, + AutoDownload as auto_download, + Username as username, + Password as password, + FeedCutoffDays as feed_cutoff_days, + UserID as user_id + FROM Podcasts + WHERE UserID = ?" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + podcasts.push(crate::handlers::refresh::PodcastForRefresh { + id: row.try_get("id")?, + name: row.try_get("name")?, + feed_url: row.try_get("feed_url")?, + artwork_url: row.try_get("artwork_url").unwrap_or_default(), + is_youtube: row.try_get("is_youtube")?, + auto_download: row.try_get("auto_download")?, + username: row.try_get("username").ok(), + password: row.try_get("password").ok(), + feed_cutoff_days: row.try_get("feed_cutoff_days").ok(), + user_id: row.try_get("user_id")?, + }); + } + Ok(podcasts) + } + } + } + + // Remove podcast by name and URL - matches Python remove_podcast function + pub async fn remove_podcast_by_name_url( + &self, + podcast_name: &str, + podcast_url: &str, + user_id: i32, + ) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // First get the podcast ID to cascade delete properly + let podcast_row = sqlx::query( + r#"SELECT podcastid FROM "Podcasts" + WHERE podcastname = $1 AND feedurl = $2 AND userid = $3"# + ) + .bind(podcast_name) + .bind(podcast_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = podcast_row { + let podcast_id: i32 = row.try_get("podcastid")?; + + // Delete in the proper order to handle foreign key constraints + // 1. PlaylistContents first + sqlx::query(r#"DELETE FROM "PlaylistContents" WHERE "EpisodeID" IN (SELECT "EpisodeID" FROM "Episodes" WHERE "PodcastID" = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 2. UserEpisodeHistory + sqlx::query(r#"DELETE FROM "UserEpisodeHistory" WHERE "EpisodeID" IN (SELECT "EpisodeID" FROM "Episodes" WHERE "PodcastID" = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 3. DownloadedEpisodes + sqlx::query(r#"DELETE FROM "DownloadedEpisodes" WHERE "EpisodeID" IN (SELECT "EpisodeID" FROM "Episodes" WHERE "PodcastID" = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 4. SavedEpisodes + sqlx::query(r#"DELETE FROM "SavedEpisodes" WHERE "EpisodeID" IN (SELECT "EpisodeID" FROM "Episodes" WHERE "PodcastID" = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 5. QueuedEpisodes (EpisodeQueue in Python) + sqlx::query(r#"DELETE FROM "QueuedEpisodes" WHERE "EpisodeID" IN (SELECT "EpisodeID" FROM "Episodes" WHERE "PodcastID" = $1)"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 6. Episodes + sqlx::query(r#"DELETE FROM "Episodes" WHERE "PodcastID" = $1"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 7. Finally delete the podcast + sqlx::query(r#"DELETE FROM "Podcasts" WHERE "PodcastID" = $1"#) + .bind(podcast_id) + .execute(pool) + .await?; + + // 8. Update UserStats - decrement PodcastsAdded + sqlx::query(r#"UPDATE "UserStats" SET podcastsadded = podcastsadded - 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + + Ok(()) + } + DatabasePool::MySQL(pool) => { + // First get the podcast ID to cascade delete properly + let podcast_row = sqlx::query( + "SELECT PodcastID FROM Podcasts + WHERE PodcastName = ? AND FeedURL = ? AND UserID = ?" + ) + .bind(podcast_name) + .bind(podcast_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = podcast_row { + let podcast_id: i32 = row.try_get("podcastid")?; + + // Delete in the proper order to handle foreign key constraints + sqlx::query("DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM QueuedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID = ?)") + .bind(podcast_id) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM Episodes WHERE PodcastID = ?") + .bind(podcast_id) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM Podcasts WHERE PodcastID = ?") + .bind(podcast_id) + .execute(pool) + .await?; + + sqlx::query("UPDATE UserStats SET PodcastsAdded = PodcastsAdded - 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + + Ok(()) + } + } + } + + // Return podcasts basic - matches Python return_pods function + pub async fn return_pods(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT + podcastid as podcastid, + COALESCE(podcastname, 'Unknown Podcast') as podcastname, + CASE + WHEN artworkurl IS NULL OR artworkurl = '' + THEN '/static/assets/default-podcast.png' + ELSE artworkurl + END as artworkurl, + COALESCE(description, 'No description available') as description, + COALESCE(episodecount, 0) as episodecount, + COALESCE(websiteurl, '') as websiteurl, + COALESCE(feedurl, '') as feedurl, + COALESCE(author, 'Unknown Author') as author, + COALESCE(categories, '') as categories, + COALESCE(explicit, false) as explicit, + COALESCE(podcastindexid, 0) as podcastindexid + FROM "Podcasts" + WHERE userid = $1 AND COALESCE(displaypodcast, TRUE) = TRUE + ORDER BY podcastname"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + podcasts.push(crate::models::PodcastResponse { + podcastid: row.try_get("podcastid")?, + podcastname: row.try_get("podcastname")?, + artworkurl: row.try_get("artworkurl").ok(), + description: row.try_get("description").ok(), + episodecount: row.try_get("episodecount").ok(), + websiteurl: row.try_get("websiteurl").ok(), + feedurl: row.try_get("feedurl")?, + author: row.try_get("author").ok(), + categories: { + let categories_str: String = row.try_get("categories")?; + self.parse_categories_json(&categories_str) + }, + explicit: row.try_get("explicit")?, + podcastindexid: row.try_get::("podcastindexid").ok().map(|i| i as i64), + }); + } + Ok(podcasts) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT + PodcastID as podcastid, + COALESCE(PodcastName, 'Unknown Podcast') as podcastname, + CASE + WHEN ArtworkURL IS NULL OR ArtworkURL = '' + THEN '/static/assets/default-podcast.png' + ELSE ArtworkURL + END as artworkurl, + COALESCE(Description, 'No description available') as description, + COALESCE(EpisodeCount, 0) as episodecount, + COALESCE(WebsiteURL, '') as websiteurl, + COALESCE(FeedURL, '') as feedurl, + COALESCE(Author, 'Unknown Author') as author, + COALESCE(Categories, '') as categories, + COALESCE(Explicit, false) as explicit, + COALESCE(PodcastIndexID, 0) as podcastindexid + FROM Podcasts + WHERE UserID = ? AND COALESCE(DisplayPodcast, 1) = 1 + ORDER BY PodcastName" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + podcasts.push(crate::models::PodcastResponse { + podcastid: row.try_get("podcastid")?, + podcastname: row.try_get("podcastname")?, + artworkurl: row.try_get("artworkurl").ok(), + description: row.try_get("description").ok(), + episodecount: row.try_get("episodecount").ok(), + websiteurl: row.try_get("websiteurl").ok(), + feedurl: row.try_get("feedurl")?, + author: row.try_get("author").ok(), + categories: { + let categories_str: String = row.try_get("categories")?; + self.parse_categories_json(&categories_str) + }, + explicit: row.try_get("explicit")?, + podcastindexid: row.try_get::("podcastindexid").ok().map(|i| i as i64), + }); + } + Ok(podcasts) + } + } + } + + // Return podcasts with extra stats - matches Python return_pods with analytics + pub async fn return_pods_extra(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT + p.podcastid as podcastid, + COALESCE(p.podcastname, 'Unknown Podcast') as podcastname, + CASE + WHEN p.artworkurl IS NULL OR p.artworkurl = '' + THEN '/static/assets/default-podcast.png' + ELSE p.artworkurl + END as artworkurl, + COALESCE(p.description, 'No description available') as description, + COALESCE(p.episodecount, 0) as episodecount, + COALESCE(p.websiteurl, '') as websiteurl, + COALESCE(p.feedurl, '') as feedurl, + COALESCE(p.author, 'Unknown Author') as author, + COALESCE(p.categories, '') as categories, + COALESCE(p.explicit, false) as explicit, + COALESCE(p.podcastindexid, 0) as podcastindexid, + COUNT(ueh.userepisodehistoryid) as play_count, + COUNT(DISTINCT ueh.episodeid) as episodes_played, + MIN(e.episodepubdate) as oldest_episode_date, + COALESCE(p.isyoutube, false) as is_youtube + FROM "Podcasts" p + LEFT JOIN "Episodes" e ON p.podcastid = e.podcastid + LEFT JOIN "UserEpisodeHistory" ueh ON e.episodeid = ueh.episodeid AND ueh.userid = $1 + WHERE p.userid = $1 AND COALESCE(p.displaypodcast, TRUE) = TRUE + GROUP BY p.podcastid, p.podcastname, p.artworkurl, p.description, + p.episodecount, p.websiteurl, p.feedurl, p.author, + p.categories, p.explicit, p.podcastindexid, p.isyoutube + ORDER BY p.podcastname"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + let feed_url: String = row.try_get("feedurl")?; + let is_youtube = row.try_get("is_youtube").unwrap_or_else(|_| feed_url.contains("youtube.com")); + + podcasts.push(crate::models::PodcastExtraResponse { + podcastid: row.try_get("podcastid")?, + podcastname: row.try_get("podcastname")?, + artworkurl: row.try_get("artworkurl").ok(), + description: row.try_get("description").ok(), + episodecount: row.try_get("episodecount").ok(), + websiteurl: row.try_get("websiteurl").ok(), + feedurl: feed_url, + author: row.try_get("author").ok(), + categories: { + let categories_str: String = row.try_get("categories")?; + self.parse_categories_json(&categories_str) + }, + explicit: row.try_get("explicit")?, + podcastindexid: row.try_get::("podcastindexid").ok().map(|i| i as i64), + play_count: row.try_get("play_count")?, + episodes_played: row.try_get("episodes_played")?, + oldest_episode_date: row.try_get("oldest_episode_date").ok(), + is_youtube, + }); + } + Ok(podcasts) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT + p.PodcastID as podcastid, + COALESCE(p.PodcastName, 'Unknown Podcast') as podcastname, + CASE + WHEN p.ArtworkURL IS NULL OR p.ArtworkURL = '' + THEN '/static/assets/default-podcast.png' + ELSE p.ArtworkURL + END as artworkurl, + COALESCE(p.Description, 'No description available') as description, + COALESCE(p.EpisodeCount, 0) as episodecount, + COALESCE(p.WebsiteURL, '') as websiteurl, + COALESCE(p.FeedURL, '') as feedurl, + COALESCE(p.Author, 'Unknown Author') as author, + COALESCE(p.Categories, '') as categories, + COALESCE(p.Explicit, false) as explicit, + COALESCE(p.PodcastIndexID, 0) as podcastindexid, + COUNT(ueh.UserEpisodeHistoryID) as play_count, + COUNT(DISTINCT ueh.EpisodeID) as episodes_played, + MIN(e.EpisodePubDate) as oldest_episode_date, + COALESCE(p.IsYouTubeChannel, false) as is_youtube + FROM Podcasts p + LEFT JOIN Episodes e ON p.PodcastID = e.PodcastID + LEFT JOIN UserEpisodeHistory ueh ON e.EpisodeID = ueh.EpisodeID AND ueh.UserID = ? + WHERE p.UserID = ? AND COALESCE(p.DisplayPodcast, 1) = 1 + GROUP BY p.PodcastID, p.PodcastName, p.ArtworkURL, p.Description, + p.EpisodeCount, p.WebsiteURL, p.FeedURL, p.Author, + p.Categories, p.Explicit, p.PodcastIndexID, p.IsYouTubeChannel + ORDER BY p.PodcastName" + ) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + let feed_url: String = row.try_get("feedurl")?; + let is_youtube = row.try_get("is_youtube").unwrap_or_else(|_| feed_url.contains("youtube.com")); + + podcasts.push(crate::models::PodcastExtraResponse { + podcastid: row.try_get("podcastid")?, + podcastname: row.try_get("podcastname")?, + artworkurl: row.try_get("artworkurl").ok(), + description: row.try_get("description").ok(), + episodecount: row.try_get("episodecount").ok(), + websiteurl: row.try_get("websiteurl").ok(), + feedurl: feed_url, + author: row.try_get("author").ok(), + categories: { + let categories_str: String = row.try_get("categories")?; + self.parse_categories_json(&categories_str) + }, + explicit: row.try_get("explicit")?, + podcastindexid: row.try_get::("podcastindexid").ok().map(|i| i as i64), + play_count: row.try_get("play_count")?, + episodes_played: row.try_get("episodes_played")?, + oldest_episode_date: row.try_get("oldest_episode_date").ok(), + is_youtube, + }); + } + Ok(podcasts) + } + } + } + + // Merge podcasts - set DisplayPodcast=FALSE for secondary podcasts and update primary with merged IDs + pub async fn merge_podcasts(&self, primary_podcast_id: i32, secondary_podcast_ids: &[i32], user_id: i32) -> AppResult<()> { + // Validate that all podcasts belong to the user + for &podcast_id in std::iter::once(&primary_podcast_id).chain(secondary_podcast_ids.iter()) { + let exists = self.verify_podcast_belongs_to_user(podcast_id, user_id).await?; + if !exists { + return Err(crate::error::AppError::forbidden("One or more podcasts do not belong to this user")); + } + } + + // Prevent circular merges - check if any secondary podcasts are already primary podcasts with merges + for &secondary_id in secondary_podcast_ids { + let existing_merges = self.get_merged_podcast_ids(secondary_id).await?; + if !existing_merges.is_empty() { + return Err(crate::error::AppError::bad_request("Cannot merge a podcast that is already a primary podcast with merged podcasts")); + } + } + + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + // Set DisplayPodcast=FALSE for secondary podcasts + for &secondary_id in secondary_podcast_ids { + sqlx::query(r#"UPDATE "Podcasts" SET displaypodcast = FALSE WHERE podcastid = $1"#) + .bind(secondary_id) + .execute(&mut *tx) + .await?; + } + + // Get current merged IDs for primary podcast + let current_merged_ids = self.get_merged_podcast_ids(primary_podcast_id).await?; + let mut all_merged_ids = current_merged_ids; + all_merged_ids.extend_from_slice(secondary_podcast_ids); + + // Update primary podcast with merged IDs + let merged_json = serde_json::to_string(&all_merged_ids)?; + sqlx::query(r#"UPDATE "Podcasts" SET mergedpodcastids = $1 WHERE podcastid = $2"#) + .bind(merged_json) + .bind(primary_podcast_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + // Set DisplayPodcast=0 for secondary podcasts + for &secondary_id in secondary_podcast_ids { + sqlx::query("UPDATE Podcasts SET DisplayPodcast = 0 WHERE PodcastID = ?") + .bind(secondary_id) + .execute(&mut *tx) + .await?; + } + + // Get current merged IDs for primary podcast + let current_merged_ids = self.get_merged_podcast_ids(primary_podcast_id).await?; + let mut all_merged_ids = current_merged_ids; + all_merged_ids.extend_from_slice(secondary_podcast_ids); + + // Update primary podcast with merged IDs + let merged_json = serde_json::to_string(&all_merged_ids)?; + sqlx::query("UPDATE Podcasts SET MergedPodcastIDs = ? WHERE PodcastID = ?") + .bind(merged_json) + .bind(primary_podcast_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + } + } + Ok(()) + } + + // Unmerge a specific podcast from a primary podcast + pub async fn unmerge_podcast(&self, primary_podcast_id: i32, target_podcast_id: i32, user_id: i32) -> AppResult<()> { + // Validate ownership + let primary_exists = self.verify_podcast_belongs_to_user(primary_podcast_id, user_id).await?; + let target_exists = self.verify_podcast_belongs_to_user(target_podcast_id, user_id).await?; + + if !primary_exists || !target_exists { + return Err(crate::error::AppError::forbidden("One or more podcasts do not belong to this user")); + } + + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + // Get current merged IDs and remove the target + let mut merged_ids = self.get_merged_podcast_ids(primary_podcast_id).await?; + merged_ids.retain(|&id| id != target_podcast_id); + + // Update primary podcast + let merged_json = if merged_ids.is_empty() { + None + } else { + Some(serde_json::to_string(&merged_ids)?) + }; + + sqlx::query(r#"UPDATE "Podcasts" SET mergedpodcastids = $1 WHERE podcastid = $2"#) + .bind(merged_json) + .bind(primary_podcast_id) + .execute(&mut *tx) + .await?; + + // Set DisplayPodcast=TRUE for the unmerged podcast + sqlx::query(r#"UPDATE "Podcasts" SET displaypodcast = TRUE WHERE podcastid = $1"#) + .bind(target_podcast_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + // Get current merged IDs and remove the target + let mut merged_ids = self.get_merged_podcast_ids(primary_podcast_id).await?; + merged_ids.retain(|&id| id != target_podcast_id); + + // Update primary podcast + let merged_json = if merged_ids.is_empty() { + None + } else { + Some(serde_json::to_string(&merged_ids)?) + }; + + sqlx::query("UPDATE Podcasts SET MergedPodcastIDs = ? WHERE PodcastID = ?") + .bind(merged_json) + .bind(primary_podcast_id) + .execute(&mut *tx) + .await?; + + // Set DisplayPodcast=1 for the unmerged podcast + sqlx::query("UPDATE Podcasts SET DisplayPodcast = 1 WHERE PodcastID = ?") + .bind(target_podcast_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + } + } + Ok(()) + } + + // Get merged podcast IDs for a primary podcast + pub async fn get_merged_podcast_ids(&self, podcast_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT mergedpodcastids FROM "Podcasts" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + if let Some(merged_json) = row.try_get::, _>("mergedpodcastids")? { + let merged_ids: Vec = serde_json::from_str(&merged_json).unwrap_or_default(); + Ok(merged_ids) + } else { + Ok(Vec::new()) + } + } else { + Ok(Vec::new()) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT MergedPodcastIDs FROM Podcasts WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + if let Some(merged_json) = row.try_get::, _>("MergedPodcastIDs")? { + let merged_ids: Vec = serde_json::from_str(&merged_json).unwrap_or_default(); + Ok(merged_ids) + } else { + Ok(Vec::new()) + } + } else { + Ok(Vec::new()) + } + } + } + } + + // Helper function to verify a podcast belongs to a user + pub async fn verify_podcast_belongs_to_user(&self, podcast_id: i32, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let count = sqlx::query(r#"SELECT COUNT(*) as count FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(count.try_get::("count")? > 0) + } + DatabasePool::MySQL(pool) => { + let count = sqlx::query("SELECT COUNT(*) as count FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(count.try_get::("count")? > 0) + } + } + } + + // Get time info for user - matches Python get_time_info function + pub async fn get_time_info(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT COALESCE(timezone, 'UTC') as timezone, + COALESCE(timeformat, 12) as hour_pref, + dateformat as date_format + FROM "Users" WHERE userid = $1"# + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(crate::models::TimeInfoResponse { + timezone: row.try_get("timezone")?, + hour_pref: row.try_get("hour_pref")?, + date_format: row.try_get("date_format").ok(), + }) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT COALESCE(Timezone, 'UTC') as timezone, + COALESCE(TimeFormat, 12) as hour_pref, + DateFormat as date_format + FROM Users WHERE UserID = ?" + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(crate::models::TimeInfoResponse { + timezone: row.try_get("timezone")?, + hour_pref: row.try_get("hour_pref")?, + date_format: row.try_get("date_format").ok(), + }) + } + } + } + + // Check if podcast exists - matches Python check_podcast function + pub async fn check_podcast(&self, user_id: i32, podcast_name: &str, podcast_url: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT podcastid FROM "Podcasts" + WHERE userid = $1 AND podcastname = $2 AND feedurl = $3"# + ) + .bind(user_id) + .bind(podcast_name) + .bind(podcast_url) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT PodcastID FROM Podcasts + WHERE UserID = ? AND PodcastName = ? AND FeedURL = ?" + ) + .bind(user_id) + .bind(podcast_name) + .bind(podcast_url) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + } + } + + // Check if episode exists in database - matches Python check_episode_exists function + pub async fn check_episode_exists(&self, user_id: i32, episode_title: &str, episode_url: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT EXISTS( + SELECT 1 FROM "Episodes" + JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + WHERE "Podcasts".userid = $1 + AND "Episodes".episodetitle = $2 + AND "Episodes".episodeurl = $3 + ) as episode_exists"# + ) + .bind(user_id) + .bind(episode_title) + .bind(episode_url) + .fetch_one(pool) + .await?; + + Ok(row.try_get("episode_exists")?) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT EXISTS( + SELECT 1 FROM Episodes + JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + WHERE Podcasts.UserID = ? + AND Episodes.EpisodeTitle = ? + AND Episodes.EpisodeURL = ? + ) as episode_exists" + ) + .bind(user_id) + .bind(episode_title) + .bind(episode_url) + .fetch_one(pool) + .await?; + + // MySQL returns integer (0 or 1) for EXISTS + let exists_int: i32 = row.try_get("episode_exists")?; + Ok(exists_int == 1) + } + } + } + + // Queue episode - matches Python queue_pod function + pub async fn queue_episode(&self, episode_id: i32, user_id: i32, is_youtube: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // First check if already queued + let existing = sqlx::query( + r#"SELECT queueid FROM "EpisodeQueue" + WHERE episodeid = $1 AND userid = $2 AND is_youtube = $3"# + ) + .bind(episode_id) + .bind(user_id) + .bind(is_youtube) + .fetch_optional(pool) + .await?; + + if existing.is_some() { + return Ok(()); // Already queued, don't duplicate + } + + // Get max queue position for user + let max_pos_row = sqlx::query( + r#"SELECT COALESCE(MAX(queueposition), 0) as max_pos FROM "EpisodeQueue" WHERE userid = $1"# + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + let max_pos: i32 = max_pos_row.try_get("max_pos")?; + let new_position = max_pos + 1; + + // Insert new queued episode + sqlx::query( + r#"INSERT INTO "EpisodeQueue" (episodeid, userid, queueposition, is_youtube) + VALUES ($1, $2, $3, $4)"# + ) + .bind(episode_id) + .bind(user_id) + .bind(new_position) + .bind(is_youtube) + .execute(pool) + .await?; + + Ok(()) + } + DatabasePool::MySQL(pool) => { + // First check if already queued + let existing = sqlx::query( + "SELECT QueueID FROM EpisodeQueue + WHERE EpisodeID = ? AND UserID = ? AND is_youtube = ?" + ) + .bind(episode_id) + .bind(user_id) + .bind(is_youtube) + .fetch_optional(pool) + .await?; + + if existing.is_some() { + return Ok(()); // Already queued, don't duplicate + } + + // Get max queue position for user + let max_pos_row = sqlx::query( + "SELECT COALESCE(MAX(QueuePosition), 0) as max_pos FROM EpisodeQueue WHERE UserID = ?" + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + let max_pos: i32 = max_pos_row.try_get("max_pos")?; + let new_position = max_pos + 1; + + // Insert new queued episode + sqlx::query( + "INSERT INTO EpisodeQueue (EpisodeID, UserID, QueuePosition, is_youtube) + VALUES (?, ?, ?, ?)" + ) + .bind(episode_id) + .bind(user_id) + .bind(new_position) + .bind(is_youtube) + .execute(pool) + .await?; + + Ok(()) + } + } + } + + // Remove queued episode - matches Python remove_queued_pod function + pub async fn remove_queued_episode(&self, episode_id: i32, user_id: i32, is_youtube: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // Get the queue position of the episode to be removed + let position_row = sqlx::query( + r#"SELECT queueposition FROM "EpisodeQueue" + WHERE episodeid = $1 AND userid = $2 AND is_youtube = $3"# + ) + .bind(episode_id) + .bind(user_id) + .bind(is_youtube) + .fetch_optional(pool) + .await?; + + if let Some(row) = position_row { + let removed_position: i32 = row.try_get("queueposition")?; + + // Delete the episode from queue + sqlx::query( + r#"DELETE FROM "EpisodeQueue" + WHERE episodeid = $1 AND userid = $2 AND is_youtube = $3"# + ) + .bind(episode_id) + .bind(user_id) + .bind(is_youtube) + .execute(pool) + .await?; + + // Update positions of all episodes that were after the removed one + sqlx::query( + r#"UPDATE "EpisodeQueue" SET queueposition = queueposition - 1 + WHERE userid = $1 AND queueposition > $2"# + ) + .bind(user_id) + .bind(removed_position) + .execute(pool) + .await?; + } + + Ok(()) + } + DatabasePool::MySQL(pool) => { + // Get the queue position of the episode to be removed + let position_row = sqlx::query( + "SELECT QueuePosition FROM EpisodeQueue + WHERE EpisodeID = ? AND UserID = ? AND is_youtube = ?" + ) + .bind(episode_id) + .bind(user_id) + .bind(is_youtube) + .fetch_optional(pool) + .await?; + + if let Some(row) = position_row { + let removed_position: i32 = row.try_get("QueuePosition")?; + + // Delete the episode from queue + sqlx::query( + "DELETE FROM EpisodeQueue + WHERE EpisodeID = ? AND UserID = ? AND is_youtube = ?" + ) + .bind(episode_id) + .bind(user_id) + .bind(is_youtube) + .execute(pool) + .await?; + + // Update positions of all episodes that were after the removed one + sqlx::query( + "UPDATE EpisodeQueue SET QueuePosition = QueuePosition - 1 + WHERE UserID = ? AND QueuePosition > ?" + ) + .bind(user_id) + .bind(removed_position) + .execute(pool) + .await?; + } + + Ok(()) + } + } + } + + // Get queued episodes - matches Python get_queued_episodes function + pub async fn get_queued_episodes(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT * FROM ( + SELECT + "Episodes".episodetitle as episodetitle, + "Podcasts".podcastname as podcastname, + "Episodes".episodepubdate as episodepubdate, + "Episodes".episodedescription as episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl as episodeurl, + "EpisodeQueue".queueposition as queueposition, + "Episodes".episodeduration as episodeduration, + "EpisodeQueue".queuedate as queuedate, + "UserEpisodeHistory".listenduration as listenduration, + "Episodes".episodeid as episodeid, + "Episodes".completed as completed, + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + TRUE as queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube + FROM "EpisodeQueue" + INNER JOIN "Episodes" ON "EpisodeQueue".episodeid = "Episodes".episodeid + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON + "EpisodeQueue".episodeid = "UserEpisodeHistory".episodeid + AND "EpisodeQueue".userid = "UserEpisodeHistory".userid + LEFT JOIN "SavedEpisodes" ON + "EpisodeQueue".episodeid = "SavedEpisodes".episodeid + AND "EpisodeQueue".userid = "SavedEpisodes".userid + LEFT JOIN "DownloadedEpisodes" ON + "EpisodeQueue".episodeid = "DownloadedEpisodes".episodeid + AND "EpisodeQueue".userid = "DownloadedEpisodes".userid + WHERE "EpisodeQueue".userid = $1 AND "EpisodeQueue".is_youtube = FALSE + + UNION ALL + + SELECT + "YouTubeVideos".videotitle as episodetitle, + "Podcasts".podcastname as podcastname, + "YouTubeVideos".publishedat as episodepubdate, + "YouTubeVideos".videodescription as episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "YouTubeVideos".thumbnailurl + END as episodeartwork, + "YouTubeVideos".videourl as episodeurl, + "EpisodeQueue".queueposition as queueposition, + "YouTubeVideos".duration as episodeduration, + "EpisodeQueue".queuedate as queuedate, + "YouTubeVideos".listenposition as listenduration, + "YouTubeVideos".videoid as episodeid, + "YouTubeVideos".completed as completed, + CASE WHEN "SavedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + TRUE as queued, + CASE WHEN "DownloadedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube + FROM "EpisodeQueue" + INNER JOIN "YouTubeVideos" ON "EpisodeQueue".episodeid = "YouTubeVideos".videoid + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "SavedVideos" ON + "EpisodeQueue".episodeid = "SavedVideos".videoid + AND "EpisodeQueue".userid = "SavedVideos".userid + LEFT JOIN "DownloadedVideos" ON + "EpisodeQueue".episodeid = "DownloadedVideos".videoid + AND "EpisodeQueue".userid = "DownloadedVideos".userid + WHERE "EpisodeQueue".userid = $2 AND "EpisodeQueue".is_youtube = TRUE + ) combined + ORDER BY queueposition ASC"# + ) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::models::QueuedEpisode { + episodetitle: row.try_get("episodetitle")?, + podcastname: row.try_get("podcastname")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + queueposition: row.try_get("queueposition").ok(), + episodeduration: row.try_get("episodeduration")?, + queuedate: { + let naive = row.try_get::("queuedate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + }); + } + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT * FROM ( + SELECT + Episodes.EpisodeTitle as episodetitle, + Podcasts.PodcastName as podcastname, + Episodes.EpisodePubDate as episodepubdate, + Episodes.EpisodeDescription as episodedescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = TRUE AND Podcasts.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + ELSE Episodes.EpisodeArtwork + END as episodeartwork, + Episodes.EpisodeURL as episodeurl, + EpisodeQueue.QueuePosition as queueposition, + Episodes.EpisodeDuration as episodeduration, + EpisodeQueue.QueueDate as queuedate, + UserEpisodeHistory.ListenDuration as listenduration, + Episodes.EpisodeID as episodeid, + Episodes.Completed as completed, + CASE WHEN SavedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + TRUE as queued, + CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube + FROM EpisodeQueue + INNER JOIN Episodes ON EpisodeQueue.EpisodeID = Episodes.EpisodeID + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN UserEpisodeHistory ON + EpisodeQueue.EpisodeID = UserEpisodeHistory.EpisodeID + AND EpisodeQueue.UserID = UserEpisodeHistory.UserID + LEFT JOIN SavedEpisodes ON + EpisodeQueue.EpisodeID = SavedEpisodes.EpisodeID + AND EpisodeQueue.UserID = SavedEpisodes.UserID + LEFT JOIN DownloadedEpisodes ON + EpisodeQueue.EpisodeID = DownloadedEpisodes.EpisodeID + AND EpisodeQueue.UserID = DownloadedEpisodes.UserID + WHERE EpisodeQueue.UserID = ? AND EpisodeQueue.is_youtube = FALSE + + UNION ALL + + SELECT + YouTubeVideos.VideoTitle as episodetitle, + Podcasts.PodcastName as podcastname, + YouTubeVideos.PublishedAt as episodepubdate, + YouTubeVideos.VideoDescription as episodedescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = TRUE AND Podcasts.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + ELSE YouTubeVideos.ThumbnailURL + END as episodeartwork, + YouTubeVideos.VideoURL as episodeurl, + EpisodeQueue.QueuePosition as queueposition, + YouTubeVideos.Duration as episodeduration, + EpisodeQueue.QueueDate as queuedate, + YouTubeVideos.ListenPosition as listenduration, + YouTubeVideos.VideoID as episodeid, + YouTubeVideos.Completed as completed, + CASE WHEN SavedVideos.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + TRUE as queued, + CASE WHEN DownloadedVideos.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube + FROM EpisodeQueue + INNER JOIN YouTubeVideos ON EpisodeQueue.EpisodeID = YouTubeVideos.VideoID + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN SavedVideos ON + EpisodeQueue.EpisodeID = SavedVideos.VideoID + AND EpisodeQueue.UserID = SavedVideos.UserID + LEFT JOIN DownloadedVideos ON + EpisodeQueue.EpisodeID = DownloadedVideos.VideoID + AND EpisodeQueue.UserID = DownloadedVideos.UserID + WHERE EpisodeQueue.UserID = ? AND EpisodeQueue.is_youtube = TRUE + ) combined + ORDER BY queueposition ASC" + ) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::models::QueuedEpisode { + episodetitle: row.try_get("episodetitle")?, + podcastname: row.try_get("podcastname")?, + episodepubdate: { + let dt = row.try_get::, _>("episodepubdate")?; + dt.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + queueposition: row.try_get("queueposition").ok(), + episodeduration: row.try_get("episodeduration")?, + queuedate: { + let dt = row.try_get::, _>("queuedate")?; + dt.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + }); + } + Ok(episodes) + } + } + } + + // Reorder queue - matches Python reorder_queued_episodes function + pub async fn reorder_queue(&self, user_id: i32, episode_ids: Vec) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + for (index, episode_id) in episode_ids.iter().enumerate() { + let new_position = (index + 1) as i32; + sqlx::query( + r#"UPDATE "EpisodeQueue" SET queueposition = $1 + WHERE episodeid = $2 AND userid = $3"# + ) + .bind(new_position) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + for (index, episode_id) in episode_ids.iter().enumerate() { + let new_position = (index + 1) as i32; + sqlx::query( + "UPDATE EpisodeQueue SET QueuePosition = ? + WHERE EpisodeID = ? AND UserID = ?" + ) + .bind(new_position) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } + } + } + + // Save episode - matches Python save_episode function + pub async fn save_episode(&self, episode_id: i32, user_id: i32, is_youtube: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + if is_youtube { + // Check if already saved + let existing = sqlx::query( + r#"SELECT "SaveID" FROM "SavedVideos" WHERE "VideoID" = $1 AND "UserID" = $2"# + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if existing.is_none() { + sqlx::query( + r#"INSERT INTO "SavedVideos" ("VideoID", "UserID") VALUES ($1, $2)"# + ) + .bind(episode_id) + .bind(user_id) + .execute(pool) + .await?; + + // Update UserStats table - increment EpisodesSaved count + sqlx::query(r#"UPDATE "UserStats" SET episodessaved = episodessaved + 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + } else { + // Check if already saved + let existing = sqlx::query( + r#"SELECT saveid FROM "SavedEpisodes" WHERE episodeid = $1 AND userid = $2"# + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if existing.is_none() { + sqlx::query( + r#"INSERT INTO "SavedEpisodes" (episodeid, userid) VALUES ($1, $2)"# + ) + .bind(episode_id) + .bind(user_id) + .execute(pool) + .await?; + + // Update UserStats table - increment EpisodesSaved count + sqlx::query(r#"UPDATE "UserStats" SET episodessaved = episodessaved + 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + DatabasePool::MySQL(pool) => { + if is_youtube { + // Check if already saved + let existing = sqlx::query( + "SELECT SaveID FROM SavedVideos WHERE VideoID = ? AND UserID = ?" + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if existing.is_none() { + sqlx::query( + "INSERT INTO SavedVideos (VideoID, UserID) VALUES (?, ?)" + ) + .bind(episode_id) + .bind(user_id) + .execute(pool) + .await?; + + // Update UserStats table - increment EpisodesSaved count + sqlx::query("UPDATE UserStats SET EpisodesSaved = EpisodesSaved + 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } else { + // Check if already saved + let existing = sqlx::query( + "SELECT SaveID FROM SavedEpisodes WHERE EpisodeID = ? AND UserID = ?" + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if existing.is_none() { + sqlx::query( + "INSERT INTO SavedEpisodes (EpisodeID, UserID) VALUES (?, ?)" + ) + .bind(episode_id) + .bind(user_id) + .execute(pool) + .await?; + + // Update UserStats table - increment EpisodesSaved count + sqlx::query("UPDATE UserStats SET EpisodesSaved = EpisodesSaved + 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + } + } + + // Remove saved episode - matches Python remove_saved_episode function + pub async fn remove_saved_episode(&self, episode_id: i32, user_id: i32, is_youtube: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + if is_youtube { + let result = sqlx::query( + r#"DELETE FROM "SavedVideos" WHERE "VideoID" = $1 AND "UserID" = $2"# + ) + .bind(episode_id) + .bind(user_id) + .execute(pool) + .await?; + + // Only update UserStats if a row was actually deleted + if result.rows_affected() > 0 { + sqlx::query(r#"UPDATE "UserStats" SET episodessaved = episodessaved - 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + } else { + let result = sqlx::query( + r#"DELETE FROM "SavedEpisodes" WHERE episodeid = $1 AND userid = $2"# + ) + .bind(episode_id) + .bind(user_id) + .execute(pool) + .await?; + + // Only update UserStats if a row was actually deleted + if result.rows_affected() > 0 { + sqlx::query(r#"UPDATE "UserStats" SET episodessaved = episodessaved - 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + DatabasePool::MySQL(pool) => { + if is_youtube { + let result = sqlx::query( + "DELETE FROM SavedVideos WHERE VideoID = ? AND UserID = ?" + ) + .bind(episode_id) + .bind(user_id) + .execute(pool) + .await?; + + // Only update UserStats if a row was actually deleted + if result.rows_affected() > 0 { + sqlx::query("UPDATE UserStats SET EpisodesSaved = EpisodesSaved - 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } else { + let result = sqlx::query( + "DELETE FROM SavedEpisodes WHERE EpisodeID = ? AND UserID = ?" + ) + .bind(episode_id) + .bind(user_id) + .execute(pool) + .await?; + + // Only update UserStats if a row was actually deleted + if result.rows_affected() > 0 { + sqlx::query("UPDATE UserStats SET EpisodesSaved = EpisodesSaved - 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + } + } + + pub async fn update_episode_duration(&self, episode_id: i32, new_duration: i32, is_youtube: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + if is_youtube { + sqlx::query(r#"UPDATE "YouTubeVideos" SET duration = $1 WHERE videoid = $2"#) + .bind(new_duration) + .bind(episode_id) + .execute(pool) + .await?; + } else { + sqlx::query(r#"UPDATE "Episodes" SET episodeduration = $1 WHERE episodeid = $2"#) + .bind(new_duration) + .bind(episode_id) + .execute(pool) + .await?; + } + } + DatabasePool::MySQL(pool) => { + if is_youtube { + sqlx::query(r#"UPDATE YouTubeVideos SET duration = ? WHERE videoid = ?"#) + .bind(new_duration) + .bind(episode_id) + .execute(pool) + .await?; + } else { + sqlx::query(r#"UPDATE Episodes SET episodeduration = ? WHERE episodeid = ?"#) + .bind(new_duration) + .bind(episode_id) + .execute(pool) + .await?; + } + } + } + Ok(()) + } + + // Mark episode as completed - matches Python mark_episode_completed function + pub async fn mark_episode_completed(&self, episode_id: i32, user_id: i32, is_youtube: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + if is_youtube { + // Get YouTube video duration + let duration_row = sqlx::query( + r#"SELECT duration FROM "YouTubeVideos" WHERE videoid = $1"# + ) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = duration_row { + let duration: Option = row.try_get("duration").ok(); + + if let Some(duration) = duration { + // Update completion status + sqlx::query( + r#"UPDATE "YouTubeVideos" SET completed = TRUE WHERE videoid = $1"# + ) + .bind(episode_id) + .execute(pool) + .await?; + + // Update history + sqlx::query( + r#"INSERT INTO "UserVideoHistory" (userid, videoid, listendate, listenduration) + VALUES ($1, $2, NOW(), $3) + ON CONFLICT (userid, videoid) + DO UPDATE SET listenduration = $4, listendate = NOW()"# + ) + .bind(user_id) + .bind(episode_id) + .bind(duration) + .bind(duration) + .execute(pool) + .await?; + } + } + } else { + // Get episode duration + let duration_row = sqlx::query( + r#"SELECT episodeduration FROM "Episodes" WHERE episodeid = $1"# + ) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = duration_row { + let duration: Option = row.try_get("episodeduration").ok(); + + if let Some(duration) = duration { + // Update completion status + sqlx::query( + r#"UPDATE "Episodes" SET completed = TRUE WHERE episodeid = $1"# + ) + .bind(episode_id) + .execute(pool) + .await?; + + // Update history + sqlx::query( + r#"INSERT INTO "UserEpisodeHistory" (userid, episodeid, listendate, listenduration) + VALUES ($1, $2, NOW(), $3) + ON CONFLICT (userid, episodeid) + DO UPDATE SET listenduration = $4, listendate = NOW()"# + ) + .bind(user_id) + .bind(episode_id) + .bind(duration) + .bind(duration) + .execute(pool) + .await?; + } + } + } + Ok(()) + } + DatabasePool::MySQL(pool) => { + if is_youtube { + // Get YouTube video duration + let duration_row = sqlx::query( + "SELECT Duration FROM YouTubeVideos WHERE VideoID = ?" + ) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = duration_row { + let duration: Option = row.try_get("Duration").ok(); + + if let Some(duration) = duration { + // Update completion status + sqlx::query( + "UPDATE YouTubeVideos SET Completed = 1 WHERE VideoID = ?" + ) + .bind(episode_id) + .execute(pool) + .await?; + + // Update history + sqlx::query( + "INSERT INTO UserVideoHistory (UserID, VideoID, ListenDate, ListenDuration) + VALUES (?, ?, NOW(), ?) + ON DUPLICATE KEY UPDATE + ListenDuration = ?, + ListenDate = NOW()" + ) + .bind(user_id) + .bind(episode_id) + .bind(duration) + .bind(duration) + .execute(pool) + .await?; + } + } + } else { + // Get episode duration + let duration_row = sqlx::query( + "SELECT EpisodeDuration FROM Episodes WHERE EpisodeID = ?" + ) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = duration_row { + let duration: Option = row.try_get("EpisodeDuration").ok(); + + if let Some(duration) = duration { + // Update completion status + sqlx::query( + "UPDATE Episodes SET Completed = 1 WHERE EpisodeID = ?" + ) + .bind(episode_id) + .execute(pool) + .await?; + + // Update history + sqlx::query( + "INSERT INTO UserEpisodeHistory (UserID, EpisodeID, ListenDate, ListenDuration) + VALUES (?, ?, NOW(), ?) + ON DUPLICATE KEY UPDATE + ListenDuration = ?, + ListenDate = NOW()" + ) + .bind(user_id) + .bind(episode_id) + .bind(duration) + .bind(duration) + .execute(pool) + .await?; + } + } + } + Ok(()) + } + } + } + + // Increment played count - matches Python increment_played function + pub async fn increment_played(&self, user_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query( + r#"UPDATE "UserStats" SET podcastsplayed = podcastsplayed + 1 WHERE userid = $1"# + ) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) + } + DatabasePool::MySQL(pool) => { + sqlx::query( + "UPDATE UserStats SET PodcastsPlayed = PodcastsPlayed + 1 WHERE UserID = ?" + ) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) + } + } + } + + // Get podcast ID from episode ID - matches Python get_podcast_id_from_episode function + pub async fn get_podcast_id_from_episode(&self, episode_id: i32, user_id: i32, is_youtube: bool) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let query = if is_youtube { + r#"SELECT "YouTubeVideos".podcastid + FROM "YouTubeVideos" + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + WHERE "YouTubeVideos".videoid = $1 AND "Podcasts".userid = $2"# + } else { + r#"SELECT "Episodes".podcastid + FROM "Episodes" + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + WHERE "Episodes".episodeid = $1 AND "Podcasts".userid = $2"# + }; + + // First try with provided user_id + let row = sqlx::query(query) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + return Ok(Some(row.try_get("podcastid")?)); + } + + // If not found, try with system user (1) + let row = sqlx::query(query) + .bind(episode_id) + .bind(1) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("podcastid")?)) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let query = if is_youtube { + "SELECT YouTubeVideos.PodcastID + FROM YouTubeVideos + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + WHERE YouTubeVideos.VideoID = ? AND Podcasts.UserID = ?" + } else { + "SELECT Episodes.PodcastID + FROM Episodes + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + WHERE Episodes.EpisodeID = ? AND Podcasts.UserID = ?" + }; + + // First try with provided user_id + let row = sqlx::query(query) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + return Ok(Some(row.try_get("PodcastID")?)); + } + + // If not found, try with system user (1) + let row = sqlx::query(query) + .bind(episode_id) + .bind(1) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("PodcastID")?)) + } else { + Ok(None) + } + } + } + } + + // Get episode ID from episode URL - public version of find_episode_by_url + pub async fn get_episode_id_from_url(&self, episode_url: &str, user_id: i32) -> AppResult> { + self.find_episode_by_url(user_id, episode_url).await + } + + // Get PinePods version - matches Python get_pinepods_version function + pub async fn get_pinepods_version(&self) -> AppResult { + match std::fs::read_to_string("/pinepods/current_version") { + Ok(version) => { + let version = version.trim(); + if version.is_empty() { + Ok("dev_mode".to_string()) + } else { + Ok(version.to_string()) + } + } + Err(_) => Ok("Version file not found.".to_string()), + } + } + + // Get user stats - matches Python get_stats function + pub async fn get_stats(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT usercreated, podcastsplayed, timelistened, podcastsadded + FROM "UserStats" WHERE userid = $1"# + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + // Count saved episodes directly from SavedEpisodes table + let saved_count_row = sqlx::query( + r#"SELECT COUNT(*) as saved_count FROM "SavedEpisodes" WHERE userid = $1"# + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + // Count downloaded episodes directly from DownloadedEpisodes table + let downloaded_count_row = sqlx::query( + r#"SELECT COUNT(*) as downloaded_count FROM "DownloadedEpisodes" WHERE userid = $1"# + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + let saved_count: i64 = saved_count_row.try_get("saved_count")?; + let downloaded_count: i64 = downloaded_count_row.try_get("downloaded_count")?; + + let stats = serde_json::json!({ + "UserCreated": row.try_get::("usercreated")?.format("%Y-%m-%dT%H:%M:%S%.f").to_string(), + "PodcastsPlayed": row.try_get::("podcastsplayed")?, + "TimeListened": row.try_get::("timelistened")?, + "PodcastsAdded": row.try_get::("podcastsadded")?, + "EpisodesSaved": saved_count as i32, + "EpisodesDownloaded": downloaded_count as i32, + "GpodderUrl": "http://localhost:8042", + "Pod_Sync_Type": "gpodder" + }); + + Ok(Some(stats)) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT UserCreated, PodcastsPlayed, TimeListened, PodcastsAdded + FROM UserStats WHERE UserID = ?" + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + + // Count saved episodes directly from SavedEpisodes table + let saved_count_row = sqlx::query( + "SELECT COUNT(*) as saved_count FROM SavedEpisodes WHERE UserID = ?" + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + // Count downloaded episodes directly from DownloadedEpisodes table + let downloaded_count_row = sqlx::query( + "SELECT COUNT(*) as downloaded_count FROM DownloadedEpisodes WHERE UserID = ?" + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + let saved_count: i64 = saved_count_row.try_get("saved_count")?; + let downloaded_count: i64 = downloaded_count_row.try_get("downloaded_count")?; + + let stats = serde_json::json!({ + "UserCreated": row.try_get::, _>("UserCreated")?.format("%Y-%m-%dT%H:%M:%S%.f").to_string(), + "PodcastsPlayed": row.try_get::("PodcastsPlayed")?, + "TimeListened": row.try_get::("TimeListened")?, + "PodcastsAdded": row.try_get::("PodcastsAdded")?, + "EpisodesSaved": saved_count as i32, + "EpisodesDownloaded": downloaded_count as i32, + "GpodderUrl": "http://localhost:8042", + "Pod_Sync_Type": "gpodder" + }); + + Ok(Some(stats)) + } else { + Ok(None) + } + } + } + } + + // Search data - matches Python search_data function (simplified version) + pub async fn search_data(&self, search_term: &str, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT + p.podcastid, + p.podcastname, + p.artworkurl, + p.author, + p.categories, + p.description, + p.episodecount, + p.feedurl, + p.websiteurl, + p.explicit, + p.userid, + COALESCE(p.isyoutubechannel, false) as is_youtube, + e.episodeid, + e.episodetitle, + e.episodedescription, + e.episodeurl, + CASE + WHEN p.usepodcastcoverscustomized = TRUE AND p.usepodcastcovers = TRUE THEN p.artworkurl + WHEN u.usepodcastcovers = TRUE THEN p.artworkurl + ELSE e.episodeartwork + END as episodeartwork, + e.episodepubdate, + e.episodeduration, + COALESCE(h.listenduration, 0) as listenduration, + COALESCE(e.completed, false) as completed, + CASE WHEN se.episodeid IS NOT NULL THEN true ELSE false END as saved, + CASE WHEN eq.episodeid IS NOT NULL THEN true ELSE false END as queued, + CASE WHEN de.episodeid IS NOT NULL THEN true ELSE false END as downloaded + FROM "Podcasts" p + LEFT JOIN "Users" u ON p.userid = u.userid + LEFT JOIN "Episodes" e ON p.podcastid = e.podcastid + LEFT JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid AND h.userid = $2 + LEFT JOIN "SavedEpisodes" se ON e.episodeid = se.episodeid AND se.userid = $2 + LEFT JOIN "EpisodeQueue" eq ON e.episodeid = eq.episodeid AND eq.userid = $2 AND eq.is_youtube = false + LEFT JOIN "DownloadedEpisodes" de ON e.episodeid = de.episodeid AND de.userid = $2 + WHERE p.userid = $2 + AND (LOWER(p.podcastname) LIKE LOWER($1) + OR LOWER(e.episodetitle) LIKE LOWER($1) + OR LOWER(e.episodedescription) LIKE LOWER($1)) + ORDER BY p.podcastname, e.episodepubdate DESC"# + ) + .bind(format!("%{}%", search_term)) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut results = Vec::new(); + for row in rows { + let pub_date = if let Ok(date) = row.try_get::("episodepubdate") { + date.format("%Y-%m-%dT%H:%M:%S").to_string() + } else { + "".to_string() + }; + + let categories_str = row.try_get::("categories").unwrap_or_default(); + let categories_value = serde_json::to_value(self.parse_categories_json(&categories_str).unwrap_or_default()).unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + + let result = serde_json::json!({ + "podcastid": row.try_get::("podcastid").unwrap_or(0), + "podcastname": row.try_get::("podcastname").unwrap_or_default(), + "artworkurl": row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_default(), + "author": row.try_get::("author").unwrap_or_default(), + "categories": categories_value, + "description": row.try_get::("description").unwrap_or_default(), + "episodecount": row.try_get::, _>("episodecount").ok().flatten(), + "feedurl": row.try_get::("feedurl").unwrap_or_default(), + "websiteurl": row.try_get::("websiteurl").unwrap_or_default(), + "explicit": if row.try_get::("explicit").unwrap_or(false) { 1 } else { 0 }, + "userid": row.try_get::("userid").unwrap_or(0), + "episodeid": row.try_get::, _>("episodeid").ok().flatten(), + "episodetitle": row.try_get::, _>("episodetitle").ok().flatten(), + "episodedescription": row.try_get::, _>("episodedescription").ok().flatten(), + "episodeurl": row.try_get::, _>("episodeurl").ok().flatten(), + "episodeartwork": row.try_get::, _>("episodeartwork").ok().flatten(), + "episodepubdate": if pub_date.is_empty() { None } else { Some(pub_date) }, + "episodeduration": row.try_get::, _>("episodeduration").ok().flatten(), + "listenduration": row.try_get::, _>("listenduration").ok().flatten(), + "completed": row.try_get::("completed").unwrap_or(false), + "saved": row.try_get::("saved").unwrap_or(false), + "queued": row.try_get::("queued").unwrap_or(false), + "downloaded": row.try_get::("downloaded").unwrap_or(false), + "is_youtube": row.try_get::("is_youtube").unwrap_or(false) + }); + results.push(result); + } + Ok(results) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT + p.PodcastID as podcastid, + p.PodcastName as podcastname, + p.ArtworkURL as artworkurl, + p.Author as author, + p.Categories as categories, + p.Description as description, + p.EpisodeCount as episodecount, + p.FeedURL as feedurl, + p.WebsiteURL as websiteurl, + p.Explicit as explicit, + p.UserID as userid, + COALESCE(p.IsYouTubeChannel, false) as is_youtube, + e.EpisodeID as episodeid, + e.EpisodeTitle as episodetitle, + e.EpisodeDescription as episodedescription, + e.EpisodeURL as episodeurl, + CASE + WHEN p.UsePodcastCoversCustomized = TRUE AND p.UsePodcastCovers = TRUE THEN p.ArtworkURL + WHEN u.UsePodcastCovers = TRUE THEN p.ArtworkURL + ELSE e.EpisodeArtwork + END as episodeartwork, + e.EpisodePubDate as episodepubdate, + e.EpisodeDuration as episodeduration, + COALESCE(h.ListenDuration, 0) as listenduration, + COALESCE(e.Completed, false) as completed, + CASE WHEN se.EpisodeID IS NOT NULL THEN true ELSE false END as saved, + CASE WHEN eq.EpisodeID IS NOT NULL THEN true ELSE false END as queued, + CASE WHEN de.EpisodeID IS NOT NULL THEN true ELSE false END as downloaded + FROM Podcasts p + LEFT JOIN Users u ON p.UserID = u.UserID + LEFT JOIN Episodes e ON p.PodcastID = e.PodcastID + LEFT JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = ? + LEFT JOIN SavedEpisodes se ON e.EpisodeID = se.EpisodeID AND se.UserID = ? + LEFT JOIN EpisodeQueue eq ON e.EpisodeID = eq.EpisodeID AND eq.UserID = ? AND eq.is_youtube = false + LEFT JOIN DownloadedEpisodes de ON e.EpisodeID = de.EpisodeID AND de.UserID = ? + WHERE p.UserID = ? + AND (LOWER(p.PodcastName) LIKE LOWER(?) + OR LOWER(e.EpisodeTitle) LIKE LOWER(?) + OR LOWER(e.EpisodeDescription) LIKE LOWER(?)) + ORDER BY p.PodcastName, e.EpisodePubDate DESC" + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(format!("%{}%", search_term)) + .bind(format!("%{}%", search_term)) + .bind(format!("%{}%", search_term)) + .fetch_all(pool) + .await?; + + let mut results = Vec::new(); + for row in rows { + let pub_date = if let Ok(date) = row.try_get::("episodepubdate") { + date.format("%Y-%m-%dT%H:%M:%S").to_string() + } else { + "".to_string() + }; + + let categories_str = row.try_get::("categories").unwrap_or_default(); + let categories_value = serde_json::to_value(self.parse_categories_json(&categories_str).unwrap_or_default()).unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + + let result = serde_json::json!({ + "podcastid": row.try_get::("podcastid").unwrap_or(0), + "podcastname": row.try_get::("podcastname").unwrap_or_default(), + "artworkurl": row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_default(), + "author": row.try_get::("author").unwrap_or_default(), + "categories": categories_value, + "description": row.try_get::("description").unwrap_or_default(), + "episodecount": row.try_get::, _>("episodecount").ok().flatten(), + "feedurl": row.try_get::("feedurl").unwrap_or_default(), + "websiteurl": row.try_get::("websiteurl").unwrap_or_default(), + "explicit": if row.try_get::("explicit").unwrap_or(false) { 1 } else { 0 }, + "userid": row.try_get::("userid").unwrap_or(0), + "episodeid": row.try_get::, _>("episodeid").ok().flatten(), + "episodetitle": row.try_get::, _>("episodetitle").ok().flatten(), + "episodedescription": row.try_get::, _>("episodedescription").ok().flatten(), + "episodeurl": row.try_get::, _>("episodeurl").ok().flatten(), + "episodeartwork": row.try_get::, _>("episodeartwork").ok().flatten(), + "episodepubdate": if pub_date.is_empty() { None } else { Some(pub_date) }, + "episodeduration": row.try_get::, _>("episodeduration").ok().flatten(), + "listenduration": row.try_get::, _>("listenduration").ok().flatten(), + "completed": row.try_get::("completed").unwrap_or(false), + "saved": row.try_get::("saved").unwrap_or(false), + "queued": row.try_get::("queued").unwrap_or(false), + "downloaded": row.try_get::("downloaded").unwrap_or(false), + "is_youtube": row.try_get::("is_youtube").unwrap_or(false) + }); + results.push(result); + } + Ok(results) + } + } + } + + // Get home overview data - matches Python get_home_overview function + pub async fn get_home_overview(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let mut home_data = serde_json::json!({ + "recent_episodes": [], + "in_progress_episodes": [], + "top_podcasts": [], + "saved_count": 0, + "downloaded_count": 0, + "queue_count": 0 + }); + + // Recent Episodes query + let recent_query = r#" + SELECT + "Episodes".episodeid, + "Episodes".episodetitle, + "Episodes".episodepubdate, + "Episodes".episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl, + "Episodes".episodeduration, + "Episodes".completed, + "Podcasts".podcastname, + "Podcasts".podcastid, + "Podcasts".isyoutubechannel as is_youtube, + "UserEpisodeHistory".listenduration, + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded + FROM "Episodes" + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON + "Episodes".episodeid = "UserEpisodeHistory".episodeid + AND "UserEpisodeHistory".userid = $1 + LEFT JOIN "SavedEpisodes" ON + "Episodes".episodeid = "SavedEpisodes".episodeid + AND "SavedEpisodes".userid = $2 + LEFT JOIN "EpisodeQueue" ON + "Episodes".episodeid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $3 + LEFT JOIN "DownloadedEpisodes" ON + "Episodes".episodeid = "DownloadedEpisodes".episodeid + AND "DownloadedEpisodes".userid = $4 + WHERE "Podcasts".userid = $5 + AND "Episodes".episodepubdate >= NOW() - INTERVAL '7 days' + ORDER BY "Episodes".episodepubdate DESC + LIMIT 10 + "#; + + let recent_rows = sqlx::query(recent_query) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut recent_episodes = Vec::new(); + for row in recent_rows { + let episodeid: i32 = row.try_get("episodeid")?; + let episodetitle: String = row.try_get("episodetitle")?; + let naive = row.try_get::("episodepubdate")?; + let episodepubdate = naive.format("%Y-%m-%dT%H:%M:%S").to_string(); + let episodedescription: String = row.try_get("episodedescription")?; + let episodeartwork: String = row.try_get("episodeartwork")?; + let episodeurl: String = row.try_get("episodeurl")?; + let episodeduration: i32 = row.try_get("episodeduration")?; + let completed: bool = row.try_get("completed")?; + let podcastname: String = row.try_get("podcastname")?; + let podcastid: i32 = row.try_get("podcastid")?; + let is_youtube: bool = row.try_get("is_youtube")?; + let listenduration: Option = row.try_get("listenduration")?; + let saved: bool = row.try_get("saved")?; + let queued: bool = row.try_get("queued")?; + let downloaded: bool = row.try_get("downloaded")?; + + recent_episodes.push(serde_json::json!({ + "episodeid": episodeid, + "episodetitle": episodetitle, + "episodepubdate": episodepubdate, + "episodedescription": episodedescription, + "episodeartwork": episodeartwork, + "episodeurl": episodeurl, + "episodeduration": episodeduration, + "completed": completed, + "podcastname": podcastname, + "podcastid": podcastid, + "is_youtube": is_youtube, + "listenduration": listenduration, + "saved": saved, + "queued": queued, + "downloaded": downloaded + })); + } + home_data["recent_episodes"] = serde_json::Value::Array(recent_episodes); + + // In Progress Episodes query + let in_progress_query = r#" + SELECT + "Episodes".episodeid, + "Episodes".episodetitle, + "Episodes".episodepubdate, + "Episodes".episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl, + "Episodes".episodeduration, + "Episodes".completed, + "Podcasts".podcastname, + "Podcasts".podcastid, + "Podcasts".isyoutubechannel as is_youtube, + "UserEpisodeHistory".listenduration, + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded + FROM "UserEpisodeHistory" + JOIN "Episodes" ON "UserEpisodeHistory".episodeid = "Episodes".episodeid + JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "SavedEpisodes" ON + "Episodes".episodeid = "SavedEpisodes".episodeid + AND "SavedEpisodes".userid = $1 + LEFT JOIN "EpisodeQueue" ON + "Episodes".episodeid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $2 + LEFT JOIN "DownloadedEpisodes" ON + "Episodes".episodeid = "DownloadedEpisodes".episodeid + AND "DownloadedEpisodes".userid = $3 + WHERE "UserEpisodeHistory".userid = $4 + AND "UserEpisodeHistory".listenduration > 0 + AND "Episodes".completed = FALSE + ORDER BY "UserEpisodeHistory".listendate DESC + LIMIT 10 + "#; + + let in_progress_rows = sqlx::query(in_progress_query) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut in_progress_episodes = Vec::new(); + for row in in_progress_rows { + let episodeid: i32 = row.try_get("episodeid")?; + let episodetitle: String = row.try_get("episodetitle")?; + let naive = row.try_get::("episodepubdate")?; + let episodepubdate = naive.format("%Y-%m-%dT%H:%M:%S").to_string(); + let episodedescription: String = row.try_get("episodedescription")?; + let episodeartwork: String = row.try_get("episodeartwork")?; + let episodeurl: String = row.try_get("episodeurl")?; + let episodeduration: i32 = row.try_get("episodeduration")?; + let completed: bool = row.try_get("completed")?; + let podcastname: String = row.try_get("podcastname")?; + let podcastid: i32 = row.try_get("podcastid")?; + let is_youtube: bool = row.try_get("is_youtube")?; + let listenduration: Option = row.try_get("listenduration")?; + let saved: bool = row.try_get("saved")?; + let queued: bool = row.try_get("queued")?; + let downloaded: bool = row.try_get("downloaded")?; + + in_progress_episodes.push(serde_json::json!({ + "episodeid": episodeid, + "episodetitle": episodetitle, + "episodepubdate": episodepubdate, + "episodedescription": episodedescription, + "episodeartwork": episodeartwork, + "episodeurl": episodeurl, + "episodeduration": episodeduration, + "completed": completed, + "podcastname": podcastname, + "podcastid": podcastid, + "is_youtube": is_youtube, + "listenduration": listenduration, + "saved": saved, + "queued": queued, + "downloaded": downloaded + })); + } + home_data["in_progress_episodes"] = serde_json::Value::Array(in_progress_episodes); + + // Top Podcasts query + let top_podcasts_query = r#" + SELECT + "Podcasts".podcastid, + "Podcasts".podcastname, + "Podcasts".podcastindexid, + "Podcasts".artworkurl, + "Podcasts".author, + "Podcasts".categories, + "Podcasts".description, + "Podcasts".episodecount, + "Podcasts".feedurl, + "Podcasts".websiteurl, + "Podcasts".explicit, + "Podcasts".isyoutubechannel as is_youtube, + COUNT(DISTINCT "UserEpisodeHistory".episodeid) as play_count, + SUM("UserEpisodeHistory".listenduration) as total_listen_time + FROM "Podcasts" + LEFT JOIN "Episodes" ON "Podcasts".podcastid = "Episodes".podcastid + LEFT JOIN "UserEpisodeHistory" ON "Episodes".episodeid = "UserEpisodeHistory".episodeid + WHERE "Podcasts".userid = $1 + GROUP BY "Podcasts".podcastid + ORDER BY total_listen_time DESC NULLS LAST + LIMIT 6 + "#; + + let top_podcasts_rows = sqlx::query(top_podcasts_query) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut top_podcasts = Vec::new(); + for row in top_podcasts_rows { + let podcastid: i32 = row.try_get("podcastid").unwrap_or(0); + let podcastname: String = row.try_get("podcastname").unwrap_or_default(); + let podcastindexid: Option = row.try_get("podcastindexid").ok(); + let artworkurl: String = row.try_get("artworkurl").unwrap_or_default(); + let author: String = row.try_get("author").unwrap_or_default(); + let categories_str: String = row.try_get("categories").unwrap_or_default(); + let categories = self.parse_categories_json(&categories_str).unwrap_or_default(); + let description: String = row.try_get("description").unwrap_or_default(); + let episodecount: i32 = row.try_get("episodecount").unwrap_or(0); + let feedurl: String = row.try_get("feedurl").unwrap_or_default(); + let websiteurl: String = row.try_get("websiteurl").unwrap_or_default(); + let explicit: bool = row.try_get("explicit").unwrap_or(false); + let is_youtube: bool = row.try_get("is_youtube").unwrap_or(false); + let play_count: i64 = row.try_get("play_count").unwrap_or(0); + let total_listen_time: Option = row.try_get("total_listen_time").ok(); + + top_podcasts.push(serde_json::json!({ + "podcastid": podcastid, + "podcastname": podcastname, + "podcastindexid": podcastindexid, + "artworkurl": artworkurl, + "author": author, + "categories": serde_json::to_value(categories).unwrap_or(serde_json::Value::Object(serde_json::Map::new())), + "description": description, + "episodecount": episodecount, + "feedurl": feedurl, + "websiteurl": websiteurl, + "explicit": explicit, + "is_youtube": is_youtube, + "play_count": play_count, + "total_listen_time": total_listen_time + })); + } + home_data["top_podcasts"] = serde_json::Value::Array(top_podcasts); + + // Get counts + let saved_count: i64 = sqlx::query_scalar(r#"SELECT COUNT(*) FROM "SavedEpisodes" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + home_data["saved_count"] = serde_json::Value::Number(serde_json::Number::from(saved_count)); + + let downloaded_count: i64 = sqlx::query_scalar(r#"SELECT COUNT(*) FROM "DownloadedEpisodes" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + home_data["downloaded_count"] = serde_json::Value::Number(serde_json::Number::from(downloaded_count)); + + let queue_count: i64 = sqlx::query_scalar(r#"SELECT COUNT(*) FROM "EpisodeQueue" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + home_data["queue_count"] = serde_json::Value::Number(serde_json::Number::from(queue_count)); + + Ok(home_data) + } + DatabasePool::MySQL(pool) => { + let mut home_data = serde_json::json!({ + "recent_episodes": [], + "in_progress_episodes": [], + "top_podcasts": [], + "saved_count": 0, + "downloaded_count": 0, + "queue_count": 0 + }); + + // Recent Episodes query for MySQL + let recent_query = r#" + SELECT + Episodes.EpisodeID, + Episodes.EpisodeTitle, + Episodes.EpisodePubDate, + Episodes.EpisodeDescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = 1 AND Podcasts.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + ELSE Episodes.EpisodeArtwork + END as EpisodeArtwork, + Episodes.EpisodeURL, + Episodes.EpisodeDuration, + Episodes.Completed, + Podcasts.PodcastName, + Podcasts.PodcastID, + Podcasts.IsYouTubeChannel as is_youtube, + UserEpisodeHistory.ListenDuration, + CASE WHEN SavedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded + FROM Episodes + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN UserEpisodeHistory ON + Episodes.EpisodeID = UserEpisodeHistory.EpisodeID + AND UserEpisodeHistory.UserID = ? + LEFT JOIN SavedEpisodes ON + Episodes.EpisodeID = SavedEpisodes.EpisodeID + AND SavedEpisodes.UserID = ? + LEFT JOIN EpisodeQueue ON + Episodes.EpisodeID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + LEFT JOIN DownloadedEpisodes ON + Episodes.EpisodeID = DownloadedEpisodes.EpisodeID + AND DownloadedEpisodes.UserID = ? + WHERE Podcasts.UserID = ? + AND Episodes.EpisodePubDate >= DATE_SUB(NOW(), INTERVAL 7 DAY) + ORDER BY Episodes.EpisodePubDate DESC + LIMIT 10 + "#; + + let recent_rows = sqlx::query(recent_query) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut recent_episodes = Vec::new(); + for row in recent_rows { + let episodeid: i32 = row.try_get("EpisodeID")?; + let episodetitle: String = row.try_get("EpisodeTitle")?; + let naive = row.try_get::("EpisodePubDate")?; + let episodepubdate = naive.format("%Y-%m-%dT%H:%M:%S").to_string(); + let episodedescription: String = row.try_get("EpisodeDescription")?; + let episodeartwork: String = row.try_get("EpisodeArtwork")?; + let episodeurl: String = row.try_get("EpisodeURL")?; + let episodeduration: i32 = row.try_get("EpisodeDuration")?; + let completed: bool = row.try_get::("Completed")? != 0; + let podcastname: String = row.try_get("PodcastName")?; + let podcastid: i32 = row.try_get("PodcastID")?; + let is_youtube: bool = row.try_get::("is_youtube")? != 0; + let listenduration: Option = row.try_get("ListenDuration")?; + let saved: bool = row.try_get::("saved")? != 0; + let queued: bool = row.try_get::("queued")? != 0; + let downloaded: bool = row.try_get::("downloaded")? != 0; + + recent_episodes.push(serde_json::json!({ + "episodeid": episodeid, + "episodetitle": episodetitle, + "episodepubdate": episodepubdate, + "episodedescription": episodedescription, + "episodeartwork": episodeartwork, + "episodeurl": episodeurl, + "episodeduration": episodeduration, + "completed": completed, + "podcastname": podcastname, + "podcastid": podcastid, + "is_youtube": is_youtube, + "listenduration": listenduration, + "saved": saved, + "queued": queued, + "downloaded": downloaded + })); + } + home_data["recent_episodes"] = serde_json::Value::Array(recent_episodes); + + // In Progress Episodes query for MySQL + let in_progress_query = r#" + SELECT + Episodes.EpisodeID, + Episodes.EpisodeTitle, + Episodes.EpisodePubDate, + Episodes.EpisodeDescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = 1 AND Podcasts.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + ELSE Episodes.EpisodeArtwork + END as EpisodeArtwork, + Episodes.EpisodeURL, + Episodes.EpisodeDuration, + Episodes.Completed, + Podcasts.PodcastName, + Podcasts.PodcastID, + Podcasts.IsYouTubeChannel as is_youtube, + UserEpisodeHistory.ListenDuration, + CASE WHEN SavedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded + FROM UserEpisodeHistory + JOIN Episodes ON UserEpisodeHistory.EpisodeID = Episodes.EpisodeID + JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN SavedEpisodes ON + Episodes.EpisodeID = SavedEpisodes.EpisodeID + AND SavedEpisodes.UserID = ? + LEFT JOIN EpisodeQueue ON + Episodes.EpisodeID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + LEFT JOIN DownloadedEpisodes ON + Episodes.EpisodeID = DownloadedEpisodes.EpisodeID + AND DownloadedEpisodes.UserID = ? + WHERE UserEpisodeHistory.UserID = ? + AND UserEpisodeHistory.ListenDuration > 0 + AND Episodes.Completed = 0 + ORDER BY UserEpisodeHistory.ListenDate DESC + LIMIT 10 + "#; + + let in_progress_rows = sqlx::query(in_progress_query) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut in_progress_episodes = Vec::new(); + for row in in_progress_rows { + let episodeid: i32 = row.try_get("EpisodeID")?; + let episodetitle: String = row.try_get("EpisodeTitle")?; + let naive = row.try_get::("EpisodePubDate")?; + let episodepubdate = naive.format("%Y-%m-%dT%H:%M:%S").to_string(); + let episodedescription: String = row.try_get("EpisodeDescription")?; + let episodeartwork: String = row.try_get("EpisodeArtwork")?; + let episodeurl: String = row.try_get("EpisodeURL")?; + let episodeduration: i32 = row.try_get("EpisodeDuration")?; + let completed: bool = row.try_get::("Completed")? != 0; + let podcastname: String = row.try_get("PodcastName")?; + let podcastid: i32 = row.try_get("PodcastID")?; + let is_youtube: bool = row.try_get::("is_youtube")? != 0; + let listenduration: Option = row.try_get("ListenDuration")?; + let saved: bool = row.try_get::("saved")? != 0; + let queued: bool = row.try_get::("queued")? != 0; + let downloaded: bool = row.try_get::("downloaded")? != 0; + + in_progress_episodes.push(serde_json::json!({ + "episodeid": episodeid, + "episodetitle": episodetitle, + "episodepubdate": episodepubdate, + "episodedescription": episodedescription, + "episodeartwork": episodeartwork, + "episodeurl": episodeurl, + "episodeduration": episodeduration, + "completed": completed, + "podcastname": podcastname, + "podcastid": podcastid, + "is_youtube": is_youtube, + "listenduration": listenduration, + "saved": saved, + "queued": queued, + "downloaded": downloaded + })); + } + home_data["in_progress_episodes"] = serde_json::Value::Array(in_progress_episodes); + + // Top Podcasts query for MySQL + let top_podcasts_query = r#" + SELECT + Podcasts.PodcastID, + Podcasts.PodcastName, + Podcasts.PodcastIndexID, + Podcasts.ArtworkURL, + Podcasts.Author, + Podcasts.Categories, + Podcasts.Description, + Podcasts.EpisodeCount, + Podcasts.FeedURL, + Podcasts.WebsiteURL, + Podcasts.Explicit, + Podcasts.IsYouTubeChannel as is_youtube, + COUNT(DISTINCT UserEpisodeHistory.EpisodeID) as play_count, + SUM(UserEpisodeHistory.ListenDuration) as total_listen_time + FROM Podcasts + LEFT JOIN Episodes ON Podcasts.PodcastID = Episodes.PodcastID + LEFT JOIN UserEpisodeHistory ON Episodes.EpisodeID = UserEpisodeHistory.EpisodeID + WHERE Podcasts.UserID = ? + GROUP BY Podcasts.PodcastID + ORDER BY total_listen_time DESC + LIMIT 5 + "#; + + let top_podcasts_rows = sqlx::query(top_podcasts_query) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut top_podcasts = Vec::new(); + for row in top_podcasts_rows { + let podcastid: i32 = row.try_get("PodcastID").unwrap_or(0); + let podcastname: String = row.try_get("PodcastName").unwrap_or_default(); + let podcastindexid: Option = row.try_get("PodcastIndexID").ok(); + let artworkurl: String = row.try_get("ArtworkURL").unwrap_or_default(); + let author: String = row.try_get("Author").unwrap_or_default(); + let categories_str: String = row.try_get("Categories").unwrap_or_default(); + let categories = self.parse_categories_json(&categories_str).unwrap_or_default(); + let description: String = row.try_get("Description").unwrap_or_default(); + let episodecount: i32 = row.try_get("EpisodeCount").unwrap_or(0); + let feedurl: String = row.try_get("FeedURL").unwrap_or_default(); + let websiteurl: String = row.try_get("WebsiteURL").unwrap_or_default(); + let explicit: bool = row.try_get::("Explicit").unwrap_or(0) != 0; + let is_youtube: bool = row.try_get::("is_youtube").unwrap_or(0) != 0; + let play_count: i64 = row.try_get("play_count").unwrap_or(0); + let total_listen_time: Option = row.try_get("total_listen_time").ok(); + + top_podcasts.push(serde_json::json!({ + "podcastid": podcastid, + "podcastname": podcastname, + "podcastindexid": podcastindexid, + "artworkurl": artworkurl, + "author": author, + "categories": serde_json::to_value(categories).unwrap_or(serde_json::Value::Object(serde_json::Map::new())), + "description": description, + "episodecount": episodecount, + "feedurl": feedurl, + "websiteurl": websiteurl, + "explicit": explicit, + "is_youtube": is_youtube, + "play_count": play_count, + "total_listen_time": total_listen_time + })); + } + home_data["top_podcasts"] = serde_json::Value::Array(top_podcasts); + + // Get counts for MySQL + let saved_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM SavedEpisodes WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + home_data["saved_count"] = serde_json::Value::Number(serde_json::Number::from(saved_count)); + + let downloaded_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM DownloadedEpisodes WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + home_data["downloaded_count"] = serde_json::Value::Number(serde_json::Number::from(downloaded_count)); + + let queue_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM EpisodeQueue WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + home_data["queue_count"] = serde_json::Value::Number(serde_json::Number::from(queue_count)); + + Ok(home_data) + } + } + } + + // Get playlists - matches Python get_playlists function + pub async fn get_playlists(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let query = r#" + SELECT + p.playlistid, + p.userid, + p.name, + p.description, + p.issystemplaylist, + p.podcastids, + p.includeunplayed, + p.includepartiallyplayed, + p.includeplayed, + p.minduration, + p.maxduration, + p.sortorder, + p.groupbypodcast, + p.maxepisodes, + p.lastupdated, + p.created, + p.iconname, + COALESCE(p.episodecount, 0) as episode_count + FROM "Playlists" p + WHERE p.userid = $1 + ORDER BY p.issystemplaylist DESC, p.name ASC + "#; + + let rows = sqlx::query(query) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut playlists = Vec::new(); + for row in rows { + let playlist_id: i32 = row.try_get("playlistid")?; + + // Get preview episodes + let preview_query = r#" + SELECT e.episodetitle, e.episodeartwork + FROM "PlaylistContents" pc + JOIN "Episodes" e ON pc.episodeid = e.episodeid + JOIN "Podcasts" p ON e.podcastid = p.podcastid + WHERE pc.playlistid = $1 + AND p.userid = $2 + ORDER BY pc.position + LIMIT 3 + "#; + + let preview_rows = sqlx::query(preview_query) + .bind(playlist_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut preview_episodes = Vec::new(); + for preview_row in preview_rows { + preview_episodes.push(serde_json::json!({ + "title": preview_row.try_get::("episodetitle")?, + "artwork": preview_row.try_get::("episodeartwork")? + })); + } + + // Process podcast_ids - in PostgreSQL it's stored as an array + let podcast_ids: Option> = row.try_get("podcastids").ok(); + + let playlist = serde_json::json!({ + "playlist_id": playlist_id, + "user_id": row.try_get::("userid")?, + "name": row.try_get::("name")?, + "description": row.try_get::("description")?, + "is_system_playlist": row.try_get::("issystemplaylist")?, + "podcast_ids": podcast_ids, + "include_unplayed": row.try_get::("includeunplayed")?, + "include_partially_played": row.try_get::("includepartiallyplayed")?, + "include_played": row.try_get::("includeplayed")?, + "min_duration": row.try_get::, _>("minduration")?, + "max_duration": row.try_get::, _>("maxduration")?, + "sort_order": row.try_get::, _>("sortorder")?, + "group_by_podcast": row.try_get::("groupbypodcast")?, + "max_episodes": row.try_get::, _>("maxepisodes")?, + "last_updated": row.try_get::, _>("lastupdated")?.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()).unwrap_or_default(), + "created": row.try_get::, _>("created")?.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()).unwrap_or_default(), + "icon_name": row.try_get::, _>("iconname")?.unwrap_or_default(), + "episode_count": row.try_get::("episode_count")?, + "preview_episodes": preview_episodes + }); + + playlists.push(playlist); + } + + Ok(playlists) + } + DatabasePool::MySQL(pool) => { + let query = r#" + SELECT + p.PlaylistID, + p.UserID, + p.Name, + p.Description, + p.IsSystemPlaylist, + p.PodcastIDs, + p.IncludeUnplayed, + p.IncludePartiallyPlayed, + p.IncludePlayed, + p.MinDuration, + p.MaxDuration, + p.SortOrder, + p.GroupByPodcast, + p.MaxEpisodes, + p.LastUpdated, + p.Created, + p.IconName, + COALESCE(p.EpisodeCount, 0) as episode_count + FROM Playlists p + WHERE p.UserID = ? + ORDER BY p.IsSystemPlaylist DESC, p.Name ASC + "#; + + let rows = sqlx::query(query) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut playlists = Vec::new(); + for row in rows { + let playlist_id: i32 = row.try_get("PlaylistID")?; + + // Get preview episodes + let preview_query = r#" + SELECT e.EpisodeTitle, e.EpisodeArtwork + FROM PlaylistContents pc + JOIN Episodes e ON pc.EpisodeID = e.EpisodeID + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE pc.PlaylistID = ? + AND p.UserID = ? + ORDER BY pc.Position + LIMIT 3 + "#; + + let preview_rows = sqlx::query(preview_query) + .bind(playlist_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut preview_episodes = Vec::new(); + for preview_row in preview_rows { + preview_episodes.push(serde_json::json!({ + "title": preview_row.try_get::("EpisodeTitle")?, + "artwork": preview_row.try_get::("EpisodeArtwork")? + })); + } + + // Process podcast_ids - in MySQL it might be stored as JSON string + let raw_podcast_ids: Option = row.try_get("PodcastIDs").ok(); + let mut podcast_ids: Option> = None; + + if let Some(raw_ids) = raw_podcast_ids { + if !raw_ids.is_empty() { + // Try to parse as JSON first + if let Ok(parsed) = serde_json::from_str::>(&raw_ids) { + podcast_ids = Some(parsed); + } else { + // Handle other formats like comma-separated strings + let parsed: Result, _> = raw_ids + .trim_matches(|c| c == '[' || c == ']' || c == '"' || c == '\'') + .split(',') + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().parse::()) + .collect(); + + if let Ok(ids) = parsed { + podcast_ids = Some(ids); + } + } + } + } + + let playlist = serde_json::json!({ + "playlist_id": playlist_id, + "user_id": row.try_get::("UserID")?, + "name": row.try_get::("Name")?, + "description": row.try_get::("Description")?, + "is_system_playlist": row.try_get::("IsSystemPlaylist")? != 0, + "podcast_ids": podcast_ids, + "include_unplayed": row.try_get::("IncludeUnplayed")? != 0, + "include_partially_played": row.try_get::("IncludePartiallyPlayed")? != 0, + "include_played": row.try_get::("IncludePlayed")? != 0, + "min_duration": row.try_get::, _>("MinDuration")?, + "max_duration": row.try_get::, _>("MaxDuration")?, + "sort_order": row.try_get::, _>("SortOrder")?, + "group_by_podcast": row.try_get::("GroupByPodcast")? != 0, + "max_episodes": row.try_get::, _>("MaxEpisodes")?, + "last_updated": row.try_get::>, _>("LastUpdated")?.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()).unwrap_or_default(), + "created": row.try_get::>, _>("Created")?.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()).unwrap_or_default(), + "icon_name": row.try_get::, _>("IconName")?.unwrap_or_default(), + "episode_count": row.try_get::("episode_count")?, + "preview_episodes": preview_episodes + }); + + playlists.push(playlist); + } + + Ok(playlists) + } + } + } + + // Create playlist - matches Python create_playlist function exactly + pub async fn create_playlist(&self, _config: &Config, playlist_data: &crate::models::CreatePlaylistRequest) -> AppResult { + let min_duration = playlist_data.min_duration.map(|d| d * 60); + let max_duration = playlist_data.max_duration.map(|d| d * 60); + + match self { + DatabasePool::Postgres(pool) => { + let podcast_ids_array = if let Some(ref ids) = playlist_data.podcast_ids { + ids.clone() + } else { + vec![] + }; + + let result = sqlx::query(r#" + INSERT INTO "Playlists" ( + userid, + name, + description, + issystemplaylist, + podcastids, + includeunplayed, + includepartiallyplayed, + includeplayed, + minduration, + maxduration, + sortorder, + groupbypodcast, + maxepisodes, + iconname, + playprogressmin, + playprogressmax, + timefilterhours + ) VALUES ( + $1, $2, $3, FALSE, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 + ) RETURNING playlistid + "#) + .bind(playlist_data.user_id) + .bind(&playlist_data.name) + .bind(&playlist_data.description) + .bind(&podcast_ids_array) + .bind(playlist_data.include_unplayed) + .bind(playlist_data.include_partially_played) + .bind(playlist_data.include_played) + .bind(min_duration) + .bind(max_duration) + .bind(&playlist_data.sort_order) + .bind(playlist_data.group_by_podcast) + .bind(playlist_data.max_episodes) + .bind(&playlist_data.icon_name) + .bind(playlist_data.play_progress_min) + .bind(playlist_data.play_progress_max) + .bind(playlist_data.time_filter_hours) + .fetch_one(pool) + .await?; + + let playlist_id = result.get::("playlistid"); + + // Update playlist contents immediately like Python does + self.update_playlist_contents(playlist_id).await?; + + Ok(playlist_id) + } + DatabasePool::MySQL(pool) => { + let podcast_ids_json = if let Some(ref ids) = playlist_data.podcast_ids { + serde_json::to_string(ids)? + } else { + "[]".to_string() + }; + + let result = sqlx::query(r#" + INSERT INTO Playlists ( + UserID, + Name, + Description, + IsSystemPlaylist, + PodcastIDs, + IncludeUnplayed, + IncludePartiallyPlayed, + IncludePlayed, + MinDuration, + MaxDuration, + SortOrder, + GroupByPodcast, + MaxEpisodes, + IconName, + PlayProgressMin, + PlayProgressMax, + TimeFilterHours + ) VALUES ( + ?, ?, ?, FALSE, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + "#) + .bind(playlist_data.user_id) + .bind(&playlist_data.name) + .bind(&playlist_data.description) + .bind(&podcast_ids_json) + .bind(playlist_data.include_unplayed) + .bind(playlist_data.include_partially_played) + .bind(playlist_data.include_played) + .bind(min_duration) + .bind(max_duration) + .bind(&playlist_data.sort_order) + .bind(playlist_data.group_by_podcast) + .bind(playlist_data.max_episodes) + .bind(&playlist_data.icon_name) + .bind(playlist_data.play_progress_min) + .bind(playlist_data.play_progress_max) + .bind(playlist_data.time_filter_hours) + .execute(pool) + .await?; + + let playlist_id = result.last_insert_id() as i32; + + // Update playlist contents immediately like Python does + self.update_playlist_contents(playlist_id).await?; + + Ok(playlist_id) + } + } + } + + // Mark episode as uncompleted - matches Python mark_episode_uncompleted function + pub async fn mark_episode_uncompleted(&self, episode_id: i32, user_id: i32, is_youtube: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + let mut transaction = pool.begin().await?; + + if is_youtube { + // Handle YouTube video + sqlx::query(r#"UPDATE "YouTubeVideos" SET completed = FALSE WHERE videoid = $1"#) + .bind(episode_id) + .execute(&mut *transaction) + .await?; + + sqlx::query(r#"UPDATE "UserVideoHistory" SET listenduration = 0, listendate = NOW() WHERE userid = $1 AND videoid = $2"#) + .bind(user_id) + .bind(episode_id) + .execute(&mut *transaction) + .await?; + } else { + // Handle regular episode + sqlx::query(r#"UPDATE "Episodes" SET completed = FALSE WHERE episodeid = $1"#) + .bind(episode_id) + .execute(&mut *transaction) + .await?; + + sqlx::query(r#"UPDATE "UserEpisodeHistory" SET listenduration = 0, listendate = NOW() WHERE userid = $1 AND episodeid = $2"#) + .bind(user_id) + .bind(episode_id) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + Ok(()) + } + DatabasePool::MySQL(pool) => { + let mut transaction = pool.begin().await?; + + if is_youtube { + // Handle YouTube video + sqlx::query("UPDATE YouTubeVideos SET Completed = 0 WHERE VideoID = ?") + .bind(episode_id) + .execute(&mut *transaction) + .await?; + + sqlx::query("UPDATE UserVideoHistory SET ListenDuration = 0, ListenDate = NOW() WHERE UserID = ? AND VideoID = ?") + .bind(user_id) + .bind(episode_id) + .execute(&mut *transaction) + .await?; + } else { + // Handle regular episode + sqlx::query("UPDATE Episodes SET Completed = 0 WHERE EpisodeID = ?") + .bind(episode_id) + .execute(&mut *transaction) + .await?; + + sqlx::query("UPDATE UserEpisodeHistory SET ListenDuration = 0, ListenDate = NOW() WHERE UserID = ? AND EpisodeID = ?") + .bind(user_id) + .bind(episode_id) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + Ok(()) + } + } + } + + // Get saved episodes - matches Python saved_episode_list function + pub async fn get_saved_episodes(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT * FROM ( + SELECT + "Podcasts".podcastname as podcastname, + "Episodes".episodetitle as episodetitle, + "Episodes".episodepubdate as episodepubdate, + "Episodes".episodedescription as episodedescription, + "Episodes".episodeid as episodeid, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl as episodeurl, + "Episodes".episodeduration as episodeduration, + "Podcasts".websiteurl as websiteurl, + "UserEpisodeHistory".listenduration as listenduration, + "Episodes".completed as completed, + TRUE as saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube, + "Podcasts".podcastid as podcastid + FROM "SavedEpisodes" + INNER JOIN "Episodes" ON "SavedEpisodes".episodeid = "Episodes".episodeid + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON + "SavedEpisodes".episodeid = "UserEpisodeHistory".episodeid + AND "UserEpisodeHistory".userid = $1 + LEFT JOIN "EpisodeQueue" ON + "SavedEpisodes".episodeid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $2 + AND "EpisodeQueue".is_youtube = FALSE + LEFT JOIN "DownloadedEpisodes" ON + "SavedEpisodes".episodeid = "DownloadedEpisodes".episodeid + AND "DownloadedEpisodes".userid = $3 + WHERE "SavedEpisodes".userid = $4 + + UNION ALL + + SELECT + "Podcasts".podcastname as podcastname, + "YouTubeVideos".videotitle as episodetitle, + "YouTubeVideos".publishedat as episodepubdate, + "YouTubeVideos".videodescription as episodedescription, + "YouTubeVideos".videoid as episodeid, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "YouTubeVideos".thumbnailurl + END as episodeartwork, + "YouTubeVideos".videourl as episodeurl, + "YouTubeVideos".duration as episodeduration, + "Podcasts".websiteurl as websiteurl, + "YouTubeVideos".listenposition as listenduration, + "YouTubeVideos".completed as completed, + TRUE as saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL AND "EpisodeQueue".is_youtube = TRUE THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube, + "Podcasts".podcastid as podcastid + FROM "SavedVideos" + INNER JOIN "YouTubeVideos" ON "SavedVideos".videoid = "YouTubeVideos".videoid + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "EpisodeQueue" ON + "SavedVideos".videoid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $5 + AND "EpisodeQueue".is_youtube = TRUE + LEFT JOIN "DownloadedVideos" ON + "SavedVideos".videoid = "DownloadedVideos".videoid + AND "DownloadedVideos".userid = $6 + WHERE "SavedVideos".userid = $7 + ) combined + ORDER BY episodepubdate DESC"# + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::models::SavedEpisode { + episodetitle: row.try_get("episodetitle")?, + podcastname: row.try_get("podcastname")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + websiteurl: row.try_get("websiteurl")?, + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + podcastid: row.try_get("podcastid").ok(), + }); + } + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT * FROM ( + SELECT + Podcasts.PodcastName as podcastname, + Episodes.EpisodeTitle as episodetitle, + Episodes.EpisodePubDate as episodepubdate, + Episodes.EpisodeDescription as episodedescription, + Episodes.EpisodeID as episodeid, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = 1 AND Podcasts.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + ELSE Episodes.EpisodeArtwork + END as episodeartwork, + Episodes.EpisodeURL as episodeurl, + Episodes.EpisodeDuration as episodeduration, + Podcasts.WebsiteURL as websiteurl, + UserEpisodeHistory.ListenDuration as listenduration, + Episodes.Completed as completed, + TRUE as saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube, + Podcasts.PodcastID as podcastid + FROM SavedEpisodes + INNER JOIN Episodes ON SavedEpisodes.EpisodeID = Episodes.EpisodeID + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN UserEpisodeHistory ON + SavedEpisodes.EpisodeID = UserEpisodeHistory.EpisodeID + AND UserEpisodeHistory.UserID = ? + LEFT JOIN EpisodeQueue ON + SavedEpisodes.EpisodeID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + AND EpisodeQueue.is_youtube = FALSE + LEFT JOIN DownloadedEpisodes ON + SavedEpisodes.EpisodeID = DownloadedEpisodes.EpisodeID + AND DownloadedEpisodes.UserID = ? + WHERE SavedEpisodes.UserID = ? + + UNION ALL + + SELECT + Podcasts.PodcastName as podcastname, + YouTubeVideos.VideoTitle as episodetitle, + YouTubeVideos.PublishedAt as episodepubdate, + YouTubeVideos.VideoDescription as episodedescription, + YouTubeVideos.VideoID as episodeid, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = 1 AND Podcasts.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + ELSE YouTubeVideos.ThumbnailURL + END as episodeartwork, + YouTubeVideos.VideoURL as episodeurl, + YouTubeVideos.Duration as episodeduration, + Podcasts.WebsiteURL as websiteurl, + YouTubeVideos.ListenPosition as listenduration, + YouTubeVideos.Completed as completed, + TRUE as saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL AND EpisodeQueue.is_youtube = TRUE THEN TRUE ELSE FALSE END AS queued, + CASE WHEN DownloadedVideos.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube, + Podcasts.PodcastID as podcastid + FROM SavedVideos + INNER JOIN YouTubeVideos ON SavedVideos.VideoID = YouTubeVideos.VideoID + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN EpisodeQueue ON + SavedVideos.VideoID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + AND EpisodeQueue.is_youtube = TRUE + LEFT JOIN DownloadedVideos ON + SavedVideos.VideoID = DownloadedVideos.VideoID + AND DownloadedVideos.UserID = ? + WHERE SavedVideos.UserID = ? + ) combined + ORDER BY episodepubdate DESC" + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::models::SavedEpisode { + episodetitle: row.try_get("episodetitle")?, + podcastname: row.try_get("podcastname")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + websiteurl: row.try_get("websiteurl")?, + completed: row.try_get("completed")?, + saved: true, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + podcastid: row.try_get("podcastid").ok(), + }); + } + Ok(episodes) + } + } + } + + // Record podcast history - matches Python record_podcast_history function + pub async fn record_podcast_history(&self, episode_id: i32, user_id: i32, episode_pos: f32, is_youtube: bool) -> AppResult<()> { + let listen_duration = (episode_pos * 100.0) as i32; // Convert position to duration + + match self { + DatabasePool::Postgres(pool) => { + if is_youtube { + // Insert or update video history + sqlx::query( + r#"INSERT INTO "UserVideoHistory" ("videoid", "userid", "listenduration", "listendate") + VALUES ($1, $2, $3, NOW()) + ON CONFLICT ("videoid", "userid") + DO UPDATE SET "listenduration" = $3, "listendate" = NOW()"# + ) + .bind(episode_id) + .bind(user_id) + .bind(listen_duration) + .execute(pool) + .await?; + } else { + // Insert or update episode history + sqlx::query( + r#"INSERT INTO "UserEpisodeHistory" ("episodeid", "userid", "listenduration", "listendate") + VALUES ($1, $2, $3, NOW()) + ON CONFLICT ("episodeid", "userid") + DO UPDATE SET "listenduration" = $3, "listendate" = NOW()"# + ) + .bind(episode_id) + .bind(user_id) + .bind(listen_duration) + .execute(pool) + .await?; + } + Ok(()) + } + DatabasePool::MySQL(pool) => { + if is_youtube { + // Insert or update video history + sqlx::query( + "INSERT INTO UserVideoHistory (VideoID, UserID, ListenDuration, ListenDate) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE ListenDuration = ?, ListenDate = NOW()" + ) + .bind(episode_id) + .bind(user_id) + .bind(listen_duration) + .bind(listen_duration) + .execute(pool) + .await?; + } else { + // Insert or update episode history + sqlx::query( + "INSERT INTO UserEpisodeHistory (EpisodeID, UserID, ListenDuration, ListenDate) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE ListenDuration = ?, ListenDate = NOW()" + ) + .bind(episode_id) + .bind(user_id) + .bind(listen_duration) + .bind(listen_duration) + .execute(pool) + .await?; + } + Ok(()) + } + } + } + + // Get user history - matches Python user_history function + pub async fn get_user_history(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT + e."EpisodeTitle" as episodetitle, + p."PodcastName" as podcastname, + e."EpisodePubDate" as episodepubdate, + e."EpisodeDescription" as episodedescription, + e."EpisodeArtwork" as episodeartwork, + e."EpisodeURL" as episodeurl, + e."EpisodeDuration" as episodeduration, + ueh."ListenDuration" as listenduration, + e."EpisodeID" as episodeid, + CASE WHEN ueh."ListenDuration" >= (e."EpisodeDuration" * 0.95) THEN true ELSE false END as completed, + ueh."ListenDate" as listendate, + false as is_youtube + FROM "UserEpisodeHistory" ueh + JOIN "Episodes" e ON ueh."EpisodeID" = e."EpisodeID" + JOIN "Podcasts" p ON e."PodcastID" = p."PodcastID" + WHERE ueh."UserID" = $1 AND p."UserID" = $1 + + UNION ALL + + SELECT + yv."VideoTitle" as episodetitle, + yc."ChannelName" as podcastname, + yv."VideoUploadDate" as episodepubdate, + yv."VideoDescription" as episodedescription, + yv."VideoThumbnail" as episodeartwork, + yv."VideoURL" as episodeurl, + COALESCE(yv."VideoDuration", 0) as episodeduration, + uvh."ListenDuration" as listenduration, + yv."VideoID" as episodeid, + CASE WHEN uvh."ListenDuration" >= (yv."VideoDuration" * 0.95) THEN true ELSE false END as completed, + uvh."ListenDate" as listendate, + true as is_youtube + FROM "UserVideoHistory" uvh + JOIN "YouTubeVideos" yv ON uvh."VideoID" = yv."VideoID" + JOIN "YouTubeChannels" yc ON yv."ChannelID" = yc."ChannelID" + WHERE uvh."UserID" = $1 AND uvh."ListenDuration" > 0 + + ORDER BY listendate DESC"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::models::HistoryEpisode { + episodetitle: row.try_get("episodetitle")?, + podcastname: row.try_get("podcastname")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + completed: row.try_get("completed")?, + listendate: row.try_get("listendate").ok(), + is_youtube: row.try_get("is_youtube")?, + }); + } + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + // Similar MySQL implementation + let rows = sqlx::query( + "SELECT + e.EpisodeTitle as episodetitle, + p.PodcastName as podcastname, + e.EpisodePubDate as episodepubdate, + e.EpisodeDescription as episodedescription, + e.EpisodeArtwork as episodeartwork, + e.EpisodeURL as episodeurl, + e.EpisodeDuration as episodeduration, + ueh.ListenDuration as listenduration, + e.EpisodeID as episodeid, + CASE WHEN ueh.ListenDuration >= (e.EpisodeDuration * 0.95) THEN true ELSE false END as completed, + ueh.ListenDate as listendate, + false as is_youtube + FROM UserEpisodeHistory ueh + JOIN Episodes e ON ueh.EpisodeID = e.EpisodeID + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE ueh.UserID = ? AND p.UserID = ? + ORDER BY listendate DESC" + ) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::models::HistoryEpisode { + episodetitle: row.try_get("episodetitle")?, + podcastname: row.try_get("podcastname")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + completed: row.try_get("completed")?, + listendate: row.try_get("listendate").ok(), + is_youtube: row.try_get("is_youtube")?, + }); + } + Ok(episodes) + } + } + } + + // Get self-service status - matches Python self_service_status function + pub async fn get_self_service_status(&self) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Get self-service status + let self_service_row = sqlx::query(r#"SELECT selfserviceuser FROM "AppSettings" WHERE selfserviceuser = true"#) + .fetch_optional(pool) + .await?; + + let self_service_enabled = self_service_row.is_some(); + + // Check if admin exists (excluding background_tasks user) + let admin_row = sqlx::query(r#"SELECT COUNT(*) as count FROM "Users" WHERE isadmin = true AND username != 'background_tasks'"#) + .fetch_one(pool) + .await?; + + let admin_count: i64 = admin_row.try_get("count")?; + let admin_exists = admin_count > 0; + + Ok(SelfServiceStatus { + status: self_service_enabled, + admin_exists, + }) + } + DatabasePool::MySQL(pool) => { + // Get self-service status + let self_service_row = sqlx::query("SELECT SelfServiceUser FROM AppSettings WHERE SelfServiceUser = 1") + .fetch_optional(pool) + .await?; + + let self_service_enabled = self_service_row.is_some(); + + // Check if admin exists (excluding background_tasks user) + let admin_row = sqlx::query("SELECT COUNT(*) as count FROM Users WHERE IsAdmin = 1 AND Username != 'background_tasks'") + .fetch_one(pool) + .await?; + + let admin_count: i64 = admin_row.try_get("count")?; + let admin_exists = admin_count > 0; + + Ok(SelfServiceStatus { + status: self_service_enabled, + admin_exists, + }) + } + } + } + + // Get public OIDC providers - matches Python get_public_oidc_providers function + pub async fn get_public_oidc_providers(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT + providerid, + providername, + clientid, + authorizationurl, + scope, + buttoncolor, + buttontext, + buttontextcolor, + iconsvg + FROM "OIDCProviders" + WHERE enabled = true"# + ) + .fetch_all(pool) + .await?; + + let mut providers = Vec::new(); + for row in rows { + providers.push(PublicOidcProvider { + provider_id: row.try_get("providerid")?, + provider_name: row.try_get("providername")?, + client_id: row.try_get("clientid")?, + authorization_url: row.try_get("authorizationurl")?, + scope: row.try_get("scope")?, + button_color: row.try_get("buttoncolor")?, + button_text: row.try_get("buttontext")?, + button_text_color: row.try_get("buttontextcolor")?, + icon_svg: row.try_get("iconsvg").ok(), + }); + } + Ok(providers) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT + ProviderID as providerid, + ProviderName as providername, + ClientID as clientid, + AuthorizationURL as authorizationurl, + Scope as scope, + ButtonColor as buttoncolor, + ButtonText as buttontext, + ButtonTextColor as buttontextcolor, + IconSVG as iconsvg + FROM OIDCProviders + WHERE Enabled = true" + ) + .fetch_all(pool) + .await?; + + let mut providers = Vec::new(); + for row in rows { + providers.push(PublicOidcProvider { + provider_id: row.try_get("providerid")?, + provider_name: row.try_get("providername")?, + client_id: row.try_get("clientid")?, + authorization_url: row.try_get("authorizationurl")?, + scope: row.try_get("scope")?, + button_color: row.try_get("buttoncolor")?, + button_text: row.try_get("buttontext")?, + button_text_color: row.try_get("buttontextcolor")?, + icon_svg: row.try_get("iconsvg").ok(), + }); + } + Ok(providers) + } + } + } + + // Add admin user - matches Python add_admin_user function + pub async fn add_admin_user(&self, fullname: &str, username: &str, email: &str, hashed_password: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + // Insert the admin user + let user_row = sqlx::query( + r#"WITH inserted_user AS ( + INSERT INTO "Users" + (fullname, username, email, hashed_pw, isadmin) + VALUES ($1, $2, $3, $4, true) + ON CONFLICT (username) DO NOTHING + RETURNING userid + ) + SELECT userid FROM inserted_user + UNION ALL + SELECT userid FROM "Users" WHERE username = $5 + LIMIT 1"# + ) + .bind(fullname) + .bind(username) + .bind(email) + .bind(hashed_password) + .bind(username) + .fetch_one(&mut *tx) + .await?; + + let user_id: i32 = user_row.try_get("userid")?; + + // Add user settings + sqlx::query( + r#"INSERT INTO "UserSettings" (userid, theme) VALUES ($1, $2) + ON CONFLICT (userid) DO NOTHING"# + ) + .bind(user_id) + .bind("Nordic") + .execute(&mut *tx) + .await?; + + // Add user stats + sqlx::query( + r#"INSERT INTO "UserStats" (userid) VALUES ($1) + ON CONFLICT (userid) DO NOTHING"# + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + + // Create API key for the user + let api_key = self.generate_api_key(); + sqlx::query( + r#"INSERT INTO "APIKeys" (userid, apikey) VALUES ($1, $2)"# + ) + .bind(user_id) + .bind(&api_key) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(user_id) + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + // Insert the admin user + let result = sqlx::query( + "INSERT INTO Users (Fullname, Username, Email, Hashed_PW, IsAdmin) VALUES (?, ?, ?, ?, 1)" + ) + .bind(fullname) + .bind(username) + .bind(email) + .bind(hashed_password) + .execute(&mut *tx) + .await?; + + let user_id = result.last_insert_id() as i32; + + // Add user settings + sqlx::query( + "INSERT INTO UserSettings (UserID, Theme) VALUES (?, ?)" + ) + .bind(user_id) + .bind("Nordic") + .execute(&mut *tx) + .await?; + + // Add user stats + sqlx::query( + "INSERT INTO UserStats (UserID) VALUES (?)" + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + + // Create API key for the user + let api_key = self.generate_api_key(); + sqlx::query( + "INSERT INTO APIKeys (UserID, APIKey) VALUES (?, ?)" + ) + .bind(user_id) + .bind(&api_key) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(user_id) + } + } + } + + // Check if admin exists - matches Python check_admin_exists function + pub async fn check_admin_exists(&self) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT COUNT(*) as count FROM "Users" WHERE isadmin = true AND username != 'background_tasks'"#) + .fetch_one(pool) + .await?; + + let count: i64 = row.try_get("count")?; + Ok(count > 0) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT COUNT(*) as count FROM Users WHERE IsAdmin = 1 AND Username != 'background_tasks'") + .fetch_one(pool) + .await?; + + let count: i64 = row.try_get("count")?; + Ok(count > 0) + } + } + } + + // Generate API key - matches Python create_api_key function + fn generate_api_key(&self) -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::rng(); + (0..64) + .map(|_| { + let idx = rng.random_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() + } + + + // Get theme - matches Python get_theme function + pub async fn get_theme(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT theme FROM "UserSettings" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => Ok(row.try_get("theme").unwrap_or_else(|_| "Nordic".to_string())), + None => Ok("Nordic".to_string()), + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT Theme FROM UserSettings WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => Ok(row.try_get("Theme").unwrap_or_else(|_| "Nordic".to_string())), + None => Ok("Nordic".to_string()), + } + } + } + } + + // First login done - matches Python first_login_done function + pub async fn first_login_done(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT firstlogin FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => Ok(row.try_get("firstlogin").unwrap_or(false)), + None => Err(AppError::not_found("User not found")), + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT FirstLogin FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => Ok(row.try_get("FirstLogin").unwrap_or(false)), + None => Err(AppError::not_found("User not found")), + } + } + } + } + + // Add episodes - matches Python add_episodes function exactly + pub async fn add_episodes( + &self, + podcast_id: i32, + feed_url: &str, + artwork_url: &str, + _auto_download: bool, + username: Option<&str>, + password: Option<&str>, + ) -> AppResult> { + // Fetch the RSS feed + let content = self.try_fetch_feed(feed_url, username, password).await?; + + // Parse the RSS feed - enable duration estimation for initial podcast adding + let episodes = self.parse_rss_feed_with_options(&content, podcast_id, artwork_url, true).await?; + + let mut first_episode_id = None; + + for episode in episodes { + // Check if episode already exists by EITHER title OR url + // This handles cases where feed maintainers edit episodes + let existing_episode_id = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT episodeid FROM "Episodes" WHERE podcastid = $1 AND (episodetitle = $2 OR episodeurl = $3)"#) + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.url) + .fetch_optional(pool) + .await?; + row.map(|r| r.try_get::("episodeid").ok()).flatten() + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT EpisodeID FROM Episodes WHERE PodcastID = ? AND (EpisodeTitle = ? OR EpisodeURL = ?)") + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.url) + .fetch_optional(pool) + .await?; + row.map(|r| r.try_get::("EpisodeID").ok()).flatten() + } + }; + + if let Some(episode_id) = existing_episode_id { + // Episode already exists (by title or URL) - UPDATE it with new metadata + match self { + DatabasePool::Postgres(pool) => { + sqlx::query( + r#"UPDATE "Episodes" + SET episodetitle = $1, episodedescription = $2, episodeurl = $3, + episodeartwork = $4, episodepubdate = $5, episodeduration = $6 + WHERE episodeid = $7"# + ) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&episode.pub_date) + .bind(episode.duration) + .bind(episode_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query( + "UPDATE Episodes + SET EpisodeTitle = ?, EpisodeDescription = ?, EpisodeURL = ?, + EpisodeArtwork = ?, EpisodePubDate = ?, EpisodeDuration = ? + WHERE EpisodeID = ?" + ) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&episode.pub_date) + .bind(episode.duration) + .bind(episode_id) + .execute(pool) + .await?; + } + } + // Skip to next episode - don't insert or send notification for updates + continue; + } + + // Insert new episode (neither title nor URL exists) + let episode_id = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"INSERT INTO "Episodes" + (podcastid, episodetitle, episodedescription, episodeurl, episodeartwork, episodepubdate, episodeduration) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING episodeid"# + ) + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&episode.pub_date) + .bind(episode.duration) + .fetch_one(pool) + .await?; + + row.try_get::("episodeid")? + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query( + "INSERT INTO Episodes + (PodcastID, EpisodeTitle, EpisodeDescription, EpisodeURL, EpisodeArtwork, EpisodePubDate, EpisodeDuration) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&episode.pub_date) + .bind(episode.duration) + .execute(pool) + .await?; + + result.last_insert_id() as i32 + } + }; + + // Send notification for new episode - matches Python implementation exactly + if let Err(e) = self.check_and_send_notification(podcast_id, &episode.title).await { + tracing::warn!("Failed to send notification for episode '{}': {}", episode.title, e); + } + + // Set first episode ID if not set + if first_episode_id.is_none() { + first_episode_id = Some(episode_id); + } + } + + // Update episode count + self.update_episode_count(podcast_id).await?; + + // Get the actual first episode ID (earliest by pub date) + let first_id = self.get_first_episode_id(podcast_id, false).await?; + + Ok(first_id) + } + + // New function to add episodes and return list of newly inserted episodes + // This matches the Python add_episodes websocket=True functionality + pub async fn add_episodes_with_new_list( + &self, + podcast_id: i32, + feed_url: &str, + artwork_url: &str, + username: Option<&str>, + password: Option<&str>, + ) -> AppResult> { + // Fetch the RSS feed + let content = self.try_fetch_feed(feed_url, username, password).await?; + + // Parse the RSS feed + let episodes = self.parse_rss_feed(&content, podcast_id, artwork_url).await?; + + let mut new_episodes = Vec::new(); + + for mut episode in episodes { + // Check if episode already exists by EITHER title OR url + // This handles cases where feed maintainers edit episodes + let existing_episode_id = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT episodeid FROM "Episodes" WHERE podcastid = $1 AND (episodetitle = $2 OR episodeurl = $3)"#) + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.url) + .fetch_optional(pool) + .await?; + row.map(|r| r.try_get::("episodeid").ok()).flatten() + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT EpisodeID FROM Episodes WHERE PodcastID = ? AND (EpisodeTitle = ? OR EpisodeURL = ?)") + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.url) + .fetch_optional(pool) + .await?; + row.map(|r| r.try_get::("EpisodeID").ok()).flatten() + } + }; + + if let Some(episode_id) = existing_episode_id { + // Episode already exists (by title or URL) - UPDATE it with new metadata + match self { + DatabasePool::Postgres(pool) => { + sqlx::query( + r#"UPDATE "Episodes" + SET episodetitle = $1, episodedescription = $2, episodeurl = $3, + episodeartwork = $4, episodepubdate = $5, episodeduration = $6 + WHERE episodeid = $7"# + ) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&episode.pub_date) + .bind(episode.duration) + .bind(episode_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query( + "UPDATE Episodes + SET EpisodeTitle = ?, EpisodeDescription = ?, EpisodeURL = ?, + EpisodeArtwork = ?, EpisodePubDate = ?, EpisodeDuration = ? + WHERE EpisodeID = ?" + ) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&episode.pub_date) + .bind(episode.duration) + .bind(episode_id) + .execute(pool) + .await?; + } + } + // Skip to next episode - don't add to new_episodes list for updates + continue; + } + + // This is a NEW episode - estimate duration if missing + if episode.duration == 0 { + let audio_url = &episode.url; + if !audio_url.is_empty() { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + if let Some(estimated_duration) = tokio::task::block_in_place(|| { + handle.block_on(self.estimate_duration_from_audio_url_async(audio_url)) + }) { + episode.duration = estimated_duration; + println!("Estimated duration {} seconds for new episode: {}", estimated_duration, episode.title); + } + } + } + } + + // Insert new episode + let episode_id = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"INSERT INTO "Episodes" + (podcastid, episodetitle, episodedescription, episodeurl, episodeartwork, episodepubdate, episodeduration) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING episodeid"# + ) + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&episode.pub_date) + .bind(episode.duration) + .fetch_one(pool) + .await?; + + row.try_get::("episodeid")? + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query( + "INSERT INTO Episodes + (PodcastID, EpisodeTitle, EpisodeDescription, EpisodeURL, EpisodeArtwork, EpisodePubDate, EpisodeDuration) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&episode.pub_date) + .bind(episode.duration) + .execute(pool) + .await?; + + result.last_insert_id() as i32 + } + }; + + // Send notification for new episode - matches Python implementation exactly + if let Err(e) = self.check_and_send_notification(podcast_id, &episode.title).await { + tracing::warn!("Failed to send notification for episode '{}': {}", episode.title, e); + } + + // Add to new episodes list - this tracks EXACTLY which episodes were just inserted + new_episodes.push(crate::handlers::podcasts::Episode { + podcastname: "".to_string(), // Will be filled by the caller if needed + episodetitle: episode.title, + episodepubdate: episode.pub_date.format("%Y-%m-%dT%H:%M:%S").to_string(), + episodedescription: episode.description, + episodeartwork: episode.artwork_url, + episodeurl: episode.url, + episodeduration: episode.duration, + listenduration: None, + episodeid: episode_id, + completed: false, + saved: false, + queued: false, + downloaded: false, + is_youtube: false, + }); + } + + // Update episode count + self.update_episode_count(podcast_id).await?; + + Ok(new_episodes) + } + + // Check and send notifications for new episodes - matches Python check_and_send_notification function + pub async fn check_and_send_notification(&self, podcast_id: i32, episode_title: &str) -> AppResult { + use std::time::Duration; + + let mut success = false; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .map_err(|e| AppError::Http(e))?; + + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT p.notificationsenabled, p.userid, p.podcastname, + uns.platform, uns.enabled, uns.ntfytopic, uns.ntfyserverurl, + uns.ntfyusername, uns.ntfypassword, uns.ntfyaccesstoken, + uns.gotifyurl, uns.gotifytoken + FROM "Podcasts" p + JOIN "UserNotificationSettings" uns ON p.userid = uns.userid + WHERE p.podcastid = $1 AND p.notificationsenabled = true AND uns.enabled = true"# + ) + .bind(podcast_id) + .fetch_all(pool) + .await?; + + for result in rows { + let podcast_name: String = result.try_get("podcastname")?; + let platform: String = result.try_get("platform")?; + + match platform.as_str() { + "ntfy" => { + let topic: String = result.try_get("ntfytopic")?; + let server_url: String = result.try_get("ntfyserverurl")?; + let username: Option = result.try_get("ntfyusername").ok(); + let password: Option = result.try_get("ntfypassword").ok(); + let access_token: Option = result.try_get("ntfyaccesstoken").ok(); + + if let Ok(sent) = Self::send_ntfy_notification(&client, &topic, &server_url, username.as_deref(), password.as_deref(), access_token.as_deref(), &podcast_name, episode_title).await { + if sent { + success = true; + } + } + } + "gotify" => { + let url: String = result.try_get("gotifyurl")?; + let token: String = result.try_get("gotifytoken")?; + + if let Ok(sent) = Self::send_gotify_notification(&client, &url, &token, &podcast_name, episode_title).await { + if sent { + success = true; + } + } + } + _ => { + tracing::warn!("Unknown notification platform: {}", platform); + } + } + } + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT p.NotificationsEnabled, p.UserID, p.PodcastName, + uns.Platform, uns.Enabled, uns.NtfyTopic, uns.NtfyServerUrl, + uns.NtfyUsername, uns.NtfyPassword, uns.NtfyAccessToken, + uns.GotifyUrl, uns.GotifyToken + FROM Podcasts p + JOIN UserNotificationSettings uns ON p.UserID = uns.UserID + WHERE p.PodcastID = ? AND p.NotificationsEnabled = true AND uns.Enabled = true" + ) + .bind(podcast_id) + .fetch_all(pool) + .await?; + + for result in rows { + let podcast_name: String = result.try_get("PodcastName")?; + let platform: String = result.try_get("Platform")?; + + match platform.as_str() { + "ntfy" => { + let topic: String = result.try_get("NtfyTopic")?; + let server_url: String = result.try_get("NtfyServerUrl")?; + let username: Option = result.try_get("NtfyUsername").ok(); + let password: Option = result.try_get("NtfyPassword").ok(); + let access_token: Option = result.try_get("NtfyAccessToken").ok(); + + if let Ok(sent) = Self::send_ntfy_notification(&client, &topic, &server_url, username.as_deref(), password.as_deref(), access_token.as_deref(), &podcast_name, episode_title).await { + if sent { + success = true; + } + } + } + "gotify" => { + let url: String = result.try_get("GotifyUrl")?; + let token: String = result.try_get("GotifyToken")?; + + if let Ok(sent) = Self::send_gotify_notification(&client, &url, &token, &podcast_name, episode_title).await { + if sent { + success = true; + } + } + } + _ => { + tracing::warn!("Unknown notification platform: {}", platform); + } + } + } + } + } + + Ok(success) + } + + // Helper function to send NTFY notification - matches Python send_ntfy_notification function + async fn send_ntfy_notification( + client: &reqwest::Client, + topic: &str, + server_url: &str, + username: Option<&str>, + password: Option<&str>, + access_token: Option<&str>, + podcast_name: &str, + episode_title: &str, + ) -> AppResult { + let url = format!("{}/{}", server_url.trim_end_matches('/'), topic); + let message = format!("New episode available for {}: {}", podcast_name, episode_title); + + let mut request = client + .post(&url) + .header("Content-Type", "text/plain") + .body(message); + + // Add authentication if provided + if let Some(token) = access_token.filter(|t| !t.is_empty()) { + // Use access token (preferred method) + request = request.header("Authorization", format!("Bearer {}", token)); + } else if let (Some(user), Some(pass)) = (username.filter(|u| !u.is_empty()), password.filter(|p| !p.is_empty())) { + // Use username/password basic auth + request = request.basic_auth(user, Some(pass)); + } + + match request.send().await + { + Ok(response) => { + if response.status().is_success() { + tracing::info!("Successfully sent NTFY notification to {}", url); + Ok(true) + } else { + tracing::warn!("NTFY notification failed with status: {}", response.status()); + Ok(false) + } + } + Err(e) => { + tracing::warn!("Failed to send NTFY notification: {}", e); + Ok(false) + } + } + } + + // Helper function to send Gotify notification - matches Python send_gotify_notification function + async fn send_gotify_notification( + client: &reqwest::Client, + server_url: &str, + token: &str, + podcast_name: &str, + episode_title: &str, + ) -> AppResult { + let url = format!("{}/message?token={}", server_url.trim_end_matches('/'), token); + let message = format!("New episode available for {}: {}", podcast_name, episode_title); + + match client + .post(&url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "message": message, + "title": "New Podcast Episode" + })) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + tracing::info!("Successfully sent Gotify notification to {}", url); + Ok(true) + } else { + tracing::warn!("Gotify notification failed with status: {}", response.status()); + Ok(false) + } + } + Err(e) => { + tracing::warn!("Failed to send Gotify notification: {}", e); + Ok(false) + } + } + } + + // Try to fetch RSS feed - matches Python try_fetch_feed function + async fn try_fetch_feed( + &self, + url: &str, + username: Option<&str>, + password: Option<&str>, + ) -> AppResult { + println!("try_fetch_feed called with URL: {}", url); + if let (Some(user), Some(pass)) = (username, password) { + println!("Using basic authentication for feed: {}", url); + } else { + println!("No authentication for feed: {}", url); + } + + // Build HTTP client with proper configuration for container environment + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| { + println!("Failed to build HTTP client: {}", e); + AppError::Http(e) + })?; + + let mut request = client.get(url); + + if let (Some(user), Some(pass)) = (username, password) { + println!("Adding basic auth to request for user: {}", user); + request = request.basic_auth(user, Some(pass)); + } + + println!("Sending HTTP request to: {}", url); + let response = request.send().await.map_err(|e| { + println!("HTTP request failed for {}: {}", url, e); + AppError::Http(e) + })?; + + if !response.status().is_success() { + // If we get a 403, the server might be blocking browser User-Agents + // Try with a podcast client User-Agent first + if response.status() == 403 { + println!("Got 403 Forbidden, trying with podcast client User-Agent"); + + let podcast_client = reqwest::Client::builder() + .user_agent("PinePods/1.0") + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| { + println!("Failed to build podcast client: {}", e); + AppError::Http(e) + })?; + + let mut podcast_request = podcast_client.get(url); + + if let (Some(user), Some(pass)) = (username, password) { + println!("Adding basic auth to podcast client request for user: {}", user); + podcast_request = podcast_request.basic_auth(user, Some(pass)); + } + + let podcast_response = podcast_request.send().await.map_err(|e| { + println!("Podcast client request failed for {}: {}", url, e); + AppError::Http(e) + })?; + + if podcast_response.status().is_success() { + println!("Podcast client request succeeded with status: {}", podcast_response.status()); + return Ok(podcast_response.text().await.map_err(|e| AppError::Http(e))?); + } + + println!("Podcast client request also failed with status: {}", podcast_response.status()); + } + + println!("Initial request failed with status: {}, trying alternate URL", response.status()); + // Try alternate URL (www vs non-www) + let alternate_url = if url.contains("://www.") { + url.replace("://www.", "://") + } else { + url.replace("://", "://www.") + }; + + println!("Trying alternate URL: {}", alternate_url); + let mut alt_request = client.get(&alternate_url); + + if let (Some(user), Some(pass)) = (username, password) { + println!("Adding basic auth to alternate request for user: {}", user); + alt_request = alt_request.basic_auth(user, Some(pass)); + } + + let alt_response = alt_request.send().await.map_err(|e| { + println!("Alternate HTTP request failed for {}: {}", alternate_url, e); + AppError::Http(e) + })?; + + if !alt_response.status().is_success() { + println!("Alternate request also failed with status: {}", alt_response.status()); + return Err(AppError::bad_request(&format!("Feed request failed: HTTP {}", alt_response.status()))); + } + + println!("Alternate request succeeded with status: {}", alt_response.status()); + return Ok(alt_response.text().await.map_err(|e| AppError::Http(e))?); + } + + println!("Request succeeded with status: {}", response.status()); + Ok(response.text().await.map_err(|e| AppError::Http(e))?) + } + + // Custom function to extract raw iTunes durations before feed_rs processes them + // This is needed because feed_rs incorrectly parses MM:SS durations as seconds only + fn extract_raw_itunes_durations(content: &str) -> std::collections::HashMap { + use regex::Regex; + let mut raw_durations = std::collections::HashMap::new(); + + // Professional-grade regex that handles real-world RSS feeds robustly + // Uses DOTALL flag (?s) to match across newlines, handles CDATA, whitespace variations + // Matches both and tags to be comprehensive + let duration_regex = Regex::new( + r"(?s)]*>.*?]*)>(?:\s*)?\s*.*?<(?:itunes:)?duration(?:[^>]*)>\s*(?:)?\s*" + ).unwrap(); + + for caps in duration_regex.captures_iter(content) { + if let (Some(title_match), Some(duration_match)) = (caps.get(1), caps.get(2)) { + let title_str = title_match.as_str().trim(); + let duration_str = duration_match.as_str().trim(); + + // Skip empty values + if !title_str.is_empty() && !duration_str.is_empty() { + // Decode HTML entities in title to match feed-rs parsed titles + let decoded_title = title_str + .replace("'", "'") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + raw_durations.insert(decoded_title, duration_str.to_string()); + } + } + } + + // Fallback: try reverse order (duration before title) for edge case XML structures + let reverse_regex = Regex::new( + r"(?s)]*>.*?<(?:itunes:)?duration(?:[^>]*)>\s*(?:)?\s*.*?]*)>(?:\s*)?\s*" + ).unwrap(); + + for caps in reverse_regex.captures_iter(content) { + if let (Some(duration_match), Some(title_match)) = (caps.get(1), caps.get(2)) { + let title_str = title_match.as_str().trim(); + let duration_str = duration_match.as_str().trim(); + + // Only add if not already found and both values are non-empty + if !title_str.is_empty() && !duration_str.is_empty() { + // Decode HTML entities in title to match feed-rs parsed titles + let decoded_title = title_str + .replace("'", "'") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + if !raw_durations.contains_key(&decoded_title) { + raw_durations.insert(decoded_title, duration_str.to_string()); + } + } + } + } + + raw_durations + } + + // Parse RSS feed - matches Python RSS parsing logic + async fn parse_rss_feed( + &self, + content: &str, + _podcast_id: i32, + artwork_url: &str, + ) -> AppResult> { + self.parse_rss_feed_with_options(content, _podcast_id, artwork_url, false).await + } + + async fn parse_rss_feed_with_options( + &self, + content: &str, + _podcast_id: i32, + artwork_url: &str, + estimate_missing_durations: bool, + ) -> AppResult> { + use chrono::Utc; + use feed_rs::parser; + use std::collections::HashMap; + + // Extract raw iTunes durations before feed_rs processes them + let raw_durations = Self::extract_raw_itunes_durations(content); + + let feed = parser::parse(content.as_bytes()) + .map_err(|e| AppError::Internal(format!("RSS parsing error: {}", e)))?; + + let mut episodes = Vec::new(); + + + + for entry in feed.entries { + // EXACT Python replication: if not all(hasattr(entry, attr) for attr in ["title", "summary", "enclosures"]): continue + if entry.title.is_none() { + continue; + } + + let mut episode = EpisodeData { + title: String::new(), + description: String::new(), + url: String::new(), + artwork_url: artwork_url.to_string(), + pub_date: Utc::now(), + duration: 0, + }; + + // Create data map to pass to Python-style parsing functions + let mut episode_data = HashMap::new(); + + // Extract all data from feed_rs entry + if let Some(title) = &entry.title { + episode_data.insert("title".to_string(), title.content.clone()); + } + + if let Some(content) = &entry.content { + if let Some(body) = &content.body { + episode_data.insert("content:encoded".to_string(), body.clone()); + } + } + + if let Some(summary) = &entry.summary { + episode_data.insert("summary".to_string(), summary.content.clone()); + } + + // Links for audio/enclosures + for link in &entry.links { + if let Some(media_type) = &link.media_type { + if media_type.starts_with("audio/") { + episode_data.insert("enclosure_url".to_string(), link.href.clone()); + if let Some(length) = &link.length { + episode_data.insert("enclosure_length".to_string(), length.to_string()); + } + } + } + } + + // Also check for RSS tags that might not be in links + // feed_rs should put enclosures in the links, but as fallback check media + + // Published date - store under both keys for compatibility + if let Some(published) = &entry.published { + let date_str = published.to_rfc3339(); + // println!("📅 DEBUG: feed-rs extracted published date: {}", date_str); + episode_data.insert("published".to_string(), date_str.clone()); + episode_data.insert("pubDate".to_string(), date_str); + } else { + // println!("⚠️ DEBUG: No published date found in feed-rs entry for episode: {:?}", entry.title); + } + + // Media extensions + for media in &entry.media { + if let Some(duration) = &media.duration { + episode_data.insert("duration".to_string(), duration.as_secs().to_string()); + // Don't use feed_rs processed duration for iTunes - we'll use raw values + } + + // Check if we have a raw iTunes duration for this episode title + if let Some(title) = &entry.title { + if let Some(raw_duration) = raw_durations.get(&title.content) { + episode_data.insert("itunes:duration".to_string(), raw_duration.clone()); + } + } + + for thumbnail in &media.thumbnails { + episode_data.insert("media_thumbnail_url".to_string(), thumbnail.image.uri.clone()); + } + + for content in &media.content { + if let Some(content_type) = &content.content_type { + let type_str = content_type.to_string(); + if type_str.starts_with("audio/") { + if let Some(url) = &content.url { + // Store as enclosure_url to match Python parsing logic + episode_data.insert("enclosure_url".to_string(), url.to_string()); + if let Some(size) = &content.size { + episode_data.insert("enclosure_length".to_string(), size.to_string()); + } + } + } else if type_str.starts_with("image/") { + if let Some(url) = &content.url { + episode_data.insert("media_image_url".to_string(), url.to_string()); + } + } + } + } + } + + // Debug what we're passing to duration parsing + + // Apply all the Python-style parsing logic with ALL fallbacks + self.apply_python_style_parsing(&mut episode, &episode_data, artwork_url, estimate_missing_durations); + + if !episode.title.is_empty() { + episodes.push(episode); + } + } + + Ok(episodes) + } + + // Apply Python-style parsing logic with all fallbacks + fn apply_python_style_parsing(&self, episode: &mut EpisodeData, data: &HashMap, default_artwork: &str, estimate_missing_durations: bool) { + // Title - REQUIRED field with robust cleaning + if let Some(title) = data.get("title") { + episode.title = self.clean_and_normalize_title(title); + } + // Skip episodes without titles - this is critical like Python version + if episode.title.is_empty() { + return; + } + + // EXACT Python replication: parsed_description = entry.get('content', [{}])[0].get('value') or entry.get('summary') or "No description available" + episode.description = self.parse_description_comprehensive(data); + + // EXACT Python replication: parsed_audio_url = entry.enclosures[0].href if entry.enclosures else "" + episode.url = self.parse_audio_url_comprehensive(data); + + // Debug logging for episode URL extraction + + // Artwork with comprehensive fallbacks and validation like Python + episode.artwork_url = self.parse_artwork_comprehensive(data, default_artwork); + + // Publication date with extensive format support and timezone handling + episode.pub_date = self.parse_publication_date_comprehensive(data); + + // Duration parsing with extensive fallbacks like Python + episode.duration = self.parse_duration_comprehensive(data, estimate_missing_durations); + } + + // Clean and normalize titles like Python version + fn clean_and_normalize_title(&self, title: &str) -> String { + // HTML entity decoding + let title = self.decode_html_entities(title); + + // HTML tag stripping + let title = self.strip_html_tags(&title); + + // Unicode normalization and whitespace cleaning + let title = title.trim() + .split_whitespace() + .collect::>() + .join(" "); + + // Truncate if too long (reasonable limit) - use char_indices for Unicode safety + if title.chars().count() > 200 { + let mut truncated = String::new(); + for (i, ch) in title.char_indices() { + if truncated.chars().count() >= 197 { + break; + } + truncated.push(ch); + } + format!("{}...", truncated) + } else { + title + } + } + + // Comprehensive description parsing with HTML cleaning + fn parse_description_comprehensive(&self, data: &HashMap) -> String { + // EXACT Python replication: entry.get('content', [{}])[0].get('value') or entry.get('summary') or "No description available" + data.get("content:encoded") + .or_else(|| data.get("content")) + .or_else(|| data.get("summary")) + .filter(|s| !s.trim().is_empty()) + .cloned() + .unwrap_or_else(|| "No description available".to_string()) + } + + // EXACT Python replication: parsed_audio_url = entry.enclosures[0].href if entry.enclosures else "" + fn parse_audio_url_comprehensive(&self, data: &HashMap) -> String { + // Python: entry.enclosures[0].href if entry.enclosures else "" + data.get("enclosure_url") + .filter(|url| !url.trim().is_empty()) + .cloned() + .unwrap_or_else(|| String::new()) + } + + // Comprehensive artwork URL parsing - prioritizes episode-specific artwork + fn parse_artwork_comprehensive(&self, data: &HashMap, default_artwork: &str) -> String { + // Priority order: episode-specific artwork first, then fallbacks + let artwork_candidates = [ + data.get("episode_artwork_href"), // or in episode + data.get("media_image_url"), // + data.get("media_thumbnail_url"), // + data.get("itunes:image"), // Text content of + data.get("image"), // Text content of + data.get("thumbnail"), // Generic thumbnail + data.get("logo"), // Logo field + ]; + + for candidate in artwork_candidates.iter().flatten() { + if !candidate.trim().is_empty() && self.is_valid_image_url(candidate) { + let cleaned_url = self.validate_and_clean_url(candidate); + return cleaned_url; + } + } + + // Use default podcast artwork as fallback + default_artwork.to_string() + } + + // Comprehensive publication date parsing + fn parse_publication_date_comprehensive(&self, data: &HashMap) -> DateTime { + // println!("🔍 DEBUG: Date parsing - episode_data keys: {:?}", data.keys().collect::>()); + + // Multiple date field sources + let date_candidates = [ + data.get("pubDate"), + data.get("published"), + data.get("dc:date"), + data.get("updated"), + data.get("lastBuildDate"), + data.get("date"), + ]; + + for (i, date_str) in date_candidates.iter().enumerate() { + if let Some(date_str) = date_str { + // println!("📅 DEBUG: Trying date candidate {}: '{}'", i, date_str); + if let Some(parsed_date) = self.try_parse_date(date_str) { + // Validate date is reasonable (not too far in future, not before 1990) + let now = Utc::now(); + let year_1990 = DateTime::parse_from_rfc3339("1990-01-01T00:00:00Z").unwrap().with_timezone(&Utc); + let one_year_future = now + chrono::Duration::days(365); + + if parsed_date >= year_1990 && parsed_date <= one_year_future { + // println!("✅ DEBUG: Successfully parsed date: {}", parsed_date); + return parsed_date; + } else { + // println!("❌ DEBUG: Date {} outside valid range", parsed_date); + } + } else { + // println!("❌ DEBUG: Failed to parse date string: '{}'", date_str); + } + } + } + + // Fallback to current time like Python version + // println!("⚠️ DEBUG: No valid date found, falling back to current time"); + Utc::now() + } + + // Try to parse a date string with multiple formats + fn try_parse_date(&self, date_str: &str) -> Option> { + let date_str = date_str.trim(); + // println!("🔧 DEBUG: Attempting to parse date: '{}'", date_str); + + // RFC 2822 format (most common in RSS) + if let Ok(parsed) = DateTime::parse_from_rfc2822(date_str) { + // println!("✅ DEBUG: Parsed as RFC 2822: {}", parsed); + return Some(parsed.with_timezone(&Utc)); + } else { + // println!("❌ DEBUG: Failed to parse as RFC 2822"); + } + + // RFC 3339/ISO 8601 format + if let Ok(parsed) = DateTime::parse_from_rfc3339(date_str) { + // println!("✅ DEBUG: Parsed as RFC 3339: {}", parsed); + return Some(parsed.with_timezone(&Utc)); + } else { + // println!("❌ DEBUG: Failed to parse as RFC 3339"); + } + + // Common custom formats found in real feeds + let formats = [ + "%Y-%m-%d %H:%M:%S %z", + "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%a, %d %b %Y %H:%M:%S %z", + "%a, %d %b %Y %H:%M:%S", + "%d %b %Y %H:%M:%S %z", + "%d %b %Y %H:%M:%S", + "%Y-%m-%d", + "%d/%m/%Y", + "%m/%d/%Y", + "%b %d, %Y", + "%B %d, %Y", + "%Y%m%d", + ]; + + // Try parsing with timezone + for format in &formats[..8] { + if let Ok(parsed) = DateTime::parse_from_str(date_str, format) { + return Some(parsed.with_timezone(&Utc)); + } + } + + // Try parsing as naive datetime (assume UTC) + for format in &formats[8..] { + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(date_str, format) { + return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + } + } + + // Try parsing date only (assume midnight UTC) + for format in &formats[10..] { + if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(date_str, format) { + if let Some(naive_datetime) = naive_date.and_hms_opt(0, 0, 0) { + return Some(DateTime::::from_naive_utc_and_offset(naive_datetime, Utc)); + } + } + } + + None + } + + // Validate and clean URLs + fn validate_and_clean_url(&self, url: &str) -> String { + let url = url.trim(); + + // Basic URL validation + if !url.starts_with("http://") && !url.starts_with("https://") { + if url.starts_with("//") { + return format!("https:{}", url); + } else if url.starts_with("/") { + // Relative URL - can't fix without base URL + return url.to_string(); + } else { + return format!("https://{}", url); + } + } + + url.to_string() + } + + // Check if URL is likely a valid image + fn is_valid_image_url(&self, url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains(".jpg") || + url_lower.contains(".jpeg") || + url_lower.contains(".png") || + url_lower.contains(".gif") || + url_lower.contains(".webp") || + url_lower.contains(".svg") || + url_lower.contains("image") || + url_lower.contains("artwork") || + url_lower.contains("thumbnail") || + url_lower.contains("cover") + } + + fn decode_html_entities(&self, text: &str) -> String { + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("'", "'") + .replace(" ", " ") + .replace(" ", " ") + .replace("—", "—") + .replace("–", "–") + .replace("…", "…") + .replace("’", "'") + .replace("‘", "'") + .replace("”", "\"") + .replace("“", "\"") + } + + // // Check if URL is likely an audio file + // fn is_audio_url(&self, url: &str) -> bool { + // let url_lower = url.to_lowercase(); + // url_lower.contains(".mp3") || + // url_lower.contains(".m4a") || + // url_lower.contains(".wav") || + // url_lower.contains(".ogg") || + // url_lower.contains(".aac") || + // url_lower.contains(".flac") || + // url_lower.contains(".wma") || + // url_lower.contains(".opus") || + // url_lower.contains("audio") || + // url_lower.contains("/podcast/") || + // url_lower.contains("/episode/") || + // url_lower.contains(".podtrac.com") || + // url_lower.contains("libsyn.com") || + // url_lower.contains("soundcloud.com") || + // url_lower.contains("spotify.com") + // } + + // Extract audio URLs from episode descriptions/content + fn extract_audio_url_from_description(&self, data: &HashMap) -> Option { + let content_fields = [ + data.get("content:encoded"), + data.get("content"), + data.get("description"), + data.get("summary"), + ]; + + for content in content_fields.iter().flatten() { + // Look for common audio file patterns in HTML content + let patterns = [ + r#"src="([^"]*\.mp3[^"]*)""#, + r#"src="([^"]*\.m4a[^"]*)""#, + r#"href="([^"]*\.mp3[^"]*)""#, + r#"href="([^"]*\.m4a[^"]*)""#, + r#"url="([^"]*\.mp3[^"]*)""#, + r#"url="([^"]*\.m4a[^"]*)""#, + ]; + + for pattern in &patterns { + // Simple regex-like matching (basic implementation) + if let Some(url) = self.extract_url_from_pattern(content, pattern) { + if self.is_audio_url(&url) { + return Some(url); + } + } + } + + // Look for direct URLs in plain text + if let Some(url) = self.extract_direct_audio_url(content) { + return Some(url); + } + } + + None + } + + // Extract URL from pattern (simple implementation) + fn extract_url_from_pattern(&self, content: &str, pattern: &str) -> Option { + // Very basic pattern matching - look for quoted URLs containing audio extensions + let content_lower = content.to_lowercase(); + let audio_extensions = [".mp3", ".m4a", ".wav", ".ogg", ".aac"]; + + for ext in &audio_extensions { + if let Some(ext_pos) = content_lower.find(ext) { + // Look backwards for quote or space + let mut start_pos = 0; + for (i, ch) in content[..ext_pos].char_indices().rev() { + if ch == '"' || ch == '\'' || ch == ' ' || ch == '>' { + start_pos = i + 1; + break; + } + } + + // Look forwards for quote or space after extension + let mut end_pos = content.len(); + let search_start = ext_pos + ext.len(); + if search_start < content.len() { + for (i, ch) in content[search_start..].char_indices() { + if ch == '"' || ch == '\'' || ch == ' ' || ch == '<' || ch == '?' { + end_pos = search_start + i; + break; + } + } + } + + if start_pos < end_pos { + let potential_url = &content[start_pos..end_pos]; + if potential_url.starts_with("http") && self.is_audio_url(potential_url) { + return Some(potential_url.to_string()); + } + } + } + } + + None + } + + // Extract direct audio URLs from plain text + fn extract_direct_audio_url(&self, content: &str) -> Option { + // Split by whitespace and look for URLs + for word in content.split_whitespace() { + if word.starts_with("http") && self.is_audio_url(word) { + // Clean up the URL (remove trailing punctuation) + let cleaned = word.trim_end_matches(&['.', ',', '!', '?', ')', ']', '}']); + return Some(cleaned.to_string()); + } + } + + None + } + + + // Strip HTML tags (basic but effective) + fn strip_html_tags(&self, text: &str) -> String { + let mut result = String::new(); + let mut in_tag = false; + + for ch in text.chars() { + match ch { + '<' => in_tag = true, + '>' => { + in_tag = false; + result.push(' '); // Replace tags with space + } + _ if !in_tag => result.push(ch), + _ => {} // Skip characters inside tags + } + } + + result + } + + // Normalize whitespace + fn normalize_whitespace(&self, text: &str) -> String { + text.split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string() + } + + // Comprehensive duration parsing matching Python logic + fn parse_duration_comprehensive(&self, data: &HashMap, estimate_missing_durations: bool) -> i32 { + // Priority order like Python version + let duration_candidates = [ + data.get("itunes:duration"), + data.get("duration"), + data.get("itunes:duration_seconds"), + data.get("length"), + data.get("time"), + ]; + + for candidate in duration_candidates.iter().flatten() { + if let Some(duration) = self.parse_duration_string(candidate) { + if duration > 0 && duration < 86400 { // Reasonable range: 0-24 hours + return duration; + } + } + } + + // Try to estimate from file size if available (like Python version) + if let Some(length_str) = data.get("enclosure_length") { + if let Ok(file_size) = length_str.parse::() { + if file_size > 1_000_000 { // > 1MB + return self.estimate_duration_from_file_size(file_size); + } + } + } + + // Only estimate duration from HTTP if we're adding a new episode AND the flag is enabled + if estimate_missing_durations { + if let Some(audio_url) = data.get("enclosure_url") { + // Check if we're in an async context and can make the HTTP request + if let Ok(handle) = tokio::runtime::Handle::try_current() { + if let Some(estimated_duration) = tokio::task::block_in_place(|| { + handle.block_on(self.estimate_duration_from_audio_url_async(audio_url)) + }) { + return estimated_duration; + } + } + } + } + + // Default to 0 if no duration can be determined + 0 + } + + // Parse duration string - EXACT Python logic replication + fn parse_duration_string(&self, duration_str: &str) -> Option { + let duration_str = duration_str.trim(); + + if duration_str.contains(':') { + // Python: time_parts = list(map(int, duration_str.split(':'))) + let parts: Result, _> = duration_str.split(':') + .map(|part| part.parse::()) + .collect(); + + if let Ok(mut time_parts) = parts { + // Python: while len(time_parts) < 3: time_parts.insert(0, 0) + while time_parts.len() < 3 { + time_parts.insert(0, 0); + } + + if time_parts.len() >= 3 { + let h = time_parts[0]; + let m = time_parts[1]; + let s = time_parts[2]; + + // Python: parsed_duration = h * 3600 + m * 60 + s + let duration = h * 3600 + m * 60 + s; + if duration >= 0 { + return Some(duration); + } + } + } + } else if duration_str.chars().all(|c| c.is_ascii_digit()) { + // Python: elif duration_str.isdigit(): parsed_duration = int(duration_str) + if let Ok(duration) = duration_str.parse::() { + return Some(duration); + } + } + + None + } + + // Parse human-readable duration formats + fn parse_human_readable_duration(&self, duration_str: &str) -> Option { + let duration_str = duration_str.to_lowercase(); + let mut total_seconds = 0; + + // Extract hours + if let Some(hours_match) = self.extract_time_component(&duration_str, &["h", "hr", "hour", "hours"]) { + total_seconds += hours_match * 3600; + } + + // Extract minutes + if let Some(minutes_match) = self.extract_time_component(&duration_str, &["m", "min", "mins", "minute", "minutes"]) { + total_seconds += minutes_match * 60; + } + + // Extract seconds + if let Some(seconds_match) = self.extract_time_component(&duration_str, &["s", "sec", "secs", "second", "seconds"]) { + total_seconds += seconds_match; + } + + if total_seconds > 0 { + Some(total_seconds) + } else { + None + } + } + + // Extract time component (e.g., "30" from "30min") + fn extract_time_component(&self, text: &str, suffixes: &[&str]) -> Option { + for suffix in suffixes { + if let Some(pos) = text.find(suffix) { + // Look backwards from position to find the number + let before = &text[..pos]; + + // Find the last sequence of digits + let mut number_start = pos; + for (i, ch) in before.char_indices().rev() { + if ch.is_ascii_digit() { + number_start = i; + } else if number_start < pos { + // Found start of number sequence + break; + } + } + + if number_start < pos { + if let Ok(number) = before[number_start..].trim().parse::() { + return Some(number); + } + } + } + } + None + } + + // Estimate duration from file size like Python version + fn estimate_duration_from_file_size(&self, file_size_bytes: i64) -> i32 { + // Assume 128 kbps average bitrate like Python version + let bitrate_kbps = 128; + let bytes_per_second = (bitrate_kbps * 1000) / 8; + (file_size_bytes / bytes_per_second) as i32 + } + + // NEW: Estimate duration by fetching HTTP HEAD request to get Content-Length + // This is a fallback for when RSS feeds don't include duration or file size + async fn estimate_duration_from_audio_url_async(&self, audio_url: &str) -> Option { + println!("Attempting to estimate duration from audio URL: {}", audio_url); + + // Build HTTP client with timeout to avoid hanging + let client = match reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .timeout(std::time::Duration::from_secs(10)) // Short timeout + .build() + { + Ok(client) => client, + Err(e) => { + println!("Failed to build HTTP client for duration estimation: {}", e); + return None; + } + }; + + // Make HEAD request to get Content-Length without downloading the file + match client.head(audio_url).send().await { + Ok(response) => { + if response.status().is_success() { + if let Some(content_length) = response.headers().get("content-length") { + if let Ok(content_length_str) = content_length.to_str() { + if let Ok(file_size) = content_length_str.parse::() { + if file_size > 1_000_000 { // > 1MB, reasonable audio file + let estimated_duration = self.estimate_duration_from_file_size(file_size); + println!("Estimated duration from file size {}: {} seconds", file_size, estimated_duration); + return Some(estimated_duration); + } + } + } + } + println!("No Content-Length header found in response"); + } else { + println!("HEAD request failed with status: {}", response.status()); + } + } + Err(e) => { + println!("HTTP HEAD request failed for {}: {}", audio_url, e); + } + } + + None + } + + + // Get first episode ID - matches Python get_first_episode_id function + pub async fn get_first_episode_id(&self, podcast_id: i32, is_youtube: bool) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let query = if is_youtube { + r#"SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1 ORDER BY publishedat ASC LIMIT 1"# + } else { + r#"SELECT episodeid FROM "Episodes" WHERE podcastid = $1 ORDER BY episodepubdate ASC LIMIT 1"# + }; + + let row = sqlx::query(query) + .bind(podcast_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + if is_youtube { + Ok(Some(row.try_get("videoid")?)) + } else { + Ok(Some(row.try_get("episodeid")?)) + } + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let query = if is_youtube { + "SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ? ORDER BY PublishedAt ASC LIMIT 1" + } else { + "SELECT EpisodeID FROM Episodes WHERE PodcastID = ? ORDER BY EpisodePubDate ASC LIMIT 1" + }; + + let row = sqlx::query(query) + .bind(podcast_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + if is_youtube { + Ok(Some(row.try_get("VideoID")?)) + } else { + Ok(Some(row.try_get("EpisodeID")?)) + } + } else { + Ok(None) + } + } + } + } + + // Setup timezone info - matches Python setup_timezone_info function + pub async fn setup_timezone_info(&self, user_id: i32, timezone: &str, hour_pref: i32, date_format: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"UPDATE "Users" SET timezone = $1, timeformat = $2, dateformat = $3, firstlogin = $4 WHERE userid = $5"#) + .bind(timezone) + .bind(hour_pref) + .bind(date_format) + .bind(true) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("UPDATE Users SET Timezone = ?, TimeFormat = ?, DateFormat = ?, FirstLogin = ? WHERE UserID = ?") + .bind(timezone) + .bind(hour_pref) + .bind(date_format) + .bind(1) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Update user timezone + pub async fn update_user_timezone(&self, user_id: i32, timezone: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"UPDATE "Users" SET timezone = $1 WHERE userid = $2"#) + .bind(timezone) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("UPDATE Users SET Timezone = ? WHERE UserID = ?") + .bind(timezone) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Update user date format + pub async fn update_user_date_format(&self, user_id: i32, date_format: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"UPDATE "Users" SET dateformat = $1 WHERE userid = $2"#) + .bind(date_format) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("UPDATE Users SET DateFormat = ? WHERE UserID = ?") + .bind(date_format) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Update user time format (hour preference) + pub async fn update_user_time_format(&self, user_id: i32, hour_pref: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"UPDATE "Users" SET timeformat = $1 WHERE userid = $2"#) + .bind(hour_pref) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("UPDATE Users SET TimeFormat = ? WHERE UserID = ?") + .bind(hour_pref) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // User admin check - matches Python user_admin_check function + pub async fn user_admin_check(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT isadmin FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("isadmin")?) + } else { + Ok(false) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT IsAdmin FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let is_admin: i8 = row.try_get("IsAdmin")?; + Ok(is_admin != 0) + } else { + Ok(false) + } + } + } + } + + // Get podcast ID by user, feed URL, and title + pub async fn get_podcast_id(&self, user_id: i32, podcast_feed: &str, podcast_title: &str) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT podcastid FROM "Podcasts" WHERE feedurl = $1 AND podcastname = $2 AND userid = $3"#) + .bind(podcast_feed) + .bind(podcast_title) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("podcastid")?)) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT PodcastID FROM Podcasts WHERE FeedURL = ? AND PodcastName = ? AND UserID = ?") + .bind(podcast_feed) + .bind(podcast_title) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("PodcastID")?)) + } else { + Ok(None) + } + } + } + } + + // Get downloaded episodes - matches Python download_episode_list function + pub async fn download_episode_list(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT * FROM ( + SELECT + "Podcasts".podcastid as podcastid, + "Podcasts".podcastname as podcastname, + "Podcasts".artworkurl as artworkurl, + "Episodes".episodeid as episodeid, + "Episodes".episodetitle as episodetitle, + "Episodes".episodepubdate as episodepubdate, + "Episodes".episodedescription as episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl as episodeurl, + "Episodes".episodeduration as episodeduration, + "Podcasts".podcastindexid as podcastindexid, + "Podcasts".websiteurl as websiteurl, + "DownloadedEpisodes".downloadedlocation as downloadedlocation, + "UserEpisodeHistory".listenduration as listenduration, + "Episodes".completed as completed, + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + TRUE as downloaded, + FALSE as is_youtube + FROM "DownloadedEpisodes" + INNER JOIN "Episodes" ON "DownloadedEpisodes".episodeid = "Episodes".episodeid + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON + "DownloadedEpisodes".episodeid = "UserEpisodeHistory".episodeid + AND "DownloadedEpisodes".userid = "UserEpisodeHistory".userid + LEFT JOIN "SavedEpisodes" ON + "DownloadedEpisodes".episodeid = "SavedEpisodes".episodeid + AND "SavedEpisodes".userid = $1 + LEFT JOIN "EpisodeQueue" ON + "DownloadedEpisodes".episodeid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $2 + AND "EpisodeQueue".is_youtube = FALSE + WHERE "DownloadedEpisodes".userid = $3 + + UNION ALL + + SELECT + "Podcasts".podcastid as podcastid, + "Podcasts".podcastname as podcastname, + "Podcasts".artworkurl as artworkurl, + "YouTubeVideos".videoid as episodeid, + "YouTubeVideos".videotitle as episodetitle, + "YouTubeVideos".publishedat as episodepubdate, + "YouTubeVideos".videodescription as episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "YouTubeVideos".thumbnailurl + END as episodeartwork, + "YouTubeVideos".videourl as episodeurl, + "YouTubeVideos".duration as episodeduration, + "Podcasts".podcastindexid as podcastindexid, + "Podcasts".websiteurl as websiteurl, + "DownloadedVideos".downloadedlocation as downloadedlocation, + "YouTubeVideos".listenposition as listenduration, + "YouTubeVideos".completed as completed, + CASE WHEN "SavedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL AND "EpisodeQueue".is_youtube = TRUE THEN TRUE ELSE FALSE END AS queued, + TRUE as downloaded, + TRUE as is_youtube + FROM "DownloadedVideos" + INNER JOIN "YouTubeVideos" ON "DownloadedVideos".videoid = "YouTubeVideos".videoid + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "SavedVideos" ON + "DownloadedVideos".videoid = "SavedVideos".videoid + AND "SavedVideos".userid = $4 + LEFT JOIN "EpisodeQueue" ON + "DownloadedVideos".videoid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $5 + AND "EpisodeQueue".is_youtube = TRUE + WHERE "DownloadedVideos".userid = $6 + ) combined + ORDER BY episodepubdate DESC"# + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::handlers::podcasts::DownloadedEpisode { + podcastid: row.try_get("podcastid")?, + podcastname: row.try_get("podcastname")?, + artworkurl: row.try_get("artworkurl").ok(), + episodeid: row.try_get("episodeid")?, + episodetitle: row.try_get("episodetitle")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork").ok(), + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + podcastindexid: row.try_get("podcastindexid").ok(), + websiteurl: row.try_get("websiteurl").ok(), + downloadedlocation: row.try_get("downloadedlocation")?, + listenduration: row.try_get("listenduration").ok(), + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + }); + } + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT * FROM ( + SELECT + Podcasts.PodcastID as podcastid, + Podcasts.PodcastName as podcastname, + Podcasts.ArtworkURL as artworkurl, + Episodes.EpisodeID as episodeid, + Episodes.EpisodeTitle as episodetitle, + Episodes.EpisodePubDate as episodepubdate, + Episodes.EpisodeDescription as episodedescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = 1 AND Podcasts.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + ELSE Episodes.EpisodeArtwork + END as episodeartwork, + Episodes.EpisodeURL as episodeurl, + Episodes.EpisodeDuration as episodeduration, + Podcasts.PodcastIndexID as podcastindexid, + Podcasts.WebsiteURL as websiteurl, + DownloadedEpisodes.DownloadedLocation as downloadedlocation, + UserEpisodeHistory.ListenDuration as listenduration, + Episodes.Completed as completed, + CASE WHEN SavedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + TRUE as downloaded, + FALSE as is_youtube + FROM DownloadedEpisodes + INNER JOIN Episodes ON DownloadedEpisodes.EpisodeID = Episodes.EpisodeID + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN UserEpisodeHistory ON + DownloadedEpisodes.EpisodeID = UserEpisodeHistory.EpisodeID + AND DownloadedEpisodes.UserID = UserEpisodeHistory.UserID + LEFT JOIN SavedEpisodes ON + DownloadedEpisodes.EpisodeID = SavedEpisodes.EpisodeID + AND SavedEpisodes.UserID = ? + LEFT JOIN EpisodeQueue ON + DownloadedEpisodes.EpisodeID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + AND EpisodeQueue.is_youtube = FALSE + WHERE DownloadedEpisodes.UserID = ? + + UNION ALL + + SELECT + Podcasts.PodcastID as podcastid, + Podcasts.PodcastName as podcastname, + Podcasts.ArtworkURL as artworkurl, + YouTubeVideos.VideoID as episodeid, + YouTubeVideos.VideoTitle as episodetitle, + YouTubeVideos.PublishedAt as episodepubdate, + YouTubeVideos.VideoDescription as episodedescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = 1 AND Podcasts.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = 1 THEN Podcasts.ArtworkURL + ELSE YouTubeVideos.ThumbnailURL + END as episodeartwork, + YouTubeVideos.VideoURL as episodeurl, + YouTubeVideos.Duration as episodeduration, + Podcasts.PodcastIndexID as podcastindexid, + Podcasts.WebsiteURL as websiteurl, + DownloadedVideos.DownloadedLocation as downloadedlocation, + YouTubeVideos.ListenPosition as listenduration, + YouTubeVideos.Completed as completed, + CASE WHEN SavedVideos.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL AND EpisodeQueue.is_youtube = TRUE THEN TRUE ELSE FALSE END AS queued, + TRUE as downloaded, + TRUE as is_youtube + FROM DownloadedVideos + INNER JOIN YouTubeVideos ON DownloadedVideos.VideoID = YouTubeVideos.VideoID + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN SavedVideos ON + DownloadedVideos.VideoID = SavedVideos.VideoID + AND SavedVideos.UserID = ? + LEFT JOIN EpisodeQueue ON + DownloadedVideos.VideoID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + AND EpisodeQueue.is_youtube = TRUE + WHERE DownloadedVideos.UserID = ? + ) combined + ORDER BY episodepubdate DESC" + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::handlers::podcasts::DownloadedEpisode { + podcastid: row.try_get("podcastid")?, + podcastname: row.try_get("podcastname")?, + artworkurl: row.try_get("artworkurl").ok(), + episodeid: row.try_get("episodeid")?, + episodetitle: row.try_get("episodetitle")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork").ok(), + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + podcastindexid: row.try_get("podcastindexid").ok(), + websiteurl: row.try_get("websiteurl").ok(), + downloadedlocation: row.try_get("downloadedlocation")?, + listenduration: row.try_get("listenduration").ok(), + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + }); + } + Ok(episodes) + } + } + } + + // Check if episode is already downloaded + pub async fn check_downloaded(&self, user_id: i32, episode_id: i32, is_youtube: bool) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let query = if is_youtube { + r#"SELECT COUNT(*) as count FROM "DownloadedVideos" WHERE userid = $1 AND videoid = $2"# + } else { + r#"SELECT COUNT(*) as count FROM "DownloadedEpisodes" WHERE userid = $1 AND episodeid = $2"# + }; + + let row = sqlx::query(query) + .bind(user_id) + .bind(episode_id) + .fetch_one(pool) + .await?; + + let count: i64 = row.try_get("count")?; + Ok(count > 0) + } + DatabasePool::MySQL(pool) => { + let query = if is_youtube { + "SELECT COUNT(*) as count FROM DownloadedVideos WHERE UserID = ? AND VideoID = ?" + } else { + "SELECT COUNT(*) as count FROM DownloadedEpisodes WHERE UserID = ? AND EpisodeID = ?" + }; + + let row = sqlx::query(query) + .bind(user_id) + .bind(episode_id) + .fetch_one(pool) + .await?; + + let count: i64 = row.try_get("count")?; + Ok(count > 0) + } + } + } + + // Delete downloaded episode + pub async fn delete_episode(&self, user_id: i32, episode_id: i32, is_youtube: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + if is_youtube { + let result = sqlx::query(r#"DELETE FROM "DownloadedVideos" WHERE userid = $1 AND videoid = $2"#) + .bind(user_id) + .bind(episode_id) + .execute(pool) + .await?; + + // Only update UserStats if a row was actually deleted + if result.rows_affected() > 0 { + sqlx::query(r#"UPDATE "UserStats" SET episodesdownloaded = episodesdownloaded - 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + } else { + let result = sqlx::query(r#"DELETE FROM "DownloadedEpisodes" WHERE userid = $1 AND episodeid = $2"#) + .bind(user_id) + .bind(episode_id) + .execute(pool) + .await?; + + // Only update UserStats if a row was actually deleted + if result.rows_affected() > 0 { + sqlx::query(r#"UPDATE "UserStats" SET episodesdownloaded = episodesdownloaded - 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + DatabasePool::MySQL(pool) => { + if is_youtube { + let result = sqlx::query("DELETE FROM DownloadedVideos WHERE UserID = ? AND VideoID = ?") + .bind(user_id) + .bind(episode_id) + .execute(pool) + .await?; + + // Only update UserStats if a row was actually deleted + if result.rows_affected() > 0 { + sqlx::query("UPDATE UserStats SET EpisodesDownloaded = EpisodesDownloaded - 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } else { + let result = sqlx::query("DELETE FROM DownloadedEpisodes WHERE UserID = ? AND EpisodeID = ?") + .bind(user_id) + .bind(episode_id) + .execute(pool) + .await?; + + // Only update UserStats if a row was actually deleted + if result.rows_affected() > 0 { + sqlx::query("UPDATE UserStats SET EpisodesDownloaded = EpisodesDownloaded - 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + } + } + + // Get download status for user + pub async fn get_download_status(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Get active download tasks + let rows = sqlx::query( + r#"SELECT taskid, tasktype, progress, status FROM "UserTasks" + WHERE userid = $1 AND tasktype IN ('download_episode', 'download_video', 'download_all_episodes', 'download_all_videos') + AND status IN ('pending', 'running')"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut tasks = Vec::new(); + for row in rows { + tasks.push(serde_json::json!({ + "task_id": row.try_get::("taskid")?, + "task_type": row.try_get::("tasktype")?, + "progress": row.try_get::, _>("progress")?, + "status": row.try_get::("status")? + })); + } + + Ok(serde_json::json!({ "active_downloads": tasks })) + } + DatabasePool::MySQL(pool) => { + // Get active download tasks + let rows = sqlx::query( + "SELECT TaskID, TaskType, Progress, Status FROM UserTasks + WHERE UserID = ? AND TaskType IN ('download_episode', 'download_video', 'download_all_episodes', 'download_all_videos') + AND Status IN ('pending', 'running')" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut tasks = Vec::new(); + for row in rows { + tasks.push(serde_json::json!({ + "task_id": row.try_get::("TaskID")?, + "task_type": row.try_get::("TaskType")?, + "progress": row.try_get::, _>("Progress")?, + "status": row.try_get::("Status")? + })); + } + + Ok(serde_json::json!({ "active_downloads": tasks })) + } + } + } + + // Get episodes for a specific podcast - matches Python return_podcast_episodes function + pub async fn return_podcast_episodes(&self, user_id: i32, podcast_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT + "Podcasts".podcastid, "Podcasts".podcastname, "Episodes".episodeid, + "Episodes".episodetitle, "Episodes".episodepubdate, "Episodes".episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl, "Episodes".episodeduration, + "Episodes".completed, + "UserEpisodeHistory".listenduration, CAST("Episodes".episodeid AS VARCHAR) AS guid + FROM "Episodes" + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON "Episodes".episodeid = "UserEpisodeHistory".episodeid AND "UserEpisodeHistory".userid = $1 + WHERE "Podcasts".podcastid = $2 AND "Podcasts".userid = $3 + ORDER BY "Episodes".episodepubdate DESC"# + ) + .bind(user_id) + .bind(podcast_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::handlers::podcasts::Episode { + podcastname: row.try_get("podcastname")?, + episodetitle: row.try_get("episodetitle")?, + episodepubdate: { + let naive = row.try_get::("episodepubdate")?; + naive.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + completed: row.try_get("completed")?, + saved: false, // Not included in this query + queued: false, // Not included in this query + downloaded: false, // Not included in this query + is_youtube: false, // This is for regular episodes + }); + } + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT + Podcasts.PodcastID, Podcasts.PodcastName, Episodes.EpisodeID, + Episodes.EpisodeTitle, Episodes.EpisodePubDate, Episodes.EpisodeDescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = TRUE AND Podcasts.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + ELSE Episodes.EpisodeArtwork + END as EpisodeArtwork, + Episodes.EpisodeURL, Episodes.EpisodeDuration, + Episodes.Completed, + UserEpisodeHistory.ListenDuration, CAST(Episodes.EpisodeID AS CHAR) AS guid + FROM Episodes + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN UserEpisodeHistory ON Episodes.EpisodeID = UserEpisodeHistory.EpisodeID AND UserEpisodeHistory.UserID = ? + WHERE Podcasts.PodcastID = ? AND Podcasts.UserID = ? + ORDER BY Episodes.EpisodePubDate DESC" + ) + .bind(user_id) + .bind(podcast_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::handlers::podcasts::Episode { + podcastname: row.try_get("PodcastName")?, + episodetitle: row.try_get("EpisodeTitle")?, + episodepubdate: { + let datetime = row.try_get::, _>("EpisodePubDate")?; + datetime.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + episodedescription: row.try_get("EpisodeDescription")?, + episodeartwork: row.try_get("EpisodeArtwork")?, + episodeurl: row.try_get("EpisodeURL")?, + episodeduration: row.try_get("EpisodeDuration")?, + listenduration: row.try_get("ListenDuration").ok(), + episodeid: row.try_get("EpisodeID")?, + completed: row.try_get::("Completed")? != 0, + saved: false, // Not included in this query + queued: false, // Not included in this query + downloaded: false, // Not included in this query + is_youtube: false, // This is for regular episodes + }); + } + Ok(episodes) + } + } + } + + // Get podcast ID from episode name and URL - matches Python get_podcast_id_from_episode_name function + pub async fn get_podcast_id_from_episode_name(&self, episode_name: &str, episode_url: &str, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + // Use UNION query exactly like Python version + let row = sqlx::query( + r#"SELECT podcast_id FROM ( + SELECT "Episodes".podcastid as podcast_id + FROM "Episodes" + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + WHERE "Episodes".episodetitle = $1 + AND "Episodes".episodeurl = $2 + AND "Podcasts".userid = $3 + + UNION + + SELECT "YouTubeVideos".podcastid as podcast_id + FROM "YouTubeVideos" + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + WHERE "YouTubeVideos".videotitle = $4 + AND "YouTubeVideos".videourl = $5 + AND "Podcasts".userid = $6 + ) combined_results + LIMIT 1"# + ) + .bind(episode_name) + .bind(episode_url) + .bind(user_id) + .bind(episode_name) + .bind(episode_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("podcast_id")?)) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + // Use UNION query exactly like Python version + let row = sqlx::query( + "SELECT podcast_id FROM ( + SELECT Episodes.PodcastID as podcast_id + FROM Episodes + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + WHERE Episodes.EpisodeTitle = ? + AND Episodes.EpisodeURL = ? + AND Podcasts.UserID = ? + + UNION + + SELECT YouTubeVideos.PodcastID as podcast_id + FROM YouTubeVideos + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + WHERE YouTubeVideos.VideoTitle = ? + AND YouTubeVideos.VideoURL = ? + AND Podcasts.UserID = ? + ) combined_results + LIMIT 1" + ) + .bind(episode_name) + .bind(episode_url) + .bind(user_id) + .bind(episode_name) + .bind(episode_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("podcast_id")?)) + } else { + Ok(None) + } + } + } + } + + // Get episode metadata - matches Python get_episode_metadata function exactly + pub async fn get_episode_metadata(&self, episode_id: i32, user_id: i32, person_episode: bool, is_youtube: bool) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + if is_youtube { + // Query for YouTube videos + let row = sqlx::query( + r#"SELECT "Podcasts".podcastid, "Podcasts".podcastindexid, "Podcasts".feedurl, + "Podcasts".podcastname, "Podcasts".artworkurl, + "YouTubeVideos".videotitle as episodetitle, + "YouTubeVideos".publishedat as episodepubdate, + "YouTubeVideos".videodescription as episodedescription, + "YouTubeVideos".thumbnailurl as episodeartwork, + "YouTubeVideos".videourl as episodeurl, + "YouTubeVideos".duration as episodeduration, + "YouTubeVideos".videoid as episodeid, + "YouTubeVideos".listenposition as listenduration, + "YouTubeVideos".completed, + CASE WHEN q.episodeid IS NOT NULL THEN true ELSE false END as is_queued, + CASE WHEN s.episodeid IS NOT NULL THEN true ELSE false END as is_saved, + CASE WHEN d.episodeid IS NOT NULL THEN true ELSE false END as is_downloaded, + TRUE::boolean as is_youtube + FROM "YouTubeVideos" + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + LEFT JOIN "EpisodeQueue" q ON "YouTubeVideos".videoid = q.episodeid AND q.userid = $1 + LEFT JOIN "SavedEpisodes" s ON "YouTubeVideos".videoid = s.episodeid AND s.userid = $1 + LEFT JOIN "DownloadedEpisodes" d ON "YouTubeVideos".videoid = d.episodeid AND d.userid = $1 + WHERE "YouTubeVideos".videoid = $2 AND "Podcasts".userid = $1"# + ) + .bind(user_id) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let naive_date = row.try_get::("episodepubdate")?; + let episode_pubdate = naive_date.format("%Y-%m-%dT%H:%M:%S").to_string(); + + return Ok(serde_json::json!({ + "podcastid": row.try_get::("podcastid")?, + "podcastindexid": row.try_get::, _>("podcastindexid")?, + "feedurl": row.try_get::("feedurl").unwrap_or_default(), + "podcastname": row.try_get::("podcastname")?, + "artworkurl": row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_default(), + "episodetitle": row.try_get::("episodetitle")?, + "episodepubdate": episode_pubdate, + "episodedescription": row.try_get::("episodedescription")?, + "episodeartwork": row.try_get::("episodeartwork")?, + "episodeurl": row.try_get::("episodeurl")?, + "episodeduration": row.try_get::("episodeduration")?, + "episodeid": row.try_get::("episodeid")?, + "listenduration": row.try_get::, _>("listenduration")?, + "completed": row.try_get::("completed")?, + "is_queued": row.try_get::("is_queued")?, + "is_saved": row.try_get::("is_saved")?, + "is_downloaded": row.try_get::("is_downloaded")?, + "is_youtube": row.try_get::("is_youtube")?, + })); + } + } else if person_episode { + // Query for person episodes - matches Python implementation + let row = sqlx::query( + r#"SELECT + p.podcastid, + p.podcastindexid, + p.feedurl, + p.podcastname, + p.artworkurl, + pe.episodetitle, + pe.episodepubdate, + pe.episodedescription, + pe.episodeartwork, + pe.episodeurl, + pe.episodeduration, + pe.episodeid, + COALESCE(e.episodeid, pe.episodeid) as real_episode_id, + CASE WHEN q.episodeid IS NOT NULL THEN true ELSE false END as is_queued, + CASE WHEN s.episodeid IS NOT NULL THEN true ELSE false END as is_saved, + CASE WHEN d.episodeid IS NOT NULL THEN true ELSE false END as is_downloaded, + FALSE::boolean as is_youtube + FROM "PeopleEpisodes" pe + INNER JOIN "Podcasts" p ON pe.podcastid = p.podcastid + LEFT JOIN "Episodes" e ON (pe.episodetitle = e.episodetitle AND pe.episodeurl = e.episodeurl) + LEFT JOIN "EpisodeQueue" q ON COALESCE(e.episodeid, pe.episodeid) = q.episodeid AND q.userid = $1 + LEFT JOIN "SavedEpisodes" s ON COALESCE(e.episodeid, pe.episodeid) = s.episodeid AND s.userid = $1 + LEFT JOIN "DownloadedEpisodes" d ON COALESCE(e.episodeid, pe.episodeid) = d.episodeid AND d.userid = $1 + WHERE pe.episodeid = $2"# + ) + .bind(user_id) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let naive_date = row.try_get::("episodepubdate")?; + let episode_pubdate = naive_date.format("%Y-%m-%dT%H:%M:%S").to_string(); + let real_episode_id: i32 = row.try_get("real_episode_id")?; + + // Get listen history using the real episode ID - matches Python implementation + let listen_history = sqlx::query( + r#"SELECT "UserEpisodeHistory".listenduration, "Episodes".completed + FROM "Episodes" + LEFT JOIN "UserEpisodeHistory" ON + "Episodes".episodeid = "UserEpisodeHistory".episodeid + AND "UserEpisodeHistory".userid = $2 + WHERE "Episodes".episodeid = $1"# + ) + .bind(real_episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + let (listenduration, completed) = if let Some(history) = listen_history { + ( + history.try_get::, _>("listenduration")?, + history.try_get::("completed")? + ) + } else { + (None, false) + }; + + return Ok(serde_json::json!({ + "podcastid": row.try_get::("podcastid")?, + "podcastindexid": row.try_get::, _>("podcastindexid")?, + "feedurl": row.try_get::("feedurl").unwrap_or_default(), + "podcastname": row.try_get::("podcastname")?, + "artworkurl": row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_default(), + "episodetitle": row.try_get::("episodetitle")?, + "episodepubdate": episode_pubdate, + "episodedescription": row.try_get::("episodedescription")?, + "episodeartwork": row.try_get::("episodeartwork")?, + "episodeurl": row.try_get::("episodeurl")?, + "episodeduration": row.try_get::("episodeduration")?, + "episodeid": real_episode_id, // Return the real episode ID, not person episode ID + "listenduration": listenduration, + "completed": completed, + "is_queued": row.try_get::("is_queued")?, + "is_saved": row.try_get::("is_saved")?, + "is_downloaded": row.try_get::("is_downloaded")?, + "is_youtube": row.try_get::("is_youtube")?, + })); + } + } + + // Query for regular episodes + let row = sqlx::query( + r#"SELECT "Podcasts".podcastid, "Podcasts".podcastindexid, "Podcasts".feedurl, + "Podcasts".podcastname, "Podcasts".artworkurl, + "Episodes".episodetitle, + "Episodes".episodepubdate, + "Episodes".episodedescription, + "Episodes".episodeartwork, + "Episodes".episodeurl, + "Episodes".episodeduration, + "Episodes".episodeid, + "UserEpisodeHistory".listenduration, + "Episodes".completed, + CASE WHEN q.episodeid IS NOT NULL THEN true ELSE false END as is_queued, + CASE WHEN s.episodeid IS NOT NULL THEN true ELSE false END as is_saved, + CASE WHEN d.episodeid IS NOT NULL THEN true ELSE false END as is_downloaded, + FALSE::boolean as is_youtube + FROM "Episodes" + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "UserEpisodeHistory" ON "Episodes".episodeid = "UserEpisodeHistory".episodeid AND "UserEpisodeHistory".userid = $1 + LEFT JOIN "EpisodeQueue" q ON "Episodes".episodeid = q.episodeid AND q.userid = $1 + LEFT JOIN "SavedEpisodes" s ON "Episodes".episodeid = s.episodeid AND s.userid = $1 + LEFT JOIN "DownloadedEpisodes" d ON "Episodes".episodeid = d.episodeid AND d.userid = $1 + WHERE "Episodes".episodeid = $2 AND "Podcasts".userid = $1"# + ) + .bind(user_id) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let naive_date = row.try_get::("episodepubdate")?; + let episode_pubdate = naive_date.format("%Y-%m-%dT%H:%M:%S").to_string(); + + Ok(serde_json::json!({ + "podcastid": row.try_get::("podcastid")?, + "podcastindexid": row.try_get::, _>("podcastindexid")?, + "feedurl": row.try_get::("feedurl").unwrap_or_default(), + "podcastname": row.try_get::("podcastname")?, + "artworkurl": row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_default(), + "episodetitle": row.try_get::("episodetitle")?, + "episodepubdate": episode_pubdate, + "episodedescription": row.try_get::("episodedescription")?, + "episodeartwork": row.try_get::("episodeartwork")?, + "episodeurl": row.try_get::("episodeurl")?, + "episodeduration": row.try_get::("episodeduration")?, + "episodeid": row.try_get::("episodeid")?, + "listenduration": row.try_get::, _>("listenduration")?, + "completed": row.try_get::("completed")?, + "is_queued": row.try_get::("is_queued")?, + "is_saved": row.try_get::("is_saved")?, + "is_downloaded": row.try_get::("is_downloaded")?, + "is_youtube": row.try_get::("is_youtube")?, + })) + } else { + Err(AppError::not_found("Episode not found")) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + r#"SELECT + Podcasts.PodcastID, + Podcasts.PodcastIndexID, + Podcasts.FeedURL, + Podcasts.PodcastName, + Podcasts.ArtworkURL, + Episodes.EpisodeTitle, + Episodes.EpisodePubDate, + Episodes.EpisodeDescription, + Episodes.EpisodeArtwork, + Episodes.EpisodeURL, + Episodes.EpisodeDuration, + Episodes.EpisodeID, + UserEpisodeHistory.ListenDuration, + Episodes.Completed, + CASE WHEN q.EpisodeID IS NOT NULL THEN 1 ELSE 0 END as is_queued, + CASE WHEN s.EpisodeID IS NOT NULL THEN 1 ELSE 0 END as is_saved, + CASE WHEN d.EpisodeID IS NOT NULL THEN 1 ELSE 0 END as is_downloaded, + 0 as is_youtube + FROM Episodes + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN UserEpisodeHistory ON Episodes.EpisodeID = UserEpisodeHistory.EpisodeID AND UserEpisodeHistory.UserID = ? + LEFT JOIN EpisodeQueue q ON Episodes.EpisodeID = q.EpisodeID AND q.UserID = ? + LEFT JOIN SavedEpisodes s ON Episodes.EpisodeID = s.EpisodeID AND s.UserID = ? + LEFT JOIN DownloadedEpisodes d ON Episodes.EpisodeID = d.EpisodeID AND d.UserID = ? + WHERE Episodes.EpisodeID = ? AND Podcasts.UserID = ?"# + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let datetime = row.try_get::, _>("EpisodePubDate")?; + + Ok(serde_json::json!({ + "podcastid": row.try_get::("PodcastID")?, + "podcastindexid": row.try_get::, _>("PodcastIndexID")?, + "feedurl": row.try_get::("FeedURL").unwrap_or_default(), + "podcastname": row.try_get::("PodcastName")?, + "artworkurl": row.try_get::, _>("ArtworkURL").unwrap_or_default().unwrap_or_default(), + "episodetitle": row.try_get::("EpisodeTitle")?, + "episodepubdate": datetime.to_rfc3339(), + "episodedescription": row.try_get::("EpisodeDescription")?, + "episodeartwork": row.try_get::("EpisodeArtwork")?, + "episodeurl": row.try_get::("EpisodeURL")?, + "episodeduration": row.try_get::("EpisodeDuration")?, + "episodeid": row.try_get::("EpisodeID")?, + "listenduration": row.try_get::, _>("ListenDuration")?, + "completed": row.try_get::("Completed")? != 0, + "is_queued": row.try_get::("is_queued")? != 0, + "is_saved": row.try_get::("is_saved")? != 0, + "is_downloaded": row.try_get::("is_downloaded")? != 0, + "is_youtube": row.try_get::("is_youtube")? != 0, + })) + } else { + Err(AppError::not_found("Episode not found")) + } + } + } + } + + // Fetch podcasting 2.0 data for episode + pub async fn fetch_podcasting_2_data(&self, episode_id: i32, user_id: i32) -> AppResult { + // Get episode metadata and podcast details + let episode_metadata = self.get_episode_metadata(episode_id, user_id, false, false).await?; + + let episode_url = episode_metadata["episodeurl"].as_str() + .ok_or_else(|| AppError::internal("Episode URL not found"))?; + let podcast_id = episode_metadata["podcastid"].as_i64() + .ok_or_else(|| AppError::internal("Podcast ID not found"))? as i32; + + let podcast_details = self.get_podcast_details(user_id, podcast_id).await?; + + let feed_url = podcast_details["feedurl"].as_str() + .ok_or_else(|| AppError::internal("Feed URL not found"))?; + + // Get authentication if available + let username = podcast_details.get("username").and_then(|v| v.as_str()); + let password = podcast_details.get("password").and_then(|v| v.as_str()); + + // Fetch the RSS feed with authentication if needed + let feed_content = self.try_fetch_feed(feed_url, username, password).await?; + + // Get podcast index ID for PodPeople API fallback + let podcast_index_id = self.get_podcast_index_id(podcast_id).await.ok().flatten(); + + // Parse podcasting 2.0 features + let chapters_url = self.parse_chapters(&feed_content, episode_url)?; + let transcripts = self.parse_transcripts(&feed_content, episode_url)?; + let people = self.parse_people(&feed_content, Some(episode_url), podcast_index_id).await?; + + // Fetch chapters data if URL is available + let mut chapters_data = serde_json::Value::Array(vec![]); + if let Some(url) = chapters_url { + if let Ok(chapters) = self.fetch_chapters_data(&url, feed_url, username, password).await { + chapters_data = chapters; + } + } + + Ok(serde_json::json!({ + "chapters": chapters_data, + "transcripts": transcripts, + "people": people + })) + } + + // Parse chapters from RSS feed content - matches Python parse_chapters function + fn parse_chapters(&self, feed_content: &str, episode_url: &str) -> AppResult> { + // Simple string-based parsing to match the Python implementation exactly + let lines: Vec<&str> = feed_content.lines().collect(); + let mut in_item = false; + let mut found_episode = false; + + for line in lines { + let trimmed = line.trim(); + + if trimmed.starts_with("") || trimmed.starts_with("") { + if found_episode { + break; // Exit after processing the matching episode + } + in_item = false; + found_episode = false; + } else if in_item && trimmed.contains(" AppResult { + // Simple string-based parsing to match the Python implementation exactly + let lines: Vec<&str> = feed_content.lines().collect(); + let mut in_item = false; + let mut found_episode = false; + let mut transcripts = Vec::new(); + + for line in lines { + let trimmed = line.trim(); + + if trimmed.starts_with("") || trimmed.starts_with("") { + if found_episode { + break; // Exit after processing the matching episode + } + in_item = false; + found_episode = false; + } else if in_item && trimmed.contains(", _podcast_index_id: Option) -> AppResult { + use std::collections::HashSet; + + // Simple string-based parsing to match the Python implementation exactly + let lines: Vec<&str> = feed_content.lines().collect(); + let mut in_item = false; + let mut in_channel = false; + let mut found_episode = false; + let mut people = Vec::new(); + let mut seen_people = HashSet::new(); + + for line in lines { + let trimmed = line.trim(); + + if trimmed.starts_with("") || trimmed.starts_with("") { + in_channel = false; + } else if trimmed.starts_with("") || trimmed.starts_with("") { + if found_episode && !people.is_empty() { + break; // Exit after processing the matching episode + } + in_item = false; + found_episode = false; + } else if in_item && trimmed.contains("") { + if let Some(end) = trimmed.rfind(", password: Option<&str>) -> AppResult { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + + let mut request = client.get(chapters_url) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Referer", feed_url); + + // Use same auth for chapters if it's from the same domain + if chapters_url.starts_with(feed_url) { + if let (Some(user), Some(pass)) = (username, password) { + request = request.basic_auth(user, Some(pass)); + } + } + + let response = request.send().await?; + let json_data: serde_json::Value = response.json().await?; + + Ok(json_data.get("chapters").unwrap_or(&serde_json::Value::Array(vec![])).clone()) + } + + // Get auto download status for user + pub async fn get_auto_download_status(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT auto_download_episodes FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get::("auto_download_episodes")?) + } else { + Ok(false) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT AutoDownloadEpisodes FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get::("AutoDownloadEpisodes")?) + } else { + Ok(false) + } + } + } + } + + + // Fetch podcasting 2.0 podcast data + pub async fn fetch_podcasting_2_pod_data(&self, podcast_id: i32, user_id: i32) -> AppResult { + // Get podcast details to fetch the feed URL and authentication + let podcast_details = self.get_podcast_details(user_id, podcast_id).await?; + + let feed_url = podcast_details["feedurl"].as_str() + .ok_or_else(|| AppError::internal("Feed URL not found"))?; + + // Get authentication if available + let username = podcast_details.get("username").and_then(|v| v.as_str()); + let password = podcast_details.get("password").and_then(|v| v.as_str()); + + // Fetch the RSS feed with authentication if needed + let feed_content = self.try_fetch_feed(feed_url, username, password).await?; + + // Get podcast index ID for PodPeople API fallback + let podcast_index_id = self.get_podcast_index_id(podcast_id).await.ok().flatten(); + + // Parse podcasting 2.0 features at the podcast level + let people = self.parse_podcast_people(&feed_content, podcast_index_id).await?; + let podroll = self.parse_podroll(&feed_content)?; + let funding = self.parse_funding(&feed_content)?; + let value = self.parse_value(&feed_content)?; + + Ok(serde_json::json!({ + "people": people, + "podroll": podroll, + "funding": funding, + "value": value + })) + } + + // Parse podcast people/hosts from RSS feed content - matches Python get_podcast_hosts function + async fn parse_podcast_people(&self, feed_content: &str, podcast_index_id: Option) -> AppResult { + use std::collections::HashSet; + + // Simple string-based parsing to match the Python implementation exactly + let lines: Vec<&str> = feed_content.lines().collect(); + let mut in_channel = false; + let mut people = Vec::new(); + let mut seen_people = HashSet::new(); + + for line in lines { + let trimmed = line.trim(); + + if trimmed.starts_with("") || trimmed.starts_with("") { + in_channel = false; + } else if in_channel && (trimmed.contains("podcast:person") || trimmed.contains(":person")) { + // Extract person data from the line + let mut name = String::new(); + let mut role = None; + let mut group = None; + let mut img = None; + let mut href = None; + + // Extract name (text between tags) + if let Some(start) = trimmed.find(">") { + if let Some(end) = trimmed.rfind(" AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT podcastindexid FROM "Podcasts" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("podcastindexid").ok()) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT PodcastIndexID FROM Podcasts WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("PodcastIndexID").ok()) + } else { + Ok(None) + } + } + } + } + + // Get hosts from PodPeople API - matches Python get_podpeople_hosts function + async fn get_podpeople_hosts(&self, podcast_index_id: i32) -> AppResult> { + use reqwest; + + // Get PodPeople API URL from environment, default to pinepods.online + let people_url = std::env::var("PEOPLE_API_URL") + .unwrap_or_else(|_| "https://people.pinepods.online".to_string()); + + let client = reqwest::Client::new(); + let url = format!("{}/api/hosts/{}", people_url, podcast_index_id); + + // Make request with 5 second timeout (matches Python) + let response = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.get(&url).send() + ).await; + + match response { + Ok(Ok(resp)) => { + if resp.status().is_success() { + if let Ok(hosts_data) = resp.json::().await { + if let Some(hosts_array) = hosts_data.as_array() { + let mut people = Vec::new(); + + for host in hosts_array { + let name = host.get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Host") + .to_string(); + + let role = host.get("role") + .and_then(|v| v.as_str()) + .unwrap_or("Host") + .to_string(); + + let img = host.get("img") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let href = host.get("link") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let description = host.get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + people.push(serde_json::json!({ + "name": name, + "role": role, + "group": null, + "img": img, + "href": href, + "description": description + })); + } + + return Ok(people); + } + } + } + } + Ok(Err(e)) => { + tracing::error!("HTTP error fetching PodPeople hosts: {}", e); + } + Err(_) => { + tracing::error!("Timeout fetching PodPeople hosts for podcast {}", podcast_index_id); + } + } + + // Return empty vector on any error (matches Python behavior) + Ok(vec![]) + } + + // Parse podroll from RSS feed content - matches Python parse_podroll function + fn parse_podroll(&self, feed_content: &str) -> AppResult { + let lines: Vec<&str> = feed_content.lines().collect(); + let mut in_channel = false; + let mut in_podroll = false; + let mut podroll = Vec::new(); + + for line in lines { + let trimmed = line.trim(); + + if trimmed.starts_with("") || trimmed.starts_with("") { + in_channel = false; + in_podroll = false; + } else if in_channel && (trimmed.contains("podcast:podroll") || trimmed.contains(":podroll")) { + if trimmed.starts_with("<") && !trimmed.contains(" AppResult { + let lines: Vec<&str> = feed_content.lines().collect(); + let mut in_channel = false; + let mut funding = Vec::new(); + + for line in lines { + let trimmed = line.trim(); + + if trimmed.starts_with("") || trimmed.starts_with("") { + in_channel = false; + } else if in_channel && (trimmed.contains("podcast:funding") || trimmed.contains(":funding")) { + let mut url = None; + let mut description = String::new(); + + // Extract URL attribute + if let Some(start) = trimmed.find("url=\"") { + let start_idx = start + 5; + if let Some(end) = trimmed[start_idx..].find("\"") { + url = Some(trimmed[start_idx..start_idx + end].to_string()); + } + } + + // Extract description (text between tags) + if let Some(start) = trimmed.find(">") { + if let Some(end) = trimmed.rfind(" AppResult { + let lines: Vec<&str> = feed_content.lines().collect(); + let mut in_channel = false; + let mut in_value = false; + let mut value_blocks = Vec::new(); + let mut current_value_block = None; + let mut recipients = Vec::new(); + + for line in lines { + let trimmed = line.trim(); + + if trimmed.starts_with("") || trimmed.starts_with("") { + in_channel = false; + in_value = false; + } else if in_channel && (trimmed.contains("podcast:value") || trimmed.contains(":value")) { + if trimmed.starts_with("<") && !trimmed.contains(" AppResult { + // Always return false - web keys are deprecated in favor of background_tasks user (ID 1) + Ok(false) + } + + // Set podcast playback speed - matches Python set_podcast_playback_speed function + pub async fn set_podcast_playback_speed(&self, user_id: i32, podcast_id: i32, playback_speed: f64) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Podcasts" SET playbackspeed = $1, playbackspeedcustomized = TRUE WHERE podcastid = $2 AND userid = $3"#) + .bind(playback_speed) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Podcasts SET PlaybackSpeed = ?, PlaybackSpeedCustomized = TRUE WHERE PodcastID = ? AND UserID = ?") + .bind(playback_speed) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Enable/disable auto download for podcast - matches Python enable_auto_download function + pub async fn enable_auto_download(&self, podcast_id: i32, auto_download: bool, user_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Podcasts" SET autodownload = $1 WHERE podcastid = $2 AND userid = $3"#) + .bind(auto_download) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Podcasts SET AutoDownload = ? WHERE PodcastID = ? AND UserID = ?") + .bind(auto_download) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Toggle podcast notifications - matches Python toggle_podcast_notifications function + pub async fn toggle_podcast_notifications(&self, user_id: i32, podcast_id: i32, enabled: bool) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // First verify user owns the podcast + let row = sqlx::query(r#"SELECT 1 FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if row.is_none() { + return Ok(false); + } + + // Update notifications setting + let result = sqlx::query(r#"UPDATE "Podcasts" SET notificationsenabled = $1 WHERE podcastid = $2 AND userid = $3"#) + .bind(enabled) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + // First verify user owns the podcast + let row = sqlx::query("SELECT 1 FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if row.is_none() { + return Ok(false); + } + + // Update notifications setting + let result = sqlx::query("UPDATE Podcasts SET NotificationsEnabled = ? WHERE PodcastID = ? AND UserID = ?") + .bind(enabled) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Adjust skip times for podcast - matches Python adjust_skip_times function + pub async fn adjust_skip_times(&self, podcast_id: i32, start_skip: i32, end_skip: i32, user_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Podcasts" SET startskip = $1, endskip = $2 WHERE podcastid = $3 AND userid = $4"#) + .bind(start_skip) + .bind(end_skip) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Podcasts SET StartSkip = ?, EndSkip = ? WHERE PodcastID = ? AND UserID = ?") + .bind(start_skip) + .bind(end_skip) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Remove category from podcast - matches Python remove_category function + pub async fn remove_category(&self, podcast_id: i32, user_id: i32, category: &str) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // Fetch current categories + let row = sqlx::query(r#"SELECT categories FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let current_categories_str: String = row.try_get("categories").unwrap_or_default(); + let mut categories = self.parse_categories_json(¤t_categories_str).unwrap_or_default(); + + // Remove the category by value (find and remove the key with this value) + categories.retain(|_key, value| value != category); + + // Convert back to JSON string + let new_categories_json = serde_json::to_string(&categories) + .unwrap_or_else(|_| "{}".to_string()); + + // Update with new categories + sqlx::query(r#"UPDATE "Podcasts" SET categories = $1 WHERE podcastid = $2 AND userid = $3"#) + .bind(&new_categories_json) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + DatabasePool::MySQL(pool) => { + // Fetch current categories + let row = sqlx::query("SELECT Categories FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let current_categories_str: String = row.try_get("Categories").unwrap_or_default(); + let mut categories = self.parse_categories_json(¤t_categories_str).unwrap_or_default(); + + // Remove the category by value (find and remove the key with this value) + categories.retain(|_key, value| value != category); + + // Convert back to JSON string + let new_categories_json = serde_json::to_string(&categories) + .unwrap_or_else(|_| "{}".to_string()); + + // Update with new categories + sqlx::query("UPDATE Podcasts SET Categories = ? WHERE PodcastID = ? AND UserID = ?") + .bind(&new_categories_json) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + } + Ok(()) + } + + // Add category to podcast - matches Python add_category function + pub async fn add_category(&self, podcast_id: i32, user_id: i32, category: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Fetch current categories + let row = sqlx::query(r#"SELECT categories FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let current_categories_str: String = row.try_get("categories").unwrap_or_default(); + let mut categories = self.parse_categories_json(¤t_categories_str).unwrap_or_default(); + + // Check if category already exists + if categories.values().any(|v| v == category) { + return Ok("Category already exists.".to_string()); + } + + // Add new category with a generated key (use the next available number) + let next_key = if categories.is_empty() { + "1".to_string() + } else { + let max_key = categories.keys() + .filter_map(|k| k.parse::().ok()) + .max() + .unwrap_or(0); + (max_key + 1).to_string() + }; + + categories.insert(next_key, category.to_string()); + + // Convert back to JSON string + let new_categories_json = serde_json::to_string(&categories) + .unwrap_or_else(|_| "{}".to_string()); + + // Update with new categories + sqlx::query(r#"UPDATE "Podcasts" SET categories = $1 WHERE podcastid = $2 AND userid = $3"#) + .bind(&new_categories_json) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok("Category added!".to_string()) + } else { + Err(AppError::not_found("Podcast not found")) + } + } + DatabasePool::MySQL(pool) => { + // Fetch current categories + let row = sqlx::query("SELECT Categories FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let current_categories_str: String = row.try_get("Categories").unwrap_or_default(); + let mut categories = self.parse_categories_json(¤t_categories_str).unwrap_or_default(); + + // Check if category already exists + if categories.values().any(|v| v == category) { + return Ok("Category already exists.".to_string()); + } + + // Add new category with a generated key (use the next available number) + let next_key = if categories.is_empty() { + "1".to_string() + } else { + let max_key = categories.keys() + .filter_map(|k| k.parse::().ok()) + .max() + .unwrap_or(0); + (max_key + 1).to_string() + }; + + categories.insert(next_key, category.to_string()); + + // Convert back to JSON string + let new_categories_json = serde_json::to_string(&categories) + .unwrap_or_else(|_| "{}".to_string()); + + // Update with new categories + sqlx::query("UPDATE Podcasts SET Categories = ? WHERE PodcastID = ? AND UserID = ?") + .bind(&new_categories_json) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok("Category added!".to_string()) + } else { + Err(AppError::not_found("Podcast not found")) + } + } + } + } + + // Call get auto download status - matches Python call_get_auto_download_status function exactly + pub async fn call_get_auto_download_status(&self, podcast_id: i32, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT autodownload FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let result: Option = row.try_get("autodownload")?; + Ok(result) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT AutoDownload FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let result: Option = row.try_get("AutoDownload")?; + Ok(result) + } else { + Ok(None) + } + } + } + } + + // Get feed cutoff days - matches Python get_feed_cutoff_days function exactly + pub async fn get_feed_cutoff_days(&self, podcast_id: i32, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT feedcutoffdays FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let result: Option = row.try_get("feedcutoffdays")?; + Ok(result.or(Some(365))) // Default to 365 if null + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT FeedCutoffDays FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let result: Option = row.try_get("FeedCutoffDays")?; + Ok(result.or(Some(365))) // Default to 365 if null + } else { + Ok(None) + } + } + } + } + + // Get play episode details - matches Python get_play_episode_details function exactly + pub async fn get_play_episode_details(&self, user_id: i32, podcast_id: i32, _is_youtube: bool) -> AppResult<(f64, i32, i32)> { + match self { + DatabasePool::Postgres(pool) => { + // First get user's default playback speed + let user_row = sqlx::query(r#"SELECT playbackspeed FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + let user_playback_speed = if let Some(row) = user_row { + if let Ok(speed) = row.try_get::, _>("playbackspeed") { + speed.map(|s| s.to_f64().unwrap_or(1.0)).unwrap_or(1.0) + } else { + 1.0 + } + } else { + 1.0 + }; + + // Now get podcast-specific settings + let podcast_row = sqlx::query(r#" + SELECT playbackspeed, playbackspeedcustomized, startskip, endskip + FROM "Podcasts" + WHERE podcastid = $1 AND userid = $2 + "#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = podcast_row { + let playback_speed_customized: Option = row.try_get("playbackspeedcustomized")?; + let podcast_playback_speed: Option = if let Ok(speed) = row.try_get::, _>("playbackspeed") { + speed.map(|s| s.to_f64().unwrap_or(1.0)) + } else { + None + }; + let start_skip: Option = row.try_get("startskip")?; + let end_skip: Option = row.try_get("endskip")?; + + let final_playback_speed = if playback_speed_customized.unwrap_or(false) { + podcast_playback_speed.unwrap_or(user_playback_speed) + } else { + user_playback_speed + }; + + Ok((final_playback_speed, start_skip.unwrap_or(0), end_skip.unwrap_or(0))) + } else { + Ok((user_playback_speed, 0, 0)) + } + } + DatabasePool::MySQL(pool) => { + // First get user's default playback speed + let user_row = sqlx::query("SELECT PlaybackSpeed FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + let user_playback_speed = if let Some(row) = user_row { + if let Ok(speed) = row.try_get::, _>("PlaybackSpeed") { + speed.map(|s| s.to_f64().unwrap_or(1.0)).unwrap_or(1.0) + } else { + 1.0 + } + } else { + 1.0 + }; + + // Now get podcast-specific settings + let podcast_row = sqlx::query(" + SELECT PlaybackSpeed, PlaybackSpeedCustomized, StartSkip, EndSkip + FROM Podcasts + WHERE PodcastID = ? AND UserID = ? + ") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = podcast_row { + let playback_speed_customized: Option = row.try_get("PlaybackSpeedCustomized")?; + let podcast_playback_speed: Option = if let Ok(speed) = row.try_get::, _>("PlaybackSpeed") { + speed.map(|s| s.to_f64().unwrap_or(1.0)) + } else { + None + }; + let start_skip: Option = row.try_get("StartSkip")?; + let end_skip: Option = row.try_get("EndSkip")?; + + let final_playback_speed = if playback_speed_customized.unwrap_or(false) { + podcast_playback_speed.unwrap_or(user_playback_speed) + } else { + user_playback_speed + }; + + Ok((final_playback_speed, start_skip.unwrap_or(0), end_skip.unwrap_or(0))) + } else { + Ok((user_playback_speed, 0, 0)) + } + } + } + } + + // Get podcast episodes with capitalized field names for frontend compatibility + pub async fn return_podcast_episodes_capitalized(&self, user_id: i32, podcast_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT * FROM ( + SELECT + "Podcasts".podcastname as podcastname, + "Episodes".episodetitle as "Episodetitle", + "Episodes".episodepubdate as "Episodepubdate", + "Episodes".episodedescription as "Episodedescription", + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as "Episodeartwork", + "Episodes".episodeurl as "Episodeurl", + "Episodes".episodeduration as "Episodeduration", + "UserEpisodeHistory".listenduration as "Listenduration", + "Episodes".episodeid as "Episodeid", + "Episodes".completed as "Completed", + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube + FROM "Episodes" + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON + "Episodes".episodeid = "UserEpisodeHistory".episodeid + AND "UserEpisodeHistory".userid = $1 + LEFT JOIN "SavedEpisodes" ON + "Episodes".episodeid = "SavedEpisodes".episodeid + AND "SavedEpisodes".userid = $1 + LEFT JOIN "EpisodeQueue" ON + "Episodes".episodeid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $1 + AND "EpisodeQueue".is_youtube = FALSE + LEFT JOIN "DownloadedEpisodes" ON + "Episodes".episodeid = "DownloadedEpisodes".episodeid + AND "DownloadedEpisodes".userid = $1 + WHERE "Podcasts".userid = $1 AND ( + "Podcasts".podcastid = $2 OR + "Podcasts".podcastid IN ( + SELECT jsonb_array_elements_text(COALESCE(mergedpodcastids::jsonb, '[]'::jsonb))::int + FROM "Podcasts" p2 + WHERE p2.podcastid = $2 AND p2.userid = $1 + ) + ) + + UNION ALL + + SELECT + "Podcasts".podcastname as podcastname, + "YouTubeVideos".videotitle as "Episodetitle", + "YouTubeVideos".publishedat as "Episodepubdate", + "YouTubeVideos".videodescription as "Episodedescription", + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "YouTubeVideos".thumbnailurl + END as "Episodeartwork", + "YouTubeVideos".videourl as "Episodeurl", + "YouTubeVideos".duration as "Episodeduration", + "YouTubeVideos".listenposition as "Listenduration", + "YouTubeVideos".videoid as "Episodeid", + "YouTubeVideos".completed as "Completed", + CASE WHEN "SavedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL AND "EpisodeQueue".is_youtube = TRUE THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube + FROM "YouTubeVideos" + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "SavedVideos" ON + "YouTubeVideos".videoid = "SavedVideos".videoid + AND "SavedVideos".userid = $1 + LEFT JOIN "EpisodeQueue" ON + "YouTubeVideos".videoid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $1 + AND "EpisodeQueue".is_youtube = TRUE + LEFT JOIN "DownloadedVideos" ON + "YouTubeVideos".videoid = "DownloadedVideos".videoid + AND "DownloadedVideos".userid = $1 + WHERE "Podcasts".userid = $1 AND ( + "Podcasts".podcastid = $2 OR + "Podcasts".podcastid IN ( + SELECT jsonb_array_elements_text(COALESCE(mergedpodcastids::jsonb, '[]'::jsonb))::int + FROM "Podcasts" p2 + WHERE p2.podcastid = $2 AND p2.userid = $1 + ) + ) + ) combined + ORDER BY "Episodepubdate" DESC"# + ) + .bind(user_id) + .bind(podcast_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + let naive_date = row.try_get::("Episodepubdate")?; + let episodepubdate = naive_date.format("%Y-%m-%dT%H:%M:%S").to_string(); + + episodes.push(crate::handlers::podcasts::PodcastEpisode { + podcastname: row.try_get("podcastname")?, + episodetitle: row.try_get("Episodetitle")?, + episodepubdate, + episodedescription: row.try_get("Episodedescription")?, + episodeartwork: row.try_get("Episodeartwork")?, + episodeurl: row.try_get("Episodeurl")?, + episodeduration: row.try_get("Episodeduration")?, + listenduration: row.try_get("Listenduration")?, + episodeid: row.try_get("Episodeid")?, + completed: row.try_get("Completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + }); + } + + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT * FROM ( + SELECT + Podcasts.PodcastName as podcastname, + Episodes.EpisodeTitle as Episodetitle, + Episodes.EpisodePubDate as Episodepubdate, + Episodes.EpisodeDescription as Episodedescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = TRUE AND Podcasts.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + ELSE Episodes.EpisodeArtwork + END as Episodeartwork, + Episodes.EpisodeURL as Episodeurl, + Episodes.EpisodeDuration as Episodeduration, + UserEpisodeHistory.ListenDuration as Listenduration, + Episodes.EpisodeID as Episodeid, + Episodes.Completed as Completed, + CASE WHEN SavedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube + FROM Episodes + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN UserEpisodeHistory ON + Episodes.EpisodeID = UserEpisodeHistory.EpisodeID + AND UserEpisodeHistory.UserID = ? + LEFT JOIN SavedEpisodes ON + Episodes.EpisodeID = SavedEpisodes.EpisodeID + AND SavedEpisodes.UserID = ? + LEFT JOIN EpisodeQueue ON + Episodes.EpisodeID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + AND EpisodeQueue.is_youtube = FALSE + LEFT JOIN DownloadedEpisodes ON + Episodes.EpisodeID = DownloadedEpisodes.EpisodeID + AND DownloadedEpisodes.UserID = ? + WHERE Podcasts.UserID = ? AND ( + Podcasts.PodcastID = ? OR + Podcasts.PodcastID IN ( + SELECT CAST(JSON_UNQUOTE(JSON_EXTRACT(COALESCE(p2.MergedPodcastIDs, '[]'), CONCAT('$[', numbers.n, ']'))) AS UNSIGNED) + FROM (SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) numbers + INNER JOIN Podcasts p2 ON p2.PodcastID = ? AND p2.UserID = ? + WHERE JSON_UNQUOTE(JSON_EXTRACT(COALESCE(p2.MergedPodcastIDs, '[]'), CONCAT('$[', numbers.n, ']'))) IS NOT NULL + ) + ) + + UNION ALL + + SELECT + Podcasts.PodcastName as podcastname, + YouTubeVideos.VideoTitle as Episodetitle, + YouTubeVideos.PublishedAt as Episodepubdate, + YouTubeVideos.VideoDescription as Episodedescription, + CASE + WHEN Podcasts.UsePodcastCoversCustomized = TRUE AND Podcasts.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + WHEN Users.UsePodcastCovers = TRUE THEN Podcasts.ArtworkURL + ELSE YouTubeVideos.ThumbnailURL + END as Episodeartwork, + YouTubeVideos.VideoURL as Episodeurl, + YouTubeVideos.Duration as Episodeduration, + YouTubeVideos.ListenPosition as Listenduration, + YouTubeVideos.VideoID as Episodeid, + YouTubeVideos.Completed as Completed, + CASE WHEN SavedVideos.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL AND EpisodeQueue.is_youtube = TRUE THEN TRUE ELSE FALSE END AS queued, + CASE WHEN DownloadedVideos.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube + FROM YouTubeVideos + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + LEFT JOIN Users ON Podcasts.UserID = Users.UserID + LEFT JOIN SavedVideos ON + YouTubeVideos.VideoID = SavedVideos.VideoID + AND SavedVideos.UserID = ? + LEFT JOIN EpisodeQueue ON + YouTubeVideos.VideoID = EpisodeQueue.EpisodeID + AND EpisodeQueue.UserID = ? + AND EpisodeQueue.is_youtube = TRUE + LEFT JOIN DownloadedVideos ON + YouTubeVideos.VideoID = DownloadedVideos.VideoID + AND DownloadedVideos.UserID = ? + WHERE Podcasts.UserID = ? AND ( + Podcasts.PodcastID = ? OR + Podcasts.PodcastID IN ( + SELECT CAST(JSON_UNQUOTE(JSON_EXTRACT(COALESCE(p2.MergedPodcastIDs, '[]'), CONCAT('$[', numbers.n, ']'))) AS UNSIGNED) + FROM (SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) numbers + INNER JOIN Podcasts p2 ON p2.PodcastID = ? AND p2.UserID = ? + WHERE JSON_UNQUOTE(JSON_EXTRACT(COALESCE(p2.MergedPodcastIDs, '[]'), CONCAT('$[', numbers.n, ']'))) IS NOT NULL + ) + ) + ) combined + ORDER BY Episodepubdate DESC" + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(podcast_id) + .bind(podcast_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(podcast_id) + .bind(podcast_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + let datetime = row.try_get::, _>("Episodepubdate")?; + let episodepubdate = datetime.format("%Y-%m-%dT%H:%M:%S").to_string(); + + episodes.push(crate::handlers::podcasts::PodcastEpisode { + podcastname: row.try_get("podcastname")?, + episodetitle: row.try_get("Episodetitle")?, + episodepubdate, + episodedescription: row.try_get("Episodedescription")?, + episodeartwork: row.try_get("Episodeartwork")?, + episodeurl: row.try_get("Episodeurl")?, + episodeduration: row.try_get("Episodeduration")?, + listenduration: row.try_get("Listenduration").ok(), + episodeid: row.try_get("Episodeid")?, + completed: row.try_get::("Completed")? != 0, + saved: row.try_get::("saved")? != 0, + queued: row.try_get::("queued")? != 0, + downloaded: row.try_get::("downloaded")? != 0, + is_youtube: row.try_get::("is_youtube")? != 0, + }); + } + + Ok(episodes) + } + } + } + + // Record listen duration - matches Python record_listen_duration function exactly + pub async fn record_listen_duration(&self, episode_id: i32, user_id: i32, listen_duration: f64) -> AppResult<()> { + println!("Recording listen duration: episode_id={}, user_id={}, duration={}", episode_id, user_id, listen_duration); + + if listen_duration < 0.0 { + println!("Skipped updating listen duration for user {} and episode {} due to invalid duration: {}", user_id, episode_id, listen_duration); + return Ok(()); + } + + let listen_duration_int = listen_duration as i32; + + match self { + DatabasePool::Postgres(pool) => { + // Check if record exists and get existing duration + let existing_row = sqlx::query(r#"SELECT listenduration FROM "UserEpisodeHistory" WHERE userid = $1 AND episodeid = $2"#) + .bind(user_id) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing_row { + let existing_duration: Option = row.try_get("listenduration")?; + let existing_duration = existing_duration.unwrap_or(0); + + // Update only if new duration is greater than existing + if listen_duration_int > existing_duration { + sqlx::query(r#"UPDATE "UserEpisodeHistory" SET listenduration = $1, listendate = NOW() WHERE userid = $2 AND episodeid = $3"#) + .bind(listen_duration_int) + .bind(user_id) + .bind(episode_id) + .execute(pool) + .await?; + println!("Updated listen duration for user {} episode {} from {} to {}", user_id, episode_id, existing_duration, listen_duration_int); + } else { + println!("No update required for user {} and episode {} as existing duration {} is greater than or equal to new duration {}", user_id, episode_id, existing_duration, listen_duration_int); + } + } else { + // Insert new record + sqlx::query(r#"INSERT INTO "UserEpisodeHistory" (userid, episodeid, listendate, listenduration) VALUES ($1, $2, NOW(), $3)"#) + .bind(user_id) + .bind(episode_id) + .bind(listen_duration_int) + .execute(pool) + .await?; + println!("Inserted new listen duration record for user {} episode {} with duration {}", user_id, episode_id, listen_duration_int); + } + } + DatabasePool::MySQL(pool) => { + // Check if record exists and get existing duration + let existing_row = sqlx::query("SELECT ListenDuration FROM UserEpisodeHistory WHERE UserID = ? AND EpisodeID = ?") + .bind(user_id) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing_row { + let existing_duration: Option = row.try_get("ListenDuration")?; + let existing_duration = existing_duration.unwrap_or(0); + + // Update only if new duration is greater than existing + if listen_duration_int > existing_duration { + sqlx::query("UPDATE UserEpisodeHistory SET ListenDuration = ?, ListenDate = NOW() WHERE UserID = ? AND EpisodeID = ?") + .bind(listen_duration_int) + .bind(user_id) + .bind(episode_id) + .execute(pool) + .await?; + println!("Updated listen duration for user {} episode {} from {} to {}", user_id, episode_id, existing_duration, listen_duration_int); + } else { + println!("No update required for user {} and episode {} as existing duration {} is greater than or equal to new duration {}", user_id, episode_id, existing_duration, listen_duration_int); + } + } else { + // Insert new record + sqlx::query("INSERT INTO UserEpisodeHistory (UserID, EpisodeID, ListenDate, ListenDuration) VALUES (?, ?, NOW(), ?)") + .bind(user_id) + .bind(episode_id) + .bind(listen_duration_int) + .execute(pool) + .await?; + println!("Inserted new listen duration record for user {} episode {} with duration {}", user_id, episode_id, listen_duration_int); + } + } + } + Ok(()) + } + + // Record YouTube listen duration - matches Python record_youtube_listen_duration function exactly + pub async fn record_youtube_listen_duration(&self, video_id: i32, user_id: i32, listen_duration: f64) -> AppResult<()> { + println!("Recording YouTube listen duration: video_id={}, user_id={}, duration={}", video_id, user_id, listen_duration); + + if listen_duration < 0.0 { + println!("Skipped updating listen duration for user {} and video {} due to invalid duration: {}", user_id, video_id, listen_duration); + return Ok(()); + } + + let listen_duration_int = listen_duration as i32; + + match self { + DatabasePool::Postgres(pool) => { + // Check if record exists and get existing duration + let existing_row = sqlx::query(r#"SELECT listenduration FROM "UserVideoHistory" WHERE userid = $1 AND videoid = $2"#) + .bind(user_id) + .bind(video_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing_row { + let existing_duration: Option = row.try_get("listenduration")?; + let existing_duration = existing_duration.unwrap_or(0); + + // Update only if new duration is greater than existing + if listen_duration_int > existing_duration { + sqlx::query(r#"UPDATE "UserVideoHistory" SET listenduration = $1, listendate = NOW() WHERE userid = $2 AND videoid = $3"#) + .bind(listen_duration_int) + .bind(user_id) + .bind(video_id) + .execute(pool) + .await?; + println!("Updated YouTube listen duration for user {} video {} from {} to {}", user_id, video_id, existing_duration, listen_duration_int); + } else { + println!("No update required for user {} and video {} as existing duration {} is greater than or equal to new duration {}", user_id, video_id, existing_duration, listen_duration_int); + } + } else { + // Insert new record + sqlx::query(r#"INSERT INTO "UserVideoHistory" (userid, videoid, listendate, listenduration) VALUES ($1, $2, NOW(), $3)"#) + .bind(user_id) + .bind(video_id) + .bind(listen_duration_int) + .execute(pool) + .await?; + println!("Inserted new YouTube listen duration record for user {} video {} with duration {}", user_id, video_id, listen_duration_int); + } + } + DatabasePool::MySQL(pool) => { + // Check if record exists and get existing duration + let existing_row = sqlx::query("SELECT ListenDuration FROM UserVideoHistory WHERE UserID = ? AND VideoID = ?") + .bind(user_id) + .bind(video_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing_row { + let existing_duration: Option = row.try_get("ListenDuration")?; + let existing_duration = existing_duration.unwrap_or(0); + + // Update only if new duration is greater than existing + if listen_duration_int > existing_duration { + sqlx::query("UPDATE UserVideoHistory SET ListenDuration = ?, ListenDate = NOW() WHERE UserID = ? AND VideoID = ?") + .bind(listen_duration_int) + .bind(user_id) + .bind(video_id) + .execute(pool) + .await?; + println!("Updated YouTube listen duration for user {} video {} from {} to {}", user_id, video_id, existing_duration, listen_duration_int); + } else { + println!("No update required for user {} and video {} as existing duration {} is greater than or equal to new duration {}", user_id, video_id, existing_duration, listen_duration_int); + } + } else { + // Insert new record + sqlx::query("INSERT INTO UserVideoHistory (UserID, VideoID, ListenDate, ListenDuration) VALUES (?, ?, NOW(), ?)") + .bind(user_id) + .bind(video_id) + .bind(listen_duration_int) + .execute(pool) + .await?; + println!("Inserted new YouTube listen duration record for user {} video {} with duration {}", user_id, video_id, listen_duration_int); + } + } + } + Ok(()) + } + + + // Helper function to check if a URL is likely an audio file + fn is_audio_url(&self, url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains(".mp3") || + url_lower.contains(".m4a") || + url_lower.contains(".wav") || + url_lower.contains(".ogg") || + url_lower.contains(".aac") || + url_lower.contains(".flac") || + url_lower.contains(".opus") || + url_lower.contains("audio") || + url_lower.contains("podcast") || + url_lower.contains("media") || + // Common podcast hosting patterns + url_lower.contains("feeds.feedburner.com") || + url_lower.contains("anchor.fm") || + url_lower.contains("buzzsprout.com") || + url_lower.contains("libsyn.com") || + url_lower.contains("soundcloud.com") || + url_lower.contains("podomatic.com") || + url_lower.contains("blubrry.com") || + url_lower.contains("simplecast.com") || + url_lower.contains("podbean.com") + } + + // Extract audio URL from description/content HTML - matches Python logic + // fn extract_audio_url_from_description(&self, data: &std::collections::HashMap) -> Option { + // // Check various description fields for audio URLs + // for field in ["content:encoded", "description", "summary", "itunes:summary"] { + // if let Some(content) = data.get(field) { + // if let Some(url) = self.find_audio_url_in_text(content) { + // return Some(url.to_string()); + // } + // } + // } + // None + // } + + fn find_audio_url_in_text<'a>(&self, text: &'a str) -> Option<&'a str> { + // Look for href= or src= attributes + for pattern in ["href=\"", "src=\"", "url=\""] { + // Use lowercase only for finding pattern positions + if let Some(start) = text.to_lowercase().find(pattern) { + // Match same index in original text + let url_start = start + pattern.len(); + if let Some(end) = text[url_start..].find('\"') { + let url = &text[url_start..url_start + end]; + if self.is_audio_url(url) { + return Some(url); + } + } + } + } + + // Look for standalone URLs + for word in text.split_whitespace() { + if word.starts_with("http") && self.is_audio_url(word) { + return Some(word); + } + } + + None + } + + + // Set user theme - matches Python set_theme function exactly + pub async fn set_theme(&self, user_id: i32, theme: &str) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "UserSettings" SET theme = $1 WHERE userid = $2"#) + .bind(theme) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE UserSettings SET Theme = ? WHERE UserID = ?") + .bind(theme) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Get all users info - matches Python get_user_info function exactly + pub async fn get_user_info(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT userid, fullname, username, email, CASE WHEN isadmin THEN true ELSE false END AS isadmin FROM "Users""# + ) + .fetch_all(pool) + .await?; + + let mut users = Vec::new(); + for row in rows { + users.push(crate::handlers::settings::UserInfo { + userid: row.try_get("userid")?, + fullname: row.try_get("fullname")?, + username: row.try_get("username")?, + email: row.try_get("email")?, + isadmin: row.try_get("isadmin")?, + }); + } + Ok(users) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT UserID as userid, Fullname as fullname, Username as username, Email as email, IsAdmin as isadmin FROM Users" + ) + .fetch_all(pool) + .await?; + + let mut users = Vec::new(); + for row in rows { + users.push(crate::handlers::settings::UserInfo { + userid: row.try_get("userid")?, + fullname: row.try_get("fullname")?, + username: row.try_get("username")?, + email: row.try_get("email")?, + isadmin: row.try_get("isadmin")?, + }); + } + Ok(users) + } + } + } + + // Get specific user info - matches Python get_my_user_info function exactly + pub async fn get_my_user_info(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT userid, fullname, username, email, CASE WHEN isadmin THEN 1 ELSE 0 END AS isadmin, timezone, timeformat, dateformat, language FROM "Users" WHERE userid = $1"# + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(serde_json::json!({ + "userid": row.try_get::("userid")?, + "fullname": row.try_get::("fullname")?, + "username": row.try_get::("username")?, + "email": row.try_get::("email")?, + "isadmin": row.try_get::("isadmin")?, + "timezone": row.try_get::("timezone")?, + "timeformat": row.try_get::("timeformat")?, + "dateformat": row.try_get::("dateformat")?, + "language": row.try_get::("language")?, + }))) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT UserID as userid, Fullname as fullname, Username as username, Email as email, IsAdmin as isadmin, TimeZone as timezone, TimeFormat as timeformat, DateFormat as dateformat, Language as language FROM Users WHERE UserID = ?" + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(serde_json::json!({ + "userid": row.try_get::("userid")?, + "fullname": row.try_get::("fullname")?, + "username": row.try_get::("username")?, + "email": row.try_get::("email")?, + "isadmin": row.try_get::("isadmin")?, + "timezone": row.try_get::("timezone")?, + "timeformat": row.try_get::("timeformat")?, + "dateformat": row.try_get::("dateformat")?, + "language": row.try_get::("language")?, + }))) + } else { + Ok(None) + } + } + } + } + + // Add user - matches Python add_user function exactly + pub async fn add_user(&self, fullname: &str, username: &str, email: &str, hashed_pw: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"INSERT INTO "Users" (fullname, username, email, hashed_pw, isadmin) VALUES ($1, $2, $3, $4, false) RETURNING userid"# + ) + .bind(fullname) + .bind(username) + .bind(email) + .bind(hashed_pw) + .fetch_one(pool) + .await?; + + let user_id: i32 = row.try_get("userid")?; + + // Add user settings like Python version + sqlx::query(r#"INSERT INTO "UserSettings" (userid, theme) VALUES ($1, $2)"#) + .bind(user_id) + .bind("light") + .execute(pool) + .await?; + + // Create default playlists for the new user + if let Err(e) = self.create_default_playlists_for_user(user_id).await { + tracing::warn!("⚠️ Failed to create default playlists for new user {}: {}", user_id, e); + // Don't fail user creation if playlist creation fails + } + + Ok(user_id) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query( + "INSERT INTO Users (Fullname, Username, Email, Hashed_PW, IsAdmin) VALUES (?, ?, ?, ?, 0)" + ) + .bind(fullname) + .bind(username) + .bind(email) + .bind(hashed_pw) + .execute(pool) + .await?; + + let user_id = result.last_insert_id() as i32; + + // Add user settings like Python version + sqlx::query("INSERT INTO UserSettings (UserID, Theme) VALUES (?, ?)") + .bind(user_id) + .bind("light") + .execute(pool) + .await?; + + // Create default playlists for the new user + if let Err(e) = self.create_default_playlists_for_user(user_id).await { + tracing::warn!("⚠️ Failed to create default playlists for new user {}: {}", user_id, e); + // Don't fail user creation if playlist creation fails + } + + Ok(user_id) + } + } + } + + // Set fullname - matches Python set_fullname function exactly + pub async fn set_fullname(&self, user_id: i32, new_name: &str) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET fullname = $1 WHERE userid = $2"#) + .bind(new_name) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET Fullname = ? WHERE UserID = ?") + .bind(new_name) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Set password - matches Python set_password function exactly + pub async fn set_password(&self, user_id: i32, hash_pw: &str) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET hashed_pw = $1 WHERE userid = $2"#) + .bind(hash_pw) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET Hashed_PW = ? WHERE UserID = ?") + .bind(hash_pw) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Delete user - matches Python delete_user function exactly + pub async fn delete_user(&self, user_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // Delete from UserEpisodeHistory + sqlx::query(r#"DELETE FROM "UserEpisodeHistory" WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from DownloadedEpisodes + sqlx::query(r#"DELETE FROM "DownloadedEpisodes" WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from EpisodeQueue + sqlx::query(r#"DELETE FROM "EpisodeQueue" WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from Podcasts + sqlx::query(r#"DELETE FROM "Podcasts" WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from UserSettings + sqlx::query(r#"DELETE FROM "UserSettings" WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from UserStats + sqlx::query(r#"DELETE FROM "UserStats" WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await.ok(); + + // CRITICAL: Delete user's playlists to avoid foreign key constraint violations + sqlx::query(r#"DELETE FROM "Playlists" WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from Users (main table) + sqlx::query(r#"DELETE FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + // Delete from UserEpisodeHistory + sqlx::query("DELETE FROM UserEpisodeHistory WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from DownloadedEpisodes + sqlx::query("DELETE FROM DownloadedEpisodes WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from EpisodeQueue + sqlx::query("DELETE FROM EpisodeQueue WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from Podcasts + sqlx::query("DELETE FROM Podcasts WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from UserSettings + sqlx::query("DELETE FROM UserSettings WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from UserStats + sqlx::query("DELETE FROM UserStats WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await.ok(); + + // CRITICAL: Delete user's playlists to avoid foreign key constraint violations + sqlx::query("DELETE FROM Playlists WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await.ok(); + + // Delete from Users (main table) + sqlx::query("DELETE FROM Users WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Set email - matches Python set_email function exactly + pub async fn set_email(&self, user_id: i32, new_email: &str) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET email = $1 WHERE userid = $2"#) + .bind(new_email) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET Email = ? WHERE UserID = ?") + .bind(new_email) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Set username - matches Python set_username function exactly + pub async fn set_username(&self, user_id: i32, new_username: &str) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET username = $1 WHERE userid = $2"#) + .bind(new_username) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET Username = ? WHERE UserID = ?") + .bind(new_username) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Set isadmin - matches Python set_isadmin function exactly + pub async fn set_isadmin(&self, user_id: i32, isadmin: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET isadmin = $1 WHERE userid = $2"#) + .bind(isadmin) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET IsAdmin = ? WHERE UserID = ?") + .bind(isadmin as i32) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Final admin check - matches Python final_admin function exactly + pub async fn final_admin(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Count total admins + let admin_count: i64 = sqlx::query_scalar(r#"SELECT COUNT(*) FROM "Users" WHERE isadmin = TRUE"#) + .fetch_one(pool) + .await?; + + if admin_count == 1 { + // Check if this user is admin + let is_admin: Option = sqlx::query_scalar(r#"SELECT isadmin FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(is_admin.unwrap_or(false)) + } else { + Ok(false) + } + } + DatabasePool::MySQL(pool) => { + // Count total admins + let admin_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM Users WHERE IsAdmin = 1") + .fetch_one(pool) + .await?; + + if admin_count == 1 { + // Check if this user is admin + let is_admin: Option = sqlx::query_scalar("SELECT IsAdmin FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(is_admin.unwrap_or(0) == 1) + } else { + Ok(false) + } + } + } + } + + // Enable/disable guest - matches Python enable_disable_guest function exactly + pub async fn enable_disable_guest(&self) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET email = CASE WHEN email = 'inactive' THEN 'active' ELSE 'inactive' END WHERE username = 'guest'"#) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET Email = CASE WHEN Email = 'inactive' THEN 'active' ELSE 'inactive' END WHERE Username = 'guest'") + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Enable/disable downloads - matches Python enable_disable_downloads function exactly + pub async fn enable_disable_downloads(&self) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "AppSettings" SET downloadenabled = CASE WHEN downloadenabled = true THEN false ELSE true END"#) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE AppSettings SET DownloadEnabled = CASE WHEN DownloadEnabled = 1 THEN 0 ELSE 1 END") + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Enable/disable self service - matches Python enable_disable_self_service function exactly + pub async fn enable_disable_self_service(&self) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "AppSettings" SET selfserviceuser = CASE WHEN selfserviceuser = true THEN false ELSE true END"#) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE AppSettings SET SelfServiceUser = CASE WHEN SelfServiceUser = 1 THEN 0 ELSE 1 END") + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Get guest status - matches Python guest_status function exactly + pub async fn guest_status(&self) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result: Option = sqlx::query_scalar(r#"SELECT email FROM "Users" WHERE email = 'active'"#) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } + DatabasePool::MySQL(pool) => { + let result: Option = sqlx::query_scalar("SELECT Email FROM Users WHERE Email = 'active'") + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } + } + } + + // Toggle RSS feeds - matches Python toggle_rss_feeds function exactly + pub async fn toggle_rss_feeds(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Get current status + let current_status: Option = sqlx::query_scalar(r#"SELECT enablerssfeeds FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + let new_status = !current_status.unwrap_or(false); + + // Update status + sqlx::query(r#"UPDATE "Users" SET enablerssfeeds = $1 WHERE userid = $2"#) + .bind(new_status) + .bind(user_id) + .execute(pool) + .await?; + + Ok(new_status) + } + DatabasePool::MySQL(pool) => { + // Get current status + let current_status: Option = sqlx::query_scalar("SELECT EnableRSSFeeds FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + let new_status = current_status.unwrap_or(0) == 0; + + // Update status + sqlx::query("UPDATE Users SET EnableRSSFeeds = ? WHERE UserID = ?") + .bind(new_status as i32) + .bind(user_id) + .execute(pool) + .await?; + + Ok(new_status) + } + } + } + + // Get download status - matches Python download_status function exactly + pub async fn download_status(&self) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result: Option = sqlx::query_scalar(r#"SELECT downloadenabled FROM "AppSettings""#) + .fetch_optional(pool) + .await?; + Ok(result.unwrap_or(false)) + } + DatabasePool::MySQL(pool) => { + let result: Option = sqlx::query_scalar("SELECT DownloadEnabled FROM AppSettings") + .fetch_optional(pool) + .await?; + Ok(result.unwrap_or(0) == 1) + } + } + } + + // Get self service status - matches Python self_service_status function exactly + pub async fn self_service_status(&self) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Get self-service status + let self_service_result: Option = sqlx::query_scalar(r#"SELECT selfserviceuser FROM "AppSettings" WHERE selfserviceuser = TRUE"#) + .fetch_optional(pool) + .await?; + + // Get admin count + let admin_count: i64 = sqlx::query_scalar(r#"SELECT COUNT(*) FROM "Users" WHERE isadmin = TRUE"#) + .fetch_one(pool) + .await?; + + Ok(SelfServiceStatus { + status: self_service_result.unwrap_or(false), + admin_exists: admin_count > 0, + }) + } + DatabasePool::MySQL(pool) => { + // Get self-service status + let self_service_result: Option = sqlx::query_scalar("SELECT SelfServiceUser FROM AppSettings WHERE SelfServiceUser = 1") + .fetch_optional(pool) + .await?; + + // Get admin count + let admin_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM Users WHERE IsAdmin = 1") + .fetch_one(pool) + .await?; + + Ok(SelfServiceStatus { + status: self_service_result.unwrap_or(0) == 1, + admin_exists: admin_count > 0, + }) + } + } + } + + // Save email settings - matches Python save_email_settings function exactly + pub async fn save_email_settings(&self, email_settings: &crate::handlers::settings::EmailSettings) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + let auth_required = email_settings.auth_required != 0; + sqlx::query( + r#"UPDATE "EmailSettings" SET + server_name = $1, server_port = $2, from_email = $3, + send_mode = $4, encryption = $5, auth_required = $6, + username = $7, password = $8 + WHERE emailsettingsid = 1"# + ) + .bind(&email_settings.server_name) + .bind(email_settings.server_port) + .bind(&email_settings.from_email) + .bind(&email_settings.send_mode) + .bind(&email_settings.encryption) + .bind(auth_required) + .bind(&email_settings.email_username) + .bind(&email_settings.email_password) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query( + "UPDATE EmailSettings SET + Server_Name = ?, Server_Port = ?, From_Email = ?, + Send_Mode = ?, Encryption = ?, Auth_Required = ?, + Username = ?, Password = ? + WHERE EmailSettingsID = 1" + ) + .bind(&email_settings.server_name) + .bind(email_settings.server_port) + .bind(&email_settings.from_email) + .bind(&email_settings.send_mode) + .bind(&email_settings.encryption) + .bind(email_settings.auth_required) + .bind(&email_settings.email_username) + .bind(&email_settings.email_password) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Get email settings - matches Python get_email_settings function exactly + pub async fn get_email_settings(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT emailsettingsid, server_name, server_port, from_email, + send_mode, encryption, auth_required, username, password + FROM "EmailSettings""# + ) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let auth_required: bool = row.try_get("auth_required")?; + Ok(Some(crate::handlers::settings::EmailSettingsResponse { + emailsettingsid: row.try_get("emailsettingsid")?, + server_name: row.try_get("server_name")?, + server_port: row.try_get("server_port")?, + from_email: row.try_get("from_email")?, + send_mode: row.try_get("send_mode")?, + encryption: row.try_get("encryption")?, + auth_required: if auth_required { 1 } else { 0 }, + username: row.try_get("username")?, + password: row.try_get("password")?, + })) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT EmailSettingsID, Server_Name, Server_Port, From_Email, + Send_Mode, Encryption, Auth_Required, Username, Password + FROM EmailSettings" + ) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(crate::handlers::settings::EmailSettingsResponse { + emailsettingsid: row.try_get("EmailSettingsID")?, + server_name: row.try_get("Server_Name")?, + server_port: row.try_get("Server_Port")?, + from_email: row.try_get("From_Email")?, + send_mode: row.try_get("Send_Mode")?, + encryption: row.try_get("Encryption")?, + auth_required: row.try_get("Auth_Required")?, + username: row.try_get("Username")?, + password: row.try_get("Password")?, + })) + } else { + Ok(None) + } + } + } + } + + // Get API info - matches Python get_api_info function exactly + pub async fn get_api_info(&self, user_id: i32) -> AppResult>> { + match self { + DatabasePool::Postgres(pool) => { + // Check if user is admin + let is_admin: Option = sqlx::query_scalar(r#"SELECT isadmin FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + let is_admin = is_admin.unwrap_or(false); + + let query = if is_admin { + // Admin sees all API keys + r#"SELECT a.apikeyid, a.userid, u.username, RIGHT(a.apikey, 4) as lastfourdigits, + a.created::text as created + FROM "APIKeys" a + JOIN "Users" u ON a.userid = u.userid"# + } else { + // Non-admin sees only their own API keys + r#"SELECT a.apikeyid, a.userid, u.username, RIGHT(a.apikey, 4) as lastfourdigits, + a.created::text as created + FROM "APIKeys" a + JOIN "Users" u ON a.userid = u.userid + WHERE a.userid = $1"# + }; + + let rows = if is_admin { + sqlx::query(query).fetch_all(pool).await? + } else { + sqlx::query(query).bind(user_id).fetch_all(pool).await? + }; + + let mut api_infos = Vec::new(); + for row in rows { + api_infos.push(crate::handlers::settings::ApiInfo { + apikeyid: row.try_get("apikeyid")?, + userid: row.try_get("userid")?, + username: row.try_get("username")?, + lastfourdigits: row.try_get("lastfourdigits")?, + created: row.try_get("created")?, + podcastids: vec![], // Empty array as in Python + }); + } + + if api_infos.is_empty() { + Ok(None) + } else { + Ok(Some(api_infos)) + } + } + DatabasePool::MySQL(pool) => { + // Check if user is admin + let is_admin: Option = sqlx::query_scalar("SELECT IsAdmin FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + let is_admin = is_admin.unwrap_or(0) == 1; + + let query = if is_admin { + // Admin sees all API keys + "SELECT a.APIKeyID as apikeyid, a.UserID as userid, u.Username as username, RIGHT(a.APIKey, 4) as lastfourdigits, + a.Created as created + FROM APIKeys a + JOIN Users u ON a.UserID = u.UserID" + } else { + // Non-admin sees only their own API keys + "SELECT a.APIKeyID as apikeyid, a.UserID as userid, u.Username as username, RIGHT(a.APIKey, 4) as lastfourdigits, + a.Created as created + FROM APIKeys a + JOIN Users u ON a.UserID = u.UserID + WHERE a.UserID = ?" + }; + + let rows = if is_admin { + sqlx::query(query).fetch_all(pool).await? + } else { + sqlx::query(query).bind(user_id).fetch_all(pool).await? + }; + + let mut api_infos = Vec::new(); + for row in rows { + api_infos.push(crate::handlers::settings::ApiInfo { + apikeyid: row.try_get("apikeyid")?, + userid: row.try_get("userid")?, + username: row.try_get("username")?, + lastfourdigits: row.try_get("lastfourdigits")?, + created: row.try_get::, _>("created")?.to_string(), + podcastids: vec![], // Empty array as in Python + }); + } + + if api_infos.is_empty() { + Ok(None) + } else { + Ok(Some(api_infos)) + } + } + } + } + + // Create API key - matches Python create_api_key function exactly + pub async fn create_api_key(&self, user_id: i32) -> AppResult { + use rand::Rng; + + // Generate 64-character API key + let charset: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789"; + let api_key: String = { + let mut rng = rand::rng(); + (0..64) + .map(|_| { + let idx = rng.random_range(0..charset.len()); + charset[idx] as char + }) + .collect() + }; + + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"INSERT INTO "APIKeys" (userid, apikey) VALUES ($1, $2)"#) + .bind(user_id) + .bind(&api_key) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("INSERT INTO APIKeys (UserID, APIKey) VALUES (?, ?)") + .bind(user_id) + .bind(&api_key) + .execute(pool) + .await?; + } + } + + Ok(api_key) + } + + // Create RSS key - matches Python create_rss_key function exactly + pub async fn create_rss_key(&self, user_id: i32, podcast_ids: Option>) -> AppResult { + use rand::Rng; + use rand::distr::Alphanumeric; + + // Generate 64-character RSS key + let rss_key: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + + match self { + DatabasePool::Postgres(pool) => { + let key_id: i32 = sqlx::query_scalar(r#"INSERT INTO "RssKeys" (userid, rsskey) VALUES ($1, $2) RETURNING rsskeyid"#) + .bind(user_id) + .bind(&rss_key) + .fetch_one(pool) + .await?; + + // Handle podcast IDs if provided + if let Some(podcast_ids) = podcast_ids { + for podcast_id in podcast_ids { + sqlx::query(r#"INSERT INTO "RssKeyPodcasts" (rsskeyid, podcastid) VALUES ($1, $2)"#) + .bind(key_id) + .bind(podcast_id) + .execute(pool) + .await?; + } + } + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("INSERT INTO RssKeys (UserID, RssKey) VALUES (?, ?)") + .bind(user_id) + .bind(&rss_key) + .execute(pool) + .await?; + + let key_id = result.last_insert_id() as i32; + + // Handle podcast IDs if provided + if let Some(podcast_ids) = podcast_ids { + for podcast_id in podcast_ids { + sqlx::query("INSERT INTO RssKeyPodcasts (RssKeyID, PodcastID) VALUES (?, ?)") + .bind(key_id) + .bind(podcast_id) + .execute(pool) + .await?; + } + } + } + } + + Ok(rss_key) + } + + // Count user API keys excluding a specific one - safety check for final API key + pub async fn count_user_api_keys_excluding(&self, user_id: i32, exclude_api_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT COUNT(*) as count FROM "APIKeys" WHERE userid = $1 AND apikeyid != $2"#) + .bind(user_id) + .bind(exclude_api_id) + .fetch_one(pool) + .await?; + Ok(row.try_get::("count")? as i32) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT COUNT(*) as count FROM APIKeys WHERE UserID = ? AND APIKeyID != ?") + .bind(user_id) + .bind(exclude_api_id) + .fetch_one(pool) + .await?; + Ok(row.try_get::("count")? as i32) + } + } + } + + // Delete API key - matches Python delete_api function exactly + pub async fn delete_api_key(&self, api_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"DELETE FROM "APIKeys" WHERE apikeyid = $1"#) + .bind(api_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("DELETE FROM APIKeys WHERE APIKeyID = ?") + .bind(api_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Check if API key is the same as the one being deleted - matches Python is_same_api_key function exactly + pub async fn is_same_api_key(&self, api_id: i32, api_key: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result: Option = sqlx::query_scalar(r#"SELECT apikey FROM "APIKeys" WHERE apikeyid = $1"#) + .bind(api_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|key| key == api_key).unwrap_or(false)) + } + DatabasePool::MySQL(pool) => { + let result: Option = sqlx::query_scalar("SELECT APIKey FROM APIKeys WHERE APIKeyID = ?") + .bind(api_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|key| key == api_key).unwrap_or(false)) + } + } + } + + // Check if API key belongs to guest user - matches Python belongs_to_guest_user function exactly + pub async fn belongs_to_guest_user(&self, api_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result: Option = sqlx::query_scalar(r#"SELECT userid FROM "APIKeys" WHERE apikeyid = $1"#) + .bind(api_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|user_id| user_id == 1).unwrap_or(false)) + } + DatabasePool::MySQL(pool) => { + let result: Option = sqlx::query_scalar("SELECT UserID FROM APIKeys WHERE APIKeyID = ?") + .bind(api_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|user_id| user_id == 1).unwrap_or(false)) + } + } + } + + // Get the owner user ID of an API key by API key ID - for authorization checks + pub async fn get_api_key_owner(&self, api_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let result: Option = sqlx::query_scalar(r#"SELECT userid FROM "APIKeys" WHERE apikeyid = $1"#) + .bind(api_id) + .fetch_optional(pool) + .await?; + + Ok(result) + } + DatabasePool::MySQL(pool) => { + let result: Option = sqlx::query_scalar("SELECT UserID FROM APIKeys WHERE APIKeyID = ?") + .bind(api_id) + .fetch_optional(pool) + .await?; + + Ok(result) + } + } + } + + // Backup user data - matches Python backup_user function exactly + pub async fn backup_user(&self, user_id: i32) -> AppResult { + let podcasts = match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT podcastname, feedurl FROM "Podcasts" WHERE userid = $1 AND (username IS NULL OR username = '') AND (password IS NULL OR password = '')"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + podcasts.push(( + row.try_get::("podcastname")?, + row.try_get::("feedurl")?, + )); + } + podcasts + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT PodcastName, FeedURL FROM Podcasts WHERE UserID = ? AND (Username IS NULL OR Username = '') AND (Password IS NULL OR Password = '')" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + podcasts.push(( + row.try_get::("PodcastName")?, + row.try_get::("FeedURL")?, + )); + } + podcasts + } + }; + + // Generate OPML content + let mut opml_content = String::from( + r#" + + + Podcast Subscriptions + + +"# + ); + + for (podcast_name, feed_url) in podcasts { + // Escape XML characters in podcast name and feed URL + let escaped_name = podcast_name + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"); + + let escaped_url = feed_url + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + + opml_content.push_str(&format!( + r#" +"#, + escaped_name, escaped_name, escaped_url + )); + } + + opml_content.push_str(" \n"); + Ok(opml_content) + } + + // Add more database operations as needed... +} + +#[derive(Debug, Clone)] +pub struct EpisodeData { + pub title: String, + pub description: String, + pub url: String, + pub artwork_url: String, + pub pub_date: DateTime, + pub duration: i32, +} + +#[derive(Debug, Clone)] +pub struct UserCredentials { + pub user_id: i32, + pub username: String, + pub hashed_password: String, + pub email: Option, +} + +#[derive(Debug, Clone)] +pub struct UserSettings { + pub user_id: i32, + pub api_key: String, + pub theme: String, + pub auto_download_episodes: bool, + pub auto_delete_episodes: bool, +} + +#[derive(Debug, Clone)] +pub struct SelfServiceStatus { + pub status: bool, + pub admin_exists: bool, +} + +#[derive(Debug, Clone)] +pub struct PublicOidcProvider { + pub provider_id: i32, + pub provider_name: String, + pub client_id: String, + pub authorization_url: String, + pub scope: String, + pub button_color: String, + pub button_text: String, + pub button_text_color: String, + pub icon_svg: Option, +} + +// Nextcloud login data structure +#[derive(Debug, Clone)] +pub struct NextcloudLoginData { + pub raw_response: serde_json::Value, +} + +// Sync result structure +#[derive(Debug, Clone)] +pub struct SyncResult { + pub synced_podcasts: i32, + pub synced_episodes: i32, +} + +// gPodder status structure - matches Python get_user_gpodder_status response +#[derive(Debug, Clone)] +pub struct GpodderStatus { + pub sync_type: String, + pub gpodder_url: Option, + pub gpodder_login: Option, +} + +// User sync settings structure +#[derive(Debug, Clone)] +pub struct UserSyncSettings { + pub url: String, + pub username: String, + pub token: String, + pub sync_type: String, +} + +#[derive(Debug, Clone)] +pub struct UserAutoComplete { + pub user_id: i32, + pub auto_complete_seconds: i32, +} + +// gPodder device structure +#[derive(Debug, Clone)] +pub struct GpodderDevice { + pub device_id: i32, + pub device_name: String, + pub device_type: String, + pub device_caption: Option, + pub is_default: bool, + pub is_remote: bool, + pub user_id: i32, +} + +// gPodder session structure for authentication +#[derive(Debug, Clone)] +pub struct GpodderSession { + pub client: reqwest::Client, + pub session_id: Option, + pub authenticated: bool, +} + +impl DatabasePool { + // Use actual psql/mysql for reliable restore + pub async fn restore_server_data(&self, sql_content: &str) -> AppResult<()> { + use tokio::process::Command; + use tokio::io::AsyncWriteExt; + + // First, clear all existing data from tables + self.clear_all_data().await?; + + match self { + DatabasePool::Postgres(_) => { + // Extract connection details from environment + let host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); + let port = std::env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string()); + let database = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods".to_string()); + let username = std::env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string()); + let password = std::env::var("DB_PASSWORD").unwrap_or_else(|_| "password".to_string()); + + // Use psql to restore data - connect to target database + let mut cmd = Command::new("psql"); + cmd.arg("--host").arg(&host) + .arg("--port").arg(&port) + .arg("--username").arg(&username) + .arg("--no-password") + .arg("--dbname").arg(&database) + .arg("--quiet") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + // Set password via environment variable + cmd.env("PGPASSWORD", &password); + + let mut child = cmd.spawn() + .map_err(|e| AppError::internal(&format!("Failed to start psql: {}", e)))?; + + // Write SQL content to stdin + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(sql_content.as_bytes()).await + .map_err(|e| AppError::internal(&format!("Failed to write SQL to psql: {}", e)))?; + stdin.shutdown().await + .map_err(|e| AppError::internal(&format!("Failed to close psql stdin: {}", e)))?; + } + + // Wait for completion + let output = child.wait_with_output().await + .map_err(|e| AppError::internal(&format!("Failed to wait for psql: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppError::internal(&format!("psql failed: {}", stderr))); + } + } + DatabasePool::MySQL(_) => { + // Extract connection details from environment + let host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); + let port = std::env::var("DB_PORT").unwrap_or_else(|_| "3306".to_string()); + let database = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods".to_string()); + let username = std::env::var("DB_USER").unwrap_or_else(|_| "root".to_string()); + let password = std::env::var("DB_PASSWORD").unwrap_or_else(|_| "password".to_string()); + + // Use mysql to restore + let mut cmd = Command::new("mysql"); + cmd.arg("--host").arg(&host) + .arg("--port").arg(&port) + .arg("--user").arg(&username) + .arg(format!("--password={}", password)) + .arg("--skip-ssl") + .arg("--default-auth=mysql_native_password") + .arg(&database) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn() + .map_err(|e| AppError::internal(&format!("Failed to start mysql: {}", e)))?; + + // Write SQL content to stdin + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(sql_content.as_bytes()).await + .map_err(|e| AppError::internal(&format!("Failed to write SQL to mysql: {}", e)))?; + stdin.shutdown().await + .map_err(|e| AppError::internal(&format!("Failed to close mysql stdin: {}", e)))?; + } + + // Wait for completion + let output = child.wait_with_output().await + .map_err(|e| AppError::internal(&format!("Failed to wait for mysql: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppError::internal(&format!("mysql failed: {}", stderr))); + } + } + } + + Ok(()) + } + + // Clear all data from tables while preserving schema + async fn clear_all_data(&self) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // Disable foreign key constraints temporarily + sqlx::query("SET session_replication_role = replica;").execute(pool).await?; + + // Get all table names + let tables = sqlx::query(r#" + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + AND tablename NOT LIKE '%_seq' + "#) + .fetch_all(pool) + .await?; + + // Clear each table + for table in tables { + let table_name: String = table.try_get("tablename")?; + let query = format!(r#"TRUNCATE TABLE "{}" RESTART IDENTITY CASCADE"#, table_name); + sqlx::query(&query).execute(pool).await?; + } + + // Re-enable foreign key constraints + sqlx::query("SET session_replication_role = DEFAULT;").execute(pool).await?; + } + DatabasePool::MySQL(pool) => { + // Disable foreign key checks + sqlx::query("SET FOREIGN_KEY_CHECKS = 0;").execute(pool).await?; + + // Get all table names + let database = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods".to_string()); + let tables = sqlx::query(&format!(r#" + SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = '{}' AND TABLE_TYPE = 'BASE TABLE' + "#, database)) + .fetch_all(pool) + .await?; + + // Clear each table + for table in tables { + // Handle both VARCHAR and VARBINARY cases for TABLE_NAME + let table_name: String = if let Ok(name) = table.try_get::("TABLE_NAME") { + name + } else if let Ok(name_bytes) = table.try_get::, _>("TABLE_NAME") { + String::from_utf8(name_bytes).map_err(|_| AppError::internal("Invalid table name encoding"))? + } else { + return Err(AppError::internal("Could not retrieve table name")); + }; + // Use DELETE instead of TRUNCATE to avoid foreign key constraint issues + let query = format!("DELETE FROM `{}`", table_name); + sqlx::query(&query).execute(pool).await?; + } + + // Re-enable foreign key checks + sqlx::query("SET FOREIGN_KEY_CHECKS = 1;").execute(pool).await?; + } + } + Ok(()) + } + + // Helper function to parse SQL statements more carefully + fn parse_sql_statements(&self, sql_content: &str) -> AppResult> { + let mut statements = Vec::new(); + let mut current_statement = String::new(); + let mut in_string = false; + let mut string_delimiter = None; + let mut escape_next = false; + + let chars: Vec = sql_content.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let ch = chars[i]; + + if escape_next { + current_statement.push(ch); + escape_next = false; + i += 1; + continue; + } + + if ch == '\\' && in_string { + escape_next = true; + current_statement.push(ch); + i += 1; + continue; + } + + if !in_string { + // Check for start of string literals + if ch == '\'' || ch == '"' { + in_string = true; + string_delimiter = Some(ch); + current_statement.push(ch); + } else if ch == ';' { + // End of statement + let trimmed = current_statement.trim(); + if !trimmed.is_empty() && + !trimmed.starts_with("--") && + !trimmed.to_lowercase().starts_with("/*!") { + // Allow SET statements for PostgreSQL configuration + statements.push(current_statement.clone()); + } + current_statement.clear(); + } else if ch == '-' && i + 1 < chars.len() && chars[i + 1] == '-' { + // Skip single-line comments + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } else { + current_statement.push(ch); + } + } else { + // Inside string literal + if ch == string_delimiter.unwrap() { + in_string = false; + string_delimiter = None; + } + current_statement.push(ch); + } + + i += 1; + } + + // Add final statement if any + let trimmed = current_statement.trim(); + if !trimmed.is_empty() && + !trimmed.starts_with("--") && + !trimmed.to_lowercase().starts_with("/*!") { + // Allow SET statements for PostgreSQL configuration + statements.push(current_statement); + } + + Ok(statements) + } + + // Generate MFA secret with QR code - matches Python generate_mfa_secret function exactly + pub async fn generate_mfa_secret(&self, user_id: i32) -> AppResult<(String, String)> { + use totp_rs::{Algorithm, Secret, TOTP}; + use qrcode::QrCode; + use rand::Rng; + + // Generate random base32 secret (matches Python random_base32()) + let secret = { + let mut rng = rand::rng(); + let secret_bytes: [u8; 20] = rng.random(); // 160 bits = 32 base32 chars + Secret::Raw(secret_bytes.to_vec()).to_encoded().to_string() + }; + + // Get user email for provisioning URI + let email = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT email FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + row.try_get::("email")? + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT Email FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + row.try_get::("Email")? + } + }; + + // Create TOTP instance and provisioning URI + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + Secret::Encoded(secret.clone()).to_bytes().unwrap(), + Some("Pinepods".to_string()), + email.clone(), + ).map_err(|e| AppError::internal(&format!("TOTP creation failed: {}", e)))?; + + let provisioning_uri = totp.get_url(); + + // Generate QR code as SVG (matches Python qrcode generation) + let qr_code = QrCode::new(&provisioning_uri) + .map_err(|e| AppError::internal(&format!("QR code generation failed: {}", e)))?; + + let qr_code_svg = qr_code + .render::() + .min_dimensions(200, 200) + .dark_color(qrcode::render::svg::Color("#000000")) + .light_color(qrcode::render::svg::Color("#ffffff")) + .build(); + + // Store temporarily in Redis (matches Python temp_mfa_secrets) + let temp_key = format!("temp_mfa_{}", user_id); + self.store_temp_mfa_secret(&temp_key, &secret).await?; + + Ok((secret, qr_code_svg)) + } + + // Store temporary MFA secret in memory (matches Python temp_mfa_secrets) + async fn store_temp_mfa_secret(&self, key: &str, secret: &str) -> AppResult<()> { + let mut secrets = TEMP_MFA_SECRETS.lock().map_err(|e| AppError::internal(&format!("Failed to lock temp MFA secrets: {}", e)))?; + secrets.insert(key.to_string(), secret.to_string()); + Ok(()) + } + + // Verify temporary MFA code - matches Python verify_temp_mfa function exactly + pub async fn verify_temp_mfa(&self, user_id: i32, mfa_code: &str) -> AppResult { + use totp_rs::{Algorithm, Secret, TOTP}; + + // Get temporary secret (in production this would be from Redis) + let temp_key = format!("temp_mfa_{}", user_id); + let secret = match self.get_temp_mfa_secret(&temp_key).await? { + Some(secret) => secret, + None => return Ok(false), + }; + + // Create TOTP instance and verify code + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + Secret::Encoded(secret.clone()).to_bytes().unwrap(), + Some("Pinepods".to_string()), + "verification".to_string(), + ).map_err(|e| AppError::internal(&format!("TOTP creation failed: {}", e)))?; + + let verified = totp.check_current(mfa_code) + .map_err(|e| AppError::internal(&format!("TOTP verification failed: {}", e)))?; + + if verified { + // Save to permanent storage and remove from temp + self.save_mfa_secret(user_id, &secret).await?; + self.remove_temp_mfa_secret(&temp_key).await?; + } + + Ok(verified) + } + + // Get temporary MFA secret from memory (matches Python temp_mfa_secrets lookup) + async fn get_temp_mfa_secret(&self, key: &str) -> AppResult> { + let secrets = TEMP_MFA_SECRETS.lock().map_err(|e| AppError::internal(&format!("Failed to lock temp MFA secrets: {}", e)))?; + Ok(secrets.get(key).cloned()) + } + + // Remove temporary MFA secret from memory (matches Python temp_mfa_secrets cleanup) + async fn remove_temp_mfa_secret(&self, key: &str) -> AppResult<()> { + let mut secrets = TEMP_MFA_SECRETS.lock().map_err(|e| AppError::internal(&format!("Failed to lock temp MFA secrets: {}", e)))?; + secrets.remove(key); + Ok(()) + } + + // Check if MFA is enabled - matches Python check_mfa_enabled function exactly + pub async fn check_mfa_enabled(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT mfa_secret FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let secret: Option = row.try_get("mfa_secret")?; + Ok(secret.is_some()) + } else { + Ok(false) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT MFA_Secret FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let secret: Option = row.try_get("MFA_Secret")?; + Ok(secret.is_some()) + } else { + Ok(false) + } + } + } + } + + // Save MFA secret - matches Python save_mfa_secret function exactly + pub async fn save_mfa_secret(&self, user_id: i32, mfa_secret: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"UPDATE "Users" SET mfa_secret = $1 WHERE userid = $2"#) + .bind(mfa_secret) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("UPDATE Users SET MFA_Secret = ? WHERE UserID = ?") + .bind(mfa_secret) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Delete MFA secret - matches Python delete_mfa_secret function exactly + pub async fn delete_mfa_secret(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"UPDATE "Users" SET mfa_secret = NULL WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("UPDATE Users SET MFA_Secret = NULL WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Initiate Nextcloud login - matches Python initiate_nextcloud_login function exactly + pub async fn initiate_nextcloud_login(&self, _user_id: i32, nextcloud_url: &str) -> AppResult { + let client = reqwest::Client::new(); + + // Call Nextcloud login flow v2 API + let login_url = format!("{}/index.php/login/v2", nextcloud_url.trim_end_matches('/')); + + let response = client + .post(&login_url) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to initiate Nextcloud login: {}", e)))?; + + if !response.status().is_success() { + return Err(AppError::internal("Nextcloud login initiation failed")); + } + + let json: serde_json::Value = response.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse Nextcloud response: {}", e)))?; + + // Return the raw JSON response from Nextcloud to match Python behavior + Ok(NextcloudLoginData { + raw_response: json, + }) + } + + // Add Nextcloud server - matches Python add_nextcloud_server function exactly + pub async fn add_nextcloud_server(&self, user_id: i32, nextcloud_url: &str, token: &str) -> AppResult { + let client = reqwest::Client::new(); + + // Poll for completion + let poll_url = format!("{}/index.php/login/v2/poll", nextcloud_url.trim_end_matches('/')); + let poll_data = serde_json::json!({ "token": token }); + + let response = client + .post(&poll_url) + .json(&poll_data) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to poll Nextcloud: {}", e)))?; + + if !response.status().is_success() { + return Ok(false); + } + + let json: serde_json::Value = response.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse Nextcloud poll response: {}", e)))?; + + let app_password = json["appPassword"].as_str() + .ok_or_else(|| AppError::internal("Missing app password in Nextcloud response"))?; + let login_name = json["loginName"].as_str() + .ok_or_else(|| AppError::internal("Missing login name in Nextcloud response"))?; + + // Encrypt the app password + let encrypted_password = self.encrypt_password(app_password).await?; + + // Store Nextcloud credentials + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET gpodderurl = $1, gpodderloginname = $2, gpoddertoken = $3, pod_sync_type = 'nextcloud' WHERE userid = $4"#) + .bind(nextcloud_url) + .bind(login_name) + .bind(&encrypted_password) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET GpodderUrl = ?, GpodderLoginName = ?, GpodderToken = ?, Pod_Sync_Type = 'nextcloud' WHERE UserID = ?") + .bind(nextcloud_url) + .bind(login_name) + .bind(&encrypted_password) + .bind(user_id) + .execute(pool) + .await?; + } + } + + // Perform initial full sync for Nextcloud to get ALL user subscriptions + if let Err(e) = self.call_nextcloud_initial_full_sync(user_id, nextcloud_url, login_name, app_password).await { + tracing::warn!("Initial Nextcloud full sync failed during setup: {}", e); + // Don't fail setup if initial sync fails + } + + Ok(true) + } + + // Save Nextcloud credentials - helper method for background polling + pub async fn save_nextcloud_credentials(&self, user_id: i32, nextcloud_url: &str, app_password: &str, login_name: &str) -> AppResult<()> { + // Encrypt the app password + let encrypted_password = self.encrypt_password(app_password).await?; + + // Store Nextcloud credentials + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET gpodderurl = $1, gpodderloginname = $2, gpoddertoken = $3, pod_sync_type = 'nextcloud' WHERE userid = $4"#) + .bind(nextcloud_url) + .bind(login_name) + .bind(&encrypted_password) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET GpodderUrl = ?, GpodderLoginName = ?, GpodderToken = ?, Pod_Sync_Type = 'nextcloud' WHERE UserID = ?") + .bind(nextcloud_url) + .bind(login_name) + .bind(&encrypted_password) + .bind(user_id) + .execute(pool) + .await?; + } + } + + Ok(()) + } + + + // Add gPodder server - matches Python add_gpodder_server function exactly + pub async fn add_gpodder_server(&self, user_id: i32, gpodder_url: &str, username: &str, password: &str) -> AppResult { + // Encrypt the password + let encrypted_password = self.encrypt_password(password).await?; + + // Try to get devices from the external server to set a default device + let default_device_name = match self.get_first_device_from_server(gpodder_url, username, password).await { + Ok(device_name) => Some(device_name), + Err(e) => { + tracing::warn!("Could not get default device from external GPodder server: {}", e); + None + } + }; + + // Store gPodder credentials + match self { + DatabasePool::Postgres(pool) => { + if let Some(device_name) = &default_device_name { + sqlx::query(r#"UPDATE "Users" SET gpodderurl = $1, gpodderloginname = $2, gpoddertoken = $3, pod_sync_type = 'external', defaultgpodderdevice = $5 WHERE userid = $4"#) + .bind(gpodder_url) + .bind(username) + .bind(&encrypted_password) + .bind(user_id) + .bind(device_name) + .execute(pool) + .await?; + } else { + sqlx::query(r#"UPDATE "Users" SET gpodderurl = $1, gpodderloginname = $2, gpoddertoken = $3, pod_sync_type = 'external' WHERE userid = $4"#) + .bind(gpodder_url) + .bind(username) + .bind(&encrypted_password) + .bind(user_id) + .execute(pool) + .await?; + } + } + DatabasePool::MySQL(pool) => { + if let Some(device_name) = &default_device_name { + sqlx::query("UPDATE Users SET GpodderUrl = ?, GpodderLoginName = ?, GpodderToken = ?, Pod_Sync_Type = 'external', DefaultGpodderDevice = ? WHERE UserID = ?") + .bind(gpodder_url) + .bind(username) + .bind(&encrypted_password) + .bind(device_name) + .bind(user_id) + .execute(pool) + .await?; + } else { + sqlx::query("UPDATE Users SET GpodderUrl = ?, GpodderLoginName = ?, GpodderToken = ?, Pod_Sync_Type = 'external' WHERE UserID = ?") + .bind(gpodder_url) + .bind(username) + .bind(&encrypted_password) + .bind(user_id) + .execute(pool) + .await?; + } + } + } + + // Spawn initial full sync as background task to avoid blocking the API response + if let Some(device_name) = default_device_name.clone() { + let pool_clone = self.clone(); + let gpodder_url_owned = gpodder_url.to_string(); + let username_owned = username.to_string(); + let password_owned = password.to_string(); + + tokio::spawn(async move { + if let Err(e) = pool_clone.call_gpodder_initial_full_sync(user_id, &gpodder_url_owned, &username_owned, &password_owned, &device_name).await { + tracing::warn!("Initial GPodder full sync failed during external server setup: {}", e); + } + }); + } + + Ok(true) + } + + // Helper function to get first device from external GPodder server + async fn get_first_device_from_server(&self, gpodder_url: &str, username: &str, password: &str) -> AppResult { + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + + let devices_url = format!("{}/api/2/devices/{}.json", gpodder_url.trim_end_matches('/'), username); + + let response = if session.authenticated { + session.client.get(&devices_url).send().await + } else { + session.client.get(&devices_url).basic_auth(username, Some(password)).send().await + }; + + match response { + Ok(resp) if resp.status().is_success() => { + let devices_data: serde_json::Value = resp.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse devices: {}", e)))?; + + if let Some(devices_array) = devices_data.as_array() { + if let Some(first_device) = devices_array.first() { + if let Some(device_id) = first_device.get("id").and_then(|v| v.as_str()) { + return Ok(device_id.to_string()); + } + } + } + + Err(AppError::internal("No devices found on external GPodder server")) + } + Ok(resp) => { + Err(AppError::internal(&format!("Failed to get devices from external server: {}", resp.status()))) + } + Err(e) => { + Err(AppError::internal(&format!("Error connecting to external server: {}", e))) + } + } + } + + // Encrypt password using Fernet - matches Python encryption + pub async fn encrypt_password(&self, password: &str) -> AppResult { + use fernet::Fernet; + + // Get encryption key from app settings (base64 string) + let encryption_key = self.get_encryption_key().await?; + let fernet = Fernet::new(&encryption_key) + .ok_or_else(|| AppError::internal("Failed to create Fernet cipher"))?; + + let encrypted = fernet.encrypt(password.as_bytes()); + Ok(encrypted) + } + + // Get encryption key from app settings + async fn get_encryption_key(&self) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Try as string first (new format), fallback to bytes (old format) + let row = sqlx::query(r#"SELECT encryptionkey FROM "AppSettings" LIMIT 1"#) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + // Try string first + if let Ok(key_string) = row.try_get::("encryptionkey") { + return Ok(key_string); + } + // Fallback to bytes and convert to string + let key_bytes: Option> = row.try_get("encryptionkey")?; + if let Some(bytes) = key_bytes { + String::from_utf8(bytes) + .map_err(|e| AppError::internal(&format!("Invalid UTF-8 in encryption key: {}", e))) + } else { + Err(AppError::internal("Encryption key not found")) + } + } else { + Err(AppError::internal("App settings not found")) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT EncryptionKey FROM AppSettings LIMIT 1") + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let key: Option = row.try_get("EncryptionKey")?; + key.ok_or_else(|| AppError::internal("Encryption key not found")) + } else { + Err(AppError::internal("App settings not found")) + } + } + } + } + + // Get gPodder settings - matches Python get_gpodder_settings function exactly + pub async fn get_gpodder_settings(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT gpodderurl, gpodderloginname, pod_sync_type FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let url: Option = row.try_get("gpodderurl")?; + let username: Option = row.try_get("gpodderloginname")?; + let sync_type: Option = row.try_get("pod_sync_type")?; + + if url.is_some() && username.is_some() { + Ok(Some(serde_json::json!({ + "gpodderurl": url.unwrap_or_default(), + "gpoddertoken": "", // Frontend expects this field but it's not used for display + "sync_type": sync_type.unwrap_or_default() + }))) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT GpodderUrl, GpodderLoginName, Pod_Sync_Type FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let url: Option = row.try_get("GpodderUrl")?; + let username: Option = row.try_get("GpodderLoginName")?; + let sync_type: Option = row.try_get("Pod_Sync_Type")?; + + if url.is_some() && username.is_some() { + Ok(Some(serde_json::json!({ + "gpodderurl": url.unwrap_or_default(), + "gpoddertoken": "", // Frontend expects this field but it's not used for display + "sync_type": sync_type.unwrap_or_default() + }))) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + } + } + + // Check gPodder settings - matches Python check_gpodder_settings function exactly + pub async fn check_gpodder_settings(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT gpodderurl, gpodderloginname FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let url: Option = row.try_get("gpodderurl")?; + let username: Option = row.try_get("gpodderloginname")?; + Ok(url.is_some() && username.is_some()) + } else { + Ok(false) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT GpodderUrl, GpodderLoginName FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let url: Option = row.try_get("GpodderUrl")?; + let username: Option = row.try_get("GpodderLoginName")?; + Ok(url.is_some() && username.is_some()) + } else { + Ok(false) + } + } + } + } + + // Remove podcast sync - matches Python remove_podcast_sync function exactly + pub async fn remove_podcast_sync(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"UPDATE "Users" SET gpodderurl = NULL, gpodderloginname = NULL, gpoddertoken = NULL, pod_sync_type = 'None' WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("UPDATE Users SET GpodderUrl = NULL, GpodderLoginName = NULL, GpodderToken = NULL, Pod_Sync_Type = 'None' WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Set default gPodder device - matches Python set_default_device function exactly + pub async fn gpodder_set_default_device(&self, user_id: i32, device_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Clear all existing defaults for user + sqlx::query(r#"UPDATE "GpodderDevices" SET isdefault = false WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + + // Set new default + let result = sqlx::query(r#"UPDATE "GpodderDevices" SET isdefault = true WHERE deviceid = $1 AND userid = $2"#) + .bind(device_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + // Clear all existing defaults for user + sqlx::query("UPDATE GpodderDevices SET IsDefault = false WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + + // Set new default + let result = sqlx::query("UPDATE GpodderDevices SET IsDefault = true WHERE DeviceID = ? AND UserID = ?") + .bind(device_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Set default gPodder device by name - for frontend compatibility + pub async fn gpodder_set_default_device_by_name(&self, user_id: i32, device_name: &str) -> AppResult { + println!("Setting default device: user_id={}, device_name='{}'", user_id, device_name); + + // Check sync type to determine which tables to update + let sync_settings = self.get_user_sync_settings(user_id).await?; + let is_external = sync_settings + .as_ref() + .map(|s| s.sync_type == "external") + .unwrap_or(false); + + match self { + DatabasePool::Postgres(pool) => { + if !is_external { + // For internal/both: update GpodderDevices table + let clear_result = sqlx::query(r#"UPDATE "GpodderDevices" SET isdefault = false WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + println!("Cleared {} devices for user {}", clear_result.rows_affected(), user_id); + + let result = sqlx::query(r#"UPDATE "GpodderDevices" SET isdefault = true WHERE devicename = $1 AND userid = $2"#) + .bind(device_name) + .bind(user_id) + .execute(pool) + .await?; + println!("Set default result: {} rows affected", result.rows_affected()); + } + + // Always update Users table (for both internal and external) + sqlx::query(r#"UPDATE "Users" SET defaultgpodderdevice = $1 WHERE userid = $2"#) + .bind(device_name) + .bind(user_id) + .execute(pool) + .await?; + println!("Updated Users table with default device"); + + Ok(true) + } + DatabasePool::MySQL(pool) => { + if !is_external { + // For internal/both: update GpodderDevices table + let clear_result = sqlx::query("UPDATE GpodderDevices SET IsDefault = false WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + println!("Cleared {} devices for user {}", clear_result.rows_affected(), user_id); + + let result = sqlx::query("UPDATE GpodderDevices SET IsDefault = true WHERE DeviceName = ? AND UserID = ?") + .bind(device_name) + .bind(user_id) + .execute(pool) + .await?; + println!("Set default result: {} rows affected", result.rows_affected()); + } + + // Always update Users table (for both internal and external) + sqlx::query("UPDATE Users SET DefaultGpodderDevice = ? WHERE UserID = ?") + .bind(device_name) + .bind(user_id) + .execute(pool) + .await?; + println!("Updated Users table with default device"); + + Ok(true) + } + } + } + + // Get gPodder devices for user - matches Python get_devices function exactly with remote device support + pub async fn gpodder_get_user_devices(&self, user_id: i32) -> AppResult> { + // Check what type of sync is configured + if let Some(sync_settings) = self.get_user_sync_settings(user_id).await? { + match sync_settings.sync_type.as_str() { + "gpodder" | "external" => { + // Both internal and external use HTTP API calls to GPodder server + // Internal: http://localhost:8042, External: user's configured URL + let mut devices = self.fetch_devices_from_gpodder_api(&sync_settings).await?; + + // Get the default device name from Users table to mark the correct device + let default_device_name = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT defaultgpodderdevice FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + row.and_then(|r| r.try_get::, _>("defaultgpodderdevice").ok().flatten()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT DefaultGpodderDevice FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + row.and_then(|r| r.try_get::, _>("DefaultGpodderDevice").ok().flatten()) + } + }; + + // Mark the default device + if let Some(default_name) = default_device_name { + for device in &mut devices { + if device.get("name").and_then(|v| v.as_str()) == Some(&default_name) { + device["is_default"] = serde_json::Value::Bool(true); + device["is_remote"] = serde_json::Value::Bool(false); // Default device is treated as local + break; + } + } + } + + Ok(devices) + } + "nextcloud" => { + // Nextcloud doesn't have device concept like GPodder + Ok(vec![]) + } + _ => { + // No sync configured - return empty list + Ok(vec![]) + } + } + } else { + // No sync settings found - return empty list + Ok(vec![]) + } + } + + // Get local devices only - internal helper + async fn get_local_devices(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT deviceid, devicename, devicetype, isdefault FROM "GpodderDevices" WHERE userid = $1 ORDER BY devicename"#) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut devices = Vec::new(); + for row in rows { + devices.push(serde_json::json!({ + "id": row.try_get::("deviceid")?, + "name": row.try_get::("devicename")?, + "type": row.try_get::("devicetype")?, + "caption": Option::::None, // Local devices don't have captions + "last_sync": Option::::None, + "is_active": true, + "is_default": row.try_get::("isdefault")?, + "is_remote": false + })); + } + Ok(devices) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT DeviceID, DeviceName, DeviceType, IsDefault FROM GpodderDevices WHERE UserID = ? ORDER BY DeviceName") + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut devices = Vec::new(); + for row in rows { + devices.push(serde_json::json!({ + "id": row.try_get::("DeviceID")?, + "name": row.try_get::("DeviceName")?, + "type": row.try_get::("DeviceType")?, + "caption": Option::::None, + "last_sync": Option::::None, + "is_active": true, + "is_default": row.try_get::("IsDefault")?, + "is_remote": false + })); + } + Ok(devices) + } + } + } + + // Fetch devices from GPodder API server - works for both internal and external + async fn fetch_devices_from_gpodder_api(&self, settings: &UserSyncSettings) -> AppResult> { + // For internal GPodder API, use X-GPodder-Token header + // For external GPodder API, use session auth with basic auth fallback + let (client, auth_headers) = if settings.url == "http://localhost:8042" { + // Internal GPodder API - use X-GPodder-Token + let client = reqwest::Client::new(); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("X-GPodder-Token", settings.token.parse().unwrap()); + (client, Some(headers)) + } else { + // External GPodder API - decrypt password and use session auth + let decrypted_password = self.decrypt_password(&settings.token).await?; + let session = self.create_gpodder_session_with_password(&settings.url, &settings.username, &decrypted_password).await?; + (session.client, None) + }; + + let devices_url = format!("{}/api/2/devices/{}.json", + settings.url.trim_end_matches('/'), settings.username); + + let mut request = client.get(&devices_url); + + // Add authentication headers if needed + if let Some(headers) = auth_headers { + request = request.headers(headers); + } else { + // External server with session auth - if session failed, fall back to basic auth + let decrypted_password = self.decrypt_password(&settings.token).await?; + request = request.basic_auth(&settings.username, Some(&decrypted_password)); + } + + let response = request + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to fetch devices from GPodder API: {}", e)))?; + + if response.status().is_success() { + let devices_data: serde_json::Value = response.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse devices from GPodder API: {}", e)))?; + + let mut devices = Vec::new(); + + if let Some(devices_array) = devices_data.as_array() { + for device in devices_array.iter() { + if let Some(device_id) = device["id"].as_str() { + // Convert GPodder API response to Pinepods device format + devices.push(serde_json::json!({ + "id": device_id, // Use actual GPodder device ID (string) + "name": device_id, + "type": device["type"].as_str().unwrap_or("other"), + "caption": device["caption"].as_str().unwrap_or(device_id), + "last_sync": Option::::None, + "is_active": true, + "is_default": false, // GPodder devices are not local defaults + "is_remote": true, + "subscriptions": device["subscriptions"].as_i64().unwrap_or(0) + })); + } + } + } + + Ok(devices) + } else { + tracing::warn!("Failed to fetch devices from GPodder API: {}", response.status()); + Ok(Vec::new()) + } + } + + // Create gPodder session with authentication - matches Python session handling + async fn create_gpodder_session(&self, settings: &UserSyncSettings) -> AppResult { + let client = reqwest::Client::builder() + .build() + .map_err(|e| AppError::internal(&format!("Failed to create HTTP client: {}", e)))?; + + let password = if settings.url == "http://localhost:8042" { + // Internal API uses encrypted token directly + settings.token.clone() + } else { + // External API needs decrypted password + self.decrypt_password(&settings.token).await? + }; + + // Try session-based authentication first - matches Python login flow + let login_url = format!("{}/api/2/auth/{}/login.json", + settings.url.trim_end_matches('/'), settings.username); + + let login_response = client + .post(&login_url) + .basic_auth(&settings.username, Some(&password)) + .send() + .await; + + let session_authenticated = match login_response { + Ok(response) if response.status().is_success() => { + tracing::info!("gPodder session authentication successful"); + true + } + _ => { + tracing::warn!("gPodder session authentication failed, will use basic auth"); + false + } + }; + + Ok(GpodderSession { + client, + session_id: None, + authenticated: session_authenticated, + }) + } + + // Create gPodder session with already-decrypted password (avoids double decryption) + async fn create_gpodder_session_with_password(&self, gpodder_url: &str, username: &str, password: &str) -> AppResult { + let jar = std::sync::Arc::new(reqwest::cookie::Jar::default()); + let client = reqwest::Client::builder() + .cookie_provider(jar) + .build() + .map_err(|e| AppError::internal(&format!("Failed to create HTTP client: {}", e)))?; + + // Try session-based authentication first - matches Python login flow + let login_url = format!("{}/api/2/auth/{}/login.json", + gpodder_url.trim_end_matches('/'), username); + + let login_response = client + .post(&login_url) + .basic_auth(username, Some(password)) + .send() + .await; + + let session_authenticated = match login_response { + Ok(response) if response.status().is_success() => { + tracing::info!("gPodder session authentication successful"); + true + } + _ => { + tracing::warn!("gPodder session authentication failed, will use basic auth"); + false + } + }; + + Ok(GpodderSession { + client, + session_id: None, + authenticated: session_authenticated, + }) + } + + // gPodder force sync - calls Go gPodder service exactly like Python force_full_sync_to_gpodder + pub async fn gpodder_force_sync(&self, user_id: i32) -> AppResult { + let sync_settings = self.get_user_sync_settings(user_id).await?; + if sync_settings.is_none() { + return Ok(false); + } + + let settings = sync_settings.unwrap(); + let device_name = self.get_or_create_default_device(user_id).await?; + + match settings.sync_type.as_str() { + "gpodder" => { + // Internal gPodder API on localhost:8042 - use unencrypted token directly + self.call_gpodder_service_sync(user_id, "http://localhost:8042", &settings.username, &settings.token, &device_name, true).await + } + "nextcloud" => { + self.sync_with_nextcloud(user_id, &settings, true).await + } + "external" => { + // External gPodder server - decrypt token first + let decrypted_token = self.decrypt_password(&settings.token).await?; + self.call_gpodder_service_sync(user_id, &settings.url, &settings.username, &decrypted_token, &device_name, true).await + } + "both" => { + let internal_result = self.call_gpodder_service_sync(user_id, "http://localhost:8042", &settings.username, &settings.token, &device_name, true).await?; + let decrypted_token = self.decrypt_password(&settings.token).await?; + let external_result = self.call_gpodder_service_sync(user_id, &settings.url, &settings.username, &decrypted_token, &device_name, true).await?; + Ok(internal_result || external_result) + } + _ => Ok(false) + } + } + + // gPodder regular sync - calls Go gPodder service exactly like Python refresh_gpodder_subscription + pub async fn gpodder_sync(&self, user_id: i32) -> AppResult { + let sync_settings = self.get_user_sync_settings(user_id).await?; + if sync_settings.is_none() { + return Ok(SyncResult { synced_podcasts: 0, synced_episodes: 0 }); + } + + let settings = sync_settings.unwrap(); + let device_name = self.get_or_create_default_device(user_id).await?; + + let success = match settings.sync_type.as_str() { + "gpodder" => { + self.call_gpodder_service_sync(user_id, "http://localhost:8042", &settings.username, &settings.token, &device_name, false).await? + } + "nextcloud" => { + self.sync_with_nextcloud(user_id, &settings, false).await? + } + "external" => { + let decrypted_token = self.decrypt_password(&settings.token).await?; + self.call_gpodder_service_sync(user_id, &settings.url, &settings.username, &decrypted_token, &device_name, false).await? + } + "both" => { + let internal_success = self.call_gpodder_service_sync(user_id, "http://localhost:8042", &settings.username, &settings.token, &device_name, false).await?; + let decrypted_token = self.decrypt_password(&settings.token).await?; + let external_success = self.call_gpodder_service_sync(user_id, &settings.url, &settings.username, &decrypted_token, &device_name, false).await?; + internal_success || external_success + } + _ => false + }; + + Ok(SyncResult { + synced_podcasts: if success { 1 } else { 0 }, + synced_episodes: 0 + }) + } + + // Call gPodder service for sync - matches Python API calls exactly with enhanced error handling + async fn call_gpodder_service_sync(&self, user_id: i32, gpodder_url: &str, username: &str, password: &str, device_name: &str, force: bool) -> AppResult { + // Step 1: Get ALL devices first (critical for detecting changes from external devices like AntennaPod) + let devices_url = format!("{}/api/2/devices/{}.json", + gpodder_url.trim_end_matches('/'), username); + + // Use correct authentication based on internal vs external + let devices_response = if gpodder_url == "http://localhost:8042" { + // Internal GPodder API - use X-GPodder-Token header + let client = reqwest::Client::new(); + client.get(&devices_url) + .header("X-GPodder-Token", password) + .send() + .await + } else { + // External GPodder API - use session auth with basic fallback + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + session.client + .get(&devices_url) + .send() + .await + } else { + session.client + .get(&devices_url) + .basic_auth(username, Some(password)) + .send() + .await + } + }; + + let devices = match devices_response { + Ok(resp) if resp.status().is_success() => { + let devices_data: serde_json::Value = resp.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse devices: {}", e)))?; + + if let Some(devices_array) = devices_data.as_array() { + let device_names: Vec = devices_array.iter() + .filter_map(|device| device.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + + tracing::info!("Found {} devices for user: {:?}", device_names.len(), device_names); + device_names + } else { + tracing::warn!("No devices found for user"); + vec![] + } + } + Ok(resp) => { + tracing::warn!("Failed to get devices: {}", resp.status()); + if force { + return Err(AppError::internal(&format!("Force sync failed to get devices: {}", resp.status()))); + } else { + return Ok(false); + } + } + Err(e) => { + tracing::error!("Failed to connect to get devices: {}", e); + if force { + return Err(AppError::internal(&format!("Force sync connection failed: {}", e))); + } else { + return Ok(false); + } + } + }; + + tracing::info!("Found {} devices to sync from: {:?}", devices.len(), devices); + + // Step 2: Get subscriptions from ALL devices with timestamps (like AntennaPod sync) + let since_timestamp = self.get_last_sync_timestamp(user_id).await?; + let mut all_subscriptions = std::collections::HashSet::new(); + let mut all_removals = std::collections::HashSet::new(); + + for device_id in &devices { + tracing::info!("Getting subscriptions from device: {}", device_id); + + let subscriptions_url = if let Some(since) = since_timestamp { + format!("{}/api/2/subscriptions/{}/{}.json?since={}", + gpodder_url.trim_end_matches('/'), username, device_id, since.timestamp()) + } else { + format!("{}/api/2/subscriptions/{}/{}.json?since=0", + gpodder_url.trim_end_matches('/'), username, device_id) + }; + + let device_response = if gpodder_url == "http://localhost:8042" { + let client = reqwest::Client::new(); + client.get(&subscriptions_url) + .header("X-GPodder-Token", password) + .send() + .await + } else { + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + session.client + .get(&subscriptions_url) + .send() + .await + } else { + session.client + .get(&subscriptions_url) + .basic_auth(username, Some(password)) + .send() + .await + } + }; + + match device_response { + Ok(resp) if resp.status().is_success() => { + let response_text = resp.text().await + .map_err(|e| AppError::internal(&format!("Failed to get response text: {}", e)))?; + + let sync_response: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|e| AppError::internal(&format!("Failed to parse gPodder sync response: {}", e)))?; + + let device_subscriptions = sync_response["add"].as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>(); + + let device_removals = sync_response["remove"].as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>(); + + tracing::info!("Device {} has {} subscriptions and {} removals", device_id, device_subscriptions.len(), device_removals.len()); + + // Add to combined sets (HashSet automatically deduplicates) + for subscription in device_subscriptions { + all_subscriptions.insert(subscription); + } + + for removal in device_removals { + all_removals.insert(removal); + } + } + Ok(resp) => { + tracing::warn!("Device {} returned error: {}", device_id, resp.status()); + // Continue with other devices instead of failing + } + Err(e) => { + tracing::warn!("Failed to get subscriptions from device {}: {}", device_id, e); + // Continue with other devices instead of failing + } + } + } + + tracing::info!("Downloaded {} unique subscriptions and {} unique removals from ALL devices", all_subscriptions.len(), all_removals.len()); + + // Step 3: Get episode actions from ALL devices with timestamps + let mut all_episode_actions = Vec::new(); + + for device_id in &devices { + tracing::info!("Getting episode actions from device: {}", device_id); + + let episode_actions_url = if let Some(since) = since_timestamp { + format!("{}/api/2/episodes/{}.json?since={}&device={}", + gpodder_url.trim_end_matches('/'), username, since.timestamp(), device_id) + } else { + format!("{}/api/2/episodes/{}.json?since=0&device={}", + gpodder_url.trim_end_matches('/'), username, device_id) + }; + + let device_response = if gpodder_url == "http://localhost:8042" { + let client = reqwest::Client::new(); + client.get(&episode_actions_url) + .header("X-GPodder-Token", password) + .send() + .await + } else { + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + session.client + .get(&episode_actions_url) + .send() + .await + } else { + session.client + .get(&episode_actions_url) + .basic_auth(username, Some(password)) + .send() + .await + } + }; + + match device_response { + Ok(resp) if resp.status().is_success() => { + let response_text = resp.text().await + .map_err(|e| AppError::internal(&format!("Failed to get episode actions response text: {}", e)))?; + + let episode_actions: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|e| AppError::internal(&format!("Failed to parse episode actions response: {}", e)))?; + + if let Some(actions_array) = episode_actions["actions"].as_array() { + let device_actions_count = actions_array.len(); + tracing::info!("Device {} has {} episode actions", device_id, device_actions_count); + + for action in actions_array { + all_episode_actions.push(action.clone()); + } + } + } + Ok(resp) => { + tracing::warn!("Device {} episode actions returned error: {}", device_id, resp.status()); + } + Err(e) => { + tracing::warn!("Failed to get episode actions from device {}: {}", device_id, e); + } + } + } + + tracing::info!("Downloaded {} episode actions from ALL devices", all_episode_actions.len()); + + // Step 4: Process all subscriptions (additions) + let subscriptions_vec: Vec = all_subscriptions.into_iter().collect(); + self.process_gpodder_subscriptions(user_id, &subscriptions_vec).await?; + + // Step 5: Process all subscription removals + let removals_vec: Vec = all_removals.into_iter().collect(); + if !removals_vec.is_empty() { + self.process_gpodder_subscription_removals(user_id, &removals_vec).await?; + } + + // Step 6: Process all episode actions + if !all_episode_actions.is_empty() { + if let Err(e) = self.apply_remote_episode_actions(user_id, &all_episode_actions).await { + tracing::warn!("Episode actions processing failed but continuing: {}", e); + } + } + + // Step 7: Upload local subscriptions to gPodder service (to default device) + if since_timestamp.is_none() || !subscriptions_vec.is_empty() || !removals_vec.is_empty() { + let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; + self.upload_subscriptions_to_gpodder(gpodder_url, username, password, device_name, &local_subscriptions).await?; + } else { + tracing::info!("Skipping subscription upload - no changes detected in incremental sync"); + } + + // Step 8: Upload local episode actions to gPodder service (to default device) + if let Err(e) = self.sync_episode_actions_with_gpodder(gpodder_url, username, password, device_name, user_id).await { + tracing::warn!("Episode actions sync failed but continuing: {}", e); + } + + // Step 9: Update last sync timestamp for next incremental sync + self.update_last_sync_timestamp(user_id).await?; + + Ok(true) + } + + // Initial full sync for GPodder - gets ALL user subscriptions from ALL devices + pub async fn call_gpodder_initial_full_sync(&self, user_id: i32, gpodder_url: &str, username: &str, password: &str, device_name: &str) -> AppResult { + tracing::info!("Starting initial full GPodder sync for user {} from {}", user_id, gpodder_url); + + // Step 1: Get ALL devices first (this is how AntennaPod and other apps do it) + let devices_url = format!("{}/api/2/devices/{}.json", + gpodder_url.trim_end_matches('/'), username); + + let devices_response = if gpodder_url == "http://localhost:8042" { + let client = reqwest::Client::new(); + client.get(&devices_url).header("X-GPodder-Token", password).send().await + } else { + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + session.client.get(&devices_url).send().await + } else { + session.client.get(&devices_url).basic_auth(username, Some(password)).send().await + } + }; + + let devices = match devices_response { + Ok(resp) if resp.status().is_success() => { + let devices_data: serde_json::Value = resp.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse devices: {}", e)))?; + + if let Some(devices_array) = devices_data.as_array() { + let device_names: Vec = devices_array.iter() + .filter_map(|device| device.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + + tracing::info!("Found {} devices for user: {:?}", device_names.len(), device_names); + device_names + } else { + tracing::warn!("No devices found for user"); + vec![] + } + } + Ok(resp) => { + tracing::error!("Failed to get devices: {}", resp.status()); + return Ok(false); + } + Err(e) => { + tracing::error!("Failed to connect for devices: {}", e); + return Ok(false); + } + }; + + // Step 2: Get subscriptions from ALL devices (like AntennaPod does) + let mut all_subscriptions = std::collections::HashSet::new(); + + for device_id in &devices { + tracing::info!("Getting subscriptions from device: {}", device_id); + + let subscriptions_url = format!("{}/api/2/subscriptions/{}/{}.json?since=0", + gpodder_url.trim_end_matches('/'), username, device_id); + + let response = if gpodder_url == "http://localhost:8042" { + let client = reqwest::Client::new(); + client.get(&subscriptions_url).header("X-GPodder-Token", password).send().await + } else { + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + session.client.get(&subscriptions_url).send().await + } else { + session.client.get(&subscriptions_url).basic_auth(username, Some(password)).send().await + } + }; + + match response { + Ok(resp) if resp.status().is_success() => { + let response_text = resp.text().await + .map_err(|e| AppError::internal(&format!("Failed to get response text: {}", e)))?; + + let sync_response: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|e| AppError::internal(&format!("Failed to parse gPodder sync response: {}", e)))?; + + let device_subscriptions = sync_response["add"].as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>(); + + tracing::info!("Device {} has {} subscriptions", device_id, device_subscriptions.len()); + + // Add all subscriptions to our set (deduplicates automatically) + for subscription in device_subscriptions { + all_subscriptions.insert(subscription); + } + } + Ok(resp) => { + tracing::warn!("Failed to get subscriptions from device {}: {}", device_id, resp.status()); + // Continue with other devices + } + Err(e) => { + tracing::warn!("Error getting subscriptions from device {}: {}", device_id, e); + // Continue with other devices + } + } + } + + let all_subscriptions: Vec = all_subscriptions.into_iter().collect(); + tracing::info!("Total unique subscriptions from all devices: {}", all_subscriptions.len()); + + // Step 2: Get episode actions from ALL devices (like subscription sync) + let mut all_episode_actions = Vec::new(); + + for device_id in &devices { + tracing::info!("Getting episode actions from device: {}", device_id); + + let episode_actions_url = format!("{}/api/2/episodes/{}.json?since=0&device={}", + gpodder_url.trim_end_matches('/'), username, device_id); + + let response = if gpodder_url == "http://localhost:8042" { + let client = reqwest::Client::new(); + client.get(&episode_actions_url).header("X-GPodder-Token", password).send().await + } else { + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + session.client.get(&episode_actions_url).send().await + } else { + session.client.get(&episode_actions_url).basic_auth(username, Some(password)).send().await + } + }; + + match response { + Ok(resp) if resp.status().is_success() => { + let episode_data: serde_json::Value = resp.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse episode actions for device {}: {}", device_id, e)))?; + + if let Some(actions) = episode_data.get("actions").and_then(|v| v.as_array()) { + tracing::info!("Device {} has {} episode actions", device_id, actions.len()); + + // Add all episode actions from this device + for action in actions { + all_episode_actions.push(action.clone()); + } + } + } + Ok(resp) => { + tracing::warn!("Failed to get episode actions from device {}: {}", device_id, resp.status()); + // Continue with other devices + } + Err(e) => { + tracing::warn!("Error getting episode actions from device {}: {}", device_id, e); + // Continue with other devices + } + } + } + + tracing::info!("Total episode actions from all devices: {}", all_episode_actions.len()); + + // Process all episode actions and apply them locally + if !all_episode_actions.is_empty() { + if let Err(e) = self.apply_remote_episode_actions(user_id, &all_episode_actions).await { + tracing::warn!("Failed to apply remote episode actions: {}", e); + } + } + + // Step 3: Process all subscriptions and add missing podcasts + self.process_gpodder_subscriptions(user_id, &all_subscriptions).await?; + + // Step 4: Upload local subscriptions to GPodder service + let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; + self.upload_subscriptions_to_gpodder(gpodder_url, username, password, device_name, &local_subscriptions).await?; + + // Step 5: Upload local episode actions to GPodder service + let local_episode_actions = self.get_user_episode_actions(user_id).await?; + if !local_episode_actions.is_empty() { + if let Err(e) = self.upload_episode_actions_to_gpodder(gpodder_url, username, password, &local_episode_actions).await { + tracing::warn!("Failed to upload local episode actions to GPodder: {}", e); + // Don't fail the sync if episode actions upload fails + } + } + + // Step 5: Clear any existing sync timestamp to start fresh for incremental syncs + self.clear_last_sync_timestamp(user_id).await?; + + tracing::info!("Initial full GPodder sync completed for user {}", user_id); + Ok(true) + } + + // Initial full sync for Nextcloud - gets ALL user subscriptions + pub async fn call_nextcloud_initial_full_sync(&self, user_id: i32, nextcloud_url: &str, username: &str, password: &str) -> AppResult { + tracing::info!("Starting initial full Nextcloud sync for user {} from {}", user_id, nextcloud_url); + + let client = reqwest::Client::new(); + + // Get ALL subscriptions from Nextcloud gPodder Sync app + let subscriptions_url = format!("{}/index.php/apps/gpoddersync/subscriptions", nextcloud_url.trim_end_matches('/')); + + let response = client + .get(&subscriptions_url) + .basic_auth(username, Some(password)) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to get Nextcloud subscriptions: {}", e)))?; + + if !response.status().is_success() { + tracing::error!("Failed to get Nextcloud subscriptions: {}", response.status()); + return Ok(false); + } + + let subscriptions: serde_json::Value = response.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse Nextcloud subscriptions: {}", e)))?; + + // Process subscriptions - Nextcloud returns array of feed URLs + let feed_urls = if let Some(feeds) = subscriptions.as_array() { + let urls: Vec = feeds.iter() + .filter_map(|f| f.as_str().map(|s| s.to_string())) + .collect(); + + tracing::info!("Downloaded {} subscriptions from Nextcloud", urls.len()); + urls + } else { + tracing::warn!("No subscriptions found in Nextcloud response"); + vec![] + }; + + // Get ALL episode actions from Nextcloud + let episode_actions_url = format!("{}/index.php/apps/gpoddersync/episode_action", nextcloud_url.trim_end_matches('/')); + + let episode_response = client + .get(&episode_actions_url) + .basic_auth(username, Some(password)) + .send() + .await; + + let mut all_episode_actions = Vec::new(); + + match episode_response { + Ok(resp) if resp.status().is_success() => { + let episode_data: serde_json::Value = resp.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse Nextcloud episode actions: {}", e)))?; + + if let Some(actions) = episode_data.get("actions").and_then(|v| v.as_array()) { + tracing::info!("Downloaded {} episode actions from Nextcloud", actions.len()); + + // Add all episode actions + for action in actions { + all_episode_actions.push(action.clone()); + } + } + } + Ok(resp) => { + tracing::warn!("Failed to get Nextcloud episode actions: {}", resp.status()); + // Continue even if episode actions fail + } + Err(e) => { + tracing::warn!("Error getting Nextcloud episode actions: {}", e); + // Continue even if episode actions fail + } + } + + // Process all episode actions and apply them locally + if !all_episode_actions.is_empty() { + if let Err(e) = self.apply_remote_episode_actions(user_id, &all_episode_actions).await { + tracing::warn!("Failed to apply remote episode actions from Nextcloud: {}", e); + } + } + + // Process all subscriptions and add missing podcasts + self.process_gpodder_subscriptions(user_id, &feed_urls).await?; + + // Upload local subscriptions to Nextcloud + let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; + self.upload_subscriptions_to_nextcloud(nextcloud_url, username, password, &local_subscriptions).await?; + + // Upload local episode actions to Nextcloud + let local_episode_actions = self.get_user_episode_actions(user_id).await?; + if !local_episode_actions.is_empty() { + if let Err(e) = self.upload_episode_actions_to_nextcloud(nextcloud_url, username, password, &local_episode_actions).await { + tracing::warn!("Failed to upload local episode actions to Nextcloud: {}", e); + // Don't fail the sync if episode actions upload fails + } + } + + // Clear any existing sync timestamp to start fresh for incremental syncs + self.clear_last_sync_timestamp(user_id).await?; + + tracing::info!("Initial full Nextcloud sync completed for user {}", user_id); + Ok(true) + } + + // Upload episode actions to GPodder server for initial sync + async fn upload_episode_actions_to_gpodder(&self, gpodder_url: &str, username: &str, password: &str, episode_actions: &[serde_json::Value]) -> AppResult<()> { + let upload_url = format!("{}/api/2/episodes/{}.json", gpodder_url.trim_end_matches('/'), username); + + // Use correct authentication based on internal vs external + let response = if gpodder_url == "http://localhost:8042" { + // Internal GPodder API - use X-GPodder-Token header + let client = reqwest::Client::new(); + client.post(&upload_url) + .header("X-GPodder-Token", password) + .json(episode_actions) + .send() + .await + } else { + // External GPodder API - use session auth with basic fallback + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + // Use session-based authentication + session.client + .post(&upload_url) + .json(episode_actions) + .send() + .await + } else { + // Fallback to basic auth + session.client + .post(&upload_url) + .basic_auth(username, Some(password)) + .json(episode_actions) + .send() + .await + } + }; + + match response { + Ok(resp) if resp.status().is_success() => { + tracing::info!("Successfully uploaded {} episode actions to GPodder", episode_actions.len()); + Ok(()) + } + Ok(resp) => { + tracing::warn!("Failed to upload episode actions to GPodder: {}", resp.status()); + Ok(()) // Don't fail the whole sync if upload fails + } + Err(e) => { + Err(AppError::internal(&format!("Failed to upload episode actions to GPodder: {}", e))) + } + } + } + + // Upload subscriptions to Nextcloud using the gPodder Sync app endpoint + async fn upload_subscriptions_to_nextcloud(&self, nextcloud_url: &str, username: &str, password: &str, subscriptions: &[String]) -> AppResult<()> { + let client = reqwest::Client::new(); + // Nextcloud gPodder Sync app uses the subscription_change endpoint + let upload_url = format!("{}/index.php/apps/gpoddersync/subscription_change/upload", nextcloud_url.trim_end_matches('/')); + + let response = client + .post(&upload_url) + .basic_auth(username, Some(password)) + .json(subscriptions) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to upload subscriptions to Nextcloud: {}", e)))?; + + if response.status().is_success() { + tracing::info!("Successfully uploaded {} subscriptions to Nextcloud", subscriptions.len()); + Ok(()) + } else { + tracing::warn!("Failed to upload subscriptions to Nextcloud: {}", response.status()); + Ok(()) // Don't fail the whole sync if upload fails + } + } + + // Upload episode actions to Nextcloud using the gPodder Sync app endpoint + async fn upload_episode_actions_to_nextcloud(&self, nextcloud_url: &str, username: &str, password: &str, episode_actions: &[serde_json::Value]) -> AppResult<()> { + let client = reqwest::Client::new(); + // Nextcloud gPodder Sync app uses the episode_action endpoint + let upload_url = format!("{}/index.php/apps/gpoddersync/episode_action/create", nextcloud_url.trim_end_matches('/')); + + let response = client + .post(&upload_url) + .basic_auth(username, Some(password)) + .json(episode_actions) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to upload episode actions to Nextcloud: {}", e)))?; + + if response.status().is_success() { + tracing::info!("Successfully uploaded {} episode actions to Nextcloud", episode_actions.len()); + Ok(()) + } else { + tracing::warn!("Failed to upload episode actions to Nextcloud: {}", response.status()); + Ok(()) // Don't fail the whole sync if upload fails + } + } + + // Upload subscriptions with session support and error handling + async fn upload_subscriptions_to_gpodder_with_session(&self, session: &GpodderSession, gpodder_url: &str, username: &str, password: &str, device_name: &str, subscriptions: &[String]) -> AppResult<()> { + let upload_url = format!("{}/api/2/subscriptions/{}/{}.json", gpodder_url.trim_end_matches('/'), username, device_name); + + // Format subscription changes according to GPodder API spec + let subscription_changes = serde_json::json!({ + "add": subscriptions, + "remove": [] + }); + + let response = if session.authenticated { + session.client + .post(&upload_url) + .json(&subscription_changes) + .send() + .await + } else { + session.client + .post(&upload_url) + .basic_auth(username, Some(password)) + .json(&subscription_changes) + .send() + .await + }; + + match response { + Ok(resp) if resp.status().is_success() => { + tracing::info!("Successfully uploaded {} subscriptions to gPodder service", subscriptions.len()); + Ok(()) + } + Ok(resp) => { + tracing::warn!("Failed to upload subscriptions: {}", resp.status()); + // Don't fail sync for upload failures - log and continue + Ok(()) + } + Err(e) => { + tracing::warn!("Error uploading subscriptions: {}", e); + // Don't fail sync for upload failures - log and continue + Ok(()) + } + } + } + + // Process gPodder subscriptions and add missing podcasts + async fn process_gpodder_subscriptions(&self, user_id: i32, subscriptions: &[String]) -> AppResult<()> { + let mut added = 0; + let mut skipped = 0; + let mut failed = 0; + + for feed_url in subscriptions { + tracing::info!("Processing podcast {} for user {}", feed_url, user_id); + + // Use the existing add_podcast_from_url function which handles duplicates and fetching + match self.add_podcast_from_url(user_id, feed_url, None).await { + Ok(_) => { + added += 1; + tracing::info!("Successfully added podcast: {}", feed_url); + } + Err(e) => { + // Check if it failed because it already exists + if self.podcast_exists_for_user(user_id, feed_url).await.unwrap_or(false) { + skipped += 1; + tracing::debug!("Podcast {} already exists for user {}", feed_url, user_id); + } else { + failed += 1; + tracing::warn!("Failed to add podcast {}: {}", feed_url, e); + } + } + } + } + + tracing::info!("GPodder subscription sync completed: {} added, {} skipped, {} failed", added, skipped, failed); + Ok(()) + } + + // Process subscription removals from gPodder service + async fn process_gpodder_subscription_removals(&self, user_id: i32, removals: &[String]) -> AppResult<()> { + let mut removed = 0; + let mut not_found = 0; + let mut failed = 0; + + for feed_url in removals { + tracing::info!("Processing podcast removal {} for user {}", feed_url, user_id); + + // Check if the podcast exists locally first + if self.podcast_exists_for_user(user_id, feed_url).await.unwrap_or(false) { + // Remove the podcast using existing function + match self.remove_podcast_by_url(user_id, feed_url).await { + Ok(_) => { + removed += 1; + tracing::info!("Successfully removed podcast: {}", feed_url); + } + Err(e) => { + failed += 1; + tracing::warn!("Failed to remove podcast {}: {}", feed_url, e); + } + } + } else { + not_found += 1; + tracing::debug!("Podcast {} not found locally for user {} (already removed)", feed_url, user_id); + } + } + + tracing::info!("GPodder subscription removal completed: {} removed, {} not found, {} failed", removed, not_found, failed); + Ok(()) + } + + // Detect and remove orphaned local podcasts that are not in the remote subscription list + async fn sync_local_podcast_removals(&self, user_id: i32, remote_subscriptions: &[String]) -> AppResult> { + let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; + let remote_set: std::collections::HashSet = remote_subscriptions.iter().cloned().collect(); + + let mut removed_podcasts = Vec::new(); + + // Find podcasts that exist locally but not in remote subscriptions + for local_feed in &local_subscriptions { + if !remote_set.contains(local_feed) { + tracing::info!("Local podcast {} not found in remote subscriptions, removing", local_feed); + + match self.remove_podcast_by_url(user_id, local_feed).await { + Ok(_) => { + removed_podcasts.push(local_feed.clone()); + tracing::info!("Successfully removed orphaned local podcast: {}", local_feed); + } + Err(e) => { + tracing::warn!("Failed to remove orphaned local podcast {}: {}", local_feed, e); + } + } + } + } + + if !removed_podcasts.is_empty() { + tracing::info!("Removed {} orphaned local podcasts", removed_podcasts.len()); + } + + Ok(removed_podcasts) + } + + // Upload local subscriptions to gPodder service - matches GPodder API spec POST /api/2/subscriptions/{username}/{device}.json + async fn upload_subscriptions_to_gpodder(&self, gpodder_url: &str, username: &str, password: &str, device_name: &str, subscriptions: &[String]) -> AppResult<()> { + let upload_url = format!("{}/api/2/subscriptions/{}/{}.json", gpodder_url.trim_end_matches('/'), username, device_name); + + // Format subscription changes according to GPodder API spec + let subscription_changes = serde_json::json!({ + "add": subscriptions, + "remove": [] + }); + + // Use correct authentication based on internal vs external + let response = if gpodder_url == "http://localhost:8042" { + // Internal GPodder API - use X-GPodder-Token header + let client = reqwest::Client::new(); + client.post(&upload_url) + .header("X-GPodder-Token", password) + .json(&subscription_changes) + .send() + .await + } else { + // External GPodder API - use session auth with basic fallback + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + session.client + .post(&upload_url) + .json(&subscription_changes) + .send() + .await + } else { + session.client + .post(&upload_url) + .basic_auth(username, Some(password)) + .json(&subscription_changes) + .send() + .await + } + }; + + match response { + Ok(resp) if resp.status().is_success() => { + tracing::info!("Successfully uploaded {} subscriptions to gPodder service", subscriptions.len()); + Ok(()) + } + Ok(resp) => { + tracing::warn!("Failed to upload subscriptions to gPodder service: {}", resp.status()); + Ok(()) // Don't fail sync for upload failures + } + Err(e) => { + tracing::warn!("Failed to upload subscriptions: {}", e); + Ok(()) // Don't fail sync for upload failures + } + } + } + + // Sync episode actions with gPodder service - matches Python episode actions sync with timestamp support + async fn sync_episode_actions_with_gpodder(&self, gpodder_url: &str, username: &str, password: &str, device_name: &str, user_id: i32) -> AppResult<()> { + // Get last sync timestamp for incremental sync (BETTER than Python - follows GPodder spec) + let since_timestamp = self.get_last_sync_timestamp(user_id).await?; + + // Get local episode actions since last sync for efficient incremental sync + let local_actions = if let Some(since) = since_timestamp { + self.get_user_episode_actions_since(user_id, since).await? + } else { + self.get_user_episode_actions(user_id).await? + }; + + // Upload local actions to gPodder service - matches Python POST /api/2/episodes/{username}.json + if !local_actions.is_empty() { + let upload_url = format!("{}/api/2/episodes/{}.json", gpodder_url.trim_end_matches('/'), username); + + // Use correct authentication based on internal vs external + let response = if gpodder_url == "http://localhost:8042" { + // Internal GPodder API - use X-GPodder-Token header + let client = reqwest::Client::new(); + client.post(&upload_url) + .header("X-GPodder-Token", password) + .json(&local_actions) + .send() + .await + } else { + // External GPodder API - use session auth with basic fallback + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + // Use session-based authentication + session.client + .post(&upload_url) + .json(&local_actions) + .send() + .await + } else { + // Fallback to basic auth + session.client + .post(&upload_url) + .basic_auth(username, Some(password)) + .json(&local_actions) + .send() + .await + } + }; + + match response { + Ok(resp) if resp.status().is_success() => { + tracing::info!("Successfully uploaded {} episode actions", local_actions.len()); + } + Ok(resp) => { + tracing::warn!("Failed to upload episode actions: {}", resp.status()); + } + Err(e) => { + return Err(AppError::internal(&format!("Failed to upload episode actions: {}", e))); + } + } + } + + // Download remote actions from gPodder service with timestamp support - use same format as working sync + let download_url = if let Some(since) = since_timestamp { + format!("{}/api/2/episodes/{}.json?since={}&device={}", + gpodder_url.trim_end_matches('/'), username, since.timestamp(), device_name) + } else { + format!("{}/api/2/episodes/{}.json?since=0&device={}", + gpodder_url.trim_end_matches('/'), username, device_name) + }; + + // Use correct authentication based on internal vs external for download + let response = if gpodder_url == "http://localhost:8042" { + // Internal GPodder API - use X-GPodder-Token header + let client = reqwest::Client::new(); + client.get(&download_url) + .header("X-GPodder-Token", password) + .send() + .await + } else { + // External GPodder API - use session auth with basic fallback + let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; + if session.authenticated { + session.client + .get(&download_url) + .send() + .await + } else { + session.client + .get(&download_url) + .basic_auth(username, Some(password)) + .send() + .await + } + }; + + match response { + Ok(resp) if resp.status().is_success() => { + let episode_response: serde_json::Value = resp.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse episode actions response: {}", e)))?; + + let remote_actions = episode_response.get("actions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + tracing::info!("Downloaded {} remote episode actions", remote_actions.len()); + + // Apply remote actions locally + self.apply_remote_episode_actions(user_id, &remote_actions).await?; + + // Update last sync timestamp for incremental sync (BETTER than Python) + self.update_last_sync_timestamp(user_id).await?; + } + Ok(resp) => { + tracing::warn!("Failed to download episode actions: {}", resp.status()); + } + Err(e) => { + return Err(AppError::internal(&format!("Failed to download episode actions: {}", e))); + } + } + + Ok(()) + } + + // Get user podcast feeds for sync + async fn get_user_podcast_feeds(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT feedurl FROM "Podcasts" WHERE userid = $1 AND (username IS NULL OR username = '') AND (password IS NULL OR password = '')"#) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut feeds = Vec::new(); + for row in rows { + feeds.push(row.try_get::("feedurl")?); + } + Ok(feeds) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT FeedURL FROM Podcasts WHERE UserID = ? AND (Username IS NULL OR Username = '') AND (Password IS NULL OR Password = '')") + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut feeds = Vec::new(); + for row in rows { + feeds.push(row.try_get::("FeedURL")?); + } + Ok(feeds) + } + } + } + + // Apply remote episode actions locally - matches Python apply_episode_actions function exactly + async fn apply_remote_episode_actions(&self, user_id: i32, actions: &[serde_json::Value]) -> AppResult<()> { + tracing::info!("Processing {} episode actions for user {}", actions.len(), user_id); + let mut applied_count = 0; + let mut not_found_count = 0; + + for action in actions { + if let (Some(episode_url), Some(action_type)) = ( + action["episode"].as_str(), + action["action"].as_str() + ) { + match action_type { + "play" => { + if let (Some(position), Some(timestamp_str)) = ( + action["position"].as_i64(), + action["timestamp"].as_str() + ) { + // Find local episode by URL + if let Some(episode_id) = self.find_episode_by_url(user_id, episode_url).await? { + // Parse timestamp - handle both RFC3339 and simple datetime formats + let timestamp = if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(timestamp_str) { + parsed.naive_utc() + } else if let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%dT%H:%M:%S") { + parsed + } else { + tracing::warn!("Failed to parse timestamp for episode action: {}", timestamp_str); + continue; + }; + + // Get episode duration to check if it should be marked as complete + if let Ok(episode_duration) = self.get_episode_duration(episode_id).await { + let position_sec = position as i32; + let remaining_time = episode_duration - position_sec; + + // Always log for debugging GPodder sync completion issues + println!("GPodder sync episode: {} - Duration: {}s, Position: {}s, Remaining: {}s", + episode_url, episode_duration, position_sec, remaining_time); + + // Mark complete if position is at/beyond the end OR within 60 seconds of completion + if episode_duration > 0 && (position_sec >= episode_duration || remaining_time <= 60) { + // At end or within 1 minute of completion - mark as complete + // GPodder sync only handles regular podcast episodes, never YouTube videos + self.mark_episode_completed(episode_id, user_id, false).await?; + applied_count += 1; + println!("✓ Marked episode as completed via GPodder sync: {} ({}s of {}s)", + episode_url, position_sec, episode_duration); + } else { + // Update progress normally + self.update_episode_progress(user_id, episode_id, position_sec, timestamp).await?; + applied_count += 1; + println!("Updated episode progress: {} -> {}s/{}s", episode_url, position_sec, episode_duration); + } + } else { + // Fallback to normal progress update if duration unavailable + self.update_episode_progress(user_id, episode_id, position as i32, timestamp).await?; + applied_count += 1; + tracing::debug!("Applied episode action (no duration): {} -> position {}", episode_url, position); + } + } else { + not_found_count += 1; + tracing::debug!("Episode not found in local database: {}", episode_url); + } + } + } + "download" => { + // Handle download actions if needed + tracing::info!("Download action for episode: {}", episode_url); + } + "delete" => { + // Handle delete actions if needed + tracing::info!("Delete action for episode: {}", episode_url); + } + _ => { + tracing::warn!("Unknown action type: {}", action_type); + } + } + } + } + + tracing::info!("Episode actions processing complete: {} applied, {} not found in local database", + applied_count, not_found_count); + Ok(()) + } + + // Find episode ID by URL for user + async fn find_episode_by_url(&self, user_id: i32, episode_url: &str) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT e.episodeid + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid + WHERE e.episodeurl = $1 AND p.userid = $2 + "#) + .bind(episode_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("episodeid")?)) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query(" + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE e.EpisodeURL = ? AND p.UserID = ? + ") + .bind(episode_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("EpisodeID")?)) + } else { + Ok(None) + } + } + } + } + + // Update episode progress from remote sync + async fn update_episode_progress(&self, user_id: i32, episode_id: i32, position: i32, timestamp: chrono::NaiveDateTime) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // Insert or update episode history + sqlx::query(r#" + INSERT INTO "UserEpisodeHistory" (userid, episodeid, listenduration, listendate) + VALUES ($1, $2, $3, $4) + ON CONFLICT (userid, episodeid) + DO UPDATE SET listenduration = GREATEST("UserEpisodeHistory".listenduration, $3), listendate = $4 + "#) + .bind(user_id) + .bind(episode_id) + .bind(position) + .bind(timestamp) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + // Insert or update episode history + sqlx::query(" + INSERT INTO UserEpisodeHistory (UserID, EpisodeID, ListenDuration, ListenDate) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + ListenDuration = GREATEST(ListenDuration, VALUES(ListenDuration)), + ListenDate = VALUES(ListenDate) + ") + .bind(user_id) + .bind(episode_id) + .bind(position) + .bind(timestamp) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Get gPodder status - matches Python get_user_gpodder_status function exactly + pub async fn gpodder_get_status(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT pod_sync_type, gpodderurl, gpodderloginname FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let sync_type: Option = row.try_get("pod_sync_type")?; + let gpodder_url: Option = row.try_get("gpodderurl")?; + let gpodder_login: Option = row.try_get("gpodderloginname")?; + + let sync_type = sync_type.unwrap_or_else(|| "None".to_string()); + + Ok(GpodderStatus { + sync_type: sync_type.clone(), + gpodder_url, + gpodder_login, + }) + } else { + Ok(GpodderStatus { + sync_type: "None".to_string(), + gpodder_url: None, + gpodder_login: None, + }) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT Pod_Sync_Type, GpodderUrl, GpodderLoginName FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let sync_type: Option = row.try_get("Pod_Sync_Type")?; + let gpodder_url: Option = row.try_get("GpodderUrl")?; + let gpodder_login: Option = row.try_get("GpodderLoginName")?; + + let sync_type = sync_type.unwrap_or_else(|| "None".to_string()); + + Ok(GpodderStatus { + sync_type: sync_type.clone(), + gpodder_url, + gpodder_login, + }) + } else { + Ok(GpodderStatus { + sync_type: "None".to_string(), + gpodder_url: None, + gpodder_login: None, + }) + } + } + } + } + + // Toggle gPodder sync - matches Python toggle_gpodder function exactly + pub async fn gpodder_toggle_sync(&self, user_id: i32) -> AppResult { + let current_status = self.gpodder_get_status(user_id).await?; + let current_enabled = current_status.sync_type != "None" && !current_status.sync_type.is_empty(); + let new_enabled = !current_enabled; + + let new_sync_type = if new_enabled { + // Restore previous sync type or default to "external" + if !current_status.sync_type.is_empty() && current_status.sync_type != "None" { + current_status.sync_type + } else { + "external".to_string() + } + } else { + "None".to_string() + }; + + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET pod_sync_type = $1 WHERE userid = $2"#) + .bind(&new_sync_type) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET Pod_Sync_Type = ? WHERE UserID = ?") + .bind(&new_sync_type) + .bind(user_id) + .execute(pool) + .await?; + } + } + + Ok(new_enabled) + } + + // Helper function to get user sync settings + pub async fn get_user_sync_settings(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT gpodderurl, gpodderloginname, gpoddertoken, pod_sync_type FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let url: Option = row.try_get("gpodderurl")?; + let username: Option = row.try_get("gpodderloginname")?; + let token: Option = row.try_get("gpoddertoken")?; + let sync_type: Option = row.try_get("pod_sync_type")?; + + if url.is_some() && username.is_some() && token.is_some() { + Ok(Some(UserSyncSettings { + url: url.unwrap(), + username: username.unwrap(), + token: token.unwrap(), + sync_type: sync_type.unwrap_or_default() + })) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT GpodderUrl, GpodderLoginName, GpodderToken, Pod_Sync_Type FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let url: Option = row.try_get("GpodderUrl")?; + let username: Option = row.try_get("GpodderLoginName")?; + let token: Option = row.try_get("GpodderToken")?; + let sync_type: Option = row.try_get("Pod_Sync_Type")?; + + if url.is_some() && username.is_some() && token.is_some() { + Ok(Some(UserSyncSettings { + url: url.unwrap(), + username: username.unwrap(), + token: token.unwrap(), + sync_type: sync_type.unwrap_or_default() + })) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + } + } + + // Check if podcast exists for user + async fn podcast_exists_for_user(&self, user_id: i32, feed_url: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT podcastid FROM "Podcasts" WHERE feedurl = $1 AND userid = $2"#) + .bind(feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT PodcastID FROM Podcasts WHERE FeedURL = ? AND UserID = ?") + .bind(feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + } + } + + // Get or create default device - matches Python device handling + pub async fn get_or_create_default_device(&self, user_id: i32) -> AppResult { + // Get the default device name from Users table - this is where PinePods tracks user preferences + let default_device_name = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT defaultgpodderdevice FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + row.and_then(|r| r.try_get::, _>("defaultgpodderdevice").ok().flatten()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT DefaultGpodderDevice FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + row.and_then(|r| r.try_get::, _>("DefaultGpodderDevice").ok().flatten()) + } + }; + + // If we have a default device name from Users table, use it + if let Some(device_name) = default_device_name { + return Ok(device_name); + } + + // Fallback: check sync settings to determine appropriate default + if let Some(sync_settings) = self.get_user_sync_settings(user_id).await? { + match sync_settings.sync_type.as_str() { + "external" => { + // For external servers, we should not create devices - they must exist on the external server + return Err(AppError::BadRequest("No default device configured for external GPodder sync. Please configure a default device.".to_string())); + } + "gpodder" | "both" => { + // For internal sync, create a default internal device + let device_name = format!("pinepods-internal-{}", user_id); + let device_type = "server"; + self.create_gpodder_device(user_id, &device_name, device_type, true).await?; + + // Set this as the default in Users table + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET defaultgpodderdevice = $1 WHERE userid = $2"#) + .bind(&device_name) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET DefaultGpodderDevice = ? WHERE UserID = ?") + .bind(&device_name) + .bind(user_id) + .execute(pool) + .await?; + } + } + + return Ok(device_name); + } + _ => { + return Err(AppError::BadRequest("GPodder sync not properly configured".to_string())); + } + } + } + + Err(AppError::BadRequest("No GPodder sync configured".to_string())) + } + + // Create gPodder device - matches Python device creation + async fn create_gpodder_device(&self, user_id: i32, device_name: &str, device_type: &str, is_default: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // Use INSERT ... ON CONFLICT to handle existing devices + sqlx::query(r#" + INSERT INTO "GpodderDevices" (userid, devicename, devicetype, isdefault) + VALUES ($1, $2, $3, $4) + ON CONFLICT (userid, devicename) + DO UPDATE SET devicetype = EXCLUDED.devicetype, isdefault = EXCLUDED.isdefault + "#) + .bind(user_id) + .bind(device_name) + .bind(device_type) + .bind(is_default) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + // Use INSERT ... ON DUPLICATE KEY UPDATE to handle existing devices + sqlx::query(" + INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsDefault) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE DeviceType = VALUES(DeviceType), IsDefault = VALUES(IsDefault) + ") + .bind(user_id) + .bind(device_name) + .bind(device_type) + .bind(is_default) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Create gPodder device with caption - matches Python create_device with all fields + pub async fn gpodder_create_device_with_caption(&self, user_id: i32, device_name: &str, device_type: &str, device_caption: Option<&str>, is_default: bool) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + INSERT INTO "GpodderDevices" (userid, devicename, devicetype, devicecaption, isdefault) + VALUES ($1, $2, $3, $4, $5) + RETURNING deviceid"#) + .bind(user_id) + .bind(device_name) + .bind(device_type) + .bind(device_caption) + .bind(is_default) + .fetch_one(pool) + .await?; + + Ok(row.try_get("deviceid")?) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, DeviceCaption, IsDefault) VALUES (?, ?, ?, ?, ?)") + .bind(user_id) + .bind(device_name) + .bind(device_type) + .bind(device_caption) + .bind(is_default) + .execute(pool) + .await?; + + Ok(result.last_insert_id() as i32) + } + } + } + + // Get default gPodder device - matches Python get_default_device function exactly + pub async fn gpodder_get_default_device(&self, user_id: i32) -> AppResult> { + // Get the default device name from Users table + let default_device_name = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT defaultgpodderdevice FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + row.and_then(|r| r.try_get::, _>("defaultgpodderdevice").ok().flatten()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT DefaultGpodderDevice FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + row.and_then(|r| r.try_get::, _>("DefaultGpodderDevice").ok().flatten()) + } + }; + + if let Some(device_name) = default_device_name { + // Get all devices from GPodder API and find the one with matching name + let devices = self.gpodder_get_user_devices(user_id).await?; + + for device in devices { + if device.get("name").and_then(|v| v.as_str()) == Some(&device_name) { + // Mark this device as default and return it + let mut default_device = device; + default_device["is_default"] = serde_json::Value::Bool(true); + return Ok(Some(default_device)); + } + } + } + + // If no default device found, return None + Ok(None) + } + + + // Sync with Nextcloud - matches Python refresh_nextcloud_subscription function exactly + async fn sync_with_nextcloud(&self, user_id: i32, settings: &UserSyncSettings, _force: bool) -> AppResult { + let client = reqwest::Client::new(); + let decrypted_password = self.decrypt_password(&settings.token).await?; + + // Step 1: Get last sync timestamp for incremental sync + let since_timestamp = self.get_last_sync_timestamp(user_id).await?; + + // Step 2: Get subscriptions from Nextcloud gPodder Sync app with timestamp + let subscriptions_url = if let Some(since) = since_timestamp { + format!("{}/index.php/apps/gpoddersync/subscription_changes/{}?since={}", + settings.url.trim_end_matches('/'), since.timestamp(), since.timestamp()) + } else { + format!("{}/index.php/apps/gpoddersync/subscriptions", settings.url.trim_end_matches('/')) + }; + + tracing::info!("Getting Nextcloud subscriptions from: {}", subscriptions_url); + + let response = client + .get(&subscriptions_url) + .basic_auth(&settings.username, Some(&decrypted_password)) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to sync with Nextcloud: {}", e)))?; + + let mut subscriptions_processed = false; + + if response.status().is_success() { + let subscriptions_response: serde_json::Value = response.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse Nextcloud subscriptions: {}", e)))?; + + // Handle both subscription change format and direct subscription list + let (subscriptions, removals) = if since_timestamp.is_some() { + // Incremental sync - expect {add: [], remove: [], timestamp: N} format + let adds = subscriptions_response["add"].as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|f| f.as_str().map(|s| s.to_string())) + .collect::>(); + + let removes = subscriptions_response["remove"].as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|f| f.as_str().map(|s| s.to_string())) + .collect::>(); + + (adds, removes) + } else { + // Full sync - expect direct array of subscription URLs, no removals in this format + let adds = subscriptions_response.as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|f| f.as_str().map(|s| s.to_string())) + .collect::>(); + + (adds, Vec::new()) + }; + + tracing::info!("Downloaded {} subscriptions and {} removals from Nextcloud", subscriptions.len(), removals.len()); + + // Process subscriptions and add missing podcasts + if !subscriptions.is_empty() { + self.process_gpodder_subscriptions(user_id, &subscriptions).await?; + subscriptions_processed = true; + } + + // Process subscription removals + if !removals.is_empty() { + self.process_gpodder_subscription_removals(user_id, &removals).await?; + subscriptions_processed = true; // Mark as processed since we did work + } + } + + // Step 3: Get episode actions from Nextcloud with timestamp + let episode_actions_url = if let Some(since) = since_timestamp { + format!("{}/index.php/apps/gpoddersync/episode_action?since={}", + settings.url.trim_end_matches('/'), since.timestamp()) + } else { + format!("{}/index.php/apps/gpoddersync/episode_action", settings.url.trim_end_matches('/')) + }; + + tracing::info!("Getting Nextcloud episode actions from: {}", episode_actions_url); + + let episode_response = client + .get(&episode_actions_url) + .basic_auth(&settings.username, Some(&decrypted_password)) + .send() + .await; + + let mut episode_actions_processed = false; + + if let Ok(resp) = episode_response { + if resp.status().is_success() { + let episode_actions: serde_json::Value = resp.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse Nextcloud episode actions: {}", e)))?; + + if let Some(actions_array) = episode_actions["actions"].as_array() { + tracing::info!("Downloaded {} episode actions from Nextcloud", actions_array.len()); + + if !actions_array.is_empty() { + if let Err(e) = self.apply_remote_episode_actions(user_id, actions_array).await { + tracing::warn!("Nextcloud episode actions processing failed but continuing: {}", e); + } else { + episode_actions_processed = true; + } + } + } else if let Some(actions_array) = episode_actions.as_array() { + // Some Nextcloud implementations return direct array + tracing::info!("Downloaded {} episode actions from Nextcloud (direct)", actions_array.len()); + + if !actions_array.is_empty() { + if let Err(e) = self.apply_remote_episode_actions(user_id, actions_array).await { + tracing::warn!("Nextcloud episode actions processing failed but continuing: {}", e); + } else { + episode_actions_processed = true; + } + } + } + } else { + tracing::warn!("Nextcloud episode actions returned error: {}", resp.status()); + } + } else { + tracing::warn!("Failed to get episode actions from Nextcloud"); + } + + // Step 4: Upload local subscriptions to Nextcloud (if needed) + if since_timestamp.is_none() || subscriptions_processed { + let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; + if let Err(e) = self.upload_subscriptions_to_nextcloud(&settings.url, &settings.username, &decrypted_password, &local_subscriptions).await { + tracing::warn!("Failed to upload subscriptions to Nextcloud: {}", e); + } else { + tracing::info!("Successfully uploaded {} subscriptions to Nextcloud", local_subscriptions.len()); + } + } else { + tracing::info!("Skipping subscription upload to Nextcloud - no changes detected"); + } + + // Step 5: Upload local episode actions to Nextcloud + let local_episode_actions = self.get_user_episode_actions(user_id).await?; + if !local_episode_actions.is_empty() { + if let Err(e) = self.upload_episode_actions_to_nextcloud(&settings.url, &settings.username, &decrypted_password, &local_episode_actions).await { + tracing::warn!("Failed to upload episode actions to Nextcloud: {}", e); + } else { + tracing::info!("Successfully uploaded {} episode actions to Nextcloud", local_episode_actions.len()); + } + } else { + tracing::info!("No local episode actions to upload to Nextcloud"); + } + + // Step 6: Update last sync timestamp for next incremental sync + self.update_last_sync_timestamp(user_id).await?; + + Ok(subscriptions_processed || episode_actions_processed) + } + + // Decrypt password using Fernet - matches Python encryption + pub async fn decrypt_password(&self, encrypted_password: &str) -> AppResult { + use fernet::Fernet; + + // Get encryption key from app settings (base64 string) + let encryption_key = self.get_encryption_key().await?; + let fernet = Fernet::new(&encryption_key) + .ok_or_else(|| AppError::internal("Failed to create Fernet cipher"))?; + + let decrypted = fernet.decrypt(encrypted_password) + .map_err(|_e| AppError::internal(&format!("Failed to decrypt password: Fernet decryption error")))?; + + String::from_utf8(decrypted) + .map_err(|e| AppError::internal(&format!("Invalid UTF-8 in decrypted password: {}", e))) + } + + // Get all users with podcasts - for admin refresh + pub async fn get_all_users_with_podcasts(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT DISTINCT userid FROM "Podcasts" ORDER BY userid"#) + .fetch_all(pool) + .await?; + + let mut users = Vec::new(); + for row in rows { + users.push(row.try_get("userid")?); + } + Ok(users) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT DISTINCT UserID FROM Podcasts ORDER BY UserID") + .fetch_all(pool) + .await?; + + let mut users = Vec::new(); + for row in rows { + users.push(row.try_get("UserID")?); + } + Ok(users) + } + } + } + + // Get all users with gPodder sync enabled - for admin gPodder sync + pub async fn get_all_users_with_gpodder_sync(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT userid FROM "Users" + WHERE pod_sync_type IS NOT NULL + AND pod_sync_type != 'None' + AND pod_sync_type != 'nextcloud' + AND gpodderurl IS NOT NULL + AND gpodderloginname IS NOT NULL + AND gpoddertoken IS NOT NULL + ORDER BY userid + "#) + .fetch_all(pool) + .await?; + + let mut users = Vec::new(); + for row in rows { + users.push(row.try_get("userid")?); + } + Ok(users) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query(" + SELECT UserID FROM Users + WHERE Pod_Sync_Type IS NOT NULL + AND Pod_Sync_Type != 'None' + AND Pod_Sync_Type != 'nextcloud' + AND GpodderUrl IS NOT NULL + AND GpodderLoginName IS NOT NULL + AND GpodderToken IS NOT NULL + ORDER BY UserID + ") + .fetch_all(pool) + .await?; + + let mut users = Vec::new(); + for row in rows { + users.push(row.try_get("UserID")?); + } + Ok(users) + } + } + } + + // Get stored authentication credentials for a feed URL + pub async fn get_feed_auth_credentials(&self, feed_url: &str, user_id: i32) -> AppResult<(Option, Option)> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"SELECT username, password FROM "Podcasts" WHERE feedurl = $1 AND userid = $2 LIMIT 1"#) + .bind(feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + let username: Option = row.try_get("username").ok().flatten(); + let password: Option = row.try_get("password").ok().flatten(); + Ok((username, password)) + } else { + Ok((None, None)) + } + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("SELECT Username, Password FROM Podcasts WHERE FeedURL = ? AND UserID = ? LIMIT 1") + .bind(feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + let username: Option = row.try_get("Username").ok().flatten(); + let password: Option = row.try_get("Password").ok().flatten(); + Ok((username, password)) + } else { + Ok((None, None)) + } + } + } + } + + // Get podcast values from RSS feed - matches Python get_podcast_values function exactly + pub async fn get_podcast_values(&self, feed_url: &str, user_id: i32, username: Option<&str>, password: Option<&str>) -> AppResult> { + use reqwest::header::AUTHORIZATION; + use feed_rs::parser; + + println!("Fetching podcast values from feed URL: {}", feed_url); + + // Build HTTP client with proper configuration for container environment + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| { + println!("Failed to build HTTP client in get_podcast_values: {}", e); + AppError::Http(e) + })?; + + let mut request = client.get(feed_url); + + if let (Some(user), Some(pass)) = (username, password) { + println!("Using basic authentication for feed: {}", feed_url); + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass)); + request = request.header(AUTHORIZATION, format!("Basic {}", encoded)); + } + + // Fetch RSS feed with better error handling + println!("Sending HTTP request to: {}", feed_url); + let response = request.send().await + .map_err(|e| { + println!("HTTP request failed for {}: {}", feed_url, e); + AppError::Http(e) + })?; + + println!("Received response with status: {}", response.status()); + if !response.status().is_success() { + // If we get a 403, the server might be blocking browser User-Agents + // Try with a podcast client User-Agent + if response.status() == 403 { + println!("Got 403 Forbidden, trying with podcast client User-Agent"); + + let podcast_client = reqwest::Client::builder() + .user_agent("PinePods/1.0") + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| { + println!("Failed to build podcast client in get_podcast_values: {}", e); + AppError::Http(e) + })?; + + let mut podcast_request = podcast_client.get(feed_url); + + if let (Some(user), Some(pass)) = (username, password) { + println!("Using basic authentication for podcast client request: {}", feed_url); + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass)); + podcast_request = podcast_request.header(AUTHORIZATION, format!("Basic {}", encoded)); + } + + let podcast_response = podcast_request.send().await + .map_err(|e| { + println!("Podcast client request failed for {}: {}", feed_url, e); + AppError::Http(e) + })?; + + if podcast_response.status().is_success() { + println!("Podcast client request succeeded with status: {}", podcast_response.status()); + let content = podcast_response.text().await?; + + // Continue with the same parsing logic + return self.parse_feed_content_to_values(content, feed_url, user_id).await; + } + + println!("Podcast client request also failed with status: {}", podcast_response.status()); + } + + return Err(AppError::bad_request(&format!("Feed request failed: HTTP {}", response.status()))); + } + + let content = response.text().await?; + + self.parse_feed_content_to_values(content, feed_url, user_id).await + } + + // Helper function to parse feed content into podcast values (extracted to avoid duplication) + async fn parse_feed_content_to_values(&self, content: String, feed_url: &str, user_id: i32) -> AppResult> { + use feed_rs::parser; + + // Parse RSS feed using feed-rs + let feed = parser::parse(content.as_bytes()) + .map_err(|e| AppError::external_error(&format!("Failed to parse RSS feed: {}", e)))?; + + // Extract podcast metadata exactly as Python implementation + let mut podcast_values = std::collections::HashMap::new(); + + podcast_values.insert("feedurl".to_string(), feed_url.to_string()); + podcast_values.insert("userid".to_string(), user_id.to_string()); + podcast_values.insert("podcastname".to_string(), feed.title.as_ref().map(|t| t.content.clone()).unwrap_or_default()); + podcast_values.insert("description".to_string(), feed.description.as_ref().map(|d| d.content.clone()).unwrap_or_default()); + podcast_values.insert("author".to_string(), feed.authors.first().map(|a| a.name.clone()).unwrap_or_default()); + podcast_values.insert("websiteurl".to_string(), feed.links.first().map(|l| l.href.clone()).unwrap_or_default()); + podcast_values.insert("explicit".to_string(), "False".to_string()); // Default to False + podcast_values.insert("episodecount".to_string(), feed.entries.len().to_string()); + + // Extract artwork URL - check feed image and iTunes image + let artwork_url = feed.logo.as_ref().map(|l| l.uri.clone()) + .or_else(|| feed.icon.as_ref().map(|i| i.uri.clone())) + .unwrap_or_default(); + podcast_values.insert("artworkurl".to_string(), artwork_url); + + // Extract categories - convert to dict format like Python + let categories = if !feed.categories.is_empty() { + let cat_dict: std::collections::HashMap = feed.categories + .iter() + .enumerate() + .map(|(i, cat)| (i.to_string(), cat.term.clone())) + .collect(); + serde_json::to_string(&cat_dict).unwrap_or_default() + } else { + "{}".to_string() + }; + podcast_values.insert("categories".to_string(), categories); + + // Set default values for fields not in RSS + podcast_values.insert("podcastindexid".to_string(), "0".to_string()); + podcast_values.insert("episodeupdatecount".to_string(), "0".to_string()); + + println!("Successfully extracted podcast values: {}", podcast_values.get("podcastname").unwrap_or(&"Unknown".to_string())); + + Ok(podcast_values) + } + + // Parse feed episodes using feed-rs and return JSON data (for frontend consumption) + pub async fn parse_feed_episodes(&self, feed_url: &str, user_id: i32) -> AppResult> { + use feed_rs::parser; + use reqwest::header::AUTHORIZATION; + + // Get stored authentication credentials for this feed + let (username, password) = self.get_feed_auth_credentials(feed_url, user_id).await?; + + // Build HTTP client with proper configuration + let client = reqwest::Client::builder() + .user_agent("PinePods/1.0") + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| AppError::external_error(&format!("Failed to build HTTP client: {}", e)))?; + + let mut request = client.get(feed_url); + + // Add authentication if available + if let (Some(user), Some(pass)) = (username.as_deref(), password.as_deref()) { + println!("Using stored authentication for feed: {}", feed_url); + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass)); + request = request.header(AUTHORIZATION, format!("Basic {}", encoded)); + } + + // Fetch RSS feed + let response = request.send().await?; + if !response.status().is_success() { + return Err(AppError::external_error(&format!("Failed to fetch RSS feed: {}", response.status()))); + } + + let content = response.text().await?; + + // Parse RSS feed using feed-rs + let feed = parser::parse(content.as_bytes()) + .map_err(|e| AppError::external_error(&format!("Failed to parse RSS feed: {}", e)))?; + + // Extract podcast-level artwork as fallback + let podcast_artwork = feed.logo.as_ref().map(|l| l.uri.clone()) + .or_else(|| feed.icon.as_ref().map(|i| i.uri.clone())) + .unwrap_or_default(); + + let mut episodes = Vec::new(); + + for entry in feed.entries.iter() { + // Skip episodes without titles + if entry.title.is_none() { + continue; + } + + // Extract episode data using same logic as add_episodes + let mut episode_data = std::collections::HashMap::new(); + + if let Some(title) = &entry.title { + episode_data.insert("title".to_string(), title.content.clone()); + } + + if let Some(summary) = &entry.summary { + episode_data.insert("summary".to_string(), summary.content.clone()); + } + + if let Some(content) = &entry.content { + if let Some(body) = &content.body { + episode_data.insert("content:encoded".to_string(), body.clone()); + } + } + + // Extract audio URL from media or links + let mut audio_url = String::new(); + let mut enclosure_length = String::new(); + + // Check media objects first + for media in &entry.media { + for content in &media.content { + if let Some(url) = &content.url { + if let Some(media_type) = &content.content_type { + if media_type.to_string().starts_with("audio/") { + audio_url = url.to_string(); + if let Some(size) = content.size { + enclosure_length = size.to_string(); + } + break; + } + } + } + } + if !audio_url.is_empty() { break; } + } + + // Fallback to links if no media found + if audio_url.is_empty() { + for link in &entry.links { + if let Some(media_type) = &link.media_type { + if media_type.starts_with("audio/") { + audio_url = link.href.clone(); + if let Some(length) = &link.length { + enclosure_length = length.to_string(); + } + break; + } + } + } + } + + // Extract episode artwork (with fallback to podcast artwork) + let mut episode_artwork = podcast_artwork.clone(); + for media in &entry.media { + if !media.thumbnails.is_empty() { + episode_artwork = media.thumbnails[0].image.uri.clone(); + break; + } + } + + // Extract duration from media or iTunes tags + let mut duration_seconds = 0; + + // First try media objects (works for some feeds) + for media in &entry.media { + if let Some(duration) = &media.duration { + duration_seconds = duration.as_secs() as i32; + break; + } + } + + // If media duration is suspicious (< 3600 seconds = 1 hour), + // try to extract iTunes duration from raw RSS to work around feed-rs parsing bugs + if duration_seconds > 0 && duration_seconds < 3600 { + if let Some(title) = &entry.title { + if let Some(corrected_duration) = Self::extract_itunes_duration_from_raw(&content, &title.content) { + if corrected_duration != duration_seconds as u64 { + println!("🔧 DURATION CORRECTED: '{}' feed-rs={} -> iTunes={}", + title.content, duration_seconds, corrected_duration); + duration_seconds = corrected_duration as i32; + } + } + } + } + + // Extract publication date + let pub_date = if let Some(published) = &entry.published { + published.to_rfc3339() + } else { + chrono::Utc::now().to_rfc3339() + }; + + // Create episode JSON object matching frontend Episode structure + let episode_json = serde_json::json!({ + "title": entry.title.as_ref().map(|t| t.content.clone()), + "description": entry.summary.as_ref().map(|s| s.content.clone()), + "pub_date": pub_date, + "enclosure_url": if audio_url.is_empty() { None } else { Some(audio_url) }, + "enclosure_length": if enclosure_length.is_empty() { None } else { Some(enclosure_length) }, + "artwork": if episode_artwork.is_empty() { None } else { Some(episode_artwork) }, + "content": entry.content.as_ref().and_then(|c| c.body.clone()), + "duration": duration_seconds, + "guid": entry.id.clone() + }); + + episodes.push(episode_json); + } + + Ok(episodes) + } + + // Extract iTunes duration from raw RSS for a specific episode title (workaround for feed-rs bugs) + fn extract_itunes_duration_from_raw(raw_rss: &str, episode_title: &str) -> Option { + // HTML encode the episode title to match raw RSS format + // IMPORTANT: encode & first, otherwise it will double-encode other entities + let html_encoded_title = episode_title + .replace("&", "&") + .replace("'", "'") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">"); + + // Try different title formats - RSS titles can vary from feed-rs parsed titles + let search_patterns = vec![ + format!("<![CDATA[{}]]>", episode_title), + format!("{}", episode_title), + format!("<![CDATA[{}]]>", html_encoded_title), + format!("{}", html_encoded_title), + // Also try searching for the core title without extra formatting + episode_title.split(" (").next().unwrap_or(episode_title).to_string(), + html_encoded_title.split(" (").next().unwrap_or(&html_encoded_title).to_string(), + ]; + + let mut item_pos = None; + let mut item_end_pos = None; + + // Find any item that contains this title or a partial match + for pattern in &search_patterns { + if let Some(pos) = raw_rss.find(pattern) { + // Find the start of the block containing this title + let item_start = raw_rss[..pos].rfind("").unwrap_or(0); + if let Some(end) = raw_rss[pos..].find("") { + item_pos = Some(item_start); + item_end_pos = Some(pos + end + "".len()); + break; + } + } + } + + // If exact matches failed, try a broader search by looking through all items + if item_pos.is_none() { + let mut start = 0; + while let Some(item_start) = raw_rss[start..].find("") { + let absolute_start = start + item_start; + if let Some(item_end) = raw_rss[absolute_start..].find("") { + let absolute_end = absolute_start + item_end + "".len(); + let item_block = &raw_rss[absolute_start..absolute_end]; + + // Check if this item contains any part of our episode title + let title_core = episode_title.split(" (").next().unwrap_or(episode_title); + if item_block.contains(title_core) { + item_pos = Some(absolute_start); + item_end_pos = Some(absolute_end); + break; + } + + start = absolute_end; + } else { + break; + } + } + } + + // Extract duration from the found item block + if let (Some(start), Some(end)) = (item_pos, item_end_pos) { + let item_block = &raw_rss[start..end]; + + // Look for in this item block + if let Some(duration_start) = item_block.find("") { + let duration_content_start = duration_start + "".len(); + if let Some(duration_end) = item_block[duration_content_start..].find("") { + let duration_str = &item_block[duration_content_start..duration_content_start + duration_end]; + return Self::parse_itunes_duration(duration_str.trim()); + } + } + } + + None + } + + // Parse iTunes duration string (HH:MM:SS, MM:SS, or seconds) to total seconds + fn parse_itunes_duration(duration_str: &str) -> Option { + if duration_str.is_empty() { + return None; + } + + // If it's just a number, treat as seconds + if let Ok(seconds) = duration_str.parse::() { + return Some(seconds); + } + + // Split by colons for time format + let parts: Vec<&str> = duration_str.split(':').collect(); + + match parts.len() { + 1 => { + // Just seconds + parts[0].parse::().ok() + }, + 2 => { + // MM:SS + let minutes = parts[0].parse::().ok()?; + let seconds = parts[1].parse::().ok()?; + Some(minutes * 60 + seconds) + }, + 3 => { + // HH:MM:SS + let hours = parts[0].parse::().ok()?; + let minutes = parts[1].parse::().ok()?; + let seconds = parts[2].parse::().ok()?; + Some(hours * 3600 + minutes * 60 + seconds) + }, + _ => None + } + } + + // Add podcast from RSS values - wrapper function for custom podcast addition + pub async fn add_podcast_from_values(&self, podcast_values: &std::collections::HashMap, user_id: i32, _feed_cutoff: i32, username: Option<&str>, password: Option<&str>) -> AppResult<(i32, Option)> { + // Convert HashMap values to PodcastValues struct + let podcast_data = crate::handlers::podcasts::PodcastValues { + user_id, + pod_title: podcast_values.get("podcastname").unwrap_or(&"".to_string()).clone(), + pod_artwork: podcast_values.get("artworkurl").unwrap_or(&"".to_string()).clone(), + pod_author: podcast_values.get("author").unwrap_or(&"".to_string()).clone(), + categories: std::collections::HashMap::new(), // Parse from string if needed + pod_description: podcast_values.get("description").unwrap_or(&"".to_string()).clone(), + pod_episode_count: podcast_values.get("episodecount").unwrap_or(&"0".to_string()).parse().unwrap_or(0), + pod_feed_url: podcast_values.get("feedurl").unwrap_or(&"".to_string()).clone(), + pod_website: podcast_values.get("websiteurl").unwrap_or(&"".to_string()).clone(), + pod_explicit: podcast_values.get("explicit").unwrap_or(&"False".to_string()) == "True", + }; + + let podcast_index_id = podcast_values.get("podcastindexid") + .unwrap_or(&"0".to_string()) + .parse::() + .unwrap_or(0); + + self.add_podcast(&podcast_data, podcast_index_id, username, password).await + } + + // // Get podcast details - matches Python get_podcast_details function exactly + // pub async fn get_podcast_details(&self, user_id: i32, podcast_id: i32) -> AppResult> { + // println!("Getting podcast details for podcast {} and user {}", podcast_id, user_id); + + // let details = match self { + // DatabasePool::Postgres(pool) => { + // // Try to get podcast for user first, then fall back to UserID=1 + // let mut row = sqlx::query(r#"SELECT * FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + // .bind(podcast_id) + // .bind(user_id) + // .fetch_optional(pool) + // .await?; + + // if row.is_none() { + // row = sqlx::query(r#"SELECT * FROM "Podcasts" WHERE podcastid = $1 AND userid = 1"#) + // .bind(podcast_id) + // .fetch_optional(pool) + // .await?; + // } + + // if let Some(row) = row { + // // Get episode count from YouTubeVideos table if this is a YouTube channel + // let mut episode_count = row.try_get::("episodecount")?; + // let is_youtube_channel = row.try_get::("isyoutubechannel").unwrap_or(false); + + // if is_youtube_channel { + // let count_result = sqlx::query(r#"SELECT COUNT(*) as count FROM "YouTubeVideos" WHERE podcastid = $1"#) + // .bind(podcast_id) + // .fetch_one(pool) + // .await?; + // episode_count = count_result.try_get::("count")? as i32; + // } + + // Some(serde_json::json!({ + // "podcastid": row.try_get::("podcastid")?, + // "podcastindexid": row.try_get::, _>("podcastindexid")?, + // "podcastname": row.try_get::("podcastname")?, + // "artworkurl": row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_default(), + // "author": row.try_get::("author")?, + // "categories": row.try_get::, _>("categories")?, + // "description": row.try_get::("description")?, + // "episodecount": episode_count, + // "feedurl": row.try_get::("feedurl")?, + // "websiteurl": row.try_get::("websiteurl")?, + // "explicit": row.try_get::("explicit")?, + // "userid": row.try_get::("userid")?, + // "autodownload": row.try_get::("autodownload").unwrap_or(false), + // "startskip": row.try_get::("startskip").unwrap_or(0), + // "endskip": row.try_get::("endskip").unwrap_or(0), + // "username": row.try_get::, _>("username")?, + // "password": row.try_get::, _>("password")?, + // "isyoutubechannel": is_youtube_channel, + // "notificationsenabled": row.try_get::("notificationsenabled").unwrap_or(false), + // "feedcutoffdays": row.try_get::("feedcutoffdays").unwrap_or(0), + // })) + // } else { + // None + // } + // } + // DatabasePool::MySQL(pool) => { + // // Try to get podcast for user first, then fall back to UserID=1 + // let mut row = sqlx::query("SELECT * FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + // .bind(podcast_id) + // .bind(user_id) + // .fetch_optional(pool) + // .await?; + + // if row.is_none() { + // row = sqlx::query("SELECT * FROM Podcasts WHERE PodcastID = ? AND UserID = 1") + // .bind(podcast_id) + // .fetch_optional(pool) + // .await?; + // } + + // if let Some(row) = row { + // // Get episode count from YouTubeVideos table if this is a YouTube channel + // let mut episode_count = row.try_get::("EpisodeCount")?; + // let is_youtube_channel = row.try_get::("IsYouTubeChannel").unwrap_or(0) != 0; + + // if is_youtube_channel { + // let count_result = sqlx::query("SELECT COUNT(*) as count FROM YouTubeVideos WHERE PodcastID = ?") + // .bind(podcast_id) + // .fetch_one(pool) + // .await?; + // episode_count = count_result.try_get::("count")? as i32; + // } + + // Some(serde_json::json!({ + // "podcastid": row.try_get::("PodcastID")?, + // "podcastindexid": row.try_get::, _>("PodcastIndexID")?, + // "podcastname": row.try_get::("PodcastName")?, + // "artworkurl": row.try_get::, _>("ArtworkURL").unwrap_or_default().unwrap_or_default(), + // "author": row.try_get::("Author")?, + // "categories": row.try_get::, _>("Categories")?, + // "description": row.try_get::("Description")?, + // "episodecount": episode_count, + // "feedurl": row.try_get::("FeedURL")?, + // "websiteurl": row.try_get::("WebsiteURL")?, + // "explicit": row.try_get::("Explicit").unwrap_or(0) != 0, + // "userid": row.try_get::("UserID")?, + // "autodownload": row.try_get::("AutoDownload").unwrap_or(0) != 0, + // "startskip": row.try_get::("StartSkip").unwrap_or(0), + // "endskip": row.try_get::("EndSkip").unwrap_or(0), + // "username": row.try_get::, _>("Username")?, + // "password": row.try_get::, _>("Password")?, + // "isyoutubechannel": is_youtube_channel, + // "notificationsenabled": row.try_get::("NotificationsEnabled").unwrap_or(0) != 0, + // "feedcutoffdays": row.try_get::("FeedCutoffDays").unwrap_or(0), + // })) + // } else { + // None + // } + // } + // }; + + // if let Some(ref result) = details { + // println!("Found podcast details for: {}", result["podcast_name"]); + // } else { + // println!("No podcast found with ID {} for user {}", podcast_id, user_id); + // } + + // Ok(details) + // } + + // Get notification settings - matches Python get_notification_settings function exactly + pub async fn get_notification_settings(&self, user_id: i32) -> AppResult> { + println!("Getting notification settings for user {}", user_id); + + let settings = match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT platform, enabled, ntfytopic, ntfyserverurl, ntfyusername, ntfypassword, ntfyaccesstoken, gotifyurl, gotifytoken, httpurl, httptoken, httpmethod + FROM "UserNotificationSettings" + WHERE userid = $1 + "#) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut settings = Vec::new(); + for row in rows { + let setting = serde_json::json!({ + "platform": row.try_get::("platform")?, + "enabled": row.try_get::("enabled")?, + "ntfy_topic": row.try_get::, _>("ntfytopic")?, + "ntfy_server_url": row.try_get::, _>("ntfyserverurl")?, + "ntfy_username": row.try_get::, _>("ntfyusername")?, + "ntfy_password": row.try_get::, _>("ntfypassword")?, + "ntfy_access_token": row.try_get::, _>("ntfyaccesstoken")?, + "gotify_url": row.try_get::, _>("gotifyurl")?, + "gotify_token": row.try_get::, _>("gotifytoken")?, + "http_url": row.try_get::, _>("httpurl")?, + "http_token": row.try_get::, _>("httptoken")?, + "http_method": row.try_get::, _>("httpmethod")? + }); + settings.push(setting); + } + settings + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query(" + SELECT Platform, Enabled, NtfyTopic, NtfyServerURL, NtfyUsername, NtfyPassword, NtfyAccessToken, GotifyURL, GotifyToken, HttpUrl, HttpToken, HttpMethod + FROM UserNotificationSettings + WHERE UserID = ? + ORDER BY Platform + ") + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut settings = Vec::new(); + for row in rows { + let setting = serde_json::json!({ + "platform": row.try_get::("Platform")?, + "enabled": row.try_get::("Enabled")?, + "ntfy_topic": row.try_get::, _>("NtfyTopic")?, + "ntfy_server_url": row.try_get::, _>("NtfyServerURL")?, + "ntfy_username": row.try_get::, _>("NtfyUsername")?, + "ntfy_password": row.try_get::, _>("NtfyPassword")?, + "ntfy_access_token": row.try_get::, _>("NtfyAccessToken")?, + "gotify_url": row.try_get::, _>("GotifyURL")?, + "gotify_token": row.try_get::, _>("GotifyToken")?, + "http_url": row.try_get::, _>("HttpUrl")?, + "http_token": row.try_get::, _>("HttpToken")?, + "http_method": row.try_get::, _>("HttpMethod")? + }); + settings.push(setting); + } + settings + } + }; + + println!("Found {} notification settings for user {}", settings.len(), user_id); + Ok(settings) + } + + // Update notification settings - matches Python update_notification_settings function exactly + pub async fn update_notification_settings(&self, user_id: i32, platform: &str, enabled: bool, ntfy_topic: Option<&str>, ntfy_server_url: Option<&str>, ntfy_username: Option<&str>, ntfy_password: Option<&str>, ntfy_access_token: Option<&str>, gotify_url: Option<&str>, gotify_token: Option<&str>, http_url: Option<&str>, http_token: Option<&str>, http_method: Option<&str>) -> AppResult { + println!("Updating notification settings for user {} platform {}", user_id, platform); + + // Check if settings exist for this user/platform combination and perform update/insert + let success = match self { + DatabasePool::Postgres(pool) => { + let existing = sqlx::query(r#"SELECT COUNT(*) as count FROM "UserNotificationSettings" WHERE userid = $1 AND platform = $2"#) + .bind(user_id) + .bind(platform) + .fetch_one(pool) + .await?; + + let count: i64 = existing.try_get("count")?; + + if count > 0 { + // Update existing record + let result = sqlx::query(r#" + UPDATE "UserNotificationSettings" + SET enabled = $3, ntfytopic = $4, ntfyserverurl = $5, ntfyusername = $6, ntfypassword = $7, ntfyaccesstoken = $8, gotifyurl = $9, gotifytoken = $10, httpurl = $11, httptoken = $12, httpmethod = $13 + WHERE userid = $1 AND platform = $2 + "#) + .bind(user_id) + .bind(platform) + .bind(enabled) + .bind(ntfy_topic) + .bind(ntfy_server_url) + .bind(ntfy_username) + .bind(ntfy_password) + .bind(ntfy_access_token) + .bind(gotify_url) + .bind(gotify_token) + .bind(http_url) + .bind(http_token) + .bind(http_method) + .execute(pool) + .await?; + result.rows_affected() > 0 + } else { + // Insert new record + let result = sqlx::query(r#" + INSERT INTO "UserNotificationSettings" + (userid, platform, enabled, ntfytopic, ntfyserverurl, ntfyusername, ntfypassword, ntfyaccesstoken, gotifyurl, gotifytoken, httpurl, httptoken, httpmethod) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + "#) + .bind(user_id) + .bind(platform) + .bind(enabled) + .bind(ntfy_topic) + .bind(ntfy_server_url) + .bind(ntfy_username) + .bind(ntfy_password) + .bind(ntfy_access_token) + .bind(gotify_url) + .bind(gotify_token) + .bind(http_url) + .bind(http_token) + .bind(http_method) + .execute(pool) + .await?; + result.rows_affected() > 0 + } + } + DatabasePool::MySQL(pool) => { + let existing = sqlx::query("SELECT COUNT(*) as count FROM UserNotificationSettings WHERE UserID = ? AND Platform = ?") + .bind(user_id) + .bind(platform) + .fetch_one(pool) + .await?; + + let count: i64 = existing.try_get("count")?; + + if count > 0 { + // Update existing record + let result = sqlx::query(" + UPDATE UserNotificationSettings + SET Enabled = ?, NtfyTopic = ?, NtfyServerURL = ?, NtfyUsername = ?, NtfyPassword = ?, NtfyAccessToken = ?, GotifyURL = ?, GotifyToken = ?, HttpUrl = ?, HttpToken = ?, HttpMethod = ? + WHERE UserID = ? AND Platform = ? + ") + .bind(enabled) + .bind(ntfy_topic) + .bind(ntfy_server_url) + .bind(ntfy_username) + .bind(ntfy_password) + .bind(ntfy_access_token) + .bind(gotify_url) + .bind(gotify_token) + .bind(http_url) + .bind(http_token) + .bind(http_method) + .bind(user_id) + .bind(platform) + .execute(pool) + .await?; + result.rows_affected() > 0 + } else { + // Insert new record + let result = sqlx::query(" + INSERT INTO UserNotificationSettings + (UserID, Platform, Enabled, NtfyTopic, NtfyServerURL, NtfyUsername, NtfyPassword, NtfyAccessToken, GotifyURL, GotifyToken, HttpUrl, HttpToken, HttpMethod) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ") + .bind(user_id) + .bind(platform) + .bind(enabled) + .bind(ntfy_topic) + .bind(ntfy_server_url) + .bind(ntfy_username) + .bind(ntfy_password) + .bind(ntfy_access_token) + .bind(gotify_url) + .bind(gotify_token) + .bind(http_url) + .bind(http_token) + .bind(http_method) + .execute(pool) + .await?; + result.rows_affected() > 0 + } + } + }; + + println!("Successfully updated notification settings for user {} platform {}: {}", user_id, platform, success); + Ok(success) + } + + // Add OIDC provider - matches Python add_oidc_provider function exactly + pub async fn add_oidc_provider(&self, provider_name: &str, client_id: &str, client_secret: &str, authorization_url: &str, token_url: &str, user_info_url: &str, button_text: &str, scope: &str, button_color: &str, button_text_color: &str, icon_svg: &str, name_claim: &str, email_claim: &str, username_claim: &str, roles_claim: &str, user_role: &str, admin_role: &str, initialized_from_env: bool) -> AppResult { + println!("Adding OIDC provider: {}", provider_name); + + let provider_id = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + INSERT INTO "OIDCProviders" ( + providername, clientid, clientsecret, authorizationurl, + tokenurl, userinfourl, buttontext, scope, + buttoncolor, buttontextcolor, iconsvg, nameclaim, emailclaim, + usernameclaim, rolesclaim, userrole, adminrole, initializedFromEnv + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + RETURNING providerid + "#) + .bind(provider_name) + .bind(client_id) + .bind(client_secret) + .bind(authorization_url) + .bind(token_url) + .bind(user_info_url) + .bind(button_text) + .bind(scope) + .bind(button_color) + .bind(button_text_color) + .bind(icon_svg) + .bind(name_claim) + .bind(email_claim) + .bind(username_claim) + .bind(roles_claim) + .bind(user_role) + .bind(admin_role) + .bind(initialized_from_env) + .fetch_one(pool) + .await?; + + row.try_get("providerid")? + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query(" + INSERT INTO OIDCProviders ( + ProviderName, ClientID, ClientSecret, AuthorizationURL, + TokenURL, UserInfoURL, ButtonText, Scope, + ButtonColor, ButtonTextColor, IconSVG, NameClaim, EmailClaim, + UsernameClaim, RolesClaim, UserRole, AdminRole, InitializedFromEnv + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ") + .bind(provider_name) + .bind(client_id) + .bind(client_secret) + .bind(authorization_url) + .bind(token_url) + .bind(user_info_url) + .bind(button_text) + .bind(scope) + .bind(button_color) + .bind(button_text_color) + .bind(icon_svg) + .bind(name_claim) + .bind(email_claim) + .bind(username_claim) + .bind(roles_claim) + .bind(user_role) + .bind(admin_role) + .bind(initialized_from_env) + .execute(pool) + .await?; + + result.last_insert_id() as i32 + } + }; + + println!("Successfully added OIDC provider with ID: {}", provider_id); + Ok(provider_id) + } + + // List OIDC providers - matches Python list_oidc_providers function exactly + pub async fn list_oidc_providers(&self) -> AppResult> { + println!("Listing all OIDC providers"); + + let providers = match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT providerid, providername, clientid, authorizationurl, + tokenurl, userinfourl, buttontext, scope, buttoncolor, + buttontextcolor, iconsvg, nameclaim, emailclaim, usernameclaim, + rolesclaim, userrole, adminrole, enabled, created, modified, initializedfromenv + FROM "OIDCProviders" + ORDER BY providername + "#) + .fetch_all(pool) + .await?; + + let mut providers = Vec::new(); + for row in rows { + let provider = serde_json::json!({ + "provider_id": row.try_get::("providerid")?, + "provider_name": row.try_get::("providername")?, + "client_id": row.try_get::("clientid")?, + "authorization_url": row.try_get::("authorizationurl")?, + "token_url": row.try_get::("tokenurl")?, + "user_info_url": row.try_get::("userinfourl")?, + "button_text": row.try_get::("buttontext")?, + "scope": row.try_get::("scope")?, + "button_color": row.try_get::("buttoncolor")?, + "button_text_color": row.try_get::("buttontextcolor")?, + "icon_svg": row.try_get::, _>("iconsvg")?, + "name_claim": row.try_get::, _>("nameclaim")?, + "email_claim": row.try_get::, _>("emailclaim")?, + "username_claim": row.try_get::, _>("usernameclaim")?, + "roles_claim": row.try_get::, _>("rolesclaim")?, + "user_role": row.try_get::, _>("userrole")?, + "admin_role": row.try_get::, _>("adminrole")?, + "enabled": row.try_get::("enabled")?, + "created": row.try_get::, _>("created")?, + "modified": row.try_get::, _>("modified")?, + "initialized_from_env": row.try_get::("initializedfromenv").unwrap_or(false) + }); + providers.push(provider); + } + providers + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query(" + SELECT ProviderID, ProviderName, ClientID, AuthorizationURL, + TokenURL, UserInfoURL, ButtonText, Scope, ButtonColor, + ButtonTextColor, IconSVG, NameClaim, EmailClaim, UsernameClaim, + RolesClaim, UserRole, AdminRole, Enabled, Created, Modified, InitializedFromEnv + FROM OIDCProviders + ORDER BY ProviderName + ") + .fetch_all(pool) + .await?; + + let mut providers = Vec::new(); + for row in rows { + let provider = serde_json::json!({ + "provider_id": row.try_get::("ProviderID")?, + "provider_name": row.try_get::("ProviderName")?, + "client_id": row.try_get::("ClientID")?, + "authorization_url": row.try_get::("AuthorizationURL")?, + "token_url": row.try_get::("TokenURL")?, + "user_info_url": row.try_get::("UserInfoURL")?, + "button_text": row.try_get::("ButtonText")?, + "scope": row.try_get::("Scope")?, + "button_color": row.try_get::("ButtonColor")?, + "button_text_color": row.try_get::("ButtonTextColor")?, + "icon_svg": row.try_get::, _>("IconSVG")?, + "name_claim": row.try_get::, _>("NameClaim")?, + "email_claim": row.try_get::, _>("EmailClaim")?, + "username_claim": row.try_get::, _>("UsernameClaim")?, + "roles_claim": row.try_get::, _>("RolesClaim")?, + "user_role": row.try_get::, _>("UserRole")?, + "admin_role": row.try_get::, _>("AdminRole")?, + "enabled": row.try_get::("Enabled")?, + "created": row.try_get::, _>("Created")?, + "modified": row.try_get::, _>("Modified")?, + "initialized_from_env": row.try_get::("InitializedFromEnv").unwrap_or(false) + }); + providers.push(provider); + } + providers + } + }; + + println!("Found {} OIDC providers", providers.len()); + Ok(providers) + } + + // Remove OIDC provider - matches Python remove_oidc_provider function exactly + pub async fn remove_oidc_provider(&self, provider_id: i32) -> AppResult { + println!("Removing OIDC provider with ID: {}", provider_id); + + let rows_affected = match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"DELETE FROM "OIDCProviders" WHERE ProviderID = $1"#) + .bind(provider_id) + .execute(pool) + .await?; + + result.rows_affected() + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("DELETE FROM OIDCProviders WHERE ProviderID = ?") + .bind(provider_id) + .execute(pool) + .await?; + + result.rows_affected() + } + }; + + let success = rows_affected > 0; + if success { + println!("Successfully removed OIDC provider with ID: {}", provider_id); + } else { + println!("No OIDC provider found with ID: {}", provider_id); + } + Ok(success) + } + + // Initialize OIDC provider from environment variables on container startup + pub async fn init_oidc_from_env(&self, oidc_config: &OIDCConfig) -> AppResult<()> { + if !oidc_config.is_configured() { + println!("OIDC environment variables not configured, skipping initialization"); + return Ok(()); + } + + let provider_name = oidc_config.provider_name.as_ref().unwrap(); + let client_id = oidc_config.client_id.as_ref().unwrap(); + + // Check if provider already exists + let existing = self.get_oidc_provider_by_client_id(client_id).await?; + if existing.is_some() { + println!("OIDC provider with client_id '{}' already exists, skipping initialization", client_id); + return Ok(()); + } + + println!("Initializing OIDC provider '{}' from environment variables", provider_name); + + // Create the provider with all the configuration + let provider_id = self.add_oidc_provider( + provider_name, + client_id, + oidc_config.client_secret.as_ref().unwrap(), + oidc_config.authorization_url.as_ref().unwrap(), + oidc_config.token_url.as_ref().unwrap(), + oidc_config.user_info_url.as_ref().unwrap(), + oidc_config.button_text.as_ref().unwrap(), + oidc_config.scope.as_deref().unwrap_or("openid email profile"), + oidc_config.button_color.as_deref().unwrap_or("#000000"), + oidc_config.button_text_color.as_deref().unwrap_or("#FFFFFF"), + oidc_config.icon_svg.as_deref().unwrap_or(""), + oidc_config.name_claim.as_deref().unwrap_or("name"), + oidc_config.email_claim.as_deref().unwrap_or("email"), + oidc_config.username_claim.as_deref().unwrap_or("preferred_username"), + oidc_config.roles_claim.as_deref().unwrap_or("roles"), + oidc_config.user_role.as_deref().unwrap_or("user"), + oidc_config.admin_role.as_deref().unwrap_or("admin"), + true, // initialized_from_env = true + ).await?; + + println!("Successfully initialized OIDC provider '{}' with ID: {}", provider_name, provider_id); + Ok(()) + } + + // Check if OIDC provider was initialized from environment variables + pub async fn is_oidc_provider_env_initialized(&self, provider_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#" + SELECT initializedfromenv FROM "OIDCProviders" WHERE providerid = $1 + "#) + .bind(provider_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row| row.try_get::("initializedfromenv").unwrap_or(false)).unwrap_or(false)) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query(r#" + SELECT InitializedFromEnv FROM OIDCProviders WHERE ProviderID = ? + "#) + .bind(provider_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row| row.try_get::("InitializedFromEnv").unwrap_or(false)).unwrap_or(false)) + } + } + } + + // Update OIDC provider - updates an existing provider with new values + // If client_secret is None, the existing secret will be preserved + pub async fn update_oidc_provider(&self, provider_id: i32, provider_name: &str, client_id: &str, client_secret: Option<&str>, authorization_url: &str, token_url: &str, user_info_url: &str, button_text: &str, scope: &str, button_color: &str, button_text_color: &str, icon_svg: &str, name_claim: &str, email_claim: &str, username_claim: &str, roles_claim: &str, user_role: &str, admin_role: &str) -> AppResult { + println!("Updating OIDC provider with ID: {}", provider_id); + + let rows_affected = match self { + DatabasePool::Postgres(pool) => { + // Build query dynamically based on whether client_secret is provided + if let Some(secret) = client_secret { + let result = sqlx::query(r#" + UPDATE "OIDCProviders" SET + providername = $2, clientid = $3, clientsecret = $4, + authorizationurl = $5, tokenurl = $6, userinfourl = $7, + buttontext = $8, scope = $9, buttoncolor = $10, + buttontextcolor = $11, iconsvg = $12, nameclaim = $13, + emailclaim = $14, usernameclaim = $15, rolesclaim = $16, + userrole = $17, adminrole = $18, modified = CURRENT_TIMESTAMP + WHERE providerid = $1 + "#) + .bind(provider_id) + .bind(provider_name) + .bind(client_id) + .bind(secret) + .bind(authorization_url) + .bind(token_url) + .bind(user_info_url) + .bind(button_text) + .bind(scope) + .bind(button_color) + .bind(button_text_color) + .bind(icon_svg) + .bind(name_claim) + .bind(email_claim) + .bind(username_claim) + .bind(roles_claim) + .bind(user_role) + .bind(admin_role) + .execute(pool) + .await?; + result.rows_affected() + } else { + // Don't update client_secret if not provided + let result = sqlx::query(r#" + UPDATE "OIDCProviders" SET + providername = $2, clientid = $3, + authorizationurl = $4, tokenurl = $5, userinfourl = $6, + buttontext = $7, scope = $8, buttoncolor = $9, + buttontextcolor = $10, iconsvg = $11, nameclaim = $12, + emailclaim = $13, usernameclaim = $14, rolesclaim = $15, + userrole = $16, adminrole = $17, modified = CURRENT_TIMESTAMP + WHERE providerid = $1 + "#) + .bind(provider_id) + .bind(provider_name) + .bind(client_id) + .bind(authorization_url) + .bind(token_url) + .bind(user_info_url) + .bind(button_text) + .bind(scope) + .bind(button_color) + .bind(button_text_color) + .bind(icon_svg) + .bind(name_claim) + .bind(email_claim) + .bind(username_claim) + .bind(roles_claim) + .bind(user_role) + .bind(admin_role) + .execute(pool) + .await?; + result.rows_affected() + } + } + DatabasePool::MySQL(pool) => { + if let Some(secret) = client_secret { + let result = sqlx::query(" + UPDATE OIDCProviders SET + ProviderName = ?, ClientID = ?, ClientSecret = ?, + AuthorizationURL = ?, TokenURL = ?, UserInfoURL = ?, + ButtonText = ?, Scope = ?, ButtonColor = ?, + ButtonTextColor = ?, IconSVG = ?, NameClaim = ?, + EmailClaim = ?, UsernameClaim = ?, RolesClaim = ?, + UserRole = ?, AdminRole = ?, Modified = CURRENT_TIMESTAMP + WHERE ProviderID = ? + ") + .bind(provider_name) + .bind(client_id) + .bind(secret) + .bind(authorization_url) + .bind(token_url) + .bind(user_info_url) + .bind(button_text) + .bind(scope) + .bind(button_color) + .bind(button_text_color) + .bind(icon_svg) + .bind(name_claim) + .bind(email_claim) + .bind(username_claim) + .bind(roles_claim) + .bind(user_role) + .bind(admin_role) + .bind(provider_id) + .execute(pool) + .await?; + result.rows_affected() + } else { + // Don't update client_secret if not provided + let result = sqlx::query(" + UPDATE OIDCProviders SET + ProviderName = ?, ClientID = ?, + AuthorizationURL = ?, TokenURL = ?, UserInfoURL = ?, + ButtonText = ?, Scope = ?, ButtonColor = ?, + ButtonTextColor = ?, IconSVG = ?, NameClaim = ?, + EmailClaim = ?, UsernameClaim = ?, RolesClaim = ?, + UserRole = ?, AdminRole = ?, Modified = CURRENT_TIMESTAMP + WHERE ProviderID = ? + ") + .bind(provider_name) + .bind(client_id) + .bind(authorization_url) + .bind(token_url) + .bind(user_info_url) + .bind(button_text) + .bind(scope) + .bind(button_color) + .bind(button_text_color) + .bind(icon_svg) + .bind(name_claim) + .bind(email_claim) + .bind(username_claim) + .bind(roles_claim) + .bind(user_role) + .bind(admin_role) + .bind(provider_id) + .execute(pool) + .await?; + result.rows_affected() + } + } + }; + + let success = rows_affected > 0; + if success { + println!("Successfully updated OIDC provider with ID: {}", provider_id); + } else { + println!("No OIDC provider found with ID: {}", provider_id); + } + Ok(success) + } + + // Get user start page - matches Python get_user_startpage function exactly + pub async fn get_user_startpage(&self, user_id: i32) -> AppResult { + println!("Getting start page for user {}", user_id); + + let startpage = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT startpage FROM "UserSettings" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::, _>("startpage")?.unwrap_or_else(|| "home".to_string()) + } else { + "home".to_string() + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT StartPage FROM UserSettings WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::, _>("StartPage")?.unwrap_or_else(|| "home".to_string()) + } else { + "home".to_string() + } + } + }; + + Ok(startpage) + } + + // Set user start page - matches Python set_user_startpage function exactly + pub async fn set_user_startpage(&self, user_id: i32, startpage: &str) -> AppResult { + println!("Setting start page for user {} to {}", user_id, startpage); + + // Check if user settings exist and perform update/insert + let success = match self { + DatabasePool::Postgres(pool) => { + let existing = sqlx::query(r#"SELECT COUNT(*) as count FROM "UserSettings" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + + let count: i64 = existing.try_get("count")?; + + if count > 0 { + // Update existing record + let result = sqlx::query(r#"UPDATE "UserSettings" SET startpage = $2 WHERE userid = $1"#) + .bind(user_id) + .bind(startpage) + .execute(pool) + .await?; + result.rows_affected() > 0 + } else { + // Insert new record + let result = sqlx::query(r#"INSERT INTO "UserSettings" (userid, startpage) VALUES ($1, $2)"#) + .bind(user_id) + .bind(startpage) + .execute(pool) + .await?; + result.rows_affected() > 0 + } + } + DatabasePool::MySQL(pool) => { + let existing = sqlx::query("SELECT COUNT(*) as count FROM UserSettings WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + + let count: i64 = existing.try_get("count")?; + + if count > 0 { + // Update existing record + let result = sqlx::query("UPDATE UserSettings SET StartPage = ? WHERE UserID = ?") + .bind(startpage) + .bind(user_id) + .execute(pool) + .await?; + result.rows_affected() > 0 + } else { + // Insert new record + let result = sqlx::query("INSERT INTO UserSettings (UserID, StartPage) VALUES (?, ?)") + .bind(user_id) + .bind(startpage) + .execute(pool) + .await?; + result.rows_affected() > 0 + } + } + }; + + println!("Successfully set start page for user {} to {}: {}", user_id, startpage, success); + Ok(success) + } + + // Update startpage wrapper function for compatibility + pub async fn update_startpage(&self, user_id: i32, startpage: &str) -> AppResult { + self.set_user_startpage(user_id, startpage).await + } + + // Get startpage wrapper function for compatibility + pub async fn get_startpage(&self, user_id: i32) -> AppResult { + self.get_user_startpage(user_id).await + } + + // Get user auto complete seconds setting + pub async fn get_user_auto_complete_seconds(&self, user_id: i32) -> AppResult { + println!("Getting auto complete seconds for user {}", user_id); + + let auto_complete_seconds = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT autocompleteseconds FROM "UserSettings" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::, _>("autocompleteseconds")?.unwrap_or(0) + } else { + 0 + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT AutoCompleteSeconds FROM UserSettings WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::, _>("AutoCompleteSeconds")?.unwrap_or(0) + } else { + 0 + } + } + }; + + Ok(auto_complete_seconds) + } + + // Set user auto complete seconds setting + pub async fn set_user_auto_complete_seconds(&self, user_id: i32, seconds: i32) -> AppResult { + println!("Setting auto complete seconds for user {} to {}", user_id, seconds); + + // Check if user settings exist and perform update/insert + let success = match self { + DatabasePool::Postgres(pool) => { + let existing = sqlx::query(r#"SELECT COUNT(*) as count FROM "UserSettings" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + + let count: i64 = existing.try_get("count")?; + + if count > 0 { + // Update existing record + let result = sqlx::query(r#"UPDATE "UserSettings" SET autocompleteseconds = $2 WHERE userid = $1"#) + .bind(user_id) + .bind(seconds) + .execute(pool) + .await?; + result.rows_affected() > 0 + } else { + // Insert new record with default theme + let result = sqlx::query(r#"INSERT INTO "UserSettings" (userid, autocompleteseconds, theme) VALUES ($1, $2, 'Nordic')"#) + .bind(user_id) + .bind(seconds) + .execute(pool) + .await?; + result.rows_affected() > 0 + } + } + DatabasePool::MySQL(pool) => { + let existing = sqlx::query("SELECT COUNT(*) as count FROM UserSettings WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + + let count: i64 = existing.try_get("count")?; + + if count > 0 { + // Update existing record + let result = sqlx::query("UPDATE UserSettings SET AutoCompleteSeconds = ? WHERE UserID = ?") + .bind(seconds) + .bind(user_id) + .execute(pool) + .await?; + result.rows_affected() > 0 + } else { + // Insert new record with default theme + let result = sqlx::query("INSERT INTO UserSettings (UserID, AutoCompleteSeconds, Theme) VALUES (?, ?, 'Nordic')") + .bind(user_id) + .bind(seconds) + .execute(pool) + .await?; + result.rows_affected() > 0 + } + } + }; + + println!("Successfully set auto complete seconds for user {} to {}: {}", user_id, seconds, success); + Ok(success) + } + + // Get episode duration in seconds + pub async fn get_episode_duration(&self, episode_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT episodeduration FROM "Episodes" WHERE episodeid = $1"#) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get::, _>("episodeduration")?.unwrap_or(0)) + } else { + Ok(0) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT EpisodeDuration FROM Episodes WHERE EpisodeID = ?") + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get::, _>("EpisodeDuration")?.unwrap_or(0)) + } else { + Ok(0) + } + } + } + } + + // Get YouTube episode duration in seconds + pub async fn get_youtube_episode_duration(&self, episode_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT duration FROM "YouTubeVideos" WHERE videoid = $1"#) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get::, _>("duration")?.unwrap_or(0)) + } else { + Ok(0) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT Duration FROM YouTubeVideos WHERE VideoID = ?") + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get::, _>("Duration")?.unwrap_or(0)) + } else { + Ok(0) + } + } + } + } + + // Get users who have auto-complete enabled (auto_complete_seconds > 0) + pub async fn get_users_with_auto_complete_enabled(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT us.userid, us.autocompleteseconds + FROM "UserSettings" us + WHERE us.autocompleteseconds > 0 + "#) + .fetch_all(pool) + .await?; + + let mut users = Vec::new(); + for row in rows { + users.push(UserAutoComplete { + user_id: row.try_get("userid")?, + auto_complete_seconds: row.try_get("autocompleteseconds")?, + }); + } + Ok(users) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query(" + SELECT UserID, AutoCompleteSeconds + FROM UserSettings + WHERE AutoCompleteSeconds > 0 + ") + .fetch_all(pool) + .await?; + + let mut users = Vec::new(); + for row in rows { + users.push(UserAutoComplete { + user_id: row.try_get("UserID")?, + auto_complete_seconds: row.try_get("AutoCompleteSeconds")?, + }); + } + Ok(users) + } + } + } + + // Auto-complete episodes for a user based on their setting + pub async fn auto_complete_user_episodes(&self, user_id: i32, auto_complete_seconds: i32) -> AppResult { + if auto_complete_seconds <= 0 { + return Ok(0); + } + + let mut completed_count = 0; + + match self { + DatabasePool::Postgres(pool) => { + // Handle regular episodes + let episode_rows = sqlx::query(r#" + SELECT e.episodeid, e.episodeduration, COALESCE(h.listenduration, 0) as listenduration + FROM "Episodes" e + LEFT JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid AND h.userid = $1 + WHERE e.completed = false + AND e.episodeduration > 0 + AND h.listenduration > 0 + AND (e.episodeduration - h.listenduration) <= $2 + "#) + .bind(user_id) + .bind(auto_complete_seconds) + .fetch_all(pool) + .await?; + + for row in episode_rows { + let episode_id: i32 = row.try_get("episodeid")?; + let _ = self.mark_episode_completed(episode_id, user_id, false).await; + completed_count += 1; + } + + // Handle YouTube episodes + let youtube_rows = sqlx::query(r#" + SELECT v.videoid, v.duration, COALESCE(h.listenduration, 0) as listenduration + FROM "YouTubeVideos" v + LEFT JOIN "UserVideoHistory" h ON v.videoid = h.videoid AND h.userid = $1 + WHERE v.completed = false + AND v.duration > 0 + AND h.listenduration > 0 + AND (v.duration - h.listenduration) <= $2 + "#) + .bind(user_id) + .bind(auto_complete_seconds) + .fetch_all(pool) + .await?; + + for row in youtube_rows { + let video_id: i32 = row.try_get("videoid")?; + let _ = self.mark_episode_completed(video_id, user_id, true).await; + completed_count += 1; + } + } + DatabasePool::MySQL(pool) => { + // Handle regular episodes + let episode_rows = sqlx::query(" + SELECT e.EpisodeID, e.EpisodeDuration, COALESCE(h.ListenDuration, 0) as ListenDuration + FROM Episodes e + LEFT JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = ? + WHERE e.Completed = 0 + AND e.EpisodeDuration > 0 + AND h.ListenDuration > 0 + AND (e.EpisodeDuration - h.ListenDuration) <= ? + ") + .bind(user_id) + .bind(auto_complete_seconds) + .fetch_all(pool) + .await?; + + for row in episode_rows { + let episode_id: i32 = row.try_get("EpisodeID")?; + let _ = self.mark_episode_completed(episode_id, user_id, false).await; + completed_count += 1; + } + + // Handle YouTube episodes + let youtube_rows = sqlx::query(" + SELECT v.VideoID, v.Duration, COALESCE(h.ListenDuration, 0) as ListenDuration + FROM YouTubeVideos v + LEFT JOIN UserVideoHistory h ON v.VideoID = h.VideoID AND h.UserID = ? + WHERE v.Completed = 0 + AND v.Duration > 0 + AND h.ListenDuration > 0 + AND (v.Duration - h.ListenDuration) <= ? + ") + .bind(user_id) + .bind(auto_complete_seconds) + .fetch_all(pool) + .await?; + + for row in youtube_rows { + let video_id: i32 = row.try_get("VideoID")?; + let _ = self.mark_episode_completed(video_id, user_id, true).await; + completed_count += 1; + } + } + } + + Ok(completed_count) + } + + // Subscribe to person - matches Python subscribe_to_person function exactly + pub async fn subscribe_to_person(&self, user_id: i32, person_id: i32, person_name: &str, person_img: &str, podcast_id: i32) -> AppResult { + println!("Subscribing user {} to person {}: {}", user_id, person_id, person_name); + + // Check if person already exists for this user and handle accordingly + let result = match self { + DatabasePool::Postgres(pool) => { + // When peopledbid is 0 or not set, use name for lookup to avoid collisions + let existing = if person_id == 0 { + sqlx::query(r#"SELECT personid FROM "People" WHERE userid = $1 AND LOWER(name) = LOWER($2)"#) + .bind(user_id) + .bind(person_name) + .fetch_optional(pool) + .await? + } else { + sqlx::query(r#"SELECT personid FROM "People" WHERE userid = $1 AND peopledbid = $2"#) + .bind(user_id) + .bind(person_id) + .fetch_optional(pool) + .await? + }; + + if let Some(row) = existing { + let person_db_id: i32 = row.try_get("personid")?; + + // Update associated podcasts to include this podcast_id + let podcast_row = sqlx::query(r#"SELECT associatedpodcasts FROM "People" WHERE personid = $1"#) + .bind(person_db_id) + .fetch_one(pool) + .await?; + let current_podcasts = podcast_row.try_get::, _>("associatedpodcasts")?; + + let updated_podcasts = if let Some(podcasts) = current_podcasts { + if !podcasts.contains(&podcast_id.to_string()) { + format!("{},{}", podcasts, podcast_id) + } else { + podcasts + } + } else { + podcast_id.to_string() + }; + + // Update the record + sqlx::query(r#"UPDATE "People" SET associatedpodcasts = $2 WHERE personid = $1"#) + .bind(person_db_id) + .bind(&updated_podcasts) + .execute(pool) + .await?; + + println!("Updated existing person subscription with ID: {}", person_db_id); + person_db_id + } else { + // Insert new person subscription + let row = sqlx::query(r#" + INSERT INTO "People" (userid, name, personimg, peopledbid, associatedpodcasts) + VALUES ($1, $2, $3, $4, $5) + RETURNING personid + "#) + .bind(user_id) + .bind(person_name) + .bind(person_img) + .bind(person_id) + .bind(podcast_id.to_string()) + .fetch_one(pool) + .await?; + + let person_db_id: i32 = row.try_get("personid")?; + println!("Successfully subscribed to person with ID: {}", person_db_id); + person_db_id + } + } + DatabasePool::MySQL(pool) => { + // When peopledbid is 0 or not set, use name for lookup to avoid collisions + let existing = if person_id == 0 { + sqlx::query("SELECT PersonID FROM People WHERE UserID = ? AND LOWER(Name) = LOWER(?)") + .bind(user_id) + .bind(person_name) + .fetch_optional(pool) + .await? + } else { + sqlx::query("SELECT PersonID FROM People WHERE UserID = ? AND PeopleDBID = ?") + .bind(user_id) + .bind(person_id) + .fetch_optional(pool) + .await? + }; + + if let Some(row) = existing { + let person_db_id: i32 = row.try_get("PersonID")?; + + // Update associated podcasts to include this podcast_id + let podcast_row = sqlx::query("SELECT AssociatedPodcasts FROM People WHERE PersonID = ?") + .bind(person_db_id) + .fetch_one(pool) + .await?; + let current_podcasts = podcast_row.try_get::, _>("AssociatedPodcasts")?; + + let updated_podcasts = if let Some(podcasts) = current_podcasts { + if !podcasts.contains(&podcast_id.to_string()) { + format!("{},{}", podcasts, podcast_id) + } else { + podcasts + } + } else { + podcast_id.to_string() + }; + + // Update the record + sqlx::query("UPDATE People SET AssociatedPodcasts = ? WHERE PersonID = ?") + .bind(&updated_podcasts) + .bind(person_db_id) + .execute(pool) + .await?; + + println!("Updated existing person subscription with ID: {}", person_db_id); + person_db_id + } else { + // Insert new person subscription + let result = sqlx::query(" + INSERT INTO People (UserID, Name, PersonImg, PeopleDBID, AssociatedPodcasts) + VALUES (?, ?, ?, ?, ?) + ") + .bind(user_id) + .bind(person_name) + .bind(person_img) + .bind(person_id) + .bind(podcast_id.to_string()) + .execute(pool) + .await?; + + let person_db_id = result.last_insert_id() as i32; + println!("Successfully subscribed to person with ID: {}", person_db_id); + person_db_id + } + } + }; + + Ok(result) + } + + // Unsubscribe from person - matches Python unsubscribe_from_person function exactly + pub async fn unsubscribe_from_person(&self, user_id: i32, person_id: i32, person_name: &str) -> AppResult { + println!("Unsubscribing user {} from person {}: {}", user_id, person_id, person_name); + + // Find and delete the person record + let rows_affected = match self { + DatabasePool::Postgres(pool) => { + // When peopledbid is 0 or not set, use name for lookup to avoid collisions + if person_id == 0 { + sqlx::query(r#"DELETE FROM "People" WHERE userid = $1 AND LOWER(name) = LOWER($2)"#) + .bind(user_id) + .bind(person_name) + .execute(pool) + .await? + .rows_affected() + } else { + sqlx::query(r#"DELETE FROM "People" WHERE userid = $1 AND peopledbid = $2"#) + .bind(user_id) + .bind(person_id) + .execute(pool) + .await? + .rows_affected() + } + } + DatabasePool::MySQL(pool) => { + // When peopledbid is 0 or not set, use name for lookup to avoid collisions + if person_id == 0 { + sqlx::query("DELETE FROM People WHERE UserID = ? AND LOWER(Name) = LOWER(?)") + .bind(user_id) + .bind(person_name) + .execute(pool) + .await? + .rows_affected() + } else { + sqlx::query("DELETE FROM People WHERE UserID = ? AND PeopleDBID = ?") + .bind(user_id) + .bind(person_id) + .execute(pool) + .await? + .rows_affected() + } + } + }; + + if rows_affected > 0 { + // Check if this was the last subscriber to this person + let count: i64 = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT COUNT(*) as count FROM "People" WHERE peopledbid = $1"#) + .bind(person_id) + .fetch_one(pool) + .await?; + row.try_get("count")? + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT COUNT(*) as count FROM People WHERE PeopleDBID = ?") + .bind(person_id) + .fetch_one(pool) + .await?; + row.try_get("count")? + } + }; + + // If no more subscribers, clean up episodes + if count == 0 { + match self { + DatabasePool::Postgres(pool) => { + let _ = sqlx::query(r#"DELETE FROM "PeopleEpisodes" WHERE personid = $1"#) + .bind(person_id) + .execute(pool) + .await; + } + DatabasePool::MySQL(pool) => { + let _ = sqlx::query("DELETE FROM PeopleEpisodes WHERE PersonID = ?") + .bind(person_id) + .execute(pool) + .await; + } + } + } + + println!("Successfully unsubscribed from person {}", person_id); + Ok(true) + } else { + println!("Person subscription not found for user {} and person {}", user_id, person_id); + Ok(false) + } + } + + // Get person subscriptions - matches Python get_person_subscriptions function exactly + pub async fn get_person_subscriptions(&self, user_id: i32) -> AppResult> { + println!("Getting person subscriptions for user {}", user_id); + + let mut subscriptions = Vec::new(); + + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT personid, userid, name, personimg, peopledbid, associatedpodcasts + FROM "People" + WHERE userid = $1 + ORDER BY name + "#) + .bind(user_id) + .fetch_all(pool) + .await?; + + for row in rows { + let person_id = row.try_get::("personid")?; + let associated_podcasts_str = row.try_get::, _>("associatedpodcasts")?; + + // Count associated podcasts by splitting the comma-separated string and filtering out empty/0 values + let podcast_count = if let Some(podcasts_str) = &associated_podcasts_str { + if podcasts_str.is_empty() { + 0 + } else { + podcasts_str.split(',') + .filter(|s| !s.trim().is_empty() && s.trim() != "0") + .count() + } + } else { + 0 + }; + + // Count episodes for this person from PeopleEpisodes table + let episode_count: i64 = sqlx::query_scalar(r#" + SELECT COUNT(*) + FROM "PeopleEpisodes" pe + INNER JOIN "Podcasts" p ON pe.podcastid = p.podcastid + WHERE pe.personid = $1 AND p.userid = $2 + "#) + .bind(person_id) + .bind(user_id) + .fetch_one(pool) + .await + .unwrap_or(0); + + let subscription = serde_json::json!({ + "personid": person_id, + "userid": row.try_get::("userid")?, + "name": row.try_get::("name")?, + "image": row.try_get::("personimg")?, + "peopledbid": row.try_get::("peopledbid")?, + "associatedpodcasts": podcast_count, + "episode_count": episode_count + }); + subscriptions.push(subscription); + } + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query(" + SELECT PersonID, UserID, Name, PersonImg, PeopleDBID, AssociatedPodcasts + FROM People + WHERE UserID = ? + ORDER BY Name + ") + .bind(user_id) + .fetch_all(pool) + .await?; + + for row in rows { + let person_id = row.try_get::("PersonID")?; + let associated_podcasts_str = row.try_get::, _>("AssociatedPodcasts")?; + + // Count associated podcasts by splitting the comma-separated string and filtering out empty/0 values + let podcast_count = if let Some(podcasts_str) = &associated_podcasts_str { + if podcasts_str.is_empty() { + 0 + } else { + podcasts_str.split(',') + .filter(|s| !s.trim().is_empty() && s.trim() != "0") + .count() + } + } else { + 0 + }; + + // Count episodes for this person from PeopleEpisodes table + let episode_count: i64 = sqlx::query_scalar(" + SELECT COUNT(*) + FROM PeopleEpisodes pe + INNER JOIN Podcasts p ON pe.PodcastID = p.PodcastID + WHERE pe.PersonID = ? AND p.UserID = ? + ") + .bind(person_id) + .bind(user_id) + .fetch_one(pool) + .await + .unwrap_or(0); + + let subscription = serde_json::json!({ + "personid": person_id, + "userid": row.try_get::("UserID")?, + "name": row.try_get::("Name")?, + "image": row.try_get::("PersonImg")?, + "peopledbid": row.try_get::("PeopleDBID")?, + "associatedpodcasts": podcast_count, + "episode_count": episode_count + }); + subscriptions.push(subscription); + } + } + } + + println!("Found {} person subscriptions for user {}", subscriptions.len(), user_id); + Ok(subscriptions) + } + + // Get person episodes - matches Python return_person_episodes function exactly + pub async fn get_person_episodes(&self, user_id: i32, person_id: i32) -> AppResult> { + println!("Getting episodes for user {} and person {}", user_id, person_id); + + let mut episodes = Vec::new(); + + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT + e.episodeid, -- Will be NULL if no match in Episodes table + pe.episodetitle, + pe.episodedescription, + pe.episodeurl, + CASE + WHEN pe.episodeartwork IS NULL THEN + (SELECT artworkurl FROM "Podcasts" WHERE podcastid = pe.podcastid) + ELSE + CASE + WHEN p.usepodcastcoverscustomized = TRUE AND p.usepodcastcovers = TRUE THEN p.artworkurl + WHEN u.usepodcastcovers = TRUE THEN p.artworkurl + ELSE pe.episodeartwork + END + END as episodeartwork, + pe.episodepubdate, + pe.episodeduration, + p.podcastname, + CASE + WHEN ( + SELECT 1 FROM "Podcasts" + WHERE podcastid = pe.podcastid + AND userid = $1 + ) IS NOT NULL THEN + CASE + WHEN s.episodeid IS NOT NULL THEN TRUE + ELSE FALSE + END + ELSE FALSE + END AS saved, + CASE + WHEN ( + SELECT 1 FROM "Podcasts" + WHERE podcastid = pe.podcastid + AND userid = $1 + ) IS NOT NULL THEN + CASE + WHEN d.episodeid IS NOT NULL THEN TRUE + ELSE FALSE + END + ELSE FALSE + END AS downloaded, + CASE + WHEN ( + SELECT 1 FROM "Podcasts" + WHERE podcastid = pe.podcastid + AND userid = $1 + ) IS NOT NULL THEN + COALESCE(h.listenduration, 0) + ELSE 0 + END AS listenduration, + FALSE as is_youtube + FROM "PeopleEpisodes" pe + INNER JOIN "People" pp ON pe.personid = pp.personid + INNER JOIN "Podcasts" p ON pe.podcastid = p.podcastid + LEFT JOIN "Users" u ON p.userid = u.userid + LEFT JOIN "Episodes" e ON e.episodeurl = pe.episodeurl AND e.podcastid = pe.podcastid + LEFT JOIN ( + SELECT * FROM "SavedEpisodes" WHERE userid = $2 + ) s ON s.episodeid = e.episodeid + LEFT JOIN ( + SELECT * FROM "DownloadedEpisodes" WHERE userid = $3 + ) d ON d.episodeid = e.episodeid + LEFT JOIN ( + SELECT * FROM "UserEpisodeHistory" WHERE userid = $4 + ) h ON h.episodeid = e.episodeid + WHERE pe.personid = $5 + AND pe.episodepubdate >= NOW() - INTERVAL '30 days' + ORDER BY pe.episodepubdate DESC + "#) + .bind(user_id) // $1 + .bind(user_id) // $2 + .bind(user_id) // $3 + .bind(user_id) // $4 + .bind(person_id) // $5 + .fetch_all(pool) + .await?; + + for row in rows { + let episodeid = row.try_get::, _>("episodeid")?; + let episodetitle = row.try_get::("episodetitle")?; + let episodedescription = row.try_get::("episodedescription")?; + let episodeurl = row.try_get::("episodeurl")?; + let episodeartwork = row.try_get::, _>("episodeartwork")?; + let dt = row.try_get::("episodepubdate")?; + let episodepubdate = dt.format("%Y-%m-%dT%H:%M:%S").to_string(); + let episodeduration = row.try_get::("episodeduration")?; + let podcastname = row.try_get::("podcastname")?; + let saved = row.try_get::("saved")?; + let downloaded = row.try_get::("downloaded")?; + let listenduration = row.try_get::("listenduration")?; + let is_youtube = row.try_get::("is_youtube")?; + + let episode = serde_json::json!({ + "episodeid": episodeid.unwrap_or(-1), + "episodetitle": episodetitle, + "episodedescription": episodedescription, + "episodeurl": episodeurl, + "episodeartwork": episodeartwork, + "episodepubdate": episodepubdate, + "episodeduration": episodeduration, + "podcastname": podcastname, + "saved": saved, + "downloaded": downloaded, + "listenduration": listenduration, + "is_youtube": is_youtube + }); + episodes.push(episode); + } + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query(" + SELECT + e.EpisodeID, -- Will be NULL if no match in Episodes table + pe.EpisodeTitle, + pe.EpisodeDescription, + pe.EpisodeURL, + CASE + WHEN pe.EpisodeArtwork IS NULL THEN p.ArtworkURL + ELSE + CASE + WHEN p.UsePodcastCoversCustomized = TRUE AND p.UsePodcastCovers = TRUE THEN p.ArtworkURL + WHEN u.UsePodcastCovers = TRUE THEN p.ArtworkURL + ELSE pe.EpisodeArtwork + END + END as EpisodeArtwork, + pe.EpisodePubDate, + pe.EpisodeDuration, + p.PodcastName, + IF( + EXISTS( + SELECT 1 FROM Podcasts + WHERE PodcastID = pe.PodcastID + AND UserID = ? + ), + IF(s.EpisodeID IS NOT NULL, TRUE, FALSE), + FALSE + ) AS Saved, + IF( + EXISTS( + SELECT 1 FROM Podcasts + WHERE PodcastID = pe.PodcastID + AND UserID = ? + ), + IF(d.EpisodeID IS NOT NULL, TRUE, FALSE), + FALSE + ) AS Downloaded, + IF( + EXISTS( + SELECT 1 FROM Podcasts + WHERE PodcastID = pe.PodcastID + AND UserID = ? + ), + COALESCE(h.ListenDuration, 0), + 0 + ) AS ListenDuration, + FALSE as is_youtube + FROM PeopleEpisodes pe + INNER JOIN People pp ON pe.PersonID = pp.PersonID + INNER JOIN Podcasts p ON pe.PodcastID = p.PodcastID + LEFT JOIN Users u ON p.UserID = u.UserID + LEFT JOIN Episodes e ON e.EpisodeURL = pe.EpisodeURL AND e.PodcastID = pe.PodcastID + LEFT JOIN ( + SELECT * FROM SavedEpisodes WHERE UserID = ? + ) s ON s.EpisodeID = e.EpisodeID + LEFT JOIN ( + SELECT * FROM DownloadedEpisodes WHERE UserID = ? + ) d ON d.EpisodeID = e.EpisodeID + LEFT JOIN ( + SELECT * FROM UserEpisodeHistory WHERE UserID = ? + ) h ON h.EpisodeID = e.EpisodeID + WHERE pe.PersonID = ? + AND pe.EpisodePubDate >= DATE_SUB(NOW(), INTERVAL 30 DAY) + ORDER BY pe.EpisodePubDate DESC + ") + .bind(user_id) // 1st ? + .bind(user_id) // 2nd ? + .bind(user_id) // 3rd ? + .bind(user_id) // 4th ? + .bind(user_id) // 5th ? + .bind(user_id) // 6th ? + .bind(person_id) // 7th ? + .fetch_all(pool) + .await?; + + for row in rows { + let episodeid = row.try_get::, _>("EpisodeID")?; + let episodetitle = row.try_get::("EpisodeTitle")?; + let episodedescription = row.try_get::("EpisodeDescription")?; + let episodeurl = row.try_get::("EpisodeURL")?; + let episodeartwork = row.try_get::, _>("EpisodeArtwork")?; + let dt = row.try_get::("EpisodePubDate")?; + let episodepubdate = dt.format("%Y-%m-%dT%H:%M:%S").to_string(); + let episodeduration = row.try_get::("EpisodeDuration")?; + let podcastname = row.try_get::("PodcastName")?; + let saved = row.try_get::("Saved")?; + let downloaded = row.try_get::("Downloaded")?; + let listenduration = row.try_get::("ListenDuration")?; + let is_youtube = row.try_get::("is_youtube")?; + + let episode = serde_json::json!({ + "episodeid": episodeid.unwrap_or(-1), + "episodetitle": episodetitle, + "episodedescription": episodedescription, + "episodeurl": episodeurl, + "episodeartwork": episodeartwork, + "episodepubdate": episodepubdate, + "episodeduration": episodeduration, + "podcastname": podcastname, + "saved": saved, + "downloaded": downloaded, + "listenduration": listenduration, + "is_youtube": is_youtube + }); + episodes.push(episode); + } + } + } + + println!("Found {} episodes for user {} and person {}", episodes.len(), user_id, person_id); + Ok(episodes) + } + + // Check existing YouTube channel subscription - matches Python check_existing_channel_subscription function exactly + pub async fn check_existing_channel_subscription(&self, channel_id: &str, user_id: i32) -> AppResult> { + println!("Checking existing channel subscription for {} and user {}", channel_id, user_id); + + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT podcastid FROM "Podcasts" WHERE feedurl = $1 AND userid = $2"#) + .bind(format!("https://www.youtube.com/channel/{}", channel_id)) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let podcast_id: i32 = row.try_get("podcastid")?; + println!("Found existing subscription with ID: {}", podcast_id); + Ok(Some(podcast_id)) + } else { + println!("No existing subscription found"); + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT PodcastID FROM Podcasts WHERE FeedURL = ? AND UserID = ?") + .bind(format!("https://www.youtube.com/channel/{}", channel_id)) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let podcast_id: i32 = row.try_get("podcastid")?; + println!("Found existing subscription with ID: {}", podcast_id); + Ok(Some(podcast_id)) + } else { + println!("No existing subscription found"); + Ok(None) + } + } + } + } + + // Add YouTube channel - matches Python add_youtube_channel function exactly + pub async fn add_youtube_channel(&self, channel_info: &std::collections::HashMap, user_id: i32, feed_cutoff: i32) -> AppResult { + println!("Adding YouTube channel to database for user {}", user_id); + + let channel_id = channel_info.get("channel_id").ok_or_else(|| AppError::bad_request("Channel ID is required"))?; + let empty_string = String::new(); + let name = channel_info.get("name").unwrap_or(&empty_string); + let description = channel_info.get("description").unwrap_or(&empty_string); + let thumbnail_url = channel_info.get("thumbnail_url").unwrap_or(&empty_string); + let feed_url = format!("https://www.youtube.com/channel/{}", channel_id); + + // Insert new YouTube channel as podcast + let podcast_id = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + INSERT INTO "Podcasts" ( + userid, podcastname, artworkurl, description, episodecount, + websiteurl, feedurl, author, categories, explicit, podcastindexid, feedcutoffdays, isyoutubechannel + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING podcastid + "#) + .bind(user_id) + .bind(name) + .bind(thumbnail_url) + .bind(description) + .bind(0) // Initial episode count + .bind(&feed_url) + .bind(&feed_url) + .bind(name) // Use channel name as author + .bind("{}") // Empty categories for YouTube + .bind(false) // Not explicit by default + .bind(0) // No podcast index ID for YouTube + .bind(feed_cutoff) + .bind(true) // Is YouTube channel + .fetch_one(pool) + .await?; + + row.try_get("podcastid")? + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query(r#" + INSERT INTO Podcasts ( + UserID, PodcastName, ArtworkURL, Description, EpisodeCount, + WebsiteURL, FeedURL, Author, Categories, Explicit, PodcastIndexID, FeedCutoffDays, IsYouTubeChannel + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#) + .bind(user_id) + .bind(name) + .bind(thumbnail_url) + .bind(description) + .bind(0) // Initial episode count + .bind(&feed_url) + .bind(&feed_url) + .bind(name) // Use channel name as author + .bind("{}") // Empty categories for YouTube + .bind(false) // Not explicit by default + .bind(0) // No podcast index ID for YouTube + .bind(feed_cutoff) + .bind(true) // Is YouTube channel + .execute(pool) + .await?; + + result.last_insert_id() as i32 + } + }; + + // Update UserStats PodcastsAdded counter + match self { + DatabasePool::Postgres(pool) => { + let _ = sqlx::query(r#" + UPDATE "UserStats" + SET podcastsadded = podcastsadded + 1 + WHERE userid = $1 + "#) + .bind(user_id) + .execute(pool) + .await; + } + DatabasePool::MySQL(pool) => { + let _ = sqlx::query("UPDATE UserStats SET PodcastsAdded = PodcastsAdded + 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await; + } + } + + println!("Successfully added YouTube channel with ID: {}", podcast_id); + Ok(podcast_id) + } + + // Check if YouTube channel already exists - matches Python check_youtube_channel function exactly + pub async fn check_youtube_channel(&self, user_id: i32, channel_name: &str, channel_url: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT podcastid FROM "Podcasts" + WHERE userid = $1 AND podcastname = $2 AND feedurl = $3 AND isyoutubechannel = TRUE + "#) + .bind(user_id) + .bind(channel_name) + .bind(channel_url) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query(r#" + SELECT PodcastID FROM Podcasts + WHERE UserID = ? AND PodcastName = ? AND FeedURL = ? AND IsYouTubeChannel = TRUE + "#) + .bind(user_id) + .bind(channel_name) + .bind(channel_url) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + } + } + + // Remove old YouTube videos - deletes videos and all their references from dependent tables + pub async fn remove_old_youtube_videos(&self, podcast_id: i32, cutoff_date: chrono::DateTime) -> AppResult<()> { + println!("Removing old YouTube videos for podcast {} before {}", podcast_id, cutoff_date); + + let cutoff_naive = cutoff_date.naive_utc(); + + let rows_affected = match self { + DatabasePool::Postgres(pool) => { + // First, delete all references from dependent tables + let cleanup_queries = vec![ + r#"DELETE FROM "UserVideoHistory" WHERE videoid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1 AND publishedat < $2)"#, + r#"DELETE FROM "SavedVideos" WHERE videoid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1 AND publishedat < $2)"#, + r#"DELETE FROM "DownloadedVideos" WHERE videoid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1 AND publishedat < $2)"#, + r#"DELETE FROM "PlaylistContents" WHERE videoid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1 AND publishedat < $2)"#, + r#"DELETE FROM "EpisodeQueue" WHERE episodeid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1 AND publishedat < $2)"#, + ]; + + for query in cleanup_queries { + sqlx::query(query) + .bind(podcast_id) + .bind(cutoff_naive) + .execute(pool) + .await?; + } + + // Now delete the videos themselves + sqlx::query(r#"DELETE FROM "YouTubeVideos" WHERE podcastid = $1 AND publishedat < $2"#) + .bind(podcast_id) + .bind(cutoff_naive) + .execute(pool) + .await? + .rows_affected() + } + DatabasePool::MySQL(pool) => { + // First, delete all references from dependent tables + let cleanup_queries = vec![ + "DELETE FROM UserVideoHistory WHERE VideoID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ? AND PublishedAt < ?)", + "DELETE FROM SavedVideos WHERE VideoID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ? AND PublishedAt < ?)", + "DELETE FROM DownloadedVideos WHERE VideoID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ? AND PublishedAt < ?)", + "DELETE FROM PlaylistContents WHERE VideoID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ? AND PublishedAt < ?)", + "DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ? AND PublishedAt < ?)", + ]; + + for query in cleanup_queries { + sqlx::query(query) + .bind(podcast_id) + .bind(cutoff_naive) + .execute(pool) + .await?; + } + + // Now delete the videos themselves + sqlx::query("DELETE FROM YouTubeVideos WHERE PodcastID = ? AND PublishedAt < ?") + .bind(podcast_id) + .bind(cutoff_naive) + .execute(pool) + .await? + .rows_affected() + } + }; + + println!("Removed {} old YouTube videos", rows_affected); + Ok(()) + } + + // Get existing YouTube videos - matches Python get_existing_youtube_videos function exactly + pub async fn get_existing_youtube_videos(&self, podcast_id: i32) -> AppResult> { + println!("Getting existing YouTube videos for podcast {}", podcast_id); + + let mut video_urls = Vec::new(); + + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT videourl FROM "YouTubeVideos" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_all(pool) + .await?; + + for row in rows { + let url: String = row.try_get("videourl")?; + video_urls.push(url); + } + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT VideoURL FROM YouTubeVideos WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_all(pool) + .await?; + + for row in rows { + let url: String = row.try_get("VideoURL")?; + video_urls.push(url); + } + } + } + + println!("Found {} existing videos", video_urls.len()); + Ok(video_urls) + } + + // Add YouTube videos - matches Python add_youtube_videos function exactly + pub async fn add_youtube_videos(&self, podcast_id: i32, videos: &[serde_json::Value]) -> AppResult<()> { + println!("Adding {} YouTube videos for podcast {}", videos.len(), podcast_id); + + for video in videos { + let video_id = video.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let title = video.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let description = video.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let url = video.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let thumbnail = video.get("thumbnail").and_then(|v| v.as_str()).unwrap_or(""); + + println!("Processing video {} for database insertion", video_id); + println!("Video data: {:?}", video); + + let duration = if let Some(duration_str) = video.get("duration").and_then(|v| v.as_str()) { + println!("Duration as string: '{}'", duration_str); + let parsed = crate::handlers::youtube::parse_youtube_duration(duration_str).unwrap_or(0) as i32; + println!("Parsed duration: {}", parsed); + parsed + } else { + let int_duration = video.get("duration").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + println!("Duration as integer: {}", int_duration); + int_duration + }; + + // Parse publish date + let publish_date = if let Some(date_str) = video.get("publish_date").and_then(|v| v.as_str()) { + chrono::DateTime::parse_from_rfc3339(date_str) + .map(|dt| dt.naive_utc()) + .unwrap_or_else(|_| chrono::Utc::now().naive_utc()) + } else { + chrono::Utc::now().naive_utc() + }; + + match self { + DatabasePool::Postgres(pool) => { + let _ = sqlx::query(r#" + INSERT INTO "YouTubeVideos" ( + podcastid, youtubevideoid, videotitle, videodescription, videourl, + thumbnailurl, publishedat, duration, completed, listenposition + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#) + .bind(podcast_id) + .bind(video_id) + .bind(title) + .bind(description) + .bind(url) + .bind(thumbnail) + .bind(publish_date) + .bind(duration) + .bind(false) // Not completed + .bind(0) // Listen position 0 + .execute(pool) + .await; + } + DatabasePool::MySQL(pool) => { + let _ = sqlx::query(r#" + INSERT IGNORE INTO YouTubeVideos ( + PodcastID, YouTubeVideoID, VideoTitle, VideoDescription, VideoURL, + ThumbnailURL, PublishedAt, Duration, Completed, ListenPosition + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#) + .bind(podcast_id) + .bind(video_id) + .bind(title) + .bind(description) + .bind(url) + .bind(thumbnail) + .bind(publish_date) + .bind(duration) + .bind(false) // Not completed + .bind(0) // Listen position 0 + .execute(pool) + .await; + } + } + } + + println!("Successfully added {} YouTube videos", videos.len()); + Ok(()) + } + + // Get video date using web scraping - matches Python get_video_date function exactly + pub async fn get_video_date(&self, video_id: &str) -> AppResult> { + let client = reqwest::Client::new(); + let url = format!("https://www.youtube.com/watch?v={}", video_id); + + let response = client.get(&url) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .send() + .await + .map_err(|e| AppError::external_error(&format!("Failed to fetch video page: {}", e)))?; + + let html = response.text().await + .map_err(|e| AppError::external_error(&format!("Failed to read response: {}", e)))?; + + // Parse HTML to find upload date (simplified version of Python's BeautifulSoup approach) + if let Some(start) = html.find("\"uploadDate\":\"") { + let date_start = start + "\"uploadDate\":\"".len(); + if let Some(end) = html[date_start..].find("\"") { + let date_str = &html[date_start..date_start + end]; + if let Ok(parsed_date) = chrono::DateTime::parse_from_rfc3339(date_str) { + return Ok(parsed_date.with_timezone(&chrono::Utc)); + } + } + } + + // Fallback to current time minus some hours if date not found + Ok(chrono::Utc::now() - chrono::Duration::hours(1)) + } + + // Update episode count for podcast - matches Python update_episode_count function exactly + pub async fn update_episode_count(&self, podcast_id: i32) -> AppResult<()> { + println!("Updating episode count for podcast {}", podcast_id); + + // Count episodes and YouTube videos + let (episode_count, youtube_count) = match self { + DatabasePool::Postgres(pool) => { + let episode_row = sqlx::query(r#"SELECT COUNT(*) as count FROM "Episodes" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_one(pool) + .await?; + let episode_count: i64 = episode_row.try_get("count")?; + + let youtube_row = sqlx::query(r#"SELECT COUNT(*) as count FROM "YouTubeVideos" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_one(pool) + .await?; + let youtube_count: i64 = youtube_row.try_get("count")?; + + (episode_count, youtube_count) + } + DatabasePool::MySQL(pool) => { + let episode_row = sqlx::query("SELECT COUNT(*) as count FROM Episodes WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_one(pool) + .await?; + let episode_count: i64 = episode_row.try_get("count")?; + + let youtube_row = sqlx::query("SELECT COUNT(*) as count FROM YouTubeVideos WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_one(pool) + .await?; + let youtube_count: i64 = youtube_row.try_get("count")?; + + (episode_count, youtube_count) + } + }; + + let total_count = episode_count + youtube_count; + + // Update podcast episode count + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Podcasts" SET episodecount = $2 WHERE podcastid = $1"#) + .bind(podcast_id) + .bind(total_count as i32) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Podcasts SET EpisodeCount = ? WHERE PodcastID = ?") + .bind(total_count as i32) + .bind(podcast_id) + .execute(pool) + .await?; + } + } + + println!("Updated episode count to {} ({} episodes + {} videos)", total_count, episode_count, youtube_count); + Ok(()) + } + + // Get user history - matches Python user_history function exactly with YouTube UNION + pub async fn user_history(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT * FROM ( + SELECT + "Episodes".episodeid as episodeid, + "UserEpisodeHistory".listendate as listendate, + "UserEpisodeHistory".listenduration as listenduration, + "Episodes".episodetitle as episodetitle, + "Episodes".episodedescription as episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl as episodeurl, + "Episodes".episodeduration as episodeduration, + "Podcasts".podcastname as podcastname, + "Episodes".episodepubdate as episodepubdate, + "Episodes".completed as completed, + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + FALSE as is_youtube + FROM "UserEpisodeHistory" + JOIN "Episodes" ON "UserEpisodeHistory".episodeid = "Episodes".episodeid + JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "SavedEpisodes" ON "Episodes".episodeid = "SavedEpisodes".episodeid AND "SavedEpisodes".userid = $1 + LEFT JOIN "EpisodeQueue" ON "Episodes".episodeid = "EpisodeQueue".episodeid AND "EpisodeQueue".userid = $1 + LEFT JOIN "DownloadedEpisodes" ON "Episodes".episodeid = "DownloadedEpisodes".episodeid AND "DownloadedEpisodes".userid = $1 + WHERE "UserEpisodeHistory".userid = $1 + + UNION ALL + + SELECT + "YouTubeVideos".videoid as episodeid, + NULL as listendate, + "YouTubeVideos".listenposition as listenduration, + "YouTubeVideos".videotitle as episodetitle, + "YouTubeVideos".videodescription as episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "YouTubeVideos".thumbnailurl + END as episodeartwork, + "YouTubeVideos".videourl as episodeurl, + "YouTubeVideos".duration as episodeduration, + "Podcasts".podcastname as podcastname, + "YouTubeVideos".publishedat as episodepubdate, + "YouTubeVideos".completed as completed, + CASE WHEN "SavedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedVideos".videoid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + TRUE as is_youtube + FROM "YouTubeVideos" + JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "SavedVideos" ON "YouTubeVideos".videoid = "SavedVideos".videoid AND "SavedVideos".userid = $1 + LEFT JOIN "EpisodeQueue" ON "YouTubeVideos".videoid = "EpisodeQueue".episodeid AND "EpisodeQueue".userid = $1 + LEFT JOIN "DownloadedVideos" ON "YouTubeVideos".videoid = "DownloadedVideos".videoid AND "DownloadedVideos".userid = $1 + WHERE "YouTubeVideos".listenposition > 0 + AND "Podcasts".userid = $1 + ) combined_results + ORDER BY listendate DESC NULLS LAST"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + let listendate = row.try_get::, _>("listendate")? + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()); + let episodepubdate = row.try_get::, _>("episodepubdate")? + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()); + + episodes.push(serde_json::json!({ + "episodeid": row.get::, _>("episodeid"), + "listendate": listendate, + "listenduration": row.get::, _>("listenduration"), + "episodetitle": row.get::, _>("episodetitle"), + "episodedescription": row.get::, _>("episodedescription"), + "episodeartwork": row.get::, _>("episodeartwork"), + "episodeurl": row.get::, _>("episodeurl"), + "episodeduration": row.get::, _>("episodeduration"), + "podcastname": row.get::, _>("podcastname"), + "episodepubdate": episodepubdate, + "completed": row.get::, _>("completed"), + "saved": row.get::, _>("saved"), + "queued": row.get::, _>("queued"), + "downloaded": row.get::, _>("downloaded"), + "is_youtube": row.get::, _>("is_youtube") + })); + } + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT * FROM ( + SELECT + e.EpisodeID as episodeid, + ueh.ListenDate as listendate, + ueh.ListenDuration as listenduration, + e.EpisodeTitle as episodetitle, + e.EpisodeDescription as episodedescription, + CASE + WHEN p.UsePodcastCoversCustomized = 1 AND p.UsePodcastCovers = 1 THEN p.ArtworkURL + WHEN u.UsePodcastCovers = 1 THEN p.ArtworkURL + ELSE e.EpisodeArtwork + END as episodeartwork, + e.EpisodeURL as episodeurl, + e.EpisodeDuration as episodeduration, + p.PodcastName as podcastname, + e.EpisodePubDate as episodepubdate, + e.Completed as completed, + CASE WHEN se.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN eq.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN de.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + 0 as is_youtube + FROM UserEpisodeHistory ueh + JOIN Episodes e ON ueh.EpisodeID = e.EpisodeID + JOIN Podcasts p ON e.PodcastID = p.PodcastID + LEFT JOIN Users u ON p.UserID = u.UserID + LEFT JOIN SavedEpisodes se ON e.EpisodeID = se.EpisodeID AND se.UserID = ? + LEFT JOIN EpisodeQueue eq ON e.EpisodeID = eq.EpisodeID AND eq.UserID = ? + LEFT JOIN DownloadedEpisodes de ON e.EpisodeID = de.EpisodeID AND de.UserID = ? + WHERE ueh.UserID = ? + + UNION ALL + + SELECT + yv.VideoID as episodeid, + NULL as listendate, + yv.ListenPosition as listenduration, + yv.VideoTitle as episodetitle, + yv.VideoDescription as episodedescription, + CASE + WHEN p.UsePodcastCoversCustomized = 1 AND p.UsePodcastCovers = 1 THEN p.ArtworkURL + WHEN u.UsePodcastCovers = 1 THEN p.ArtworkURL + ELSE yv.ThumbnailURL + END as episodeartwork, + yv.VideoURL as episodeurl, + yv.Duration as episodeduration, + p.PodcastName as podcastname, + yv.PublishedAt as episodepubdate, + yv.Completed as completed, + CASE WHEN sv.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN eq.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN dv.VideoID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + 1 as is_youtube + FROM YouTubeVideos yv + JOIN Podcasts p ON yv.PodcastID = p.PodcastID + LEFT JOIN Users u ON p.UserID = u.UserID + LEFT JOIN SavedVideos sv ON yv.VideoID = sv.VideoID AND sv.UserID = ? + LEFT JOIN EpisodeQueue eq ON yv.VideoID = eq.EpisodeID AND eq.UserID = ? + LEFT JOIN DownloadedVideos dv ON yv.VideoID = dv.VideoID AND dv.UserID = ? + WHERE yv.ListenPosition > 0 + AND p.UserID = ? + ) combined_results + ORDER BY listendate DESC" + ) + .bind(user_id) // SavedEpisodes join + .bind(user_id) // EpisodeQueue join + .bind(user_id) // DownloadedEpisodes join + .bind(user_id) // WHERE clause + .bind(user_id) // SavedVideos join + .bind(user_id) // EpisodeQueue join (YouTube) + .bind(user_id) // DownloadedVideos join + .bind(user_id) // WHERE clause (YouTube) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + let listendate = row.try_get::, _>("listendate")? + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()); + let episodepubdate = row.try_get::, _>("episodepubdate")? + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()); + + episodes.push(serde_json::json!({ + "episodeid": row.get::, _>("episodeid"), + "listendate": listendate, + "listenduration": row.get::, _>("listenduration"), + "episodetitle": row.get::, _>("episodetitle"), + "episodedescription": row.get::, _>("episodedescription"), + "episodeartwork": row.get::, _>("episodeartwork"), + "episodeurl": row.get::, _>("episodeurl"), + "episodeduration": row.get::, _>("episodeduration"), + "podcastname": row.get::, _>("podcastname"), + "episodepubdate": episodepubdate, + "completed": row.get::, _>("completed"), + "saved": row.get::, _>("saved"), + "queued": row.get::, _>("queued"), + "downloaded": row.get::, _>("downloaded"), + "is_youtube": row.get::, _>("is_youtube") + })); + } + Ok(episodes) + } + } + } + + // Increment listen time - matches Python increment_listen_time function exactly + pub async fn increment_listen_time(&self, user_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "UserStats" SET TimeListened = TimeListened + 1 WHERE UserID = $1"#) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE UserStats SET TimeListened = TimeListened + 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + Ok(()) + } + } + } + + // Get playback speed - matches Python get_playback_speed function exactly + pub async fn get_playback_speed(&self, user_id: i32, _is_youtube: bool, podcast_id: Option) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let query = if let Some(_pod_id) = podcast_id { + r#"SELECT PlaybackSpeed FROM "Podcasts" WHERE PodcastID = $1"# + } else { + r#"SELECT PlaybackSpeed FROM "Users" WHERE UserID = $1"# + }; + + let param = podcast_id.unwrap_or(user_id); + let row = sqlx::query(query) + .bind(param) + .fetch_one(pool) + .await?; + + Ok(row.try_get::("PlaybackSpeed").unwrap_or(1.0)) + } + DatabasePool::MySQL(pool) => { + let query = if let Some(_pod_id) = podcast_id { + "SELECT PlaybackSpeed FROM Podcasts WHERE PodcastID = ?" + } else { + "SELECT PlaybackSpeed FROM Users WHERE UserID = ?" + }; + + let param = podcast_id.unwrap_or(user_id); + let row = sqlx::query(query) + .bind(param) + .fetch_one(pool) + .await?; + + if let Ok(speed) = row.try_get::("PlaybackSpeed") { + Ok(speed.to_f64().unwrap_or(1.0)) + } else { + Ok(1.0) + } + } + } + } + + // Add news feed if not already added - matches Python add_news_feed_if_not_added function exactly + + // Cleanup old episodes - matches Python cleanup_old_episodes function exactly + pub async fn cleanup_old_episodes(&self) -> AppResult<()> { + self.cleanup_old_people_episodes(30).await?; + self.cleanup_expired_shared_episodes().await?; + Ok(()) + } + + // Cleanup old people episodes - matches Python cleanup_old_people_episodes function exactly + pub async fn cleanup_old_people_episodes(&self, days: i32) -> AppResult<()> { + let cutoff_date = chrono::Utc::now() - chrono::Duration::days(days as i64); + + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"DELETE FROM "PeopleEpisodes" WHERE addeddate < $1"#) + .bind(cutoff_date) + .execute(pool) + .await?; + + tracing::info!("Cleaned up {} old PeopleEpisodes records older than {} days", result.rows_affected(), days); + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("DELETE FROM PeopleEpisodes WHERE AddedDate < ?") + .bind(cutoff_date) + .execute(pool) + .await?; + + tracing::info!("Cleaned up {} old PeopleEpisodes records older than {} days", result.rows_affected(), days); + } + } + Ok(()) + } + + // Cleanup expired shared episodes - matches Python cleanup_expired_shared_episodes function exactly + pub async fn cleanup_expired_shared_episodes(&self) -> AppResult<()> { + let now = chrono::Utc::now(); + + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"DELETE FROM "SharedEpisodes" WHERE expirationdate < $1"#) + .bind(now) + .execute(pool) + .await?; + + tracing::info!("Cleaned up {} expired SharedEpisodes records", result.rows_affected()); + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("DELETE FROM SharedEpisodes WHERE ExpirationDate < ?") + .bind(now) + .execute(pool) + .await?; + + tracing::info!("Cleaned up {} expired SharedEpisodes records", result.rows_affected()); + } + } + Ok(()) + } + + // Update all playlists - matches Python update_all_playlists function exactly + pub async fn update_all_playlists(&self) -> AppResult<()> { + tracing::info!("=================== PLAYLIST UPDATE STARTING ==================="); + tracing::info!("Starting to fetch all playlists"); + + match self { + DatabasePool::Postgres(pool) => { + let playlists = sqlx::query(r#" + SELECT playlistid, name, userid, podcastids, includeunplayed, + includepartiallyplayed, includeplayed, playprogressmin, playprogressmax, + timefilterhours, minduration, maxduration, sortorder, + groupbypodcast, maxepisodes + FROM "Playlists" + "#) + .fetch_all(pool) + .await?; + + tracing::info!("Found {} playlists to update", playlists.len()); + + for playlist in playlists { + let playlist_id: i32 = playlist.try_get("playlistid")?; + let playlist_name: String = playlist.try_get("name")?; + let user_id: i32 = playlist.try_get("userid")?; + + tracing::info!("Updating playlist: {} (ID: {}, User: {})", playlist_name, playlist_id, user_id); + + match self.update_playlist_contents(playlist_id).await { + Ok(episode_count) => { + tracing::info!("Successfully updated playlist '{}': {} episodes", playlist_name, episode_count); + } + Err(e) => { + tracing::error!("Failed to update playlist '{}' (ID: {}): {}", playlist_name, playlist_id, e); + // Continue with other playlists + } + } + } + } + DatabasePool::MySQL(pool) => { + let playlists = sqlx::query(" + SELECT PlaylistID, Name, UserID, PodcastIds, IncludeUnplayed, + IncludePartiallyPlayed, IncludePlayed, PlayProgressMin, PlayProgressMax, + TimeFilterHours, MinDuration, MaxDuration, SortOrder, + GroupByPodcast, MaxEpisodes + FROM Playlists + ") + .fetch_all(pool) + .await?; + + tracing::info!("Found {} playlists to update", playlists.len()); + + for playlist in playlists { + let playlist_id: i32 = playlist.try_get("PlaylistID")?; + let playlist_name: String = playlist.try_get("Name")?; + let user_id: i32 = playlist.try_get("UserID")?; + + tracing::info!("Updating playlist: {} (ID: {}, User: {})", playlist_name, playlist_id, user_id); + + match self.update_playlist_contents(playlist_id).await { + Ok(episode_count) => { + tracing::info!("Successfully updated playlist '{}': {} episodes", playlist_name, episode_count); + } + Err(e) => { + tracing::error!("Failed to update playlist '{}' (ID: {}): {}", playlist_name, playlist_id, e); + // Continue with other playlists + } + } + } + } + } + + tracing::info!("=================== PLAYLIST UPDATE COMPLETED ==================="); + Ok(()) + } + + // Update playlist contents - matches Python update_playlist_contents function exactly + pub async fn update_playlist_contents(&self, playlist_id: i32) -> AppResult { + tracing::info!("======= UPDATE PLAYLIST ID: {} =======", playlist_id); + + match self { + DatabasePool::Postgres(pool) => { + // Get playlist configuration first + let playlist = sqlx::query(r#" + SELECT playlistid, name, userid, podcastids, includeunplayed, + includepartiallyplayed, includeplayed, playprogressmin, playprogressmax, + timefilterhours, minduration, maxduration, sortorder, + groupbypodcast, maxepisodes, issystemplaylist + FROM "Playlists" WHERE playlistid = $1 + "#) + .bind(playlist_id) + .fetch_one(pool) + .await?; + + // Clear existing contents + sqlx::query(r#"DELETE FROM "PlaylistContents" WHERE playlistid = $1"#) + .bind(playlist_id) + .execute(pool) + .await?; + + // Handle special playlists + let playlist_name: String = playlist.try_get("name")?; + let is_system_playlist: bool = playlist.try_get("issystemplaylist").unwrap_or(false); + + let episode_count = if playlist_name == "Fresh Releases" && is_system_playlist { + // Special handling for Fresh Releases + self.update_fresh_releases_playlist_postgres(pool, playlist_id).await? + } else if playlist_name == "Currently Listening" && is_system_playlist { + // Special handling for Currently Listening + self.update_currently_listening_playlist_postgres(pool, playlist_id).await? + } else if playlist_name == "Almost Done" && is_system_playlist { + // Special handling for Almost Done + self.update_almost_done_playlist_postgres(pool, playlist_id).await? + } else if playlist_name == "Quick Listens" && is_system_playlist { + // Special handling for Quick Listens + self.update_quick_listens_playlist_postgres(pool, playlist_id).await? + } else { + // Standard playlist query building + self.build_and_execute_playlist_query_postgres(pool, &playlist).await? + }; + + // Update timestamp + sqlx::query(r#"UPDATE "Playlists" SET lastupdated = CURRENT_TIMESTAMP WHERE playlistid = $1"#) + .bind(playlist_id) + .execute(pool) + .await?; + + Ok(episode_count) + } + DatabasePool::MySQL(pool) => { + // Get playlist configuration first + let playlist = sqlx::query(" + SELECT PlaylistID, Name, UserID, PodcastIDs, IncludeUnplayed, + IncludePartiallyPlayed, IncludePlayed, PlayProgressMin, PlayProgressMax, + TimeFilterHours, MinDuration, MaxDuration, SortOrder, + GroupByPodcast, MaxEpisodes, IsSystemPlaylist + FROM Playlists WHERE PlaylistID = ? + ") + .bind(playlist_id) + .fetch_one(pool) + .await?; + + // Clear existing contents + sqlx::query("DELETE FROM PlaylistContents WHERE PlaylistID = ?") + .bind(playlist_id) + .execute(pool) + .await?; + + // Handle special playlists + let playlist_name: String = playlist.try_get("Name")?; + let is_system_playlist: bool = playlist.try_get("IsSystemPlaylist").unwrap_or(false); + + let episode_count = if playlist_name == "Fresh Releases" && is_system_playlist { + // Special handling for Fresh Releases + self.update_fresh_releases_playlist_mysql(pool, playlist_id).await? + } else if playlist_name == "Currently Listening" && is_system_playlist { + // Special handling for Currently Listening + self.update_currently_listening_playlist_mysql(pool, playlist_id).await? + } else if playlist_name == "Almost Done" && is_system_playlist { + // Special handling for Almost Done + self.update_almost_done_playlist_mysql(pool, playlist_id).await? + } else if playlist_name == "Quick Listens" && is_system_playlist { + // Special handling for Quick Listens + self.update_quick_listens_playlist_mysql(pool, playlist_id).await? + } else { + // Standard playlist query building + self.build_and_execute_playlist_query_mysql(pool, &playlist).await? + }; + + // Update timestamp + sqlx::query("UPDATE Playlists SET LastUpdated = CURRENT_TIMESTAMP WHERE PlaylistID = ?") + .bind(playlist_id) + .execute(pool) + .await?; + + Ok(episode_count) + } + } + } + + // Get all people/hosts for refresh_hosts endpoint - matches Python refresh_all_hosts function + pub async fn get_all_people_for_refresh(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let people = sqlx::query(r#" + SELECT DISTINCT p.personid, p.name, p.userid + FROM "People" p + "#) + .fetch_all(pool) + .await?; + + let mut result = Vec::new(); + for person in people { + let person_id: i32 = person.try_get("personid")?; + let name: String = person.try_get("name")?; + let user_id: i32 = person.try_get("userid")?; + result.push((person_id, name, user_id)); + } + Ok(result) + } + DatabasePool::MySQL(pool) => { + let people = sqlx::query(" + SELECT DISTINCT p.PersonID, p.Name, p.UserID + FROM People p + ") + .fetch_all(pool) + .await?; + + let mut result = Vec::new(); + for person in people { + let person_id: i32 = person.try_get("PersonID")?; + let name: String = person.try_get("Name")?; + let user_id: i32 = person.try_get("UserID")?; + result.push((person_id, name, user_id)); + } + Ok(result) + } + } + } + + // Process person subscription - matches Python process_person_subscription function exactly + pub async fn process_person_subscription(&self, user_id: i32, person_id: i32, person_name: String) -> AppResult<()> { + use std::collections::HashSet; + + tracing::info!("Starting refresh for host: {} (ID: {})", person_name, person_id); + + let mut processed_shows: HashSet<(String, String, i32)> = HashSet::new(); + let people_url = std::env::var("PEOPLE_API_URL").unwrap_or_else(|_| "https://podpeople.pinepods.online".to_string()); + let api_url = std::env::var("SEARCH_API_URL").unwrap_or_else(|_| "https://api.pinepods.online/api/search".to_string()); + + // 1. Get podcasts from podpeople + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| AppError::internal(&format!("Failed to create HTTP client: {}", e)))?; + + match client + .get(&format!("{}/api/hostsearch", people_url)) + .query(&[("name", &person_name)]) + .send() + .await + { + Ok(response) => { + if let Ok(podpeople_data) = response.json::().await { + if podpeople_data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Some(podcasts) = podpeople_data.get("podcasts").and_then(|v| v.as_array()) { + for podcast in podcasts { + if let (Some(title), Some(feed_url), Some(id)) = ( + podcast.get("title").and_then(|v| v.as_str()), + podcast.get("feed_url").and_then(|v| v.as_str()), + podcast.get("id").and_then(|v| v.as_i64()), + ) { + processed_shows.insert((title.to_string(), feed_url.to_string(), id as i32)); + } + } + } + } + } + } + Err(e) => { + tracing::error!("Error getting data from podpeople: {}", e); + } + } + + // 2. Get podcasts from podcast index + tracing::info!("API URL configured as: {}", api_url); + match client + .get(&api_url) + .query(&[ + ("query", person_name.as_str()), + ("index", "person"), + ("search_type", "person") + ]) + .send() + .await + { + Ok(response) => { + if let Ok(index_data) = response.json::().await { + if let Some(items) = index_data.get("items").and_then(|v| v.as_array()) { + for episode in items { + if let (Some(title), Some(feed_url), Some(feed_id)) = ( + episode.get("feedTitle").and_then(|v| v.as_str()), + episode.get("feedUrl").and_then(|v| v.as_str()), + episode.get("feedId").and_then(|v| v.as_i64()), + ) { + processed_shows.insert((title.to_string(), feed_url.to_string(), feed_id as i32)); + } + } + } + } + } + Err(e) => { + tracing::error!("Error getting data from podcast index: {}", e); + } + } + + if processed_shows.is_empty() { + tracing::info!("No shows found for person: {}", person_name); + return Ok(()); + } + + // 3. Process each unique show + for (title, feed_url, feed_id) in processed_shows { + match self.process_person_show(user_id, person_id, &title, &feed_url, feed_id).await { + Ok(_) => { + tracing::info!("Successfully processed show: {}", title); + } + Err(e) => { + tracing::error!("Error processing show {}: {}", title, e); + continue; + } + } + } + + Ok(()) + } + + // Helper function to process individual show for person - matches Python logic + async fn process_person_show(&self, user_id: i32, person_id: i32, title: &str, feed_url: &str, _feed_id: i32) -> AppResult<()> { + // First check if podcast exists for user + let user_podcast_id = self.get_podcast_id_by_feed_url(user_id, feed_url).await?; + + let podcast_id = if user_podcast_id.is_none() { + // Check if system podcast exists (UserID = 1) + let system_podcast_id = self.get_podcast_id_by_feed_url(1, feed_url).await?; + + if system_podcast_id.is_none() { + // Add as new system podcast + tracing::info!("Creating system podcast for feed: {}", feed_url); + let podcast_values = self.get_podcast_values_for_person(feed_url).await?; + let add_result = self.add_person_podcast_from_values(&podcast_values, 1).await?; + tracing::info!("Add podcast result: {}", add_result); + + // Get the podcast ID after adding + tracing::info!("Looking for podcast with UserID=1 and FeedURL='{}'", feed_url); + match self.get_podcast_id_by_feed_url(1, feed_url).await? { + Some(id) => { + tracing::info!("Successfully created system podcast with ID: {}", id); + id + } + None => { + // Let's debug by listing all podcasts for UserID=1 + tracing::error!("Failed to get podcast ID after adding system podcast for feed: {}", feed_url); + + // Debug: List all system podcasts to see what's there + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT podcastid, podcastname, feedurl FROM "Podcasts" WHERE userid = $1"#) + .bind(1) + .fetch_all(pool) + .await?; + + tracing::error!("System podcasts (UserID=1):"); + for row in rows { + let id: i32 = row.try_get("podcastid")?; + let name: String = row.try_get("podcastname")?; + let url: String = row.try_get("feedurl")?; + tracing::error!(" ID: {}, Name: '{}', URL: '{}'", id, name, url); + } + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT PodcastID, PodcastName, FeedURL FROM Podcasts WHERE UserID = ?") + .bind(1) + .fetch_all(pool) + .await?; + + tracing::error!("System podcasts (UserID=1):"); + for row in rows { + let id: i32 = row.try_get("PodcastID")?; + let name: String = row.try_get("PodcastName")?; + let url: String = row.try_get("FeedURL")?; + tracing::error!(" ID: {}, Name: '{}', URL: '{}'", id, name, url); + } + } + } + + return Err(AppError::internal("Failed to create system podcast")); + } + } + } else { + system_podcast_id.unwrap() + } + } else { + user_podcast_id.unwrap() + }; + + tracing::info!("Using podcast: ID={}, Title={}", podcast_id, title); + + // Add episodes to PeopleEpisodes + self.add_people_episodes(person_id, podcast_id, feed_url).await?; + + Ok(()) + } + + // Add people episodes - matches Python add_people_episodes function exactly + pub async fn add_people_episodes(&self, person_id: i32, podcast_id: i32, feed_url: &str) -> AppResult<()> { + // Validate that we have a valid podcast ID + if podcast_id <= 0 { + return Err(AppError::internal(&format!("Invalid podcast ID {} for person episodes", podcast_id))); + } + + // Use the same robust feed fetching and parsing as add_episodes + let content = self.try_fetch_feed(feed_url, None, None).await?; + let episodes = self.parse_rss_feed(&content, podcast_id, "").await?; + + println!("Parsed {} episodes from feed for person {} with podcast ID {}", episodes.len(), person_id, podcast_id); + + let mut added_count = 0; + + for episode in episodes { + // Check if episode already exists + let episode_exists = match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#" + SELECT episodeid FROM "PeopleEpisodes" + WHERE personid = $1 AND podcastid = $2 AND episodeurl = $3 + "#) + .bind(person_id) + .bind(podcast_id) + .bind(&episode.url) + .fetch_optional(pool) + .await?; + + result.is_some() + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query(" + SELECT EpisodeID FROM PeopleEpisodes + WHERE PersonID = ? AND PodcastID = ? AND EpisodeURL = ? + ") + .bind(person_id) + .bind(podcast_id) + .bind(&episode.url) + .fetch_optional(pool) + .await?; + + result.is_some() + } + }; + + if episode_exists { + continue; + } + + // Insert new episode + match self { + DatabasePool::Postgres(pool) => { + // PostgreSQL expects timestamp type, not string + let naive_datetime = episode.pub_date.naive_utc(); + sqlx::query(r#" + INSERT INTO "PeopleEpisodes" + (personid, podcastid, episodetitle, episodedescription, + episodeurl, episodeartwork, episodepubdate, episodeduration) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#) + .bind(person_id) + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(naive_datetime) + .bind(episode.duration as i32) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + // MySQL accepts string format + let pub_date_str = episode.pub_date.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query(" + INSERT INTO PeopleEpisodes + (PersonID, PodcastID, EpisodeTitle, EpisodeDescription, + EpisodeURL, EpisodeArtwork, EpisodePubDate, EpisodeDuration) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ") + .bind(person_id) + .bind(podcast_id) + .bind(&episode.title) + .bind(&episode.description) + .bind(&episode.url) + .bind(&episode.artwork_url) + .bind(&pub_date_str) + .bind(episode.duration as i32) + .execute(pool) + .await?; + } + } + + added_count += 1; + } + + println!("Successfully added {} new episodes for person {} from podcast {}", added_count, person_id, podcast_id); + Ok(()) + } + + // Helper function to parse duration from string + fn parse_duration(&self, duration_str: &str) -> Option { + if duration_str.contains(':') { + let parts: Vec<&str> = duration_str.split(':').collect(); + match parts.len() { + 2 => { + let minutes: i64 = parts[0].parse().ok()?; + let seconds: i64 = parts[1].parse().ok()?; + Some(minutes * 60 + seconds) + } + 3 => { + let hours: i64 = parts[0].parse().ok()?; + let minutes: i64 = parts[1].parse().ok()?; + let seconds: i64 = parts[2].parse().ok()?; + Some(hours * 3600 + minutes * 60 + seconds) + } + _ => None, + } + } else if let Ok(duration) = duration_str.parse::() { + Some(duration) + } else { + None + } + } + + // Add person podcast from values map - matches Python add_person_podcast function exactly + pub async fn add_person_podcast_from_values(&self, podcast_values: &std::collections::HashMap, user_id: i32) -> AppResult { + // Use the same key mapping as add_podcast_from_values + let pod_title = podcast_values.get("podcastname").cloned().unwrap_or_default(); + let pod_feed_url = podcast_values.get("feedurl").cloned().unwrap_or_default(); + let pod_artwork = podcast_values.get("artworkurl").cloned().unwrap_or_default(); + let pod_description = podcast_values.get("description").cloned().unwrap_or_default(); + // First check if podcast already exists for user with a valid feed URL + match self { + DatabasePool::Postgres(pool) => { + let existing = sqlx::query(r#"SELECT podcastid FROM "Podcasts" WHERE feedurl = $1 AND userid = $2 AND feedurl != ''"#) + .bind(&pod_feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if existing.is_some() { + return Ok(true); // Already exists + } + + // Insert new podcast + sqlx::query(r#" + INSERT INTO "Podcasts" (podcastname, feedurl, artworkurl, description, + userid, autodownload, isyoutubechannel, podcastindexid) + VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, $6) + "#) + .bind(&pod_title) + .bind(&pod_feed_url) + .bind(&pod_artwork) + .bind(&pod_description) + .bind(user_id) + .bind(0) // podcast_index_id placeholder + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + let existing = sqlx::query("SELECT PodcastID FROM Podcasts WHERE FeedURL = ? AND UserID = ? AND FeedURL != ''") + .bind(&pod_feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if existing.is_some() { + return Ok(true); // Already exists + } + + // Insert new podcast + sqlx::query(" + INSERT INTO Podcasts (PodcastName, FeedURL, ArtworkURL, Description, + UserID, AutoDownload, IsYouTubeChannel, PodcastIndexID) + VALUES (?, ?, ?, ?, ?, 0, 0, ?) + ") + .bind(&pod_title) + .bind(&pod_feed_url) + .bind(&pod_artwork) + .bind(&pod_description) + .bind(user_id) + .bind(0) // podcast_index_id placeholder + .execute(pool) + .await?; + } + } + + Ok(true) + } + + // Get podcast ID by feed URL + pub async fn get_podcast_id_by_feed_url(&self, user_id: i32, feed_url: &str) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"SELECT podcastid FROM "Podcasts" WHERE feedurl = $1 AND userid = $2"#) + .bind(feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row| row.try_get("podcastid")).transpose()?) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("SELECT PodcastID FROM Podcasts WHERE FeedURL = ? AND UserID = ?") + .bind(feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row| row.try_get("PodcastID")).transpose()?) + } + } + } + + // Get podcast values for person podcasts - uses existing get_podcast_values function + pub async fn get_podcast_values_for_person(&self, feed_url: &str) -> AppResult> { + self.get_podcast_values(feed_url, 1, None, None).await + } + + // Helper function to normalize timezone names for database compatibility + fn normalize_timezone(tz_str: &str) -> String { + // Try to parse the timezone string with chrono-tz + if let Ok(tz) = tz_str.parse::() { + // Return the canonical name for this timezone + tz.name().to_string() + } else { + // If parsing fails, return UTC as fallback + tracing::warn!("Unable to parse timezone '{}', falling back to UTC", tz_str); + "UTC".to_string() + } + } + + // COMPLETE PLAYLIST SYSTEM IMPLEMENTATION - matches Python functionality exactly + + // Update Fresh Releases playlist with timezone-aware logic - matches Python update_fresh_releases_playlist + async fn update_fresh_releases_playlist_postgres(&self, pool: &Pool, playlist_id: i32) -> AppResult { + tracing::info!("Updating Fresh Releases playlist with timezone logic"); + + // Get all users with their timezones + let users = sqlx::query(r#"SELECT userid, timezone FROM "Users""#) + .fetch_all(pool) + .await?; + + let mut added_episodes: std::collections::HashSet = std::collections::HashSet::new(); + let mut position = 1; + + for user in users { + let user_id: i32 = user.try_get("userid")?; + let timezone: Option = user.try_get("timezone").ok(); + let raw_tz = timezone.as_deref() + .filter(|s| !s.is_empty()) // Filter out empty strings + .unwrap_or("UTC"); + + let normalized_tz = Self::normalize_timezone(raw_tz); + + tracing::info!("Processing Fresh Releases for user {} with timezone {} (normalized: {})", user_id, raw_tz, normalized_tz); + + // Get episodes from last 24 hours in user's timezone + let episodes = sqlx::query(r#" + SELECT e.episodeid + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid + WHERE e.episodepubdate AT TIME ZONE 'UTC' AT TIME ZONE $1 > + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AT TIME ZONE $1 - INTERVAL '24 hours') + ORDER BY e.episodepubdate DESC + "#) + .bind(&normalized_tz) + .fetch_all(pool) + .await?; + + // Add unique episodes to playlist + for episode in episodes { + let episode_id: i32 = episode.try_get("episodeid")?; + if !added_episodes.contains(&episode_id) { + sqlx::query(r#" + INSERT INTO "PlaylistContents" (playlistid, episodeid, position) + VALUES ($1, $2, $3) + "#) + .bind(playlist_id) + .bind(episode_id) + .bind(position) + .execute(pool) + .await?; + + added_episodes.insert(episode_id); + position += 1; + } + } + } + + tracing::info!("Fresh Releases playlist updated with {} episodes", added_episodes.len()); + Ok(added_episodes.len() as i32) + } + + // Update Currently Listening playlist - episodes that users have started but not finished + async fn update_currently_listening_playlist_postgres(&self, pool: &Pool, playlist_id: i32) -> AppResult { + tracing::info!("Updating Currently Listening playlist"); + + // Get all users and their currently listening episodes + let users = sqlx::query(r#"SELECT userid FROM "Users""#) + .fetch_all(pool) + .await?; + + let mut added_episodes: std::collections::HashSet = std::collections::HashSet::new(); + let mut position = 1; + + for user in users { + let user_id: i32 = user.try_get("userid")?; + + tracing::info!("Processing Currently Listening for user {}", user_id); + + // Get episodes user has started but not finished + let episodes = sqlx::query(r#" + SELECT e.episodeid + FROM "Episodes" e + JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid + WHERE h.userid = $1 + AND h.listenduration > 0 + AND h.listenduration < e.episodeduration + ORDER BY h.listendate DESC + "#) + .bind(user_id) + .fetch_all(pool) + .await?; + + // Add unique episodes to playlist + for episode in episodes { + let episode_id: i32 = episode.try_get("episodeid")?; + if !added_episodes.contains(&episode_id) { + sqlx::query(r#"INSERT INTO "PlaylistContents" (playlistid, episodeid, position) VALUES ($1, $2, $3)"#) + .bind(playlist_id) + .bind(episode_id) + .bind(position) + .execute(pool) + .await?; + + added_episodes.insert(episode_id); + position += 1; + } + } + } + + tracing::info!("Currently Listening playlist updated with {} episodes", added_episodes.len()); + Ok(added_episodes.len() as i32) + } + + // Update Almost Done playlist - episodes that users are 75%+ through + async fn update_almost_done_playlist_postgres(&self, pool: &Pool, playlist_id: i32) -> AppResult { + tracing::info!("Updating Almost Done playlist"); + + // Get all users and their almost done episodes + let users = sqlx::query(r#"SELECT userid FROM "Users""#) + .fetch_all(pool) + .await?; + + let mut added_episodes: std::collections::HashSet = std::collections::HashSet::new(); + let mut position = 1; + + for user in users { + let user_id: i32 = user.try_get("userid")?; + + tracing::info!("Processing Almost Done for user {}", user_id); + + // Get episodes user is 75%+ through but not completed + let episodes = sqlx::query(r#" + SELECT e.episodeid + FROM "Episodes" e + JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid + WHERE h.userid = $1 + AND h.listenduration > 0 + AND h.listenduration < e.episodeduration + AND (h.listenduration::float / NULLIF(e.episodeduration, 0)) >= 0.75 + ORDER BY h.listendate DESC + "#) + .bind(user_id) + .fetch_all(pool) + .await?; + + // Add unique episodes to playlist + for episode in episodes { + let episode_id: i32 = episode.try_get("episodeid")?; + if !added_episodes.contains(&episode_id) { + sqlx::query(r#"INSERT INTO "PlaylistContents" (playlistid, episodeid, position) VALUES ($1, $2, $3)"#) + .bind(playlist_id) + .bind(episode_id) + .bind(position) + .execute(pool) + .await?; + + added_episodes.insert(episode_id); + position += 1; + } + } + } + + tracing::info!("Almost Done playlist updated with {} episodes", added_episodes.len()); + Ok(added_episodes.len() as i32) + } + + // Update Quick Listens playlist - episodes under 15 minutes from ALL users' podcasts + async fn update_quick_listens_playlist_postgres(&self, pool: &Pool, playlist_id: i32) -> AppResult { + tracing::info!("Updating Quick Listens playlist"); + + // Get shortest 1000 episodes under 15 minutes (900 seconds) and over 1 second from ALL podcasts + let episodes = sqlx::query(r#" + SELECT e.episodeid + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid + WHERE e.episodeduration >= 1 + AND e.episodeduration <= 900 + AND e.completed = FALSE + ORDER BY e.episodeduration ASC + LIMIT 1000 + "#) + .fetch_all(pool) + .await?; + + let mut position = 1; + + // Add episodes to playlist + for episode in episodes { + let episode_id: i32 = episode.try_get("episodeid")?; + sqlx::query(r#"INSERT INTO "PlaylistContents" (playlistid, episodeid, position) VALUES ($1, $2, $3)"#) + .bind(playlist_id) + .bind(episode_id) + .bind(position) + .execute(pool) + .await?; + + position += 1; + } + + let episode_count = position - 1; + tracing::info!("Quick Listens playlist updated with {} episodes", episode_count); + Ok(episode_count) + } + + async fn update_quick_listens_playlist_mysql(&self, pool: &Pool, playlist_id: i32) -> AppResult { + tracing::info!("Updating Quick Listens playlist"); + + // Get shortest 1000 episodes under 15 minutes (900 seconds) and over 1 second from ALL podcasts + let episodes = sqlx::query(" + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE e.EpisodeDuration >= 1 + AND e.EpisodeDuration <= 900 + AND e.Completed = FALSE + ORDER BY e.EpisodeDuration ASC + LIMIT 1000 + ") + .fetch_all(pool) + .await?; + + let mut position = 1; + + // Add episodes to playlist + for episode in episodes { + let episode_id: i32 = episode.try_get("EpisodeID")?; + sqlx::query("INSERT INTO PlaylistContents (PlaylistID, EpisodeID, Position) VALUES (?, ?, ?)") + .bind(playlist_id) + .bind(episode_id) + .bind(position) + .execute(pool) + .await?; + + position += 1; + } + + let episode_count = position - 1; + tracing::info!("Quick Listens playlist updated with {} episodes", episode_count); + Ok(episode_count) + } + + async fn update_fresh_releases_playlist_mysql(&self, pool: &Pool, playlist_id: i32) -> AppResult { + tracing::info!("Updating Fresh Releases playlist with timezone logic"); + + // Get all users with their timezones + let users = sqlx::query("SELECT UserID, TimeZone FROM Users") + .fetch_all(pool) + .await?; + + let mut added_episodes: std::collections::HashSet = std::collections::HashSet::new(); + let mut position = 1; + + for user in users { + let user_id: i32 = user.try_get("UserID")?; + let timezone: Option = user.try_get("TimeZone").ok(); + let raw_tz = timezone.as_deref() + .filter(|s| !s.is_empty()) // Filter out empty strings + .unwrap_or("UTC"); + + let normalized_tz = Self::normalize_timezone(raw_tz); + + tracing::info!("Processing Fresh Releases for user {} with timezone {} (normalized: {})", user_id, raw_tz, normalized_tz); + + // Get episodes from last 24 hours in user's timezone + let episodes = sqlx::query(" + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE CONVERT_TZ(e.EpisodePubDate, 'UTC', ?) > + DATE_SUB(CONVERT_TZ(NOW(), 'UTC', ?), INTERVAL 24 HOUR) + ORDER BY e.EpisodePubDate DESC + ") + .bind(&normalized_tz) + .bind(&normalized_tz) + .fetch_all(pool) + .await?; + + // Add unique episodes to playlist + for episode in episodes { + let episode_id: i32 = episode.try_get("EpisodeID")?; + if !added_episodes.contains(&episode_id) { + sqlx::query(" + INSERT INTO PlaylistContents (PlaylistID, EpisodeID, Position) + VALUES (?, ?, ?) + ") + .bind(playlist_id) + .bind(episode_id) + .bind(position) + .execute(pool) + .await?; + + added_episodes.insert(episode_id); + position += 1; + } + } + } + + tracing::info!("Fresh Releases playlist updated with {} episodes", added_episodes.len()); + Ok(added_episodes.len() as i32) + } + + async fn update_currently_listening_playlist_mysql(&self, pool: &Pool, playlist_id: i32) -> AppResult { + tracing::info!("Updating Currently Listening playlist"); + + // Clear existing playlist contents + sqlx::query("DELETE FROM PlaylistContents WHERE PlaylistID = ?") + .bind(playlist_id) + .execute(pool) + .await?; + + // Get all users + let users = sqlx::query("SELECT UserID FROM Users") + .fetch_all(pool) + .await?; + + let mut added_episodes: std::collections::HashSet = std::collections::HashSet::new(); + let mut position = 1; + + for user in users { + let user_id: i32 = user.try_get("UserID")?; + + tracing::info!("Processing Currently Listening for user {}", user_id); + + // Get episodes that are currently being listened to (started but not completed) + let episodes = sqlx::query(" + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = ? + WHERE h.ListenDuration > 0 AND h.ListenDuration < e.EpisodeDuration + ORDER BY h.ListenDate DESC + ") + .bind(user_id) + .fetch_all(pool) + .await?; + + // Add unique episodes to playlist + for episode in episodes { + let episode_id: i32 = episode.try_get("EpisodeID")?; + if !added_episodes.contains(&episode_id) { + sqlx::query(" + INSERT INTO PlaylistContents (PlaylistID, EpisodeID, Position) + VALUES (?, ?, ?) + ") + .bind(playlist_id) + .bind(episode_id) + .bind(position) + .execute(pool) + .await?; + + added_episodes.insert(episode_id); + position += 1; + } + } + } + + tracing::info!("Currently Listening playlist updated with {} episodes", added_episodes.len()); + Ok(added_episodes.len() as i32) + } + + async fn update_almost_done_playlist_mysql(&self, pool: &Pool, playlist_id: i32) -> AppResult { + tracing::info!("Updating Almost Done playlist"); + + // Clear existing playlist contents + sqlx::query("DELETE FROM PlaylistContents WHERE PlaylistID = ?") + .bind(playlist_id) + .execute(pool) + .await?; + + // Get all users + let users = sqlx::query("SELECT UserID FROM Users") + .fetch_all(pool) + .await?; + + let mut added_episodes: std::collections::HashSet = std::collections::HashSet::new(); + let mut position = 1; + + for user in users { + let user_id: i32 = user.try_get("UserID")?; + + tracing::info!("Processing Almost Done for user {}", user_id); + + // Get episodes that are almost done (75%+ listened and not completed) + let episodes = sqlx::query(" + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = ? + WHERE h.ListenDuration > 0 + AND h.ListenDuration < e.EpisodeDuration + AND (h.ListenDuration / NULLIF(e.EpisodeDuration, 0)) >= 0.75 + ORDER BY h.ListenDate DESC + ") + .bind(user_id) + .fetch_all(pool) + .await?; + + // Add unique episodes to playlist + for episode in episodes { + let episode_id: i32 = episode.try_get("EpisodeID")?; + if !added_episodes.contains(&episode_id) { + sqlx::query(" + INSERT INTO PlaylistContents (PlaylistID, EpisodeID, Position) + VALUES (?, ?, ?) + ") + .bind(playlist_id) + .bind(episode_id) + .bind(position) + .execute(pool) + .await?; + + added_episodes.insert(episode_id); + position += 1; + } + } + } + + tracing::info!("Almost Done playlist updated with {} episodes", added_episodes.len()); + Ok(added_episodes.len() as i32) + } + + // Build and execute playlist query for PostgreSQL - matches Python build_playlist_query exactly + async fn build_and_execute_playlist_query_postgres(&self, pool: &Pool, playlist: &sqlx::postgres::PgRow) -> AppResult { + let playlist_id: i32 = playlist.try_get("playlistid")?; + let user_id: i32 = playlist.try_get("userid")?; + let playlist_name: String = playlist.try_get("name")?; + let is_system_playlist: bool = playlist.try_get("issystemplaylist").unwrap_or(false); + + // Parse playlist configuration + let config = PlaylistConfig::from_postgres_row(playlist)?; + + // Determine if this playlist needs user history filtering + let needs_user_history = playlist_name == "Currently Listening" || + playlist_name == "Almost Done" || + !is_system_playlist; + + // Check for special optimized queries for partially played + if config.include_partially_played && !config.include_unplayed && !config.include_played { + return self.execute_partially_played_query_postgres(pool, playlist_id, user_id, &config).await; + } + + // Build the appropriate base query - FIXED PARAMETER INDEXING + let (base_query, params) = if is_system_playlist { + if needs_user_history { + // System playlist with user history filtering + (r#" + SELECT e.episodeid, p.podcastid, u.timezone + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid + LEFT JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid AND h.userid = $2 + JOIN "Users" u ON u.userid = $3 + WHERE 1=1 + "#.to_string(), vec![user_id, user_id]) + } else { + // System playlist without user history filtering (Fresh Releases, etc.) + // But still need user context for history when needed + (r#" + SELECT e.episodeid, p.podcastid, u.timezone + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid + LEFT JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid AND h.userid = $2 + JOIN "Users" u ON u.userid = $3 + WHERE 1=1 + "#.to_string(), vec![user_id, user_id]) + } + } else { + // User-specific playlist + (r#" + SELECT e.episodeid, p.podcastid, u.timezone + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid + LEFT JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid AND h.userid = $2 + JOIN "Users" u ON u.userid = $3 + WHERE p.userid = $4 + "#.to_string(), vec![user_id, user_id, user_id]) + }; + + // Build the complete query with all filters + let (complete_query, all_params) = self.build_complete_postgres_query( + base_query, params, &config, playlist_id + )?; + + // Execute the query and insert episodes + self.execute_playlist_query_postgres(pool, &complete_query, &all_params, playlist_id).await + } + + // Build and execute playlist query for MySQL - matches Python build_playlist_query exactly + async fn build_and_execute_playlist_query_mysql(&self, pool: &Pool, playlist: &sqlx::mysql::MySqlRow) -> AppResult { + let playlist_id: i32 = playlist.try_get("PlaylistID")?; + let user_id: i32 = playlist.try_get("UserID")?; + let playlist_name: String = playlist.try_get("Name")?; + let is_system_playlist: bool = playlist.try_get("IsSystemPlaylist").unwrap_or(false); + + // Parse playlist configuration + let config = PlaylistConfig::from_mysql_row(playlist)?; + + // Determine if this playlist needs user history filtering + let needs_user_history = playlist_name == "Currently Listening" || + playlist_name == "Almost Done" || + !is_system_playlist; + + // Check for special optimized queries for partially played + if config.include_partially_played && !config.include_unplayed && !config.include_played { + return self.execute_partially_played_query_mysql(pool, playlist_id, user_id, &config).await; + } + + // Build the appropriate base query + let (base_query, params) = if is_system_playlist { + if needs_user_history { + // System playlist with user history filtering + (" + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + LEFT JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = ? + JOIN Users u ON u.UserID = ? + WHERE 1=1 + ".to_string(), vec![user_id, user_id]) + } else { + // System playlist without user history filtering (Fresh Releases, etc.) + // But still need user context for history when needed + (" + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + LEFT JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = ? + JOIN Users u ON u.UserID = ? + WHERE 1=1 + ".to_string(), vec![user_id, user_id]) + } + } else { + // User-specific playlist + (" + SELECT e.EpisodeID + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + LEFT JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = ? + JOIN Users u ON u.UserID = ? + WHERE p.UserID = ? + ".to_string(), vec![user_id, user_id, user_id]) + }; + + // Build the complete query with all filters + let (complete_query, all_params) = self.build_complete_mysql_query( + base_query, params, &config, playlist_id + )?; + + // Execute the query and insert episodes + self.execute_playlist_query_mysql(pool, &complete_query, &all_params, playlist_id).await + } + + // Execute optimized partially played query for PostgreSQL - FIXED VERSION + async fn execute_partially_played_query_postgres(&self, pool: &Pool, playlist_id: i32, user_id: i32, config: &PlaylistConfig) -> AppResult { + // Use direct INSERT without subquery for this optimized case - no alias scoping issues + let sort_order = config.get_postgres_sort_order().replace("ORDER BY ", ""); + + // Build base query + let mut query = format!(r#" + INSERT INTO "PlaylistContents" (playlistid, episodeid, position) + SELECT $1, e.episodeid, ROW_NUMBER() OVER (ORDER BY {}) as position + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid + JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid + WHERE h.listenduration > 0 + AND h.listenduration < e.episodeduration + AND e.completed = FALSE + AND e.episodeduration > 0 + AND h.userid = $2 + "#, sort_order); + + let params = vec![playlist_id, user_id]; + + // Add progress filters using hardcoded values instead of parameters to avoid type issues + if let Some(min_progress) = config.play_progress_min { + query.push_str(&format!(" AND (h.listenduration::float / NULLIF(e.episodeduration, 0)) >= {}", min_progress / 100.0)); + } + + if let Some(max_progress) = config.play_progress_max { + query.push_str(&format!(" AND (h.listenduration::float / NULLIF(e.episodeduration, 0)) <= {}", max_progress / 100.0)); + } + + // Add limit + if let Some(max_episodes) = config.max_episodes { + query.push_str(&format!(" LIMIT {}", max_episodes)); + } + + tracing::info!("Executing partially played query with {} parameters", params.len()); + tracing::debug!("Query: {}", query); + + // Execute with proper parameter binding + let mut sqlx_query = sqlx::query(&query); + for param in ¶ms { + sqlx_query = sqlx_query.bind(*param); + } + + let result = sqlx_query.execute(pool).await?; + Ok(result.rows_affected() as i32) + } + + // Execute optimized partially played query for MySQL + async fn execute_partially_played_query_mysql(&self, pool: &Pool, playlist_id: i32, user_id: i32, config: &PlaylistConfig) -> AppResult { + // Use direct INSERT without subquery for this optimized case - no alias scoping issues + let sort_order = config.get_mysql_sort_order().replace("ORDER BY ", ""); + + let query = format!(" + INSERT INTO PlaylistContents (PlaylistID, EpisodeID, Position) + SELECT ?, e.EpisodeID, ROW_NUMBER() OVER (ORDER BY {}) as position + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID + WHERE h.ListenDuration > 0 + AND h.ListenDuration < e.EpisodeDuration + AND e.Completed = FALSE + AND e.EpisodeDuration > 0 + AND h.UserID = ? + ", sort_order); + + // For simplicity, execute basic version - full implementation would add all filters + let result = sqlx::query(&query) + .bind(playlist_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() as i32) + } + + // Build complete PostgreSQL query with all filters - EXACT PYTHON MATCH + fn build_complete_postgres_query(&self, base_query: String, params: Vec, config: &PlaylistConfig, playlist_id: i32) -> AppResult<(String, Vec)> { + // Build proper SELECT query first - need to include columns for ordering in subquery + let mut select_columns = vec![ + "e.episodeid".to_string(), + "p.podcastid".to_string(), + "e.episodepubdate".to_string(), + "e.episodeduration".to_string(), + "COALESCE(h.listenduration, 0) as listenduration".to_string() + ]; + + let mut select_query = base_query.replace( + "SELECT e.episodeid, p.podcastid, u.timezone", + &format!("SELECT {}", select_columns.join(", ")) + ); + + let mut all_params = params; + let mut param_index = all_params.len() + 2; // +2 because playlist_id will be inserted as $1 + + // Add podcast filter (PostgreSQL IN clause support) + if let Some(ref podcast_ids) = config.podcast_ids { + println!("Playlist {}: Applying podcast filter with IDs: {:?}", playlist_id, podcast_ids); + if !podcast_ids.is_empty() { + if podcast_ids.len() == 1 { + println!("PostgreSQL single podcast filter: p.podcastid = {}", podcast_ids[0]); + select_query.push_str(&format!(" AND p.podcastid = ${}", param_index)); + all_params.push(podcast_ids[0]); + param_index += 1; + } else { + let placeholders: String = (0..podcast_ids.len()) + .map(|i| format!("${}", param_index + i)) + .collect::>() + .join(","); + println!("PostgreSQL multiple podcast filter: p.podcastid IN ({})", placeholders); + select_query.push_str(&format!(" AND p.podcastid IN ({})", placeholders)); + all_params.extend(podcast_ids); + param_index += podcast_ids.len(); + } + } else { + // If podcast_ids is Some but empty, user selected specific podcasts but list is empty + // This should return no results (exclude all podcasts) + println!("PostgreSQL podcast filter is empty - excluding all podcasts"); + select_query.push_str(" AND FALSE"); + } + } else { + println!("PostgreSQL no podcast filter applied - config.podcast_ids is None"); + } + + // Add duration filters + if let Some(min_duration) = config.min_duration { + select_query.push_str(&format!(" AND e.episodeduration >= ${}", param_index)); + all_params.push(min_duration); + param_index += 1; + } + + if let Some(max_duration) = config.max_duration { + select_query.push_str(&format!(" AND e.episodeduration <= ${}", param_index)); + all_params.push(max_duration); + param_index += 1; + } + + // Add time filter with timezone awareness + if let Some(time_filter_hours) = config.time_filter_hours { + select_query.push_str(&format!( + " AND e.episodepubdate AT TIME ZONE 'UTC' AT TIME ZONE COALESCE(u.timezone, 'UTC') > \ + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AT TIME ZONE COALESCE(u.timezone, 'UTC') - INTERVAL '{}' HOUR)", + time_filter_hours + )); + } + + // Add play state filters - EXACT PYTHON LOGIC + println!("Playlist {}: Applying play state filters - unplayed: {}, partially_played: {}, played: {}", + playlist_id, config.include_unplayed, config.include_partially_played, config.include_played); + let mut play_state_conditions = Vec::new(); + + if config.include_unplayed { + play_state_conditions.push("h.listenduration IS NULL".to_string()); + println!("Playlist {}: Added unplayed episode filter", playlist_id); + } + + if config.include_partially_played { + let mut partial_condition = "(h.listenduration > 0 AND h.listenduration < e.episodeduration AND e.completed = FALSE)".to_string(); + + if let Some(min_progress) = config.play_progress_min { + partial_condition.push_str(&format!(" AND (h.listenduration::float / NULLIF(e.episodeduration, 0)) >= {}", min_progress / 100.0)); + } + + if let Some(max_progress) = config.play_progress_max { + partial_condition.push_str(&format!(" AND (h.listenduration::float / NULLIF(e.episodeduration, 0)) <= {}", max_progress / 100.0)); + } + + play_state_conditions.push(partial_condition); + } + + if config.include_played { + play_state_conditions.push("h.listenduration >= e.episodeduration".to_string()); + tracing::debug!("Playlist {}: Added played/completed episode filter", playlist_id); + } + + if !play_state_conditions.is_empty() { + select_query.push_str(&format!(" AND ({})", play_state_conditions.join(" OR "))); + tracing::debug!("Playlist {}: Applied play state filter: ({})", playlist_id, play_state_conditions.join(" OR ")); + } else { + // If no play states are selected, exclude all episodes (return no results) + select_query.push_str(" AND FALSE"); + tracing::debug!("Playlist {}: No play states selected - excluding all episodes", playlist_id); + } + + // Note: No ORDER BY in inner query - final sorting is handled by ROW_NUMBER() OVER clause + + // Add limit + if let Some(max_episodes) = config.max_episodes { + select_query.push_str(&format!(" LIMIT {}", max_episodes)); + } + + // Now wrap the SELECT query in INSERT with ROW_NUMBER() - FIXED ALIAS SCOPING + println!("Playlist {}: group_by_podcast setting: {}", playlist_id, config.group_by_podcast); + let sort_for_insert = if config.group_by_podcast { + let sort_with_grouping = format!("ORDER BY episodes.podcastid, {}", config.get_postgres_outer_sort_order().replace("ORDER BY ", "")); + println!("Playlist {}: Using grouped sort: {}", playlist_id, sort_with_grouping); + sort_with_grouping + } else { + let sort_without_grouping = config.get_postgres_outer_sort_order(); + println!("Playlist {}: Using non-grouped sort: {}", playlist_id, sort_without_grouping); + println!("Playlist {}: Debug - raw sort_order: '{}'", playlist_id, config.sort_order); + sort_without_grouping + }; + + let insert_query = format!(r#" + INSERT INTO "PlaylistContents" (playlistid, episodeid, position) + SELECT $1, episodes.episodeid, ROW_NUMBER() OVER ({}) as position + FROM ({}) episodes + "#, sort_for_insert, select_query); + + println!("Playlist {}: Final insert query: {}", playlist_id, insert_query); + + // Final params: playlist_id first, then all query params + let mut final_params = vec![playlist_id]; + final_params.extend(all_params); + + Ok((insert_query, final_params)) + } + + // Build complete MySQL query with all filters - EXACT PYTHON MATCH + fn build_complete_mysql_query(&self, base_query: String, params: Vec, config: &PlaylistConfig, playlist_id: i32) -> AppResult<(String, Vec)> { + // Build proper SELECT query first - need to include columns for ordering in subquery + let mut select_columns = vec![ + "e.EpisodeID".to_string(), + "p.PodcastID".to_string(), + "e.EpisodePubDate".to_string(), + "e.EpisodeDuration".to_string(), + "COALESCE(h.ListenDuration, 0) as ListenDuration".to_string() + ]; + + let mut select_query = base_query.replace( + "SELECT e.EpisodeID", + &format!("SELECT {}", select_columns.join(", ")) + ); + + let mut all_params = params; + + // Add podcast filter (MySQL JSON/IN support) + if let Some(ref podcast_ids) = config.podcast_ids { + if !podcast_ids.is_empty() { + println!("MySQL applying podcast filter with IDs: {:?}", podcast_ids); + if podcast_ids.len() == 1 { + select_query.push_str(" AND p.PodcastID = ?"); + all_params.push(podcast_ids[0]); + println!("MySQL single podcast filter: p.PodcastID = {}", podcast_ids[0]); + } else { + let placeholders: String = podcast_ids.iter().map(|_| "?").collect::>().join(","); + select_query.push_str(&format!(" AND p.PodcastID IN ({})", placeholders)); + all_params.extend(podcast_ids); + println!("MySQL multiple podcast filter: p.PodcastID IN ({})", placeholders); + } + } else { + // If podcast_ids is Some but empty, user selected specific podcasts but list is empty + // This should return no results (exclude all podcasts) + println!("MySQL podcast filter is empty - excluding all podcasts"); + select_query.push_str(" AND FALSE"); + } + } else { + println!("MySQL no podcast_ids specified, no filter applied"); + } + + // Add duration filters + if let Some(min_duration) = config.min_duration { + select_query.push_str(" AND e.EpisodeDuration >= ?"); + all_params.push(min_duration); + } + + if let Some(max_duration) = config.max_duration { + select_query.push_str(" AND e.EpisodeDuration <= ?"); + all_params.push(max_duration); + } + + // Add time filter with timezone awareness + if let Some(time_filter_hours) = config.time_filter_hours { + select_query.push_str(&format!( + " AND CONVERT_TZ(e.EpisodePubDate, 'UTC', COALESCE(u.TimeZone, 'UTC')) > \ + DATE_SUB(CONVERT_TZ(NOW(), 'UTC', COALESCE(u.TimeZone, 'UTC')), INTERVAL {} HOUR)", + time_filter_hours + )); + } + + // Add play state filters - EXACT PYTHON LOGIC + let mut play_state_conditions = Vec::new(); + + if config.include_unplayed { + play_state_conditions.push("h.ListenDuration IS NULL".to_string()); + } + + if config.include_partially_played { + let mut partial_condition = "(h.ListenDuration > 0 AND h.ListenDuration < e.EpisodeDuration AND e.Completed = FALSE)".to_string(); + + if let Some(min_progress) = config.play_progress_min { + partial_condition.push_str(&format!(" AND (h.ListenDuration / NULLIF(e.EpisodeDuration, 0)) >= {}", min_progress / 100.0)); + } + + if let Some(max_progress) = config.play_progress_max { + partial_condition.push_str(&format!(" AND (h.ListenDuration / NULLIF(e.EpisodeDuration, 0)) <= {}", max_progress / 100.0)); + } + + play_state_conditions.push(partial_condition); + } + + if config.include_played { + play_state_conditions.push("h.ListenDuration >= e.EpisodeDuration".to_string()); + } + + if !play_state_conditions.is_empty() { + select_query.push_str(&format!(" AND ({})", play_state_conditions.join(" OR "))); + } else { + // If no play states are selected, exclude all episodes (return no results) + select_query.push_str(" AND FALSE"); + } + + // Note: No ORDER BY in inner query - final sorting is handled by ROW_NUMBER() OVER clause + + // Add limit + if let Some(max_episodes) = config.max_episodes { + select_query.push_str(&format!(" LIMIT {}", max_episodes)); + } + + // Now wrap the SELECT query in INSERT with ROW_NUMBER() - FIXED ALIAS SCOPING + println!("Playlist {}: group_by_podcast setting: {}", playlist_id, config.group_by_podcast); + let sort_for_insert = if config.group_by_podcast { + let sort_with_grouping = format!("ORDER BY episodes.PodcastID, {}", config.get_mysql_outer_sort_order().replace("ORDER BY ", "")); + println!("Playlist {}: Using grouped sort: {}", playlist_id, sort_with_grouping); + sort_with_grouping + } else { + let sort_without_grouping = config.get_mysql_outer_sort_order(); + println!("Playlist {}: Using non-grouped sort: {}", playlist_id, sort_without_grouping); + sort_without_grouping + }; + + let insert_query = format!(r#" + INSERT INTO PlaylistContents (PlaylistID, EpisodeID, Position) + SELECT ?, episodes.EpisodeID, ROW_NUMBER() OVER ({}) as position + FROM ({}) episodes + "#, sort_for_insert, select_query); + + // Final params: playlist_id first, then all query params + let mut final_params = vec![playlist_id]; + final_params.extend(all_params); + + Ok((insert_query, final_params)) + } + + // Add play state filters for PostgreSQL + fn add_play_state_filters_postgres(&self, query: &mut String, params: &mut Vec, param_index: &mut usize, config: &PlaylistConfig) -> AppResult<()> { + let mut play_state_conditions = Vec::new(); + + if config.include_unplayed { + play_state_conditions.push("h.listenduration IS NULL".to_string()); + } + + if config.include_partially_played { + let mut partial_condition = "(h.listenduration > 0 AND h.listenduration < e.episodeduration AND e.completed = FALSE)".to_string(); + + if let Some(min_progress) = config.play_progress_min { + partial_condition.push_str(&format!(" AND (h.listenduration::float / NULLIF(e.episodeduration, 0)) >= {}", min_progress / 100.0)); + } + + if let Some(max_progress) = config.play_progress_max { + partial_condition.push_str(&format!(" AND (h.listenduration::float / NULLIF(e.episodeduration, 0)) <= {}", max_progress / 100.0)); + } + + play_state_conditions.push(partial_condition); + } + + if config.include_played { + play_state_conditions.push("h.listenduration >= e.episodeduration".to_string()); + } + + if !play_state_conditions.is_empty() { + query.push_str(&format!(" AND ({})", play_state_conditions.join(" OR "))); + } + + Ok(()) + } + + // Add play state filters for MySQL + fn add_play_state_filters_mysql(&self, query: &mut String, config: &PlaylistConfig) -> AppResult<()> { + let mut play_state_conditions = Vec::new(); + + if config.include_unplayed { + play_state_conditions.push("h.ListenDuration IS NULL".to_string()); + } + + if config.include_partially_played { + let mut partial_condition = "(h.ListenDuration > 0 AND h.ListenDuration < e.EpisodeDuration AND e.Completed = FALSE)".to_string(); + + if let Some(min_progress) = config.play_progress_min { + partial_condition.push_str(&format!(" AND (h.ListenDuration / NULLIF(e.EpisodeDuration, 0)) >= {}", min_progress / 100.0)); + } + + if let Some(max_progress) = config.play_progress_max { + partial_condition.push_str(&format!(" AND (h.ListenDuration / NULLIF(e.EpisodeDuration, 0)) <= {}", max_progress / 100.0)); + } + + play_state_conditions.push(partial_condition); + } + + if config.include_played { + play_state_conditions.push("h.ListenDuration >= e.EpisodeDuration".to_string()); + } + + if !play_state_conditions.is_empty() { + query.push_str(&format!(" AND ({})", play_state_conditions.join(" OR "))); + } + + Ok(()) + } + + // Add common filters for PostgreSQL (simplified helper) + fn add_common_filters_postgres(&self, _query: &mut String, _params: &mut Vec + Send + 'static>>, _param_index: &mut usize, _config: &PlaylistConfig, _user_id: i32) -> AppResult<()> { + // Implementation for duration, time, podcast filters + // Simplified for now due to complexity of dynamic parameters + Ok(()) + } + + // Helper function to parse categories JSON string into HashMap - matches Python version + fn parse_categories_json(&self, categories_str: &str) -> Option> { + if categories_str.is_empty() { + return Some(std::collections::HashMap::new()); + } + + if categories_str.starts_with('{') { + // Try to parse as JSON first + if let Ok(parsed) = serde_json::from_str::>(categories_str) { + return Some(parsed); + } + } else { + // Fall back to comma-separated parsing like Python version + let mut result = std::collections::HashMap::new(); + for (i, cat) in categories_str.split(',').enumerate() { + result.insert(i.to_string(), cat.trim().to_string()); + } + return Some(result); + } + + // Return empty map if parsing fails + Some(std::collections::HashMap::new()) + } + + // Execute the final playlist query for PostgreSQL - FIXED VERSION + async fn execute_playlist_query_postgres(&self, pool: &Pool, query: &str, params: &[i32], _playlist_id: i32) -> AppResult { + println!("PostgreSQL executing playlist query with {} parameters", params.len()); + println!("PostgreSQL Query: {}", query); + println!("PostgreSQL Params: {:?}", params); + + // Build query with proper parameter binding + let mut sqlx_query = sqlx::query(query); + for param in params { + sqlx_query = sqlx_query.bind(*param); + } + + let result = sqlx_query.execute(pool).await?; + println!("PostgreSQL playlist query affected {} rows", result.rows_affected()); + Ok(result.rows_affected() as i32) + } + + // Execute the final playlist query for MySQL + async fn execute_playlist_query_mysql(&self, pool: &Pool, query: &str, params: &[i32], _playlist_id: i32) -> AppResult { + tracing::info!("Executing MySQL playlist query with {} parameters", params.len()); + tracing::debug!("Query: {}", query); + tracing::debug!("Params: {:?}", params); + + // Build query with proper parameter binding + let mut sqlx_query = sqlx::query(query); + for param in params { + sqlx_query = sqlx_query.bind(*param); + } + + let result = sqlx_query.execute(pool) + .await?; + + Ok(result.rows_affected() as i32) + } + + // Get podcast details - matches Python get_podcast_details function exactly + pub async fn get_podcast_details(&self, user_id: i32, podcast_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // First try to get podcast for specific user + let mut podcast_row = sqlx::query(r#" + SELECT podcastid, podcastname, feedurl, description, author, artworkurl, + explicit, episodecount, categories, websiteurl, podcastindexid, isyoutubechannel, + userid, autodownload, startskip, endskip, username, password, notificationsenabled, feedcutoffdays, + playbackspeed, playbackspeedcustomized + FROM "Podcasts" + WHERE podcastid = $1 AND userid = $2 + "#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + // Fallback to admin/public podcasts (userid = 1) if not found for user + if podcast_row.is_none() { + podcast_row = sqlx::query(r#" + SELECT podcastid, podcastname, feedurl, description, author, artworkurl, + explicit, episodecount, categories, websiteurl, podcastindexid, isyoutubechannel, + userid, autodownload, startskip, endskip, username, password, notificationsenabled, feedcutoffdays, + playbackspeed, playbackspeedcustomized + FROM "Podcasts" + WHERE podcastid = $1 AND userid = 1 + "#) + .bind(podcast_id) + .fetch_optional(pool) + .await?; + } + + let row = podcast_row.ok_or_else(|| AppError::not_found("Podcast not found"))?; + + // Get episode count (special handling for YouTube channels) + let is_youtube: bool = row.try_get("isyoutubechannel").unwrap_or(false); + let episode_count = if is_youtube { + // Get count from YouTubeVideos table for YouTube channels + let count_row = sqlx::query(r#"SELECT COUNT(*) as count FROM "YouTubeVideos" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_one(pool) + .await?; + count_row.try_get::("count")? as i32 + } else { + row.try_get("episodecount").unwrap_or(0) + }; + + // Get categories and parse to HashMap - matches Python version exactly + let categories_str = row.try_get::("categories").unwrap_or_else(|_| String::new()); + let categories = self.parse_categories_json(&categories_str); + + Ok(serde_json::json!({ + "podcastid": row.try_get::("podcastid")?, + "podcastindexid": row.try_get::("podcastindexid").unwrap_or(0), + "podcastname": row.try_get::("podcastname").unwrap_or_else(|_| "Unknown Podcast".to_string()), + "artworkurl": row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_else(|| String::new()), + "author": row.try_get::("author").unwrap_or_else(|_| "Unknown Author".to_string()), + "categories": categories, + "description": row.try_get::("description").unwrap_or_else(|_| String::new()), + "episodecount": episode_count, + "feedurl": row.try_get::("feedurl").unwrap_or_else(|_| String::new()), + "websiteurl": row.try_get::("websiteurl").unwrap_or_else(|_| String::new()), + "explicit": row.try_get::("explicit").unwrap_or(false), + "userid": row.try_get::("userid")?, + "autodownload": row.try_get::("autodownload").unwrap_or(false), + "startskip": row.try_get::("startskip").unwrap_or(0), + "endskip": row.try_get::("endskip").unwrap_or(0), + "username": row.try_get::, _>("username")?, + "password": row.try_get::, _>("password")?, + "isyoutubechannel": is_youtube, + "notificationsenabled": row.try_get::("notificationsenabled").unwrap_or(false), + "feedcutoffdays": row.try_get::("feedcutoffdays").unwrap_or(0), + "playbackspeedcustomized": row.try_get::("playbackspeedcustomized").unwrap_or(false), + "playbackspeed": row.try_get::("playbackspeed").unwrap_or(1.0) + })) + } + DatabasePool::MySQL(pool) => { + // First try to get podcast for specific user + let mut podcast_row = sqlx::query(r#" + SELECT PodcastID, PodcastName, FeedURL, Description, Author, ArtworkURL, + Explicit, EpisodeCount, Categories, WebsiteURL, PodcastIndexID, IsYouTubeChannel, + UserID, AutoDownload, StartSkip, EndSkip, Username, Password, NotificationsEnabled, FeedCutoffDays, + PlaybackSpeed, PlaybackSpeedCustomized + FROM Podcasts + WHERE PodcastID = ? AND UserID = ? + "#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + // Fallback to admin/public podcasts (UserID = 1) if not found for user + if podcast_row.is_none() { + podcast_row = sqlx::query(r#" + SELECT PodcastID, PodcastName, FeedURL, Description, Author, ArtworkURL, + Explicit, EpisodeCount, Categories, WebsiteURL, PodcastIndexID, IsYouTubeChannel, + UserID, AutoDownload, StartSkip, EndSkip, Username, Password, NotificationsEnabled, FeedCutoffDays, + PlaybackSpeed, PlaybackSpeedCustomized + FROM Podcasts + WHERE PodcastID = ? AND UserID = 1 + "#) + .bind(podcast_id) + .fetch_optional(pool) + .await?; + } + + let row = podcast_row.ok_or_else(|| AppError::not_found("Podcast not found"))?; + + // Get episode count (special handling for YouTube channels) + let is_youtube: bool = row.try_get("IsYouTubeChannel").unwrap_or(false); + let episode_count = if is_youtube { + // Get count from YouTubeVideos table for YouTube channels + let count_row = sqlx::query("SELECT COUNT(*) as count FROM YouTubeVideos WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_one(pool) + .await?; + count_row.try_get::("count")? as i32 + } else { + row.try_get("EpisodeCount").unwrap_or(0) + }; + + // Get categories and parse to HashMap - matches Python version exactly + let categories_str = row.try_get::("Categories").unwrap_or_else(|_| String::new()); + let categories = self.parse_categories_json(&categories_str); + + Ok(serde_json::json!({ + "podcastid": row.try_get::("PodcastID")?, + "podcastindexid": row.try_get::("PodcastIndexID").unwrap_or(0), + "podcastname": row.try_get::("PodcastName").unwrap_or_else(|_| "Unknown Podcast".to_string()), + "artworkurl": row.try_get::, _>("ArtworkURL").unwrap_or_default().unwrap_or_else(|| String::new()), + "author": row.try_get::("Author").unwrap_or_else(|_| "Unknown Author".to_string()), + "categories": categories, + "description": row.try_get::("Description").unwrap_or_else(|_| String::new()), + "episodecount": episode_count, + "feedurl": row.try_get::("FeedURL").unwrap_or_else(|_| String::new()), + "websiteurl": row.try_get::("WebsiteURL").unwrap_or_else(|_| String::new()), + "explicit": row.try_get::("Explicit").unwrap_or(0) != 0, + "userid": row.try_get::("UserID")?, + "autodownload": row.try_get::("AutoDownload").unwrap_or(0) != 0, + "startskip": row.try_get::("StartSkip").unwrap_or(0), + "endskip": row.try_get::("EndSkip").unwrap_or(0), + "username": row.try_get::, _>("Username")?, + "password": row.try_get::, _>("Password")?, + "isyoutubechannel": is_youtube, + "notificationsenabled": row.try_get::("NotificationsEnabled").unwrap_or(0) != 0, + "feedcutoffdays": row.try_get::("FeedCutoffDays").unwrap_or(0), + "playbackspeedcustomized": row.try_get::("PlaybackSpeedCustomized").unwrap_or(0) != 0, + "playbackspeed": if let Ok(speed) = row.try_get::("PlaybackSpeed") { + speed.to_f64().unwrap_or(1.0) + } else { + 1.0 + } + })) + } + } + } + + // OIDC Provider Management - matches Python OIDC functions + + // Get OIDC provider by client ID - for callback processing + pub async fn get_oidc_provider_by_client_id(&self, client_id: &str) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT providerid, providername, clientid, clientsecret, authorizationurl, + tokenurl, userinfourl, scope, buttoncolor, buttontext, buttontextcolor, + iconsvg, enabled, nameclaim, emailclaim, usernameclaim, rolesclaim, + userrole, adminrole + FROM "OIDCProviders" + WHERE clientid = $1 AND enabled = true + "#) + .bind(client_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(serde_json::json!({ + "provider_id": row.try_get::("providerid")?, + "provider_name": row.try_get::("providername")?, + "client_id": row.try_get::("clientid")?, + "client_secret": row.try_get::("clientsecret")?, + "authorization_url": row.try_get::("authorizationurl")?, + "token_url": row.try_get::("tokenurl")?, + "userinfo_url": row.try_get::("userinfourl")?, + "scope": row.try_get::("scope")?, + "button_color": row.try_get::("buttoncolor")?, + "button_text": row.try_get::("buttontext")?, + "button_text_color": row.try_get::("buttontextcolor")?, + "icon_svg": row.try_get::, _>("iconsvg")?, + "enabled": row.try_get::("enabled")?, + "name_claim": row.try_get::, _>("nameclaim")?, + "email_claim": row.try_get::, _>("emailclaim")?, + "username_claim": row.try_get::, _>("usernameclaim")?, + "roles_claim": row.try_get::, _>("rolesclaim")?, + "user_role": row.try_get::, _>("userrole")?, + "admin_role": row.try_get::, _>("adminrole")? + }))) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query(r#" + SELECT ProviderID, ProviderName, ClientID, ClientSecret, AuthorizationURL, + TokenURL, UserInfoURL, Scope, ButtonColor, ButtonText, ButtonTextColor, + IconSVG, Enabled, NameClaim, EmailClaim, UsernameClaim, RolesClaim, + UserRole, AdminRole + FROM OIDCProviders + WHERE ClientID = ? AND Enabled = true + "#) + .bind(client_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(serde_json::json!({ + "provider_id": row.try_get::("ProviderID")?, + "provider_name": row.try_get::("ProviderName")?, + "client_id": row.try_get::("ClientID")?, + "client_secret": row.try_get::("ClientSecret")?, + "authorization_url": row.try_get::("AuthorizationURL")?, + "token_url": row.try_get::("TokenURL")?, + "userinfo_url": row.try_get::("UserInfoURL")?, + "scope": row.try_get::("Scope")?, + "button_color": row.try_get::("ButtonColor")?, + "button_text": row.try_get::("ButtonText")?, + "button_text_color": row.try_get::("ButtonTextColor")?, + "icon_svg": row.try_get::, _>("IconSVG")?, + "enabled": row.try_get::("Enabled")? != 0, + "name_claim": row.try_get::, _>("NameClaim")?, + "email_claim": row.try_get::, _>("EmailClaim")?, + "username_claim": row.try_get::, _>("UsernameClaim")?, + "roles_claim": row.try_get::, _>("RolesClaim")?, + "user_role": row.try_get::, _>("UserRole")?, + "admin_role": row.try_get::, _>("AdminRole")? + }))) + } else { + Ok(None) + } + } + } + } + + // Get OIDC provider - matches Python get_oidc_provider function EXACTLY + pub async fn get_oidc_provider(&self, client_id: &str) -> AppResult, Option, Option, Option, Option, Option)>> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#" + SELECT providerid, clientid, clientsecret, tokenurl, userinfourl, nameclaim, emailclaim, usernameclaim, rolesclaim, userrole, adminrole + FROM "OIDCProviders" + WHERE clientid = $1 AND enabled = true + "#) + .bind(client_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + Ok(Some(( + row.try_get("providerid")?, + row.try_get("clientid")?, + row.try_get("clientsecret")?, + row.try_get("tokenurl")?, + row.try_get("userinfourl")?, + row.try_get("nameclaim")?, + row.try_get("emailclaim")?, + row.try_get("usernameclaim")?, + row.try_get("rolesclaim")?, + row.try_get("userrole")?, + row.try_get("adminrole")?, + ))) + } else { + Ok(None) + } + }, + DatabasePool::MySQL(pool) => { + let result = sqlx::query(r#" + SELECT ProviderID, ClientID, ClientSecret, TokenURL, UserInfoURL, NameClaim, EmailClaim, UsernameClaim, RolesClaim, UserRole, AdminRole + FROM OIDCProviders + WHERE ClientID = ? AND Enabled = true + "#) + .bind(client_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + Ok(Some(( + row.try_get("ProviderID")?, + row.try_get("ClientID")?, + row.try_get("ClientSecret")?, + row.try_get("TokenURL")?, + row.try_get("UserInfoURL")?, + row.try_get("NameClaim")?, + row.try_get("EmailClaim")?, + row.try_get("UsernameClaim")?, + row.try_get("RolesClaim")?, + row.try_get("UserRole")?, + row.try_get("AdminRole")?, + ))) + } else { + Ok(None) + } + } + } + } + + // Get user by email - matches Python get_user_by_email function EXACTLY + pub async fn get_user_by_email(&self, email: &str) -> AppResult, Option, bool)>> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#" + SELECT userid, email, username, fullname, isadmin + FROM "Users" + WHERE email = $1 + "#) + .bind(email) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + Ok(Some(( + row.try_get("userid")?, + row.try_get("email")?, + row.try_get("username")?, + row.try_get("fullname")?, + row.try_get("isadmin")?, + ))) + } else { + Ok(None) + } + }, + DatabasePool::MySQL(pool) => { + let result = sqlx::query(r#" + SELECT UserID, Email, Username, Fullname, IsAdmin + FROM Users + WHERE Email = ? + "#) + .bind(email) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + let is_admin: i32 = row.try_get("IsAdmin")?; + Ok(Some(( + row.try_get("UserID")?, + row.try_get("Email")?, + row.try_get("Username")?, + row.try_get("Fullname")?, + is_admin != 0, + ))) + } else { + Ok(None) + } + } + } + } + + // Check if username exists - matches Python check_usernames function EXACTLY + pub async fn check_usernames(&self, username: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"SELECT COUNT(*) as count FROM "Users" WHERE username = $1"#) + .bind(username) + .fetch_one(pool) + .await?; + let count: i64 = result.try_get("count")?; + Ok(count > 0) + }, + DatabasePool::MySQL(pool) => { + let result = sqlx::query(r#"SELECT COUNT(*) as count FROM Users WHERE Username = ?"#) + .bind(username) + .fetch_one(pool) + .await?; + let count: i64 = result.try_get("count")?; + Ok(count > 0) + } + } + } + + // Get user API key - matches Python get_user_api_key function EXACTLY + pub async fn get_user_api_key(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#" + SELECT apikey FROM "APIKeys" + WHERE userid = $1 + ORDER BY created DESC + LIMIT 1 + "#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + Ok(Some(row.try_get("apikey")?)) + } else { + Ok(None) + } + }, + DatabasePool::MySQL(pool) => { + let result = sqlx::query(r#" + SELECT APIKey FROM APIKeys + WHERE UserID = ? + ORDER BY Created DESC + LIMIT 1 + "#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + Ok(Some(row.try_get("APIKey")?)) + } else { + Ok(None) + } + } + } + } + + // Create OIDC user - matches Python create_oidc_user function EXACTLY + pub async fn create_oidc_user(&self, email: &str, fullname: &str, username: &str) -> AppResult { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use rand::Rng; + + // Create salt exactly like Python version + let salt_bytes: [u8; 16] = rand::rng().random(); + let salt = STANDARD.encode(salt_bytes); + let hashed_password = format!("$argon2id$v=19$m=65536,t=3,p=4${}${}_OIDC_ACCOUNT_NO_PASSWORD", + salt, "X".repeat(43)); + + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#" + INSERT INTO "Users" (fullname, username, email, hashed_pw, isadmin) + VALUES ($1, $2, $3, $4, false) + RETURNING userid + "#) + .bind(fullname) + .bind(username) + .bind(email) + .bind(&hashed_password) + .fetch_one(pool) + .await?; + + let user_id: i32 = result.try_get("userid")?; + + // Add default user settings + sqlx::query(r#"INSERT INTO "UserSettings" (userid, theme) VALUES ($1, $2)"#) + .bind(user_id) + .bind("Nordic") + .execute(pool) + .await?; + + // Add default user stats + sqlx::query(r#"INSERT INTO "UserStats" (userid) VALUES ($1)"#) + .bind(user_id) + .execute(pool) + .await?; + + Ok(user_id) + }, + DatabasePool::MySQL(pool) => { + let result = sqlx::query(r#" + INSERT INTO Users (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (?, ?, ?, ?, 0) + "#) + .bind(fullname) + .bind(username) + .bind(email) + .bind(&hashed_password) + .execute(pool) + .await?; + + let user_id = result.last_insert_id() as i32; + + // Add default user settings + sqlx::query(r#"INSERT INTO UserSettings (UserID, Theme) VALUES (?, ?)"#) + .bind(user_id) + .bind("Nordic") + .execute(pool) + .await?; + + // Add default user stats + sqlx::query(r#"INSERT INTO UserStats (UserID) VALUES (?)"#) + .bind(user_id) + .execute(pool) + .await?; + + Ok(user_id) + } + } + } + + + + // Check if username exists - helper for OIDC user creation + pub async fn username_exists(&self, username: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"SELECT 1 FROM "Users" WHERE username = $1 LIMIT 1"#) + .bind(username) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("SELECT 1 FROM Users WHERE Username = ? LIMIT 1") + .bind(username) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } + } + } + + // Create or get API key for user - for OIDC login completion + pub async fn create_or_get_api_key(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Check for existing API key + let existing_key = sqlx::query(r#"SELECT apikey FROM "APIKeys" WHERE userid = $1 LIMIT 1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing_key { + return Ok(row.try_get("apikey")?); + } + + // Generate new API key + let api_key = format!("pk_{}", uuid::Uuid::new_v4().simple()); + + sqlx::query(r#"INSERT INTO "APIKeys" (userid, apikey) VALUES ($1, $2)"#) + .bind(user_id) + .bind(&api_key) + .execute(pool) + .await?; + + Ok(api_key) + } + DatabasePool::MySQL(pool) => { + let existing_key = sqlx::query("SELECT APIKey FROM APIKeys WHERE UserID = ? LIMIT 1") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing_key { + return Ok(row.try_get("APIKey")?); + } + + let api_key = format!("pk_{}", uuid::Uuid::new_v4().simple()); + + sqlx::query("INSERT INTO APIKeys (UserID, APIKey) VALUES (?, ?)") + .bind(user_id) + .bind(&api_key) + .execute(pool) + .await?; + + Ok(api_key) + } + } + } + + // Get playlist episodes - matches Python get_playlist_episodes function exactly + pub async fn get_playlist_episodes(&self, user_id: i32, playlist_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Get playlist info with episode count - matches Python exactly + let playlist_row = sqlx::query(r#" + SELECT + p.name, + p.description, + (SELECT COUNT(*) + FROM "PlaylistContents" pc + JOIN "Episodes" e ON pc.episodeid = e.episodeid + JOIN "Podcasts" pod ON e.podcastid = pod.podcastid + LEFT JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid AND h.userid = $1 + WHERE pc.playlistid = p.playlistid + AND (p.issystemplaylist = FALSE OR + (p.issystemplaylist = TRUE AND + (h.episodeid IS NOT NULL OR pod.userid = $2))) + ) as episode_count, + p.iconname, + p.issystemplaylist + FROM "Playlists" p + WHERE p.playlistid = $3 AND (p.userid = $4 OR p.issystemplaylist = TRUE) + "#) + .bind(user_id) + .bind(user_id) + .bind(playlist_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if playlist_row.is_none() { + return Err(AppError::not_found("Playlist not found")); + } + + let row = playlist_row.unwrap(); + let playlist_name: String = row.try_get("name")?; + let playlist_description: String = row.try_get("description").unwrap_or_default(); + let episode_count: i64 = row.try_get("episodecount")?; + let icon_name: String = row.try_get("iconname").unwrap_or_default(); + let is_system_playlist: bool = row.try_get("issystemplaylist")?; + + let episodes_rows = if is_system_playlist { + // For system playlists, update playlist first to get current episodes, then filter to user's podcasts + self.update_playlist_contents(playlist_id).await?; + + // Same query as user playlists but with additional filter for user's podcasts + sqlx::query(r#" + SELECT DISTINCT + "Episodes".episodeid, + "Episodes".episodetitle, + "Episodes".episodepubdate, + "Episodes".episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl, + "Episodes".episodeduration, + "Episodes".completed, + "Podcasts".podcastname, + "Podcasts".podcastid, + "Podcasts".isyoutubechannel as is_youtube, + "UserEpisodeHistory".listenduration, + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + "PlaylistContents".dateadded + FROM "PlaylistContents" + JOIN "Episodes" ON "PlaylistContents".episodeid = "Episodes".episodeid + JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON "Episodes".episodeid = "UserEpisodeHistory".episodeid + AND "UserEpisodeHistory".userid = $1 + LEFT JOIN "SavedEpisodes" ON "Episodes".episodeid = "SavedEpisodes".episodeid + AND "SavedEpisodes".userid = $1 + LEFT JOIN "EpisodeQueue" ON "Episodes".episodeid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $1 + AND "EpisodeQueue".is_youtube = FALSE + LEFT JOIN "DownloadedEpisodes" ON "Episodes".episodeid = "DownloadedEpisodes".episodeid + AND "DownloadedEpisodes".userid = $1 + WHERE "PlaylistContents".playlistid = $2 + AND "Podcasts".userid = $1 + ORDER BY "PlaylistContents".dateadded DESC + "#) + .bind(user_id) + .bind(playlist_id) + .fetch_all(pool) + .await? + } else { + // For user playlists, use existing PlaylistContents logic + sqlx::query(r#" + SELECT DISTINCT + "Episodes".episodeid, + "Episodes".episodetitle, + "Episodes".episodepubdate, + "Episodes".episodedescription, + CASE + WHEN "Podcasts".usepodcastcoverscustomized = TRUE AND "Podcasts".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + WHEN "Users".usepodcastcovers = TRUE THEN "Podcasts".artworkurl + ELSE "Episodes".episodeartwork + END as episodeartwork, + "Episodes".episodeurl, + "Episodes".episodeduration, + "Episodes".completed, + "Podcasts".podcastname, + "Podcasts".podcastid, + "Podcasts".isyoutubechannel as is_youtube, + "UserEpisodeHistory".listenduration, + CASE WHEN "SavedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS saved, + CASE WHEN "EpisodeQueue".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS queued, + CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded, + "PlaylistContents".dateadded + FROM "PlaylistContents" + JOIN "Episodes" ON "PlaylistContents".episodeid = "Episodes".episodeid + JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid + LEFT JOIN "UserEpisodeHistory" ON "Episodes".episodeid = "UserEpisodeHistory".episodeid + AND "UserEpisodeHistory".userid = $1 + LEFT JOIN "SavedEpisodes" ON "Episodes".episodeid = "SavedEpisodes".episodeid + AND "SavedEpisodes".userid = $1 + LEFT JOIN "EpisodeQueue" ON "Episodes".episodeid = "EpisodeQueue".episodeid + AND "EpisodeQueue".userid = $1 + AND "EpisodeQueue".is_youtube = FALSE + LEFT JOIN "DownloadedEpisodes" ON "Episodes".episodeid = "DownloadedEpisodes".episodeid + AND "DownloadedEpisodes".userid = $1 + WHERE "PlaylistContents".playlistid = $2 + ORDER BY "PlaylistContents".dateadded DESC + "#) + .bind(user_id) + .bind(playlist_id) + .fetch_all(pool) + .await? + }; + + let mut episodes = Vec::new(); + for row in episodes_rows { + let episodeid: i32 = row.try_get("episodeid")?; + let episodetitle: String = row.try_get("episodetitle")?; + let naive = row.try_get::("episodepubdate")?; + let episodepubdate = naive.format("%Y-%m-%dT%H:%M:%S").to_string(); + let episodedescription: String = row.try_get("episodedescription")?; + let episodeartwork: String = row.try_get("episodeartwork")?; + let episodeurl: String = row.try_get("episodeurl")?; + let episodeduration: i32 = row.try_get("episodeduration")?; + let completed: bool = row.try_get("completed")?; + let podcastname: String = row.try_get("podcastname")?; + let podcastid: i32 = row.try_get("podcastid")?; + let is_youtube: bool = row.try_get("is_youtube")?; + let listenduration: Option = row.try_get("listenduration")?; + let saved: bool = row.try_get("saved")?; + let queued: bool = row.try_get("queued")?; + let downloaded: bool = row.try_get("downloaded")?; + let dateadded_naive = row.try_get::("dateadded")?; + let dateadded = dateadded_naive.format("%Y-%m-%dT%H:%M:%S").to_string(); + + episodes.push(serde_json::json!({ + "episodeid": episodeid, + "episodetitle": episodetitle, + "episodepubdate": episodepubdate, + "episodedescription": episodedescription, + "episodeartwork": episodeartwork, + "episodeurl": episodeurl, + "episodeduration": episodeduration, + "completed": completed, + "podcastname": podcastname, + "podcastid": podcastid, + "is_youtube": is_youtube, + "listenduration": listenduration, + "saved": saved, + "queued": queued, + "downloaded": downloaded, + "dateadded": dateadded + })); + } + + // Build playlist_info structure matching Python exactly + let playlist_info = serde_json::json!({ + "name": playlist_name, + "description": playlist_description, + "episode_count": episode_count, + "icon_name": icon_name + }); + + Ok(serde_json::json!({ + "playlist_info": playlist_info, + "episodes": episodes + })) + } + DatabasePool::MySQL(pool) => { + // Get playlist info with episode count - matches Python exactly + let playlist_row = sqlx::query( + "SELECT + p.Name, + p.Description, + (SELECT COUNT(*) + FROM PlaylistContents pc + JOIN Episodes e ON pc.EpisodeID = e.EpisodeID + JOIN Podcasts pod ON e.PodcastID = pod.PodcastID + LEFT JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = ? + WHERE pc.PlaylistID = p.PlaylistID + AND (p.IsSystemPlaylist = 0 OR + (p.IsSystemPlaylist = 1 AND + (h.EpisodeID IS NOT NULL OR pod.UserID = ?))) + ) as episode_count, + p.IconName, + p.IsSystemPlaylist + FROM Playlists p + WHERE p.PlaylistID = ? AND (p.UserID = ? OR p.IsSystemPlaylist = 1)" + ) + .bind(user_id) + .bind(user_id) + .bind(playlist_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if playlist_row.is_none() { + return Err(AppError::not_found("Playlist not found")); + } + + let row = playlist_row.unwrap(); + let playlist_name: String = row.try_get("Name")?; + let playlist_description: String = row.try_get("Description").unwrap_or_default(); + let episode_count: i64 = row.try_get("episode_count")?; + let icon_name: String = row.try_get("IconName").unwrap_or_default(); + let is_system_playlist: bool = row.try_get::("IsSystemPlaylist")? != 0; + + let episodes_rows = if is_system_playlist { + // For system playlists, update playlist first to get current episodes, then filter to user's podcasts + self.update_playlist_contents(playlist_id).await?; + + // Same query as user playlists but with additional filter for user's podcasts + sqlx::query( + "SELECT DISTINCT + e.EpisodeID as episodeid, + e.EpisodeTitle as episodetitle, + e.EpisodePubDate as episodepubdate, + e.EpisodeDescription as episodedescription, + CASE + WHEN p.UsePodcastCoversCustomized = TRUE AND p.UsePodcastCovers = TRUE THEN p.ArtworkURL + WHEN u.UsePodcastCovers = TRUE THEN p.ArtworkURL + ELSE e.EpisodeArtwork + END as episodeartwork, + e.EpisodeURL as episodeurl, + e.EpisodeDuration as episodeduration, + e.Completed as completed, + p.PodcastName as podcastname, + p.PodcastID as podcastid, + p.IsYouTubeChannel as is_youtube, + ueh.ListenDuration as listenduration, + CASE WHEN se.EpisodeID IS NOT NULL THEN 1 ELSE 0 END AS saved, + CASE WHEN eq.EpisodeID IS NOT NULL THEN 1 ELSE 0 END AS queued, + CASE WHEN de.EpisodeID IS NOT NULL THEN 1 ELSE 0 END AS downloaded, + pc.DateAdded as addeddate + FROM PlaylistContents pc + JOIN Episodes e ON pc.EpisodeID = e.EpisodeID + JOIN Podcasts p ON e.PodcastID = p.PodcastID + LEFT JOIN Users u ON p.UserID = u.UserID + LEFT JOIN UserEpisodeHistory ueh ON e.EpisodeID = ueh.EpisodeID AND ueh.UserID = ? + LEFT JOIN SavedEpisodes se ON e.EpisodeID = se.EpisodeID AND se.UserID = ? + LEFT JOIN EpisodeQueue eq ON e.EpisodeID = eq.EpisodeID AND eq.UserID = ? AND eq.is_youtube = 0 + LEFT JOIN DownloadedEpisodes de ON e.EpisodeID = de.EpisodeID AND de.UserID = ? + WHERE pc.PlaylistID = ? AND p.UserID = ? + ORDER BY pc.DateAdded DESC" + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(playlist_id) + .bind(user_id) + .fetch_all(pool) + .await? + } else { + // For user playlists, use existing PlaylistContents logic + sqlx::query( + "SELECT DISTINCT + e.EpisodeID as episodeid, + e.EpisodeTitle as episodetitle, + e.EpisodePubDate as episodepubdate, + e.EpisodeDescription as episodedescription, + CASE + WHEN p.UsePodcastCoversCustomized = TRUE AND p.UsePodcastCovers = TRUE THEN p.ArtworkURL + WHEN u.UsePodcastCovers = TRUE THEN p.ArtworkURL + ELSE e.EpisodeArtwork + END as episodeartwork, + e.EpisodeURL as episodeurl, + e.EpisodeDuration as episodeduration, + e.Completed as completed, + p.PodcastName as podcastname, + p.PodcastID as podcastid, + p.IsYouTubeChannel as is_youtube, + ueh.ListenDuration as listenduration, + CASE WHEN se.EpisodeID IS NOT NULL THEN 1 ELSE 0 END AS saved, + CASE WHEN eq.EpisodeID IS NOT NULL THEN 1 ELSE 0 END AS queued, + CASE WHEN de.EpisodeID IS NOT NULL THEN 1 ELSE 0 END AS downloaded, + pc.DateAdded as addeddate + FROM PlaylistContents pc + JOIN Episodes e ON pc.EpisodeID = e.EpisodeID + JOIN Podcasts p ON e.PodcastID = p.PodcastID + LEFT JOIN Users u ON p.UserID = u.UserID + LEFT JOIN UserEpisodeHistory ueh ON e.EpisodeID = ueh.EpisodeID AND ueh.UserID = ? + LEFT JOIN SavedEpisodes se ON e.EpisodeID = se.EpisodeID AND se.UserID = ? + LEFT JOIN EpisodeQueue eq ON e.EpisodeID = eq.EpisodeID AND eq.UserID = ? AND eq.is_youtube = 0 + LEFT JOIN DownloadedEpisodes de ON e.EpisodeID = de.EpisodeID AND de.UserID = ? + WHERE pc.PlaylistID = ? + ORDER BY pc.DateAdded DESC" + ) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(playlist_id) + .fetch_all(pool) + .await? + }; + + let mut episodes = Vec::new(); + for row in episodes_rows { + let episodeid: i32 = row.try_get("episodeid")?; + let episodetitle: String = row.try_get("episodetitle")?; + let naive = row.try_get::("episodepubdate")?; + let episodepubdate = naive.format("%Y-%m-%dT%H:%M:%S").to_string(); + let episodedescription: String = row.try_get("episodedescription")?; + let episodeartwork: String = row.try_get("episodeartwork")?; + let episodeurl: String = row.try_get("episodeurl")?; + let episodeduration: i32 = row.try_get("episodeduration")?; + let completed: bool = row.try_get::("completed")? != 0; + let podcastname: String = row.try_get("podcastname")?; + let podcastid: i32 = row.try_get("podcastid")?; + let is_youtube: bool = row.try_get::("is_youtube")? != 0; + let listenduration: Option = row.try_get("listenduration")?; + let saved: bool = row.try_get::("saved")? != 0; + let queued: bool = row.try_get::("queued")? != 0; + let downloaded: bool = row.try_get::("downloaded")? != 0; + let addeddate_dt = row.try_get::, _>("addeddate")?; + let addeddate = addeddate_dt.format("%Y-%m-%dT%H:%M:%S").to_string(); + + episodes.push(serde_json::json!({ + "episodeid": episodeid, + "episodetitle": episodetitle, + "episodepubdate": episodepubdate, + "episodedescription": episodedescription, + "episodeartwork": episodeartwork, + "episodeurl": episodeurl, + "episodeduration": episodeduration, + "completed": completed, + "podcastname": podcastname, + "podcastid": podcastid, + "is_youtube": is_youtube, + "listenduration": listenduration, + "saved": saved, + "queued": queued, + "downloaded": downloaded, + "dateadded": addeddate + })); + } + + // Build playlist_info structure matching Python exactly + let playlist_info = serde_json::json!({ + "name": playlist_name, + "description": playlist_description, + "episode_count": episode_count, + "icon_name": icon_name + }); + + Ok(serde_json::json!({ + "playlist_info": playlist_info, + "episodes": episodes + })) + } + } + } + + // Set user playback speed - matches Python set_playback_speed_user function + pub async fn set_playback_speed_user(&self, user_id: i32, playback_speed: f64) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET playbackspeed = $1 WHERE userid = $2"#) + .bind(playback_speed) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET PlaybackSpeed = ? WHERE UserID = ?") + .bind(playback_speed) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Set user podcast cover preference - global setting + pub async fn set_global_podcast_cover_preference(&self, user_id: i32, use_podcast_covers: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET usepodcastcovers = $1 WHERE userid = $2"#) + .bind(use_podcast_covers) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET UsePodcastCovers = ? WHERE UserID = ?") + .bind(use_podcast_covers as i32) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Set podcast cover preference - per-podcast setting + pub async fn set_podcast_cover_preference(&self, user_id: i32, podcast_id: i32, use_podcast_covers: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Podcasts" SET usepodcastcovers = $1, usepodcastcoverscustomized = TRUE WHERE podcastid = $2 AND userid = $3"#) + .bind(use_podcast_covers) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Podcasts SET UsePodcastCovers = ?, UsePodcastCoversCustomized = 1 WHERE PodcastID = ? AND UserID = ?") + .bind(use_podcast_covers as i32) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Clear podcast cover preference - reset to use global setting + pub async fn clear_podcast_cover_preference(&self, user_id: i32, podcast_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Podcasts" SET usepodcastcovers = FALSE, usepodcastcoverscustomized = FALSE WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Podcasts SET UsePodcastCovers = 0, UsePodcastCoversCustomized = 0 WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Get global podcast cover preference - for settings page + pub async fn get_global_podcast_cover_preference(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query_scalar::<_, Option>(r#"SELECT usepodcastcovers FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_one(pool) + .await?; + Ok(result.unwrap_or(false)) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query_scalar::<_, Option>("SELECT UsePodcastCovers FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + Ok(result.unwrap_or(0) != 0) + } + } + } + + // Get per-podcast cover preference - for episode layout page + pub async fn get_podcast_cover_preference(&self, user_id: i32, podcast_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query_scalar::<_, Option>(r#"SELECT usepodcastcovers FROM "Podcasts" WHERE podcastid = $1 AND userid = $2 AND usepodcastcoverscustomized = TRUE"#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(result.flatten()) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query_scalar::<_, Option>("SELECT UsePodcastCovers FROM Podcasts WHERE PodcastID = ? AND UserID = ? AND UsePodcastCoversCustomized = 1") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(result.flatten().map(|val| val != 0)) + } + } + } + + // Get all admin user IDs - matches Python add_news_feed_if_not_added logic + pub async fn get_all_admin_user_ids(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT userid FROM "Users" WHERE isadmin = TRUE"#) + .fetch_all(pool) + .await?; + + let user_ids: Vec = rows.into_iter() + .map(|row| row.try_get("userid")) + .collect::, _>>()?; + + Ok(user_ids) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT UserID FROM Users WHERE IsAdmin = 1") + .fetch_all(pool) + .await?; + + let user_ids: Vec = rows.into_iter() + .map(|row| row.try_get("UserID")) + .collect::, _>>()?; + + Ok(user_ids) + } + } + } + + // Check if user already has a specific podcast feed - matches Python logic + pub async fn user_has_podcast_feed(&self, user_id: i32, feed_url: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT podcastid FROM "Podcasts" WHERE userid = $1 AND feedurl = $2"#) + .bind(user_id) + .bind(feed_url) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?") + .bind(user_id) + .bind(feed_url) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) + } + } + } + + // Add PinePods news feed to admin users - matches Python add_news_feed_if_not_added function + pub async fn add_news_feed_if_not_added(&self) -> AppResult<()> { + let admin_user_ids = self.get_all_admin_user_ids().await?; + let feed_url = "https://news.pinepods.online/feed.xml"; + + for user_id in admin_user_ids { + // Check if this user already has the news feed + if !self.user_has_podcast_feed(user_id, feed_url).await? { + // Add the PinePods news feed using existing functions - matches Python add_custom_podcast + match self.get_podcast_values(feed_url, user_id, None, None).await { + Ok(podcast_values) => { + let feed_cutoff = 30; // Default cutoff like Python + if let Err(e) = self.add_podcast_from_values(&podcast_values, user_id, feed_cutoff, None, None).await { + eprintln!("Failed to add PinePods news feed for user {}: {}", user_id, e); + // Continue with other users even if one fails + } + }, + Err(e) => { + eprintln!("Failed to get podcast values for PinePods news feed for user {}: {}", user_id, e); + // Continue with other users even if one fails + } + } + } + } + + Ok(()) + } + + // Get YouTube video location - matches Python get_youtube_video_location function exactly + pub async fn get_youtube_video_location( + &self, + episode_id: i32, + user_id: i32, + ) -> AppResult> { + println!("Looking up YouTube video location for episode_id: {}, user_id: {}", episode_id, user_id); + + let youtube_id = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT "YouTubeVideos".youtubevideoid + FROM "YouTubeVideos" + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + WHERE "YouTubeVideos".videoid = $1 AND "Podcasts".userid = $2 + "#) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::("youtubevideoid")? + } else { + return Ok(None); + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query(r#" + SELECT YouTubeVideos.YouTubeVideoID + FROM YouTubeVideos + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + WHERE YouTubeVideos.VideoID = ? AND Podcasts.UserID = ? + "#) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::("YouTubeVideoID")? + } else { + return Ok(None); + } + } + }; + + println!("Found YouTube ID: {}", youtube_id); + + let file_path = format!("/opt/pinepods/downloads/youtube/{}.mp3", youtube_id); + let file_path_double = format!("/opt/pinepods/downloads/youtube/{}.mp3.mp3", youtube_id); + + println!("Checking paths: {} and {}", file_path, file_path_double); + + if tokio::fs::metadata(&file_path).await.is_ok() { + println!("Found file at {}", file_path); + Ok(Some(file_path)) + } else if tokio::fs::metadata(&file_path_double).await.is_ok() { + println!("Found file at {}", file_path_double); + Ok(Some(file_path_double)) + } else { + println!("No file found for YouTube ID: {}", youtube_id); + Ok(None) + } + } + + // Get download location - matches Python get_download_location function exactly + pub async fn get_download_location( + &self, + episode_id: i32, + user_id: i32, + ) -> AppResult> { + println!("Looking up download location for episode_id: {}, user_id: {}", episode_id, user_id); + + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT downloadedlocation FROM "DownloadedEpisodes" WHERE episodeid = $1 AND userid = $2"#) + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let location: String = row.try_get("downloadedlocation")?; + println!("DownloadedLocation found: {}", location); + Ok(Some(location)) + } else { + println!("No DownloadedLocation found for the given EpisodeID and UserID"); + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT DownloadedLocation FROM DownloadedEpisodes WHERE EpisodeID = ? AND UserID = ?") + .bind(episode_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let location: String = row.try_get("DownloadedLocation")?; + println!("DownloadedLocation found: {}", location); + Ok(Some(location)) + } else { + println!("No DownloadedLocation found for the given EpisodeID and UserID"); + Ok(None) + } + } + } + } + + // Update YouTube video duration after download - updates duration from MP3 file + pub async fn update_youtube_video_duration(&self, video_id: &str, duration_seconds: i32) -> AppResult<()> { + println!("Updating duration for YouTube video {} to {} seconds", video_id, duration_seconds); + + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "YouTubeVideos" SET duration = $1 WHERE youtubevideoid = $2"#) + .bind(duration_seconds) + .bind(video_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE YouTubeVideos SET Duration = ? WHERE YouTubeVideoID = ?") + .bind(duration_seconds) + .bind(video_id) + .execute(pool) + .await?; + } + } + + println!("Successfully updated duration for YouTube video {}", video_id); + Ok(()) + } +} + +#[derive(Debug)] +pub struct PodcastValues { + pub pod_title: String, + pub pod_description: String, + pub pod_artwork: String, + pub pod_feed_url: String, + pub user_id: i32, +} + +// Playlist configuration struct - matches Python playlist data structure exactly +#[derive(Debug, Clone)] +pub struct PlaylistConfig { + pub playlist_id: i32, + pub name: String, + pub user_id: i32, + pub podcast_ids: Option>, + pub include_unplayed: bool, + pub include_partially_played: bool, + pub include_played: bool, + pub play_progress_min: Option, + pub play_progress_max: Option, + pub time_filter_hours: Option, + pub min_duration: Option, + pub max_duration: Option, + pub sort_order: String, + pub group_by_podcast: bool, + pub max_episodes: Option, + pub is_system_playlist: bool, +} + +impl PlaylistConfig { + // Create from PostgreSQL row - matches Python playlist dictionary extraction + pub fn from_postgres_row(row: &sqlx::postgres::PgRow) -> AppResult { + use sqlx::Row; + + // Parse podcast IDs from PostgreSQL int4 array - can be NULL or {29,57} format + let podcast_ids = match row.try_get::>, _>("podcastids") { + Ok(Some(ids)) => { + println!("PostgreSQL got podcastids array: {:?}", ids); + if ids.is_empty() { + None + } else { + Some(ids) + } + } + Ok(None) => { + println!("PostgreSQL podcastids is NULL"); + None + } + Err(_) => { + println!("PostgreSQL failed to get podcastids as array"); + None + } + }; + + Ok(PlaylistConfig { + playlist_id: row.try_get("playlistid")?, + name: row.try_get("name")?, + user_id: row.try_get("userid")?, + podcast_ids, + include_unplayed: row.try_get("includeunplayed").unwrap_or(true), + include_partially_played: row.try_get("includepartiallyplayed").unwrap_or(true), + include_played: row.try_get("includeplayed").unwrap_or(false), + play_progress_min: row.try_get("playprogressmin").ok(), + play_progress_max: row.try_get("playprogressmax").ok(), + time_filter_hours: row.try_get("timefilterhours").ok(), + min_duration: row.try_get("minduration").ok(), + max_duration: row.try_get("maxduration").ok(), + sort_order: row.try_get("sortorder").unwrap_or_else(|_| "date_desc".to_string()), + group_by_podcast: row.try_get("groupbypodcast").unwrap_or(false), + max_episodes: row.try_get("maxepisodes").ok(), + is_system_playlist: row.try_get("issystemplaylist").unwrap_or(false), + }) + } + + // Create from MySQL row - matches Python playlist dictionary extraction + pub fn from_mysql_row(row: &sqlx::mysql::MySqlRow) -> AppResult { + use sqlx::Row; + + // Parse podcast IDs from MySQL JSON (stored as BLOB) + let podcast_ids = match row.try_get::>, _>("PodcastIDs") { + Ok(Some(ids_bytes)) => { + let ids_str = String::from_utf8_lossy(&ids_bytes); + println!("Got PodcastIDs from BLOB: '{}'", ids_str); + if ids_str.is_empty() || ids_str == "null" || ids_str == "[]" { + None + } else { + let parsed: Vec = serde_json::from_str(&ids_str).unwrap_or_default(); + if parsed.is_empty() { + None + } else { + Some(parsed) + } + } + } + Ok(None) => { + println!("PodcastIDs is NULL"); + None + } + Err(_) => { + // Fallback to try as String for older records + match row.try_get::, _>("PodcastIDs") { + Ok(Some(ids_str)) => { + println!("Got PodcastIDs as String: '{}'", ids_str); + if ids_str.is_empty() || ids_str == "null" || ids_str == "[]" { + None + } else { + let parsed: Vec = serde_json::from_str(&ids_str).unwrap_or_default(); + if parsed.is_empty() { None } else { Some(parsed) } + } + } + _ => None + } + } + }; + + Ok(PlaylistConfig { + playlist_id: row.try_get("PlaylistID")?, + name: row.try_get("Name")?, + user_id: row.try_get("UserID")?, + podcast_ids, + include_unplayed: row.try_get("IncludeUnplayed").unwrap_or(true), + include_partially_played: row.try_get("IncludePartiallyPlayed").unwrap_or(true), + include_played: row.try_get("IncludePlayed").unwrap_or(false), + play_progress_min: row.try_get("PlayProgressMin").ok(), + play_progress_max: row.try_get("PlayProgressMax").ok(), + time_filter_hours: row.try_get("TimeFilterHours").ok(), + min_duration: row.try_get("MinDuration").ok(), + max_duration: row.try_get("MaxDuration").ok(), + sort_order: row.try_get("SortOrder").unwrap_or_else(|_| "date_desc".to_string()), + group_by_podcast: row.try_get("GroupByPodcast").unwrap_or(false), + max_episodes: row.try_get("MaxEpisodes").ok(), + is_system_playlist: row.try_get("IsSystemPlaylist").unwrap_or(false), + }) + } + + // Get PostgreSQL sort order - matches Python sort_mapping exactly (for inner query) + pub fn get_postgres_sort_order(&self) -> String { + match self.sort_order.as_str() { + "date_asc" => "ORDER BY e.episodepubdate ASC".to_string(), + "date_desc" => "ORDER BY e.episodepubdate DESC".to_string(), + "duration_asc" => "ORDER BY e.episodeduration ASC".to_string(), + "duration_desc" => "ORDER BY e.episodeduration DESC".to_string(), + "listen_progress" => "ORDER BY (COALESCE(h.listenduration, 0)::float / NULLIF(e.episodeduration, 0)) DESC".to_string(), + "completion" => "ORDER BY COALESCE(h.listenduration::float / NULLIF(e.episodeduration, 0), 0) DESC".to_string(), + _ => "ORDER BY e.episodepubdate DESC".to_string(), + } + } + + // Get PostgreSQL sort order for outer query (ROW_NUMBER OVER) - fixed alias scoping + pub fn get_postgres_outer_sort_order(&self) -> String { + match self.sort_order.as_str() { + "date_asc" => "ORDER BY episodes.episodepubdate ASC".to_string(), + "date_desc" => "ORDER BY episodes.episodepubdate DESC".to_string(), + "duration_asc" => "ORDER BY episodes.episodeduration ASC".to_string(), + "duration_desc" => "ORDER BY episodes.episodeduration DESC".to_string(), + "listen_progress" => "ORDER BY (episodes.listenduration::float / NULLIF(episodes.episodeduration, 0)) DESC".to_string(), + "completion" => "ORDER BY (episodes.listenduration::float / NULLIF(episodes.episodeduration, 0)) DESC".to_string(), + _ => "ORDER BY episodes.episodepubdate DESC".to_string(), + } + } + + // Get MySQL sort order - matches Python sort_mapping exactly (for inner query) + pub fn get_mysql_sort_order(&self) -> String { + match self.sort_order.as_str() { + "date_asc" => "ORDER BY e.EpisodePubDate ASC".to_string(), + "date_desc" => "ORDER BY e.EpisodePubDate DESC".to_string(), + "duration_asc" => "ORDER BY e.EpisodeDuration ASC".to_string(), + "duration_desc" => "ORDER BY e.EpisodeDuration DESC".to_string(), + "listen_progress" => "ORDER BY (COALESCE(h.ListenDuration, 0) / NULLIF(e.EpisodeDuration, 0)) DESC".to_string(), + "completion" => "ORDER BY COALESCE(h.ListenDuration / NULLIF(e.EpisodeDuration, 0), 0) DESC".to_string(), + _ => "ORDER BY e.EpisodePubDate DESC".to_string(), + } + } + + // Get MySQL sort order for outer query (ROW_NUMBER OVER) - fixed alias scoping + pub fn get_mysql_outer_sort_order(&self) -> String { + match self.sort_order.as_str() { + "date_asc" => "ORDER BY episodes.EpisodePubDate ASC".to_string(), + "date_desc" => "ORDER BY episodes.EpisodePubDate DESC".to_string(), + "duration_asc" => "ORDER BY episodes.EpisodeDuration ASC".to_string(), + "duration_desc" => "ORDER BY episodes.EpisodeDuration DESC".to_string(), + "listen_progress" => "ORDER BY (episodes.ListenDuration / NULLIF(episodes.EpisodeDuration, 0)) DESC".to_string(), + "completion" => "ORDER BY (episodes.ListenDuration / NULLIF(episodes.EpisodeDuration, 0)) DESC".to_string(), + _ => "ORDER BY episodes.EpisodePubDate DESC".to_string(), + } + } + + // Check if this is an "Almost Done" playlist - matches Python logic + pub fn is_almost_done(&self) -> bool { + self.name == "Almost Done" || + (self.include_partially_played && + !self.include_unplayed && + !self.include_played && + self.play_progress_min.map_or(false, |min| min >= 75.0)) + } + + // Check if this is a "Currently Listening" playlist - matches Python logic + pub fn is_currently_listening(&self) -> bool { + self.name == "Currently Listening" || + (self.include_partially_played && + !self.include_unplayed && + !self.include_played && + self.play_progress_min.is_none() && + self.play_progress_max.is_none()) + } + + // Check if this is a "Fresh Releases" playlist - matches Python logic + pub fn is_fresh_releases(&self) -> bool { + self.name == "Fresh Releases" && self.is_system_playlist + } + + // Get effective time filter hours - Fresh Releases defaults to 24 if not set + pub fn get_effective_time_filter_hours(&self) -> Option { + if self.is_fresh_releases() && self.time_filter_hours.is_none() { + Some(24) // Default 24 hours for Fresh Releases + } else { + self.time_filter_hours + } + } +} + +impl DatabasePool { + // RSS key validation - matches Python get_rss_key_if_valid function + pub async fn get_rss_key_if_valid(&self, api_key: &str, podcast_ids: Option<&Vec>) -> AppResult> { + use crate::handlers::feed::RssKeyInfo; + + let filter_podcast_ids = podcast_ids.map_or(false, |ids| !ids.is_empty() && !ids.contains(&-1)); + + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT fk.userid, STRING_AGG(CAST(fkm.podcastid AS TEXT), ',') as podcastids + FROM "RssKeys" fk + LEFT JOIN "RssKeyMap" fkm ON fk.rsskeyid = fkm.rsskeyid + WHERE fk.rsskey = $1 + GROUP BY fk.userid + "#) + .bind(api_key) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let user_id: i32 = row.try_get("userid")?; + let podcast_ids_str: Option = row.try_get("podcastids").ok(); + + let key_podcast_ids = if let Some(ids_str) = podcast_ids_str { + ids_str.split(',') + .filter_map(|s| s.parse::().ok()) + .collect::>() + } else { + vec![-1] // Universal access if no specific podcasts + }; + + // Check if access is allowed + if filter_podcast_ids { + if let Some(requested_ids) = podcast_ids { + let has_universal = key_podcast_ids.contains(&-1); + let has_specific_access = requested_ids.iter() + .all(|id| key_podcast_ids.contains(id)); + + if !has_universal && !has_specific_access { + return Ok(None); + } + } + } + + Ok(Some(RssKeyInfo { + podcast_ids: key_podcast_ids, + user_id, + key: api_key.to_string(), + })) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query(r#" + SELECT fk.UserID, GROUP_CONCAT(fkm.PodcastID) as podcastids + FROM RssKeys fk + LEFT JOIN RssKeyMap fkm ON fk.RssKeyID = fkm.RssKeyID + WHERE fk.RssKey = ? + GROUP BY fk.UserID + "#) + .bind(api_key) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let user_id: i32 = row.try_get("UserID")?; + let podcast_ids_str: Option = row.try_get("podcastids").ok(); + + let key_podcast_ids = if let Some(ids_str) = podcast_ids_str { + ids_str.split(',') + .filter_map(|s| s.parse::().ok()) + .collect::>() + } else { + vec![-1] // Universal access if no specific podcasts + }; + + // Check if access is allowed + if filter_podcast_ids { + if let Some(requested_ids) = podcast_ids { + let has_universal = key_podcast_ids.contains(&-1); + let has_specific_access = requested_ids.iter() + .all(|id| key_podcast_ids.contains(id)); + + if !has_universal && !has_specific_access { + return Ok(None); + } + } + } + + Ok(Some(RssKeyInfo { + podcast_ids: key_podcast_ids, + user_id, + key: api_key.to_string(), + })) + } else { + Ok(None) + } + } + } + } + + // Get RSS feed status - matches Python get_rss_feed_status function + pub async fn get_rss_feed_status(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT enablerssfeeds FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let enabled: Option = row.try_get("enablerssfeeds").ok(); + Ok(enabled.unwrap_or(false)) + } else { + Ok(false) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT EnableRSSFeeds FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let enabled: Option = row.try_get("EnableRSSFeeds").ok(); + Ok(enabled.unwrap_or(false)) + } else { + Ok(false) + } + } + } + } + + // Generate podcast RSS feed - matches Python generate_podcast_rss function + pub async fn generate_podcast_rss( + &self, + rss_key: crate::handlers::feed::RssKeyInfo, + limit: i32, + source_type: Option<&str>, + domain: &str, + podcast_ids: Option<&Vec>, + ) -> AppResult { + + let user_id = rss_key.user_id; + let mut effective_podcast_ids = rss_key.podcast_ids.clone(); + + // If podcast_id parameter is provided, use it; otherwise use RSS key podcast_ids + let explicit_podcast_filter = podcast_ids.is_some(); + if let Some(ids) = podcast_ids { + if !ids.is_empty() { + effective_podcast_ids = ids.clone(); + } + } + + // Only use podcast filter if explicitly requested via URL parameter + // RSS key podcast_ids should not affect "All Podcasts" feed behavior + let podcast_filter = explicit_podcast_filter; + + // Check if RSS feeds are enabled for user + if !self.get_rss_feed_status(user_id).await? { + return Err(AppError::forbidden("RSS feeds not enabled for this user")); + } + + // Get user info for feed metadata + let username = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT username FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::("username").unwrap_or_else(|_| "Unknown User".to_string()) + } else { + return Err(AppError::not_found("User not found")); + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT Username FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::("Username").unwrap_or_else(|_| "Unknown User".to_string()) + } else { + return Err(AppError::not_found("User not found")); + } + } + }; + + // Get podcast details for feed metadata - exact Python logic + let (podcast_name, feed_image, feed_description) = if podcast_filter { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT podcastname, artworkurl, description FROM "Podcasts" WHERE podcastid = ANY($1)"#) + .bind(&effective_podcast_ids) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + ( + row.try_get::("podcastname").unwrap_or_else(|_| "Unknown Podcast".to_string()), + row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_else(|| format!("{}/static/assets/favicon.png", domain)), + row.try_get::("description").unwrap_or_else(|_| "No description available".to_string()), + ) + } else { + ("Unknown Podcast".to_string(), format!("{}/static/assets/favicon.png", domain), "No description available".to_string()) + } + } + DatabasePool::MySQL(pool) => { + if effective_podcast_ids.len() == 1 { + let row = sqlx::query("SELECT PodcastName, ArtworkURL, Description FROM Podcasts WHERE PodcastID = ?") + .bind(effective_podcast_ids[0]) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + ( + row.try_get::("PodcastName").unwrap_or_else(|_| "Unknown Podcast".to_string()), + row.try_get::, _>("ArtworkURL").unwrap_or_default().unwrap_or_else(|| format!("{}/static/assets/favicon.png", domain)), + row.try_get::("Description").unwrap_or_else(|_| "No description available".to_string()), + ) + } else { + ("Unknown Podcast".to_string(), format!("{}/static/assets/favicon.png", domain), "No description available".to_string()) + } + } else { + let placeholders = vec!["?"; effective_podcast_ids.len()].join(","); + let query_str = format!("SELECT PodcastName, ArtworkURL, Description FROM Podcasts WHERE PodcastID IN ({})", placeholders); + let mut query = sqlx::query(&query_str); + for &id in &effective_podcast_ids { + query = query.bind(id); + } + let row = query.fetch_optional(pool).await?; + + if let Some(row) = row { + ( + row.try_get::("PodcastName").unwrap_or_else(|_| "Unknown Podcast".to_string()), + row.try_get::, _>("ArtworkURL").unwrap_or_default().unwrap_or_else(|| format!("{}/static/assets/favicon.png", domain)), + row.try_get::("Description").unwrap_or_else(|_| "No description available".to_string()), + ) + } else { + ("Unknown Podcast".to_string(), format!("{}/static/assets/favicon.png", domain), "No description available".to_string()) + } + } + } + } + } else { + ("All Podcasts".to_string(), format!("{}/static/assets/favicon.png", domain), "RSS feed for all podcasts from Pinepods".to_string()) + }; + + // Build RSS feed using quick-xml for proper XML escaping + use quick_xml::events::{Event, BytesStart, BytesEnd, BytesText, BytesCData}; + use quick_xml::Writer; + use std::io::Cursor; + + let mut writer = Writer::new(Cursor::new(Vec::new())); + + // XML declaration + writer.write_event(Event::Decl(quick_xml::events::BytesDecl::new("1.0", Some("UTF-8"), None)))?; + + // RSS root element with namespace + let mut rss_elem = BytesStart::new("rss"); + rss_elem.push_attribute(("version", "2.0")); + rss_elem.push_attribute(("xmlns:itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd")); + writer.write_event(Event::Start(rss_elem))?; + + // Channel + writer.write_event(Event::Start(BytesStart::new("channel")))?; + + // Channel metadata + writer.write_event(Event::Start(BytesStart::new("title")))?; + writer.write_event(Event::Text(BytesText::new(&format!("Pinepods - {}", podcast_name))))?; + writer.write_event(Event::End(BytesEnd::new("title")))?; + + writer.write_event(Event::Start(BytesStart::new("link")))?; + writer.write_event(Event::Text(BytesText::new("https://github.com/madeofpendletonwool/pinepods")))?; + writer.write_event(Event::End(BytesEnd::new("link")))?; + + writer.write_event(Event::Start(BytesStart::new("description")))?; + writer.write_event(Event::Text(BytesText::new(&feed_description)))?; + writer.write_event(Event::End(BytesEnd::new("description")))?; + + writer.write_event(Event::Start(BytesStart::new("language")))?; + writer.write_event(Event::Text(BytesText::new("en")))?; + writer.write_event(Event::End(BytesEnd::new("language")))?; + + writer.write_event(Event::Start(BytesStart::new("itunes:author")))?; + writer.write_event(Event::Text(BytesText::new(&username)))?; + writer.write_event(Event::End(BytesEnd::new("itunes:author")))?; + + // iTunes image + let mut itunes_image = BytesStart::new("itunes:image"); + itunes_image.push_attribute(("href", feed_image.as_str())); + writer.write_event(Event::Empty(itunes_image))?; + + // RSS image block + writer.write_event(Event::Start(BytesStart::new("image")))?; + + writer.write_event(Event::Start(BytesStart::new("url")))?; + writer.write_event(Event::Text(BytesText::new(&feed_image)))?; + writer.write_event(Event::End(BytesEnd::new("url")))?; + + writer.write_event(Event::Start(BytesStart::new("title")))?; + writer.write_event(Event::Text(BytesText::new(&format!("Pinepods - {}", podcast_name))))?; + writer.write_event(Event::End(BytesEnd::new("title")))?; + + writer.write_event(Event::Start(BytesStart::new("link")))?; + writer.write_event(Event::Text(BytesText::new("https://github.com/madeofpendletonwool/pinepods")))?; + writer.write_event(Event::End(BytesEnd::new("link")))?; + + writer.write_event(Event::End(BytesEnd::new("image")))?; + + // TTL + writer.write_event(Event::Start(BytesStart::new("ttl")))?; + writer.write_event(Event::Text(BytesText::new("60")))?; + writer.write_event(Event::End(BytesEnd::new("ttl")))?; + + // Get or create RSS key for this user to use in stream URLs + let user_rss_key = self.get_or_create_user_rss_key(user_id).await?; + + // Get episodes (use the user's RSS key for stream URLs, not the requesting key) + let episodes = self.get_rss_episodes(user_id, limit, source_type, &effective_podcast_ids, podcast_filter, domain, &user_rss_key).await?; + + // Write episodes + for episode in episodes { + writer.write_event(Event::Start(BytesStart::new("item")))?; + + // Title (using CDATA for safety) + writer.write_event(Event::Start(BytesStart::new("title")))?; + writer.write_event(Event::CData(BytesCData::new(&episode.title)))?; + writer.write_event(Event::End(BytesEnd::new("title")))?; + + // Link (URL will be properly escaped) + writer.write_event(Event::Start(BytesStart::new("link")))?; + writer.write_event(Event::Text(BytesText::new(&episode.url)))?; + writer.write_event(Event::End(BytesEnd::new("link")))?; + + // Description (using CDATA for safety) + writer.write_event(Event::Start(BytesStart::new("description")))?; + writer.write_event(Event::CData(BytesCData::new(&episode.description)))?; + writer.write_event(Event::End(BytesEnd::new("description")))?; + + // GUID + writer.write_event(Event::Start(BytesStart::new("guid")))?; + writer.write_event(Event::Text(BytesText::new(&episode.url)))?; + writer.write_event(Event::End(BytesEnd::new("guid")))?; + + // Pub date + writer.write_event(Event::Start(BytesStart::new("pubDate")))?; + writer.write_event(Event::Text(BytesText::new(&episode.pub_date)))?; + writer.write_event(Event::End(BytesEnd::new("pubDate")))?; + + // Author (if present) + if let Some(ref author) = episode.author { + writer.write_event(Event::Start(BytesStart::new("itunes:author")))?; + writer.write_event(Event::Text(BytesText::new(author)))?; + writer.write_event(Event::End(BytesEnd::new("itunes:author")))?; + } + + // Artwork (if present) + if let Some(ref artwork_url) = episode.artwork_url { + let mut itunes_img = BytesStart::new("itunes:image"); + itunes_img.push_attribute(("href", artwork_url.as_str())); + writer.write_event(Event::Empty(itunes_img))?; + } + + // Duration (iTunes format: HH:MM:SS or MM:SS) + if let Some(duration_seconds) = episode.duration { + let hours = duration_seconds / 3600; + let minutes = (duration_seconds % 3600) / 60; + let seconds = duration_seconds % 60; + + let duration_str = if hours > 0 { + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + } else { + format!("{:02}:{:02}", minutes, seconds) + }; + + writer.write_event(Event::Start(BytesStart::new("itunes:duration")))?; + writer.write_event(Event::Text(BytesText::new(&duration_str)))?; + writer.write_event(Event::End(BytesEnd::new("itunes:duration")))?; + } + + // Enclosure (length should be file size in bytes, using duration as placeholder) + let mut enclosure = BytesStart::new("enclosure"); + enclosure.push_attribute(("url", episode.url.as_str())); + enclosure.push_attribute(("length", episode.duration.unwrap_or(0).to_string().as_str())); + enclosure.push_attribute(("type", "audio/mpeg")); + writer.write_event(Event::Empty(enclosure))?; + + writer.write_event(Event::End(BytesEnd::new("item")))?; + } + + // Close channel and RSS + writer.write_event(Event::End(BytesEnd::new("channel")))?; + writer.write_event(Event::End(BytesEnd::new("rss")))?; + + // Convert to string + let result = writer.into_inner().into_inner(); + let rss_content = String::from_utf8(result) + .map_err(|e| AppError::internal(&format!("Failed to convert RSS to UTF-8: {}", e)))?; + + Ok(rss_content) + } + + // Get user RSS key - matches Python get_user_rss_key function + pub async fn get_user_rss_key(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT rsskey FROM "RssKeys" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("rsskey").ok()) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT RssKey FROM RssKeys WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("RssKey").ok()) + } else { + Ok(None) + } + } + } + } + + // Get or create RSS key for user - ensures user always has an RSS key for stream URLs + pub async fn get_or_create_user_rss_key(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Try to get existing RSS key + let existing_key = sqlx::query(r#"SELECT rsskey FROM "RssKeys" WHERE userid = $1 LIMIT 1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing_key { + let key: String = row.try_get("rsskey")?; + Ok(key) + } else { + // Create new RSS key + let new_key = uuid::Uuid::new_v4().to_string(); + sqlx::query(r#"INSERT INTO "RssKeys" (userid, rsskey) VALUES ($1, $2)"#) + .bind(user_id) + .bind(&new_key) + .execute(pool) + .await?; + Ok(new_key) + } + } + DatabasePool::MySQL(pool) => { + // Try to get existing RSS key + let existing_key = sqlx::query("SELECT RssKey FROM RssKeys WHERE UserID = ? LIMIT 1") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = existing_key { + let key: String = row.try_get("RssKey")?; + Ok(key) + } else { + // Create new RSS key + let new_key = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO RssKeys (UserID, RssKey) VALUES (?, ?)") + .bind(user_id) + .bind(&new_key) + .execute(pool) + .await?; + Ok(new_key) + } + } + } + } + + // Helper function to get RSS episodes + async fn get_rss_episodes( + &self, + user_id: i32, + limit: i32, + source_type: Option<&str>, + podcast_ids: &[i32], + podcast_filter: bool, + domain: &str, + api_key: &str, + ) -> AppResult> { + use chrono::{DateTime, Utc}; + + match self { + DatabasePool::Postgres(pool) => { + let mut base_query = r#" + SELECT + e.episodeid, + e.podcastid, + e.episodetitle, + e.episodedescription, + CASE WHEN de.episodeid IS NULL + THEN e.episodeurl + ELSE CONCAT(CAST($1 AS TEXT), '/api/data/stream/', e.episodeid, '?api_key=', CAST($2 AS TEXT), '&user_id=', pp.userid) + END as episodeurl, + e.episodeartwork, + e.episodepubdate, + e.episodeduration, + pp.podcastname, + pp.author, + pp.artworkurl, + pp.description as podcastdescription + FROM "Episodes" e + JOIN "Podcasts" pp ON e.podcastid = pp.podcastid + LEFT JOIN "DownloadedEpisodes" de ON e.episodeid = de.episodeid + WHERE pp.userid = $3 + "#.to_string(); + + let mut param_count = 3; + if podcast_filter && !podcast_ids.is_empty() { + param_count += 1; + base_query.push_str(&format!(" AND pp.podcastid = ANY(${})", param_count)); + } + + // Add YouTube union if needed (exact Python logic) + let add_youtube_union = source_type.is_none() || source_type == Some("youtube"); + if add_youtube_union { + base_query.push_str(r#" + UNION ALL + SELECT + y.videoid as episodeid, + y.podcastid, + y.videotitle as episodetitle, + y.videodescription as episodedescription, + CONCAT(CAST($1 AS TEXT), '/api/data/stream/', CAST(y.videoid AS TEXT), '?api_key=', CAST($2 AS TEXT), '&type=youtube&user_id=', pv.userid) as episodeurl, + y.thumbnailurl as episodeartwork, + y.publishedat as episodepubdate, + y.duration as episodeduration, + pv.podcastname, + pv.author, + pv.artworkurl, + pv.description as podcastdescription + FROM "YouTubeVideos" y + JOIN "Podcasts" pv on y.podcastid = pv.podcastid + WHERE pv.userid = $3 + "#); + + if podcast_filter && !podcast_ids.is_empty() { + base_query.push_str(&format!(" AND pv.podcastid = ANY(${})", param_count)); + } + } + + base_query.push_str(" ORDER BY episodepubdate DESC"); + if limit > 0 { + base_query.push_str(&format!(" LIMIT {}", limit)); + } + + // Execute query + let mut query = sqlx::query(&base_query) + .bind(domain) + .bind(api_key) + .bind(user_id); + + if podcast_filter && !podcast_ids.is_empty() { + query = query.bind(podcast_ids); + } + + let rows = query.fetch_all(pool).await?; + + let mut episodes = Vec::new(); + for row in rows { + let title: String = row.try_get("episodetitle").unwrap_or_else(|_| "Untitled Episode".to_string()); + let description: String = row.try_get("episodedescription").unwrap_or_else(|_| String::new()); + let url: String = row.try_get("episodeurl").unwrap_or_else(|_| String::new()); + let duration: Option = row.try_get("episodeduration").ok(); + let author: Option = row.try_get("author").ok(); + // Use episode-specific artwork if available, otherwise fall back to podcast artwork + let episode_artwork: Option = row.try_get("episodeartwork").ok(); + let podcast_artwork: Option = row.try_get("artworkurl").ok(); + let artwork_url = episode_artwork.filter(|url| !url.is_empty()).or(podcast_artwork); + + let pub_date = if let Ok(dt) = row.try_get::, _>("episodepubdate") { + dt.format("%a, %d %b %Y %H:%M:%S %z").to_string() + } else { + Utc::now().format("%a, %d %b %Y %H:%M:%S %z").to_string() + }; + + episodes.push(RssEpisode { + title, + description, + url, + pub_date, + duration, + author, + artwork_url, + }); + } + + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let mut base_query = r#" + SELECT + e.EpisodeID, + e.PodcastID, + e.EpisodeTitle COLLATE utf8mb4_unicode_ci as EpisodeTitle, + e.EpisodeDescription COLLATE utf8mb4_unicode_ci as EpisodeDescription, + CASE WHEN de.EpisodeID IS NULL + THEN e.EpisodeURL COLLATE utf8mb4_unicode_ci + ELSE CONCAT(CAST(? AS CHAR), '/api/data/stream/', CAST(e.EpisodeID AS CHAR), '?api_key=', CAST(? AS CHAR), '&user_id=', pp.UserID) + END COLLATE utf8mb4_unicode_ci as EpisodeURL, + e.EpisodeArtwork COLLATE utf8mb4_unicode_ci as EpisodeArtwork, + e.EpisodePubDate, + e.EpisodeDuration, + pp.PodcastName COLLATE utf8mb4_unicode_ci as PodcastName, + pp.Author COLLATE utf8mb4_unicode_ci as Author, + pp.ArtworkURL COLLATE utf8mb4_unicode_ci as ArtworkURL, + pp.Description COLLATE utf8mb4_unicode_ci as PodcastDescription + FROM Episodes e + JOIN Podcasts pp ON e.PodcastID = pp.PodcastID + LEFT JOIN DownloadedEpisodes de ON e.EpisodeID = de.EpisodeID + WHERE pp.UserID = ? + "#.to_string(); + + if podcast_filter && !podcast_ids.is_empty() { + let placeholders = vec!["?"; podcast_ids.len()].join(","); + base_query.push_str(&format!(" AND pp.PodcastID IN ({})", placeholders)); + } + + // Add YouTube union if needed + let add_youtube_union = source_type.is_none() || source_type == Some("youtube"); + if add_youtube_union { + base_query.push_str(r#" + UNION ALL + SELECT + y.VideoID as EpisodeID, + y.PodcastID as PodcastID, + y.VideoTitle COLLATE utf8mb4_unicode_ci as EpisodeTitle, + y.VideoDescription COLLATE utf8mb4_unicode_ci as EpisodeDescription, + CONCAT(CAST(? AS CHAR), '/api/data/stream/', CAST(y.VideoID AS CHAR), '?api_key=', CAST(? AS CHAR), '&type=youtube&user_id=', pv.UserID) COLLATE utf8mb4_unicode_ci as EpisodeURL, + y.ThumbnailURL COLLATE utf8mb4_unicode_ci as EpisodeArtwork, + y.PublishedAt as EpisodePubDate, + y.Duration as EpisodeDuration, + pv.PodcastName COLLATE utf8mb4_unicode_ci as PodcastName, + pv.Author COLLATE utf8mb4_unicode_ci as Author, + pv.ArtworkURL COLLATE utf8mb4_unicode_ci as ArtworkURL, + pv.Description COLLATE utf8mb4_unicode_ci as PodcastDescription + FROM YouTubeVideos y + JOIN Podcasts pv on y.PodcastID = pv.PodcastID + WHERE pv.UserID = ? + "#); + + if podcast_filter && !podcast_ids.is_empty() { + let placeholders = vec!["?"; podcast_ids.len()].join(","); + base_query.push_str(&format!(" AND pv.PodcastID IN ({})", placeholders)); + } + } + + base_query.push_str(" ORDER BY EpisodePubDate DESC"); + if limit > 0 { + base_query.push_str(&format!(" LIMIT {}", limit)); + } + + // Build query with parameters + let mut query = sqlx::query(&base_query) + .bind(domain) + .bind(api_key) + .bind(user_id); + + if podcast_filter && !podcast_ids.is_empty() { + for &id in podcast_ids { + query = query.bind(id); + } + } + + if add_youtube_union { + query = query.bind(domain).bind(api_key).bind(user_id); + if podcast_filter && !podcast_ids.is_empty() { + for &id in podcast_ids { + query = query.bind(id); + } + } + } + + let rows = query.fetch_all(pool).await?; + + let mut episodes = Vec::new(); + for row in rows { + let title: String = row.try_get("EpisodeTitle").unwrap_or_else(|_| "Untitled Episode".to_string()); + let description: String = row.try_get("EpisodeDescription").unwrap_or_else(|_| String::new()); + let url: String = row.try_get("EpisodeURL").unwrap_or_else(|_| String::new()); + let duration: Option = row.try_get("EpisodeDuration").ok(); + let author: Option = row.try_get("Author").ok(); + // Use episode-specific artwork if available, otherwise fall back to podcast artwork + let episode_artwork: Option = row.try_get("EpisodeArtwork").ok(); + let podcast_artwork: Option = row.try_get("ArtworkURL").ok(); + let artwork_url = episode_artwork.filter(|url| !url.is_empty()).or(podcast_artwork); + + let pub_date = if let Ok(dt) = row.try_get::, _>("EpisodePubDate") { + dt.format("%a, %d %b %Y %H:%M:%S %z").to_string() + } else { + Utc::now().format("%a, %d %b %Y %H:%M:%S %z").to_string() + }; + + episodes.push(RssEpisode { + title, + description, + url, + pub_date, + duration, + author, + artwork_url, + }); + } + + Ok(episodes) + } + } + } + + // Get podcast notification status - matches Python get_podcast_notification_status function + pub async fn get_podcast_notification_status(&self, podcast_id: i32, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT notificationsenabled + FROM "Podcasts" + WHERE podcastid = $1 AND userid = $2 + "#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("notificationsenabled").unwrap_or(false)) + } else { + Ok(false) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query(" + SELECT NotificationsEnabled + FROM Podcasts + WHERE PodcastID = ? AND UserID = ? + ") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let enabled: i8 = row.try_get("NotificationsEnabled").unwrap_or(0); + Ok(enabled != 0) + } else { + Ok(false) + } + } + } + } + + // Get MFA secret - matches Python get_mfa_secret function + pub async fn get_mfa_secret(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT mfa_secret FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("mfa_secret").ok()) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT MFA_Secret FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("MFA_Secret").ok()) + } else { + Ok(None) + } + } + } + } + + // Return YouTube episodes - matches Python return_youtube_episodes function exactly + pub async fn return_youtube_episodes( + &self, + user_id: i32, + podcast_id: i32, + ) -> AppResult>> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT "Podcasts".podcastid, "Podcasts".podcastname, "YouTubeVideos".videoid AS episodeid, + "YouTubeVideos".videotitle AS episodetitle, "YouTubeVideos".publishedat AS episodepubdate, + "YouTubeVideos".videodescription AS episodedescription, + "YouTubeVideos".thumbnailurl AS episodeartwork, "YouTubeVideos".videourl AS episodeurl, + "YouTubeVideos".duration AS episodeduration, + "YouTubeVideos".listenposition AS listenduration, + "YouTubeVideos".youtubevideoid AS guid + FROM "YouTubeVideos" + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + WHERE "Podcasts".podcastid = $1 AND "Podcasts".userid = $2 + ORDER BY "YouTubeVideos".publishedat DESC + "#) + .bind(podcast_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + if rows.is_empty() { + return Ok(None); + } + + let mut episodes = Vec::new(); + for row in rows { + let episode = serde_json::json!({ + "Podcastid": row.try_get::("podcastid").unwrap_or(0), + "Podcastname": row.try_get::("podcastname").unwrap_or_default(), + "Episodeid": row.try_get::("episodeid").unwrap_or(0), + "Episodetitle": row.try_get::("episodetitle").unwrap_or_default(), + "Episodepubdate": row.try_get::("episodepubdate") + .map(|dt| dt.and_utc().to_rfc3339()) + .unwrap_or_default(), + "Episodedescription": row.try_get::("episodedescription").unwrap_or_default(), + "Episodeartwork": row.try_get::("episodeartwork").unwrap_or_default(), + "Episodeurl": row.try_get::("episodeurl").unwrap_or_default(), + "Episodeduration": row.try_get::("episodeduration").unwrap_or(0), + "Listenduration": row.try_get::("listenduration").unwrap_or(0), + "Guid": row.try_get::("guid").unwrap_or_default() + }); + episodes.push(episode); + } + + Ok(Some(episodes)) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query(r#" + SELECT Podcasts.PodcastID, Podcasts.PodcastName, YouTubeVideos.VideoID AS EpisodeID, + YouTubeVideos.VideoTitle AS EpisodeTitle, YouTubeVideos.PublishedAt AS EpisodePubDate, + YouTubeVideos.VideoDescription AS EpisodeDescription, + YouTubeVideos.ThumbnailURL AS EpisodeArtwork, YouTubeVideos.VideoURL AS EpisodeURL, + YouTubeVideos.Duration AS EpisodeDuration, + YouTubeVideos.ListenPosition AS ListenDuration, + YouTubeVideos.YouTubeVideoID AS guid + FROM YouTubeVideos + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + WHERE Podcasts.PodcastID = ? AND Podcasts.UserID = ? + ORDER BY YouTubeVideos.PublishedAt DESC + "#) + .bind(podcast_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + if rows.is_empty() { + return Ok(None); + } + + let mut episodes = Vec::new(); + for row in rows { + let episode = serde_json::json!({ + "Podcastid": row.try_get::("PodcastID").unwrap_or(0), + "Podcastname": row.try_get::("PodcastName").unwrap_or_default(), + "Episodeid": row.try_get::("EpisodeID").unwrap_or(0), + "Episodetitle": row.try_get::("EpisodeTitle").unwrap_or_default(), + "Episodepubdate": row.try_get::("EpisodePubDate") + .map(|dt| dt.and_utc().to_rfc3339()) + .unwrap_or_default(), + "Episodedescription": row.try_get::("EpisodeDescription").unwrap_or_default(), + "Episodeartwork": row.try_get::("EpisodeArtwork").unwrap_or_default(), + "Episodeurl": row.try_get::("EpisodeURL").unwrap_or_default(), + "Episodeduration": row.try_get::("EpisodeDuration").unwrap_or(0), + "Listenduration": row.try_get::("ListenDuration").unwrap_or(0), + "Guid": row.try_get::("guid").unwrap_or_default() + }); + episodes.push(episode); + } + + Ok(Some(episodes)) + } + } + } + + // Remove YouTube channel by URL - matches Python remove_youtube_channel_by_url function exactly + pub async fn remove_youtube_channel_by_url( + &self, + channel_name: &str, + channel_url: &str, + user_id: i32, + ) -> AppResult<()> { + println!("got to remove youtube channel"); + + // Get the PodcastID first + let podcast_id = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT podcastid + FROM "Podcasts" + WHERE podcastname = $1 + AND feedurl = $2 + AND userid = $3 + AND isyoutubechannel = TRUE + "#) + .bind(channel_name) + .bind(channel_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::("podcastid")? + } else { + return Err(AppError::external_error(&format!("No YouTube channel found with name {}", channel_name))); + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query(r#" + SELECT PodcastID + FROM Podcasts + WHERE PodcastName = ? + AND FeedURL = ? + AND UserID = ? + AND IsYouTubeChannel = TRUE + "#) + .bind(channel_name) + .bind(channel_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + row.try_get::("PodcastID")? + } else { + return Err(AppError::external_error(&format!("No YouTube channel found with name {}", channel_name))); + } + } + }; + + // Remove the channel by ID + self.remove_youtube_channel_by_id(podcast_id, user_id).await + } + + // Remove YouTube channel by ID - matches Python remove_youtube_channel function exactly + pub async fn remove_youtube_channel_by_id( + &self, + podcast_id: i32, + user_id: i32, + ) -> AppResult<()> { + // First, get all video IDs for the podcast so we can delete the files + let video_ids: Vec = match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT youtubevideoid FROM "YouTubeVideos" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| row.try_get::("youtubevideoid").unwrap_or_default()) + .collect() + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT YouTubeVideoID FROM YouTubeVideos WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| row.try_get::("YouTubeVideoID").unwrap_or_default()) + .collect() + } + }; + + // Delete the MP3 files for each video + for video_id in &video_ids { + let file_paths = vec![ + format!("/opt/pinepods/downloads/youtube/{}.mp3", video_id), + format!("/opt/pinepods/downloads/youtube/{}.mp3.mp3", video_id), // In case of double extension + ]; + + for file_path in file_paths { + if tokio::fs::metadata(&file_path).await.is_ok() { + match tokio::fs::remove_file(&file_path).await { + Ok(_) => println!("Deleted file: {}", file_path), + Err(e) => println!("Failed to delete file {}: {}", file_path, e), + } + } + } + } + + // Delete from the related tables in the correct order + match self { + DatabasePool::Postgres(pool) => { + let delete_queries = vec![ + r#"DELETE FROM "PlaylistContents" WHERE episodeid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1)"#, + r#"DELETE FROM "UserEpisodeHistory" WHERE episodeid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1)"#, + r#"DELETE FROM "UserVideoHistory" WHERE videoid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1)"#, + r#"DELETE FROM "DownloadedEpisodes" WHERE episodeid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1)"#, + r#"DELETE FROM "DownloadedVideos" WHERE videoid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1)"#, + r#"DELETE FROM "SavedVideos" WHERE videoid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1)"#, + r#"DELETE FROM "SavedEpisodes" WHERE episodeid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1)"#, + r#"DELETE FROM "EpisodeQueue" WHERE episodeid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = $1)"#, + r#"DELETE FROM "YouTubeVideos" WHERE podcastid = $1"#, + r#"DELETE FROM "Podcasts" WHERE podcastid = $1 AND isyoutubechannel = TRUE"#, + ]; + + for query in delete_queries { + sqlx::query(query) + .bind(podcast_id) + .execute(pool) + .await?; + } + + // Update user stats + sqlx::query(r#"UPDATE "UserStats" SET podcastsadded = podcastsadded - 1 WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + let delete_queries = vec![ + "DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ?)", + "DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ?)", + "DELETE FROM UserVideoHistory WHERE VideoID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ?)", + "DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ?)", + "DELETE FROM DownloadedVideos WHERE VideoID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ?)", + "DELETE FROM SavedVideos WHERE VideoID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ?)", + "DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ?)", + "DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = ?)", + "DELETE FROM YouTubeVideos WHERE PodcastID = ?", + "DELETE FROM Podcasts WHERE PodcastID = ? AND IsYouTubeChannel = TRUE", + ]; + + for query in delete_queries { + sqlx::query(query) + .bind(podcast_id) + .execute(pool) + .await?; + } + + // Update user stats + sqlx::query("UPDATE UserStats SET PodcastsAdded = PodcastsAdded - 1 WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } + + Ok(()) + } + + // Get podcast ID by feed URL and title - for get_podcast_details_dynamic + pub async fn get_podcast_id_by_feed(&self, user_id: i32, feed_url: &str, _podcast_title: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT podcastid FROM "Podcasts" WHERE feedurl = $1 AND userid = $2"# + ) + .bind(feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("podcastid")?) + } else { + Err(AppError::not_found("Podcast not found")) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT PodcastID FROM Podcasts WHERE FeedURL = ? AND UserID = ?" + ) + .bind(feed_url) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("PodcastID")?) + } else { + Err(AppError::not_found("Podcast not found")) + } + } + } + } + + // Get raw podcast details - returns all fields as JSON for get_podcast_details_dynamic + pub async fn get_podcast_details_raw(&self, user_id: i32, podcast_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT * FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"# + ) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let mut details = serde_json::Map::new(); + + details.insert("podcastname".to_string(), serde_json::Value::String(row.try_get::("podcastname").unwrap_or_default())); + details.insert("feedurl".to_string(), serde_json::Value::String(row.try_get::("feedurl").unwrap_or_default())); + details.insert("description".to_string(), serde_json::Value::String(row.try_get::("description").unwrap_or_default())); + details.insert("author".to_string(), serde_json::Value::String(row.try_get::("author").unwrap_or_default())); + details.insert("artworkurl".to_string(), serde_json::Value::String(row.try_get::, _>("artworkurl").unwrap_or_default().unwrap_or_default())); + details.insert("explicit".to_string(), serde_json::Value::Bool(row.try_get::("explicit").unwrap_or(false))); + details.insert("episodecount".to_string(), serde_json::Value::Number(serde_json::Number::from(row.try_get::("episodecount").unwrap_or(0)))); + let categories_str = row.try_get::("categories").unwrap_or_default(); + let categories_parsed = self.parse_categories_json(&categories_str).unwrap_or_default(); + details.insert("categories".to_string(), serde_json::to_value(categories_parsed).unwrap_or(serde_json::Value::Object(serde_json::Map::new()))); + details.insert("websiteurl".to_string(), serde_json::Value::String(row.try_get::("websiteurl").unwrap_or_default())); + details.insert("podcastindexid".to_string(), serde_json::Value::Number(serde_json::Number::from(row.try_get::("podcastindexid").unwrap_or(0)))); + details.insert("isyoutubechannel".to_string(), serde_json::Value::Bool(row.try_get::("isyoutubechannel").unwrap_or(false))); + + Ok(Some(serde_json::Value::Object(details))) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT * FROM Podcasts WHERE PodcastID = ? AND UserID = ?" + ) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let mut details = serde_json::Map::new(); + + details.insert("podcastname".to_string(), serde_json::Value::String(row.try_get::("PodcastName").unwrap_or_default())); + details.insert("feedurl".to_string(), serde_json::Value::String(row.try_get::("FeedURL").unwrap_or_default())); + details.insert("description".to_string(), serde_json::Value::String(row.try_get::("Description").unwrap_or_default())); + details.insert("author".to_string(), serde_json::Value::String(row.try_get::("Author").unwrap_or_default())); + details.insert("artworkurl".to_string(), serde_json::Value::String(row.try_get::, _>("ArtworkURL").unwrap_or_default().unwrap_or_default())); + details.insert("explicit".to_string(), serde_json::Value::Bool(row.try_get::("Explicit").unwrap_or(false))); + details.insert("episodecount".to_string(), serde_json::Value::Number(serde_json::Number::from(row.try_get::("EpisodeCount").unwrap_or(0)))); + let categories_str = row.try_get::("Categories").unwrap_or_default(); + let categories_parsed = self.parse_categories_json(&categories_str).unwrap_or_default(); + details.insert("categories".to_string(), serde_json::to_value(categories_parsed).unwrap_or(serde_json::Value::Object(serde_json::Map::new()))); + details.insert("websiteurl".to_string(), serde_json::Value::String(row.try_get::("WebsiteURL").unwrap_or_default())); + details.insert("podcastindexid".to_string(), serde_json::Value::Number(serde_json::Number::from(row.try_get::("PodcastIndexID").unwrap_or(0)))); + details.insert("isyoutubechannel".to_string(), serde_json::Value::Bool(row.try_get::("IsYouTubeChannel").unwrap_or(false))); + + Ok(Some(serde_json::Value::Object(details))) + } else { + Ok(None) + } + } + } + } + + // Get podcast values from feed - for get_podcast_details_dynamic when podcast is not added + pub async fn get_podcast_values_from_feed(&self, feed_url: &str, user_id: i32, _display_only: bool) -> AppResult { + // Use the real get_podcast_values function that exists in the codebase + let podcast_values = self.get_podcast_values(feed_url, user_id, None, None).await?; + + // Convert HashMap to the expected JSON format for get_podcast_details_dynamic + let response = serde_json::json!({ + "pod_title": podcast_values.get("podcastname").unwrap_or(&"Unknown Podcast".to_string()), + "pod_feed_url": feed_url, + "pod_description": podcast_values.get("description").unwrap_or(&"".to_string()), + "pod_author": podcast_values.get("author").unwrap_or(&"Unknown Author".to_string()), + "pod_artwork": podcast_values.get("artworkurl").unwrap_or(&"/static/assets/default-podcast.png".to_string()), + "pod_explicit": podcast_values.get("explicit").unwrap_or(&"False".to_string()) == "True", + "pod_episode_count": podcast_values.get("episodecount").unwrap_or(&"0".to_string()).parse::().unwrap_or(0), + "categories": podcast_values.get("categories").unwrap_or(&"{}".to_string()), + "pod_website": podcast_values.get("websiteurl").unwrap_or(&"".to_string()), + }); + + Ok(response) + } + + // Update feed cutoff days - for update_feed_cutoff_days endpoint + pub async fn update_feed_cutoff_days(&self, podcast_id: i32, user_id: i32, feed_cutoff_days: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // First verify podcast exists and belongs to user + let existing = sqlx::query(r#"SELECT podcastid FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if existing.is_none() { + return Ok(false); + } + + // Update the feed cutoff days + let result = sqlx::query(r#"UPDATE "Podcasts" SET feedcutoffdays = $1 WHERE podcastid = $2 AND userid = $3"#) + .bind(feed_cutoff_days) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + // First verify podcast exists and belongs to user + let existing = sqlx::query("SELECT PodcastID FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if existing.is_none() { + return Ok(false); + } + + // Update the feed cutoff days + let result = sqlx::query("UPDATE Podcasts SET FeedCutoffDays = ? WHERE PodcastID = ? AND UserID = ?") + .bind(feed_cutoff_days) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Update podcast basic info - for edit podcast functionality + pub async fn update_podcast_info(&self, podcast_id: i32, user_id: i32, feed_url: Option, username: Option, password: Option, podcast_name: Option, description: Option, author: Option, artwork_url: Option, website_url: Option, podcast_index_id: Option) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // First verify podcast exists and belongs to user + let existing = sqlx::query(r#"SELECT podcastid FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + if existing.is_none() { + return Ok(false); + } + + // Build dynamic update query based on provided fields + let mut update_parts = Vec::new(); + let mut bind_count = 1; + + if feed_url.is_some() { + update_parts.push(format!("feedurl = ${}", bind_count)); + bind_count += 1; + } + if username.is_some() { + update_parts.push(format!("username = ${}", bind_count)); + bind_count += 1; + } + if password.is_some() { + update_parts.push(format!("password = ${}", bind_count)); + bind_count += 1; + } + if podcast_name.is_some() { + update_parts.push(format!("podcastname = ${}", bind_count)); + bind_count += 1; + } + if description.is_some() { + update_parts.push(format!("description = ${}", bind_count)); + bind_count += 1; + } + if author.is_some() { + update_parts.push(format!("author = ${}", bind_count)); + bind_count += 1; + } + if artwork_url.is_some() { + update_parts.push(format!("artworkurl = ${}", bind_count)); + bind_count += 1; + } + if website_url.is_some() { + update_parts.push(format!("websiteurl = ${}", bind_count)); + bind_count += 1; + } + if podcast_index_id.is_some() { + update_parts.push(format!("podcastindexid = ${}", bind_count)); + bind_count += 1; + } + + if update_parts.is_empty() { + return Ok(false); + } + + let query_str = format!( + r#"UPDATE "Podcasts" SET {} WHERE podcastid = ${} AND userid = ${}"#, + update_parts.join(", "), + bind_count, + bind_count + 1 + ); + + let mut query = sqlx::query(&query_str); + + if let Some(url) = feed_url { + query = query.bind(url); + } + if let Some(uname) = username { + query = query.bind(uname); + } + if let Some(pwd) = password { + query = query.bind(pwd); + } + if let Some(name) = podcast_name { + query = query.bind(name); + } + if let Some(desc) = description { + query = query.bind(desc); + } + if let Some(auth) = author { + query = query.bind(auth); + } + if let Some(artwork) = artwork_url { + query = query.bind(artwork); + } + if let Some(website) = website_url { + query = query.bind(website); + } + if let Some(idx_id) = podcast_index_id { + query = query.bind(idx_id); + } + + query = query.bind(podcast_id).bind(user_id); + + let result = query.execute(pool).await?; + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + // First verify podcast exists and belongs to user + let existing = sqlx::query("SELECT PodcastID FROM Podcasts WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + if existing.is_none() { + return Ok(false); + } + + // Build dynamic update query based on provided fields + let mut update_parts = Vec::new(); + + if feed_url.is_some() { + update_parts.push("FeedURL = ?"); + } + if username.is_some() { + update_parts.push("Username = ?"); + } + if password.is_some() { + update_parts.push("Password = ?"); + } + if podcast_name.is_some() { + update_parts.push("PodcastName = ?"); + } + if description.is_some() { + update_parts.push("Description = ?"); + } + if author.is_some() { + update_parts.push("Author = ?"); + } + if artwork_url.is_some() { + update_parts.push("ArtworkURL = ?"); + } + if website_url.is_some() { + update_parts.push("WebsiteURL = ?"); + } + if podcast_index_id.is_some() { + update_parts.push("PodcastIndexID = ?"); + } + + if update_parts.is_empty() { + return Ok(false); + } + + let query_str = format!( + "UPDATE Podcasts SET {} WHERE PodcastID = ? AND UserID = ?", + update_parts.join(", ") + ); + + let mut query = sqlx::query(&query_str); + + if let Some(url) = feed_url { + query = query.bind(url); + } + if let Some(uname) = username { + query = query.bind(uname); + } + if let Some(pwd) = password { + query = query.bind(pwd); + } + if let Some(name) = podcast_name { + query = query.bind(name); + } + if let Some(desc) = description { + query = query.bind(desc); + } + if let Some(auth) = author { + query = query.bind(auth); + } + if let Some(artwork) = artwork_url { + query = query.bind(artwork); + } + if let Some(website) = website_url { + query = query.bind(website); + } + if let Some(idx_id) = podcast_index_id { + query = query.bind(idx_id); + } + + query = query.bind(podcast_id).bind(user_id); + + let result = query.execute(pool).await?; + Ok(result.rows_affected() > 0) + } + } + } + + // Bulk episode operations for efficient batch processing + pub async fn bulk_mark_episodes_completed(&self, episode_ids: Vec, user_id: i32, is_youtube: bool) -> AppResult<(i32, i32)> { + if episode_ids.is_empty() { + return Ok((0, 0)); + } + + let mut processed = 0; + let mut failed = 0; + + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + if is_youtube { + for episode_id in episode_ids { + match self.mark_episode_completed(episode_id, user_id, is_youtube).await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } else { + // Batch update regular episodes + let episode_ids_str: Vec = episode_ids.iter().map(|id| id.to_string()).collect(); + let ids_clause = episode_ids_str.join(","); + + let query = format!( + r#"UPDATE "Episodes" SET completed = TRUE WHERE episodeid IN ({})"#, + ids_clause + ); + + let result = sqlx::query(&query).execute(&mut *tx).await?; + processed = result.rows_affected() as i32; + } + + tx.commit().await?; + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + if is_youtube { + for episode_id in episode_ids { + match self.mark_episode_completed(episode_id, user_id, is_youtube).await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } else { + // Batch update regular episodes + let episode_ids_str: Vec = episode_ids.iter().map(|id| id.to_string()).collect(); + let ids_clause = episode_ids_str.join(","); + + let query = format!( + "UPDATE Episodes SET Completed = TRUE WHERE EpisodeID IN ({})", + ids_clause + ); + + let result = sqlx::query(&query).execute(&mut *tx).await?; + processed = result.rows_affected() as i32; + } + + tx.commit().await?; + } + } + + Ok((processed, failed)) + } + + pub async fn bulk_save_episodes(&self, episode_ids: Vec, user_id: i32, is_youtube: bool) -> AppResult<(i32, i32)> { + if episode_ids.is_empty() { + return Ok((0, 0)); + } + + let mut processed = 0; + let mut failed = 0; + + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + if is_youtube { + for episode_id in episode_ids { + // Check if already saved to avoid duplicates + let existing = sqlx::query( + r#"SELECT "SaveID" FROM "SavedVideos" WHERE "VideoID" = $1 AND "UserID" = $2"# + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_none() { + match sqlx::query( + r#"INSERT INTO "SavedVideos" ("VideoID", "UserID") VALUES ($1, $2)"# + ) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } + } else { + for episode_id in episode_ids { + // Check if already saved to avoid duplicates + let existing = sqlx::query( + r#"SELECT saveid FROM "SavedEpisodes" WHERE episodeid = $1 AND userid = $2"# + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_none() { + match sqlx::query( + r#"INSERT INTO "SavedEpisodes" (episodeid, userid) VALUES ($1, $2)"# + ) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } + } + + tx.commit().await?; + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + if is_youtube { + for episode_id in episode_ids { + let existing = sqlx::query( + "SELECT SaveID FROM SavedVideos WHERE VideoID = ? AND UserID = ?" + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_none() { + match sqlx::query( + "INSERT INTO SavedVideos (VideoID, UserID) VALUES (?, ?)" + ) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } + } else { + for episode_id in episode_ids { + let existing = sqlx::query( + "SELECT SaveID FROM SavedEpisodes WHERE EpisodeID = ? AND UserID = ?" + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_none() { + match sqlx::query( + "INSERT INTO SavedEpisodes (EpisodeID, UserID) VALUES (?, ?)" + ) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } + } + + tx.commit().await?; + } + } + + Ok((processed, failed)) + } + + pub async fn bulk_queue_episodes(&self, episode_ids: Vec, user_id: i32, is_youtube: bool) -> AppResult<(i32, i32)> { + if episode_ids.is_empty() { + return Ok((0, 0)); + } + + let mut processed = 0; + let mut failed = 0; + + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + if is_youtube { + for episode_id in episode_ids { + // Check if already queued to avoid duplicates + let existing = sqlx::query( + r#"SELECT "QueueID" FROM "QueuedVideos" WHERE "VideoID" = $1 AND "UserID" = $2"# + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_none() { + match sqlx::query( + r#"INSERT INTO "QueuedVideos" ("VideoID", "UserID") VALUES ($1, $2)"# + ) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } + } else { + // Get max queue position for user + let max_pos_row = sqlx::query( + r#"SELECT COALESCE(MAX(queueposition), 0) as max_pos FROM "EpisodeQueue" WHERE userid = $1"# + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + + let mut max_pos: i32 = max_pos_row.try_get("max_pos")?; + + for episode_id in episode_ids { + // Check if already queued to avoid duplicates + let existing = sqlx::query( + r#"SELECT queueid FROM "EpisodeQueue" WHERE episodeid = $1 AND userid = $2 AND is_youtube = $3"# + ) + .bind(episode_id) + .bind(user_id) + .bind(is_youtube) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_none() { + max_pos += 1; + match sqlx::query( + r#"INSERT INTO "EpisodeQueue" (episodeid, userid, queueposition, is_youtube) VALUES ($1, $2, $3, $4)"# + ) + .bind(episode_id) + .bind(user_id) + .bind(max_pos) + .bind(is_youtube) + .execute(&mut *tx) + .await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } + } + + tx.commit().await?; + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + if is_youtube { + for episode_id in episode_ids { + let existing = sqlx::query( + "SELECT QueueID FROM QueuedVideos WHERE VideoID = ? AND UserID = ?" + ) + .bind(episode_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_none() { + match sqlx::query( + "INSERT INTO QueuedVideos (VideoID, UserID) VALUES (?, ?)" + ) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } + } else { + // Get max queue position for user + let max_pos_row = sqlx::query( + "SELECT COALESCE(MAX(QueuePosition), 0) as max_pos FROM EpisodeQueue WHERE UserID = ?" + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + + let mut max_pos: i32 = max_pos_row.try_get("max_pos")?; + + for episode_id in episode_ids { + let existing = sqlx::query( + "SELECT QueueID FROM EpisodeQueue WHERE EpisodeID = ? AND UserID = ? AND is_youtube = ?" + ) + .bind(episode_id) + .bind(user_id) + .bind(is_youtube) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_none() { + max_pos += 1; + match sqlx::query( + "INSERT INTO EpisodeQueue (EpisodeID, UserID, QueuePosition, is_youtube) VALUES (?, ?, ?, ?)" + ) + .bind(episode_id) + .bind(user_id) + .bind(max_pos) + .bind(is_youtube) + .execute(&mut *tx) + .await { + Ok(_) => processed += 1, + Err(_) => failed += 1, + } + } + } + } + + tx.commit().await?; + } + } + + Ok((processed, failed)) + } + + // Bulk delete downloaded episodes - efficient batch processing for mass deletion + pub async fn bulk_delete_downloaded_episodes(&self, episode_ids: Vec, user_id: i32, is_youtube: bool) -> AppResult<(i32, i32)> { + if episode_ids.is_empty() { + return Ok((0, 0)); + } + + let mut processed = 0; + let mut failed = 0; + + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + if is_youtube { + // Delete YouTube videos from DownloadedEpisodes (they use the same table but different logic) + for episode_id in episode_ids { + match sqlx::query( + r#"DELETE FROM "DownloadedEpisodes" WHERE episodeid = $1 AND userid = $2"# + ) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await { + Ok(result) => { + if result.rows_affected() > 0 { + processed += 1; + } else { + failed += 1; // Episode wasn't downloaded by this user + } + }, + Err(_) => failed += 1, + } + } + } else { + // Batch delete regular episodes using IN clause for efficiency + let episode_ids_str: Vec = episode_ids.iter().map(|id| id.to_string()).collect(); + let ids_clause = episode_ids_str.join(","); + + let query = format!( + r#"DELETE FROM "DownloadedEpisodes" WHERE episodeid IN ({}) AND userid = $1"#, + ids_clause + ); + + let result = sqlx::query(&query) + .bind(user_id) + .execute(&mut *tx) + .await?; + processed = result.rows_affected() as i32; + failed = episode_ids.len() as i32 - processed; // Assume failures are episodes not found + } + + tx.commit().await?; + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + if is_youtube { + // Delete YouTube videos from DownloadedEpisodes + for episode_id in episode_ids { + match sqlx::query( + "DELETE FROM DownloadedEpisodes WHERE EpisodeID = ? AND UserID = ?" + ) + .bind(episode_id) + .bind(user_id) + .execute(&mut *tx) + .await { + Ok(result) => { + if result.rows_affected() > 0 { + processed += 1; + } else { + failed += 1; // Episode wasn't downloaded by this user + } + }, + Err(_) => failed += 1, + } + } + } else { + // Batch delete regular episodes using IN clause for efficiency + let episode_ids_str: Vec = episode_ids.iter().map(|id| id.to_string()).collect(); + let ids_clause = episode_ids_str.join(","); + + let query = format!( + "DELETE FROM DownloadedEpisodes WHERE EpisodeID IN ({}) AND UserID = ?", + ids_clause + ); + + let result = sqlx::query(&query) + .bind(user_id) + .execute(&mut *tx) + .await?; + processed = result.rows_affected() as i32; + failed = episode_ids.len() as i32 - processed; // Assume failures are episodes not found + } + + tx.commit().await?; + } + } + + Ok((processed, failed)) + } + + // Set up internal gpodder sync - matches Python set_gpodder_internal_sync function exactly + pub async fn set_gpodder_internal_sync(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // Get the username and current sync type + let user_row = sqlx::query(r#"SELECT username, pod_sync_type FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + let (username, current_sync_type) = if let Some(row) = user_row { + let username: String = row.try_get("username")?; + let sync_type: Option = row.try_get("pod_sync_type")?; + (username, sync_type.unwrap_or_else(|| "None".to_string())) + } else { + return Err(AppError::not_found("User not found")); + }; + + // Generate new sync type based on current + let new_sync_type = match current_sync_type.as_str() { + "external" => "both", + "None" | "" => "gpodder", + _ => ¤t_sync_type, + }; + + // Generate a secure internal token (64 characters alphanumeric) + use rand::{distr::Alphanumeric, Rng}; + let internal_token: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + + let local_gpodder_url = "http://localhost:8042"; + + // Create default device name + let default_device_name = format!("pinepods-internal-{}", user_id); + + // Update user with internal gpodder settings and set default device + sqlx::query(r#" + UPDATE "Users" + SET gpodderurl = $1, gpoddertoken = $2, gpodderloginname = $3, pod_sync_type = $4, defaultgpodderdevice = $6 + WHERE userid = $5 + "#) + .bind(local_gpodder_url) + .bind(&internal_token) + .bind(&username) + .bind(new_sync_type) + .bind(user_id) + .bind(&default_device_name) + .execute(pool) + .await?; + + // Create device via gPodder API (matches Python version exactly) + let device_result = match self.create_device_via_gpodder_api(local_gpodder_url, &username, &internal_token, &default_device_name).await { + Ok(device_id) => { + serde_json::json!({ + "device_name": default_device_name, + "device_id": device_id, + "success": true + }) + } + Err(e) => { + tracing::warn!("Failed to create device via API: {}, continuing anyway", e); + // Even if device creation fails, still return success (matches Python behavior) + serde_json::json!({ + "device_name": default_device_name, + "device_id": user_id, + "success": true + }) + } + }; + + // Perform initial full sync to get ALL user subscriptions from all devices + if let Err(e) = self.call_gpodder_initial_full_sync(user_id, local_gpodder_url, &username, &internal_token, &default_device_name).await { + tracing::warn!("Initial GPodder full sync failed during setup: {}", e); + // Don't fail setup if initial sync fails + } + + Ok(device_result) + } + DatabasePool::MySQL(pool) => { + // Get the username and current sync type + let user_row = sqlx::query("SELECT Username, Pod_Sync_Type FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + let (username, current_sync_type) = if let Some(row) = user_row { + let username: String = row.try_get("Username")?; + let sync_type: Option = row.try_get("Pod_Sync_Type")?; + (username, sync_type.unwrap_or_else(|| "None".to_string())) + } else { + return Err(AppError::not_found("User not found")); + }; + + // Generate new sync type based on current + let new_sync_type = match current_sync_type.as_str() { + "external" => "both", + "None" | "" => "gpodder", + _ => ¤t_sync_type, + }; + + // Generate a secure internal token (64 characters alphanumeric) + use rand::{distr::Alphanumeric, Rng}; + let internal_token: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + + let local_gpodder_url = "http://localhost:8042"; + + // Create default device name + let default_device_name = format!("pinepods-internal-{}", user_id); + + // Update user with internal gpodder settings and set default device + sqlx::query(" + UPDATE Users + SET GpodderUrl = ?, GpodderToken = ?, GpodderLoginName = ?, Pod_Sync_Type = ?, DefaultGpodderDevice = ? + WHERE UserID = ? + ") + .bind(local_gpodder_url) + .bind(&internal_token) + .bind(&username) + .bind(new_sync_type) + .bind(&default_device_name) + .bind(user_id) + .execute(pool) + .await?; + + // Create device via gPodder API (matches Python version exactly) + let device_result = match self.create_device_via_gpodder_api(local_gpodder_url, &username, &internal_token, &default_device_name).await { + Ok(device_id) => { + serde_json::json!({ + "device_name": default_device_name, + "device_id": device_id, + "success": true + }) + } + Err(e) => { + tracing::warn!("Failed to create device via API: {}, continuing anyway", e); + // Even if device creation fails, still return success (matches Python behavior) + serde_json::json!({ + "device_name": default_device_name, + "device_id": user_id, + "success": true + }) + } + }; + + // Perform initial full sync to get ALL user subscriptions from all devices + if let Err(e) = self.call_gpodder_initial_full_sync(user_id, local_gpodder_url, &username, &internal_token, &default_device_name).await { + tracing::warn!("Initial GPodder full sync failed during setup: {}", e); + // Don't fail setup if initial sync fails + } + + Ok(device_result) + } + } + } + + // Disable internal gpodder sync - matches Python disable_gpodder_internal_sync function exactly + pub async fn disable_gpodder_internal_sync(&self, user_id: i32) -> AppResult { + // Get current user gpodder status + let user_status = self.gpodder_get_status(user_id).await?; + let current_sync_type = &user_status.sync_type; + + // Determine new sync type + let new_sync_type = match current_sync_type.as_str() { + "both" => "external", + "gpodder" => "None", + _ => current_sync_type, + }; + + match self { + DatabasePool::Postgres(pool) => { + // If internal API is being used, clear the settings + if user_status.gpodder_url.as_deref() == Some("http://localhost:8042") { + sqlx::query(r#" + UPDATE "Users" + SET gpodderurl = '', gpoddertoken = '', gpodderloginname = '', pod_sync_type = $1 + WHERE userid = $2 + "#) + .bind(new_sync_type) + .bind(user_id) + .execute(pool) + .await?; + } else { + // Just update the sync type + sqlx::query(r#"UPDATE "Users" SET pod_sync_type = $1 WHERE userid = $2"#) + .bind(new_sync_type) + .bind(user_id) + .execute(pool) + .await?; + } + } + DatabasePool::MySQL(pool) => { + // If internal API is being used, clear the settings + if user_status.gpodder_url.as_deref() == Some("http://localhost:8042") { + sqlx::query(" + UPDATE Users + SET GpodderUrl = '', GpodderToken = '', GpodderLoginName = '', Pod_Sync_Type = ? + WHERE UserID = ? + ") + .bind(new_sync_type) + .bind(user_id) + .execute(pool) + .await?; + } else { + // Just update the sync type + sqlx::query("UPDATE Users SET Pod_Sync_Type = ? WHERE UserID = ?") + .bind(new_sync_type) + .bind(user_id) + .execute(pool) + .await?; + } + } + } + + Ok(true) + } + + // Helper function to create device via gPodder API - matches Python create device logic exactly + pub async fn create_device_via_gpodder_api(&self, gpodder_url: &str, username: &str, token: &str, device_name: &str) -> AppResult { + use reqwest; + use serde_json; + + // Use correct authentication based on internal vs external + let (client, auth_method) = if gpodder_url == "http://localhost:8042" { + // Internal GPodder API - use X-GPodder-Token header + let client = reqwest::Client::new(); + (client, "internal") + } else { + // External GPodder API - use session auth with basic fallback + let decrypted_password = self.decrypt_password(token).await?; + let session = self.create_gpodder_session_with_password(gpodder_url, username, &decrypted_password).await?; + (session.client, "external") + }; + + // First, check if device already exists + let device_list_url = format!("{}/api/2/devices/{}.json", gpodder_url.trim_end_matches('/'), username); + + let response = if auth_method == "internal" { + client.get(&device_list_url) + .header("X-GPodder-Token", token) + .send() + .await + } else { + // External - session auth with basic fallback handled by session client + let decrypted_password = self.decrypt_password(token).await?; + client.get(&device_list_url) + .basic_auth(username, Some(&decrypted_password)) + .send() + .await + }; + + match response { + Ok(response) if response.status().is_success() => { + if let Ok(devices) = response.json::>().await { + for device in devices { + if device.get("id").and_then(|v| v.as_str()) == Some(device_name) { + tracing::info!("Found existing device with ID: {}", device_name); + return Ok(device_name.to_string()); + } + } + } + } + Ok(response) => { + tracing::warn!("Failed to fetch device list: {}", response.status()); + } + Err(e) => { + tracing::warn!("Error fetching device list: {}", e); + } + } + + // Device doesn't exist, create it + let device_url = format!("{}/api/2/devices/{}/{}.json", gpodder_url.trim_end_matches('/'), username, device_name); + let device_data = serde_json::json!({ + "caption": format!("PinePods Internal Device {}", device_name.split('-').last().unwrap_or("unknown")), + "type": "server" + }); + + let create_response = if auth_method == "internal" { + client.post(&device_url) + .header("X-GPodder-Token", token) + .json(&device_data) + .send() + .await + } else { + let decrypted_password = self.decrypt_password(token).await?; + client.post(&device_url) + .basic_auth(username, Some(&decrypted_password)) + .json(&device_data) + .send() + .await + }; + + match create_response + { + Ok(response) if response.status().is_success() => { + tracing::info!("Created device with ID: {}", device_name); + Ok(device_name.to_string()) + } + Ok(response) => { + let status = response.status(); + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + Err(AppError::internal(&format!("Failed to create device: {} - {}", status, error_text))) + } + Err(e) => { + Err(AppError::internal(&format!("Error creating device via API: {}", e))) + } + } + } + + // Background task for GPodder subscription refresh - matches Python refresh_gpodder_subscription_for_background + pub async fn refresh_gpodder_subscription_background(&self, user_id: i32) -> AppResult { + // Get user sync settings + let settings_opt = self.get_user_sync_settings(user_id).await?; + let settings = match settings_opt { + Some(s) => s, + None => return Ok(false), // No sync configured + }; + + // Get default device + let device_name = match self.get_default_gpodder_device_name(user_id).await? { + Some(name) => name, + None => format!("pinepods-internal-{}", user_id), // Fallback device name + }; + + // Call the appropriate sync method based on sync type + match settings.sync_type.as_str() { + "gpodder" => { + // Internal GPodder API - token is already unencrypted for internal use + self.call_gpodder_service_sync(user_id, "http://localhost:8042", &settings.username, &settings.token, &device_name, false).await + } + "external" => { + // External GPodder server - decrypt token using existing encryption system + let decrypted_token = match self.decrypt_gpodder_token(&settings.token).await { + Ok(token) => token, + Err(_) => settings.token.clone(), // Fallback to original token if decryption fails + }; + self.call_gpodder_service_sync(user_id, &settings.url, &settings.username, &decrypted_token, &device_name, false).await + } + "both" => { + // Both internal and external + let internal_result = self.call_gpodder_service_sync(user_id, "http://localhost:8042", &settings.username, &settings.token, &device_name, false).await?; + let decrypted_token = match self.decrypt_gpodder_token(&settings.token).await { + Ok(token) => token, + Err(_) => settings.token.clone(), + }; + let external_result = self.call_gpodder_service_sync(user_id, &settings.url, &settings.username, &decrypted_token, &device_name, false).await?; + Ok(internal_result || external_result) + } + "nextcloud" => { + // Nextcloud sync - use existing nextcloud refresh functionality + self.refresh_nextcloud_subscription_background(user_id).await + } + _ => Ok(false), // No sync or unsupported type + } + } + + // Helper to get default device name + async fn get_default_gpodder_device_name(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT devicename FROM "GpodderDevices" WHERE userid = $1 AND isdefault = true LIMIT 1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(row.and_then(|r| r.try_get("devicename").ok())) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT DeviceName FROM GpodderDevices WHERE UserID = ? AND IsDefault = 1 LIMIT 1") + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(row.and_then(|r| r.try_get("DeviceName").ok())) + } + } + } + + // Decrypt GPodder token using existing encryption system - matches Python token decryption + // Get comprehensive GPodder server statistics by calling actual GPodder API endpoints + pub async fn get_gpodder_server_statistics(&self, user_id: i32) -> AppResult { + use crate::handlers::sync::{GpodderStatistics, ServerDevice, ServerSubscription, ServerEpisodeAction, EndpointTest}; + use std::time::Instant; + + // Get user's sync settings using the same method as sync operations + let sync_settings = self.get_user_sync_settings(user_id).await?; + let settings = match sync_settings { + Some(s) => s, + None => { + return Ok(GpodderStatistics { + server_url: "No sync configured".to_string(), + sync_type: "None".to_string(), + sync_enabled: false, + server_devices: vec![], + total_devices: 0, + server_subscriptions: vec![], + total_subscriptions: 0, + recent_episode_actions: vec![], + total_episode_actions: 0, + connection_status: "Not configured".to_string(), + last_sync_timestamp: None, + api_endpoints_tested: vec![], + }); + } + }; + + // Use the same authentication logic as sync operations + let (gpodder_url, username, password) = match settings.sync_type.as_str() { + "gpodder" => { + // Internal gPodder API - use token directly (no decryption needed) + ("http://localhost:8042".to_string(), settings.username.clone(), settings.token.clone()) + } + "external" => { + // External gPodder server - decrypt token first + let decrypted_token = self.decrypt_password(&settings.token).await?; + (settings.url.clone(), settings.username.clone(), decrypted_token) + } + "nextcloud" => { + // Nextcloud sync - decrypt token first + let decrypted_token = self.decrypt_password(&settings.token).await?; + (settings.url.clone(), settings.username.clone(), decrypted_token) + } + _ => { + return Ok(GpodderStatistics { + server_url: settings.url.clone(), + sync_type: settings.sync_type.clone(), + sync_enabled: false, + server_devices: vec![], + total_devices: 0, + server_subscriptions: vec![], + total_subscriptions: 0, + recent_episode_actions: vec![], + total_episode_actions: 0, + connection_status: "Unsupported sync type".to_string(), + last_sync_timestamp: None, + api_endpoints_tested: vec![], + }); + } + }; + + let mut api_endpoints_tested = Vec::new(); + let mut server_devices = Vec::new(); + let mut server_subscriptions = Vec::new(); + let mut recent_episode_actions = Vec::new(); + + // Handle Nextcloud differently from standard GPodder API + if settings.sync_type == "nextcloud" { + // Nextcloud uses different endpoints and doesn't have devices concept + let client = reqwest::Client::new(); + + // Test 1: Get subscriptions from Nextcloud + let subscriptions_url = format!("{}/index.php/apps/gpoddersync/subscriptions", gpodder_url.trim_end_matches('/')); + let start = Instant::now(); + + let subscriptions_response = client + .get(&subscriptions_url) + .basic_auth(&username, Some(&password)) + .send() + .await; + + match subscriptions_response { + Ok(resp) if resp.status().is_success() => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /index.php/apps/gpoddersync/subscriptions".to_string(), + status: "success".to_string(), + response_time_ms: Some(duration), + error: None, + }); + + match resp.json::().await { + Ok(subs_data) => { + tracing::info!("Nextcloud subscriptions response: {:?}", subs_data); + if let Some(subs_array) = subs_data.as_array() { + tracing::info!("Found {} subscriptions in Nextcloud array", subs_array.len()); + for sub in subs_array { + if let Some(url) = sub.as_str() { + server_subscriptions.push(ServerSubscription { + url: url.to_string(), + title: None, + description: None, + }); + } + } + } else { + tracing::warn!("Nextcloud subscriptions response is not an array: {:?}", subs_data); + } + } + Err(e) => { + tracing::warn!("Failed to parse Nextcloud subscriptions response: {}", e); + } + } + } + Ok(resp) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /index.php/apps/gpoddersync/subscriptions".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(format!("HTTP {}", resp.status())), + }); + } + Err(e) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /index.php/apps/gpoddersync/subscriptions".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(e.to_string()), + }); + } + } + + // Test 2: Get episode actions from Nextcloud + let episode_actions_url = format!("{}/index.php/apps/gpoddersync/episode_action", gpodder_url.trim_end_matches('/')); + let start = Instant::now(); + + let episode_response = client + .get(&episode_actions_url) + .basic_auth(&username, Some(&password)) + .send() + .await; + + match episode_response { + Ok(resp) if resp.status().is_success() => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /index.php/apps/gpoddersync/episode_action".to_string(), + status: "success".to_string(), + response_time_ms: Some(duration), + error: None, + }); + + match resp.json::().await { + Ok(episode_data) => { + if let Some(actions) = episode_data.get("actions").and_then(|v| v.as_array()) { + for action in actions.iter().take(10) { // Show last 10 actions + recent_episode_actions.push(ServerEpisodeAction { + podcast: action["podcast"].as_str().unwrap_or("").to_string(), + episode: action["episode"].as_str().unwrap_or("").to_string(), + action: action["action"].as_str().unwrap_or("").to_string(), + timestamp: action["timestamp"].as_str().unwrap_or("").to_string(), + position: action["position"].as_i64().map(|p| p as i32), + device: Some("nextcloud".to_string()), + }); + } + } + } + Err(e) => { + tracing::warn!("Failed to parse Nextcloud episode actions response: {}", e); + } + } + } + Ok(resp) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /index.php/apps/gpoddersync/episode_action".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(format!("HTTP {}", resp.status())), + }); + } + Err(e) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /index.php/apps/gpoddersync/episode_action".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(e.to_string()), + }); + } + } + + // Nextcloud doesn't have devices concept, so add a fake device entry + server_devices.push(ServerDevice { + id: "nextcloud".to_string(), + caption: "Nextcloud gPodder Sync".to_string(), + device_type: "cloud".to_string(), + subscriptions: server_subscriptions.len() as i32, + }); + } else { + // Standard GPodder API (internal or external) + // Create GPodder session directly with already-decrypted password to avoid double decryption + let session = self.create_gpodder_session_with_password(&gpodder_url, &username, &password).await?; + + // Test 1: Get devices from GPodder API + let devices_url = format!("{}/api/2/devices/{}.json", gpodder_url.trim_end_matches('/'), username); + let start = Instant::now(); + + let devices_response = if session.authenticated { + session.client.get(&devices_url).send().await + } else { + session.client.get(&devices_url).basic_auth(&username, Some(&password)).send().await + }; + + match devices_response + { + Ok(resp) if resp.status().is_success() => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/devices/{username}.json".to_string(), + status: "success".to_string(), + response_time_ms: Some(duration), + error: None, + }); + + match resp.json::().await { + Ok(devices_data) => { + if let Some(devices_array) = devices_data.as_array() { + for device in devices_array { + server_devices.push(ServerDevice { + id: device["id"].as_str().unwrap_or("unknown").to_string(), + caption: device["caption"].as_str().unwrap_or("").to_string(), + device_type: device["type"].as_str().unwrap_or("unknown").to_string(), + subscriptions: device["subscriptions"].as_i64().unwrap_or(0) as i32, + }); + } + } + } + Err(e) => { + tracing::warn!("Failed to parse devices response: {}", e); + } + } + } + Ok(resp) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/devices/{username}.json".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(format!("HTTP {}", resp.status())), + }); + } + Err(e) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/devices/{username}.json".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(e.to_string()), + }); + } + } + + // Test 2: Get subscriptions from GPodder API - use the user's actual default device + let device_name = self.get_or_create_default_device(user_id).await?; + let subscriptions_url = format!("{}/api/2/subscriptions/{}/{}.json?since=0", + gpodder_url.trim_end_matches('/'), username, device_name); + let start = Instant::now(); + + let subscriptions_response = if session.authenticated { + session.client.get(&subscriptions_url).send().await + } else { + session.client.get(&subscriptions_url).basic_auth(&username, Some(&password)).send().await + }; + + match subscriptions_response + { + Ok(resp) if resp.status().is_success() => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/subscriptions/{username}/{device}.json?since=0".to_string(), + status: "success".to_string(), + response_time_ms: Some(duration), + error: None, + }); + + match resp.json::().await { + Ok(subs_data) => { + // GPodder API returns subscriptions in format: {"add": ["url1", "url2"], "remove": ["url3"]} + if let Some(add_array) = subs_data["add"].as_array() { + for sub in add_array { + if let Some(url) = sub.as_str() { + server_subscriptions.push(ServerSubscription { + url: url.to_string(), + title: None, + description: None, + }); + } + } + } + } + Err(e) => { + tracing::warn!("Failed to parse subscriptions response: {}", e); + } + } + } + Ok(resp) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/subscriptions/{username}/{device}.json?since=0".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(format!("HTTP {}", resp.status())), + }); + } + Err(e) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/subscriptions/{username}/{device}.json?since=0".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(e.to_string()), + }); + } + } + + // Test 3: Get episode actions from GPodder API + let episodes_url = format!("{}/api/2/episodes/{}.json?since=0&device={}", + gpodder_url.trim_end_matches('/'), username, device_name); + let start = Instant::now(); + + let episodes_response = if session.authenticated { + session.client.get(&episodes_url).send().await + } else { + session.client.get(&episodes_url).basic_auth(&username, Some(&password)).send().await + }; + + match episodes_response + { + Ok(resp) if resp.status().is_success() => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/episodes/{username}.json?since=0&device={device}".to_string(), + status: "success".to_string(), + response_time_ms: Some(duration), + error: None, + }); + + match resp.json::().await { + Ok(episodes_data) => { + if let Some(actions) = episodes_data["actions"].as_array() { + for action in actions.iter().take(10) { // Show last 10 actions + recent_episode_actions.push(ServerEpisodeAction { + podcast: action["podcast"].as_str().unwrap_or("").to_string(), + episode: action["episode"].as_str().unwrap_or("").to_string(), + action: action["action"].as_str().unwrap_or("").to_string(), + timestamp: action["timestamp"].as_str().unwrap_or("").to_string(), + position: action["position"].as_i64().map(|p| p as i32), + device: action["device"].as_str().map(|s| s.to_string()), + }); + } + } + } + Err(e) => { + tracing::warn!("Failed to parse episode actions response: {}", e); + } + } + } + Ok(resp) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/episodes/{username}.json?since=0&device={device}".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(format!("HTTP {}", resp.status())), + }); + } + Err(e) => { + let duration = start.elapsed().as_millis() as i64; + api_endpoints_tested.push(EndpointTest { + endpoint: "GET /api/2/episodes/{username}.json?since=0&device={device}".to_string(), + status: "failed".to_string(), + response_time_ms: Some(duration), + error: Some(e.to_string()), + }); + } + } + } + + // Get sync status + let status = self.gpodder_get_status(user_id).await?; + let last_sync = self.get_last_sync_timestamp(user_id).await?; + + // Determine overall connection status + let connection_status = if api_endpoints_tested.iter().any(|t| t.status == "success") { + if api_endpoints_tested.iter().all(|t| t.status == "success") { + "All endpoints working" + } else { + "Partial connectivity" + } + } else { + "Connection failed" + }; + + Ok(GpodderStatistics { + server_url: gpodder_url, + sync_type: status.sync_type.clone(), + sync_enabled: status.sync_type != "None", + server_devices: server_devices.clone(), + total_devices: server_devices.len() as i32, + server_subscriptions: server_subscriptions.clone(), + total_subscriptions: server_subscriptions.len() as i32, + recent_episode_actions: recent_episode_actions.clone(), + total_episode_actions: recent_episode_actions.len() as i32, + connection_status: connection_status.to_string(), + last_sync_timestamp: last_sync.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()), + api_endpoints_tested, + }) + } + + async fn decrypt_gpodder_token(&self, encrypted_token: &str) -> AppResult { + // Get encryption key from app settings + let encryption_key = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT encryptionkey FROM "AppSettings" WHERE appsettingsid = 1"#) + .fetch_optional(pool) + .await?; + + row.and_then(|r| r.try_get("encryptionkey").ok()) + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT EncryptionKey FROM AppSettings WHERE AppSettingsID = 1") + .fetch_optional(pool) + .await?; + + row.and_then(|r| r.try_get("EncryptionKey").ok()) + } + }; + + let encryption_key: String = encryption_key + .ok_or_else(|| AppError::internal("Encryption key not found"))?; + + // Decrypt using Fernet (matches Python implementation) + use fernet::Fernet; + let fernet = match Fernet::new(&encryption_key) { + Some(f) => f, + None => return Err(AppError::internal("Failed to create Fernet cipher with provided key")), + }; + + let decrypted = fernet.decrypt(encrypted_token) + .map_err(|e| AppError::internal(&format!("Failed to decrypt token: {}", e)))?; + + String::from_utf8(decrypted) + .map_err(|e| AppError::internal(&format!("Failed to parse decrypted token: {}", e))) + } + + // Nextcloud subscription refresh for background tasks - matches Python nextcloud refresh + async fn refresh_nextcloud_subscription_background(&self, user_id: i32) -> AppResult { + // Get user nextcloud settings + let settings = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT gpodderurl, gpoddertoken, gpodderloginname FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(r) = row { + ( + r.try_get::, _>("gpodderurl")?.unwrap_or_default(), + r.try_get::, _>("gpoddertoken")?.unwrap_or_default(), + r.try_get::, _>("gpodderloginname")?.unwrap_or_default(), + ) + } else { + return Ok(false); + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT GpodderUrl, GpodderToken, GpodderLoginName FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(r) = row { + ( + r.try_get::, _>("GpodderUrl")?.unwrap_or_default(), + r.try_get::, _>("GpodderToken")?.unwrap_or_default(), + r.try_get::, _>("GpodderLoginName")?.unwrap_or_default(), + ) + } else { + return Ok(false); + } + } + }; + + let (gpodder_url, _gpodder_token, gpodder_login) = settings; + + if gpodder_url.is_empty() || gpodder_login.is_empty() { + return Ok(false); + } + + // Call existing nextcloud sync functionality + self.sync_with_nextcloud_for_user(user_id).await + } + + // Get last sync timestamp for incremental sync - PROPER GPodder spec implementation + async fn get_last_sync_timestamp(&self, user_id: i32) -> AppResult>> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT lastsynctime FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("lastsynctime").unwrap_or(None)) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT LastSyncTime FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.try_get("LastSyncTime").unwrap_or(None)) + } else { + Ok(None) + } + } + } + } + + // Update last sync timestamp - PROPER GPodder spec implementation for incremental sync + async fn update_last_sync_timestamp(&self, user_id: i32) -> AppResult<()> { + let now = chrono::Utc::now(); + + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET lastsynctime = $1 WHERE userid = $2"#) + .bind(now) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET LastSyncTime = ? WHERE UserID = ?") + .bind(now) + .bind(user_id) + .execute(pool) + .await?; + } + } + + Ok(()) + } + + // Clear last sync timestamp - for initial full sync to start fresh + async fn clear_last_sync_timestamp(&self, user_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET lastsynctime = NULL WHERE userid = $1"#) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET LastSyncTime = NULL WHERE UserID = ?") + .bind(user_id) + .execute(pool) + .await?; + } + } + + Ok(()) + } + + // Get user episode actions since timestamp - CRITICAL for incremental sync performance + async fn get_user_episode_actions_since(&self, user_id: i32, since: chrono::DateTime) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT + e.episodeurl as podcast, + e.episodeurl as episode, + eh.listenduration as position, + CASE + WHEN eh.listenduration > 0 THEN 'play' + WHEN d.episodeid IS NOT NULL THEN 'download' + ELSE 'new' + END as action, + COALESCE(eh.listendate, '1970-01-01'::timestamp) as timestamp + FROM "Episodes" e + LEFT JOIN "UserEpisodeHistory" eh ON e.episodeid = eh.episodeid AND eh.userid = $1 + LEFT JOIN "DownloadedEpisodes" d ON e.episodeid = d.episodeid AND d.userid = $1 + WHERE (eh.userid = $1 OR d.userid = $1) + AND COALESCE(eh.listendate, '1970-01-01'::timestamp) > $2 + ORDER BY timestamp DESC + "#) + .bind(user_id) + .bind(since) + .fetch_all(pool) + .await?; + + let mut actions = Vec::new(); + for row in rows { + // Handle PostgreSQL TIMESTAMP (not TIMESTAMPTZ) column + let naive_timestamp: chrono::NaiveDateTime = row.try_get("timestamp")?; + let utc_timestamp = chrono::DateTime::::from_naive_utc_and_offset(naive_timestamp, chrono::Utc); + let timestamp_str = utc_timestamp.to_rfc3339(); + + actions.push(serde_json::json!({ + "podcast": row.try_get::("podcast")?, + "episode": row.try_get::("episode")?, + "action": row.try_get::("action")?, + "timestamp": timestamp_str, + "position": row.try_get::, _>("position").unwrap_or(None) + })); + } + Ok(actions) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query(" + SELECT + e.EpisodeURL as podcast, + e.EpisodeURL as episode, + eh.ListenDuration as position, + CASE + WHEN eh.ListenDuration > 0 THEN 'play' + WHEN d.EpisodeID IS NOT NULL THEN 'download' + ELSE 'new' + END as action, + COALESCE(eh.ListenDate, '1970-01-01 00:00:00') as timestamp + FROM Episodes e + LEFT JOIN UserEpisodeHistory eh ON e.EpisodeID = eh.EpisodeID AND eh.UserID = ? + LEFT JOIN DownloadedEpisodes d ON e.EpisodeID = d.EpisodeID AND d.UserID = ? + WHERE (eh.UserID = ? OR d.UserID = ?) + AND COALESCE(eh.ListenDate, '1970-01-01 00:00:00') > ? + ORDER BY timestamp DESC + ") + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(user_id) + .bind(since) + .fetch_all(pool) + .await?; + + let mut actions = Vec::new(); + for row in rows { + // Handle MySQL DATETIME column + let naive_timestamp: chrono::NaiveDateTime = row.try_get("timestamp")?; + let utc_timestamp = chrono::DateTime::::from_naive_utc_and_offset(naive_timestamp, chrono::Utc); + let timestamp_str = utc_timestamp.to_rfc3339(); + + actions.push(serde_json::json!({ + "podcast": row.try_get::("podcast")?, + "episode": row.try_get::("episode")?, + "action": row.try_get::("action")?, + "timestamp": timestamp_str, + "position": row.try_get::, _>("position").unwrap_or(None) + })); + } + Ok(actions) + } + } + } + + // Get all user episode actions - fallback for first sync + async fn get_user_episode_actions(&self, user_id: i32) -> AppResult> { + // Use since timestamp of epoch (1970) to get all actions + let epoch = chrono::DateTime::from_timestamp(0, 0).unwrap_or_else(chrono::Utc::now); + self.get_user_episode_actions_since(user_id, epoch).await + } +} + +#[derive(Debug)] +struct RssEpisode { + title: String, + description: String, + url: String, + pub_date: String, + duration: Option, + author: Option, + artwork_url: Option, +} + +impl DatabasePool { + // Get all users with nextcloud sync enabled - matches Python get_all_users_with_nextcloud_sync + pub async fn get_all_users_with_nextcloud_sync(&self) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let user_ids: Vec = sqlx::query_scalar( + r#"SELECT userid FROM "Users" WHERE pod_sync_type = 'nextcloud'"# + ) + .fetch_all(pool) + .await?; + Ok(user_ids) + } + DatabasePool::MySQL(pool) => { + let user_ids: Vec = sqlx::query_scalar( + "SELECT UserID FROM Users WHERE Pod_Sync_Type = 'nextcloud'" + ) + .fetch_all(pool) + .await?; + Ok(user_ids) + } + } + } + + // Complete implementation of sync_with_nextcloud_for_user - matches Python nextcloud sync functionality + pub async fn sync_with_nextcloud_for_user(&self, user_id: i32) -> AppResult { + tracing::info!("Starting Nextcloud sync for user {}", user_id); + + // Get user's Nextcloud configuration + let gpodder_status = self.gpodder_get_status(user_id).await?; + + // Only proceed if sync type is nextcloud + if gpodder_status.sync_type != "nextcloud" { + tracing::info!("User {} does not have Nextcloud sync enabled", user_id); + return Ok(false); + } + + // Get Nextcloud credentials from database + let (gpodder_url, username, encrypted_token) = match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT gpodderurl, gpodderloginname, gpoddertoken FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let url: Option = row.try_get("gpodderurl")?; + let login: Option = row.try_get("gpodderloginname")?; + let token: Option = row.try_get("gpoddertoken")?; + + ( + url.ok_or_else(|| AppError::internal("Nextcloud URL not configured"))?, + login.ok_or_else(|| AppError::internal("Nextcloud username not configured"))?, + token.ok_or_else(|| AppError::internal("Nextcloud token not configured"))? + ) + } else { + return Err(AppError::not_found("User not found")); + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT GpodderUrl, GpodderLoginName, GpodderToken FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let url: Option = row.try_get("GpodderUrl")?; + let login: Option = row.try_get("GpodderLoginName")?; + let token: Option = row.try_get("GpodderToken")?; + + ( + url.ok_or_else(|| AppError::internal("Nextcloud URL not configured"))?, + login.ok_or_else(|| AppError::internal("Nextcloud username not configured"))?, + token.ok_or_else(|| AppError::internal("Nextcloud token not configured"))? + ) + } else { + return Err(AppError::not_found("User not found")); + } + } + }; + + // Decrypt token using existing decrypt_password method + let password = self.decrypt_password(&encrypted_token).await?; + + // Get last sync timestamp for incremental sync + let since_timestamp = if let Some(last_sync) = self.get_last_sync_timestamp(user_id).await? { + last_sync.timestamp() + } else { + 0 + }; + + // Build Nextcloud API endpoint URLs + let base_url = if gpodder_url.ends_with('/') { + gpodder_url.trim_end_matches('/').to_string() + } else { + gpodder_url.clone() + }; + + let subscriptions_url = format!("{}/index.php/apps/gpoddersync/subscriptions", base_url); + let episode_action_url = format!("{}/index.php/apps/gpoddersync/episode_action", base_url); + + let client = reqwest::Client::new(); + let mut has_changes = false; + + // Sync subscriptions from Nextcloud + let subscriptions_response = client + .get(&subscriptions_url) + .basic_auth(&username, Some(&password)) + .query(&[("since", since_timestamp.to_string())]) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to fetch Nextcloud subscriptions: {}", e)))?; + + if subscriptions_response.status().is_success() { + let subscription_data: serde_json::Value = subscriptions_response.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse subscription response: {}", e)))?; + + // Process subscription changes + if let Some(add_list) = subscription_data.get("add").and_then(|v| v.as_array()) { + for url in add_list { + if let Some(podcast_url) = url.as_str() { + tracing::info!("Adding Nextcloud subscription: {}", podcast_url); + if let Err(e) = self.add_podcast_from_url(user_id, podcast_url, None).await { + tracing::error!("Failed to add podcast {}: {}", podcast_url, e); + } else { + has_changes = true; + } + } + } + } + + if let Some(remove_list) = subscription_data.get("remove").and_then(|v| v.as_array()) { + for url in remove_list { + if let Some(podcast_url) = url.as_str() { + tracing::info!("Removing Nextcloud subscription: {}", podcast_url); + if let Err(e) = self.remove_podcast_by_url(user_id, podcast_url).await { + tracing::error!("Failed to remove podcast {}: {}", podcast_url, e); + } else { + has_changes = true; + } + } + } + } + } + + // Sync episode actions from Nextcloud + let episode_actions_response = client + .get(&episode_action_url) + .basic_auth(&username, Some(&password)) + .query(&[("since", since_timestamp.to_string())]) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to fetch Nextcloud episode actions: {}", e)))?; + + if episode_actions_response.status().is_success() { + let episode_actions_data: serde_json::Value = episode_actions_response.json().await + .map_err(|e| AppError::internal(&format!("Failed to parse episode actions response: {}", e)))?; + + if let Some(actions) = episode_actions_data.get("actions").and_then(|v| v.as_array()) { + for action in actions { + if let Err(e) = self.process_nextcloud_episode_action(user_id, action).await { + tracing::error!("Failed to process episode action: {}", e); + } else { + has_changes = true; + } + } + } + } + + // Update last sync timestamp + if let Err(e) = self.update_last_sync_timestamp(user_id).await { + tracing::error!("Failed to update sync timestamp for user {}: {}", user_id, e); + } + + tracing::info!("Nextcloud sync completed for user {} - changes: {}", user_id, has_changes); + Ok(has_changes) + } + + // Process individual episode action from Nextcloud + async fn process_nextcloud_episode_action(&self, user_id: i32, action: &serde_json::Value) -> AppResult<()> { + let episode_url = action.get("episode") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::internal("Missing episode URL in episode action"))?; + + let action_type = action.get("action") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::internal("Missing action type in episode action"))?; + + // Find the episode by URL + let episode_id = match self.get_episode_id_by_url(episode_url).await { + Ok(Some(id)) => id, + Ok(None) => { + tracing::warn!("Episode not found for URL: {}", episode_url); + return Ok(()); + } + Err(_) => { + tracing::warn!("Error finding episode for URL: {}", episode_url); + return Ok(()); + } + }; + + match action_type { + "play" => { + if let Some(position) = action.get("position").and_then(|v| v.as_i64()) { + self.save_episode_history(user_id, episode_id, position as i32, 0).await?; + } + } + "download" => { + self.mark_episode_completed(episode_id, user_id, false).await?; + } + "delete" => { + // Remove episode from user's history + self.remove_episode_from_history(user_id, episode_id).await?; + } + _ => { + tracing::debug!("Unknown action type: {}", action_type); + } + } + + Ok(()) + } + + // Add podcast from URL - used by Nextcloud sync + pub async fn add_podcast_from_url(&self, user_id: i32, feed_url: &str, _feed_cutoff: Option) -> AppResult<()> { + // Check if podcast already exists for this user + if self.podcast_exists_for_user(user_id, feed_url).await? { + tracing::info!("Podcast {} already exists for user {}", feed_url, user_id); + return Ok(()); + } + + // Get podcast metadata from feed URL using existing function + let podcast_values = self.get_podcast_values(feed_url, user_id, None, None).await?; + + // Add podcast using existing function + let _result = self.add_podcast_from_values(&podcast_values, user_id, 30, None, None).await?; + + tracing::info!("Successfully added podcast {} for user {}", feed_url, user_id); + Ok(()) + } + + // Remove podcast by URL - used by Nextcloud sync + pub async fn remove_podcast_by_url(&self, user_id: i32, feed_url: &str) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"DELETE FROM "Podcasts" WHERE feedurl = $1 AND userid = $2"#) + .bind(feed_url) + .bind(user_id) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + tracing::info!("Successfully removed podcast {} for user {}", feed_url, user_id); + } else { + tracing::info!("Podcast {} not found for user {}", feed_url, user_id); + } + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("DELETE FROM Podcasts WHERE FeedURL = ? AND UserID = ?") + .bind(feed_url) + .bind(user_id) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + tracing::info!("Successfully removed podcast {} for user {}", feed_url, user_id); + } else { + tracing::info!("Podcast {} not found for user {}", feed_url, user_id); + } + } + } + Ok(()) + } + + // Get episode ID by URL - used by Nextcloud episode actions + pub async fn get_episode_id_by_url(&self, episode_url: &str) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT episodeid FROM "Episodes" WHERE episodeurl = $1 LIMIT 1"#) + .bind(episode_url) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("episodeid")?)) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT EpisodeID FROM Episodes WHERE EpisodeURL = ? LIMIT 1") + .bind(episode_url) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(row.try_get("EpisodeID")?)) + } else { + Ok(None) + } + } + } + } + + // Save episode history - used by Nextcloud episode actions + pub async fn save_episode_history(&self, user_id: i32, episode_id: i32, position: i32, _total_time: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#" + INSERT INTO "UserEpisodeHistory" (userid, episodeid, listenduration, episodecompleted, episodeprogress) + VALUES ($1, $2, $3, FALSE, $4) + ON CONFLICT (userid, episodeid) + DO UPDATE SET listenduration = $3, episodeprogress = $4 + "#) + .bind(user_id) + .bind(episode_id) + .bind(position) + .bind(position) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query(r#" + INSERT INTO UserEpisodeHistory (UserID, EpisodeID, ListenDuration, EpisodeCompleted, EpisodeProgress) + VALUES (?, ?, ?, FALSE, ?) + ON DUPLICATE KEY UPDATE ListenDuration = ?, EpisodeProgress = ? + "#) + .bind(user_id) + .bind(episode_id) + .bind(position) + .bind(position) + .bind(position) + .bind(position) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Remove episode from history - used by Nextcloud episode actions + pub async fn remove_episode_from_history(&self, user_id: i32, episode_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"DELETE FROM "UserEpisodeHistory" WHERE userid = $1 AND episodeid = $2"#) + .bind(user_id) + .bind(episode_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("DELETE FROM UserEpisodeHistory WHERE UserID = ? AND EpisodeID = ?") + .bind(user_id) + .bind(episode_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Remove GPodder sync settings for a user - matches Python remove_gpodder_settings function exactly + pub async fn remove_gpodder_settings(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + + // First delete any device records + sqlx::query(r#"DELETE FROM "GpodderDevices" WHERE userid = $1"#) + .bind(user_id) + .execute(&mut *tx) + .await?; + + sqlx::query(r#"DELETE FROM "GpodderSyncState" WHERE userid = $1"#) + .bind(user_id) + .execute(&mut *tx) + .await?; + + // Then clear GPodder settings from user record + sqlx::query(r#" + UPDATE "Users" + SET gpodderurl = '', gpodderloginname = '', gpoddertoken = '', pod_sync_type = 'None' + WHERE userid = $1 + "#) + .bind(user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + + // First delete any device records + sqlx::query("DELETE FROM GpodderDevices WHERE UserID = ?") + .bind(user_id) + .execute(&mut *tx) + .await?; + + sqlx::query("DELETE FROM GpodderSyncState WHERE UserID = ?") + .bind(user_id) + .execute(&mut *tx) + .await?; + + // Then clear GPodder settings from user record + sqlx::query(r#" + UPDATE Users + SET GpodderUrl = '', GpodderLoginName = '', GpodderToken = '', Pod_Sync_Type = 'None' + WHERE UserID = ? + "#) + .bind(user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) + } + } + } + + // Check if a user exists with the given username and email + pub async fn check_reset_user(&self, username: &str, email: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"SELECT userid FROM "Users" WHERE username = $1 AND email = $2"#) + .bind(username) + .bind(email) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("SELECT UserID FROM Users WHERE Username = ? AND Email = ?") + .bind(username) + .bind(email) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } + } + } + + // Create a password reset code for the user + pub async fn reset_password_create_code(&self, user_email: &str) -> AppResult> { + use rand::Rng; + use chrono::{Utc, Duration}; + + // Generate 6-character reset code with uppercase letters and digits + let reset_code: String = (0..6) + .map(|_| { + let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + chars[rand::rng().random_range(0..chars.len())] as char + }) + .collect(); + + let reset_expiry: sqlx::types::chrono::DateTime = (Utc::now() + Duration::hours(1)).into(); + + match self { + DatabasePool::Postgres(pool) => { + // Check if user exists first + let user_exists = sqlx::query(r#"SELECT userid FROM "Users" WHERE email = $1"#) + .bind(user_email) + .fetch_optional(pool) + .await?; + + if user_exists.is_none() { + return Ok(None); + } + + // Update reset code and expiry + let result = sqlx::query(r#" + UPDATE "Users" + SET reset_code = $1, reset_expiry = $2 + WHERE email = $3 + "#) + .bind(&reset_code) + .bind(reset_expiry) + .bind(user_email) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + Ok(Some(reset_code)) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + // Check if user exists first + let user_exists = sqlx::query("SELECT UserID FROM Users WHERE Email = ?") + .bind(user_email) + .fetch_optional(pool) + .await?; + + if user_exists.is_none() { + return Ok(None); + } + + // Update reset code and expiry + let result = sqlx::query(r#" + UPDATE Users + SET Reset_Code = ?, Reset_Expiry = ? + WHERE Email = ? + "#) + .bind(&reset_code) + .bind(reset_expiry) + .bind(user_email) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + Ok(Some(reset_code)) + } else { + Ok(None) + } + } + } + } + + // Remove reset code from user (used when email sending fails) + pub async fn reset_password_remove_code(&self, email: &str) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET reset_code = NULL, reset_expiry = NULL WHERE email = $1"#) + .bind(email) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET Reset_Code = NULL, Reset_Expiry = NULL WHERE Email = ?") + .bind(email) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Verify reset code is valid and not expired + pub async fn verify_reset_code(&self, user_email: &str, reset_code: &str) -> AppResult> { + use chrono::Utc; + + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"SELECT reset_code, reset_expiry FROM "Users" WHERE email = $1"#) + .bind(user_email) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + let stored_code: Option = row.try_get("reset_code").ok(); + let expiry: Option = row.try_get("reset_expiry").ok(); + + if let (Some(stored_code), Some(expiry)) = (stored_code.clone(), expiry.clone()) { + // Convert NaiveDateTime to UTC for comparison + let expiry_utc = expiry.and_utc(); + let is_valid = stored_code == reset_code && Utc::now() < expiry_utc; + Ok(Some(is_valid)) + } else { + Ok(Some(false)) // No reset code set + } + } else { + Ok(None) // User not found + } + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("SELECT Reset_Code, Reset_Expiry FROM Users WHERE Email = ?") + .bind(user_email) + .fetch_optional(pool) + .await?; + + if let Some(row) = result { + let stored_code: Option = row.try_get("Reset_Code").ok(); + let expiry: Option> = row.try_get("Reset_Expiry").ok(); + + if let (Some(stored_code), Some(expiry)) = (stored_code, expiry) { + Ok(Some(stored_code == reset_code && Utc::now() < expiry)) + } else { + Ok(Some(false)) // No reset code set + } + } else { + Ok(None) // User not found + } + } + } + } + + // Reset password and clear reset code/expiry + pub async fn reset_password_prompt(&self, user_email: &str, hashed_password: &str) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#" + UPDATE "Users" + SET hashed_pw = $1, reset_code = NULL, reset_expiry = NULL + WHERE email = $2 + "#) + .bind(hashed_password) + .bind(user_email) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + Ok(Some("Password Reset Successfully".to_string())) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query(r#" + UPDATE Users + SET Hashed_PW = ?, Reset_Code = NULL, Reset_Expiry = NULL + WHERE Email = ? + "#) + .bind(hashed_password) + .bind(user_email) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + Ok(Some("Password Reset Successfully".to_string())) + } else { + Ok(None) + } + } + } + } + + // Set scheduled backup configuration + pub async fn set_scheduled_backup(&self, user_id: i32, cron_schedule: &str, enabled: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#" + INSERT INTO "ScheduledBackups" (userid, cron_schedule, enabled, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (userid) + DO UPDATE SET + cron_schedule = EXCLUDED.cron_schedule, + enabled = EXCLUDED.enabled, + updated_at = NOW() + "#) + .bind(user_id) + .bind(cron_schedule) + .bind(enabled) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query(r#" + INSERT INTO ScheduledBackups (UserID, CronSchedule, Enabled, CreatedAt, UpdatedAt) + VALUES (?, ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + CronSchedule = VALUES(CronSchedule), + Enabled = VALUES(Enabled), + UpdatedAt = NOW() + "#) + .bind(user_id) + .bind(cron_schedule) + .bind(enabled) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Get scheduled backup configuration + pub async fn get_scheduled_backup(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT cron_schedule, enabled, created_at, updated_at + FROM "ScheduledBackups" + WHERE userid = $1 + "#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(serde_json::json!({ + "schedule": row.get::("cron_schedule"), + "enabled": row.get::("enabled"), + "created_at": row.get::("created_at").format("%Y-%m-%dT%H:%M:%S").to_string(), + "updated_at": row.get::("updated_at").format("%Y-%m-%dT%H:%M:%S").to_string() + })) + } else { + Ok(serde_json::json!({ + "schedule": null, + "enabled": false, + "created_at": null, + "updated_at": null + })) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query(r#" + SELECT CronSchedule, Enabled, CreatedAt, UpdatedAt + FROM ScheduledBackups + WHERE UserID = ? + "#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let created_datetime = row.try_get::, _>("CreatedAt")?; + let updated_datetime = row.try_get::, _>("UpdatedAt")?; + + Ok(serde_json::json!({ + "schedule": row.try_get::("CronSchedule")?, + "enabled": row.try_get::("Enabled")?, + "created_at": created_datetime.format("%Y-%m-%dT%H:%M:%S").to_string(), + "updated_at": updated_datetime.format("%Y-%m-%dT%H:%M:%S").to_string() + })) + } else { + Ok(serde_json::json!({ + "schedule": null, + "enabled": false, + "created_at": null, + "updated_at": null + })) + } + } + } + } + + // Execute backup to file (called by scheduler) + pub async fn execute_scheduled_backup(&self, _user_id: i32) -> AppResult { + use tokio::process::Command; + use chrono::Utc; + + // Generate backup filename with timestamp + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let backup_filename = format!("scheduled_backup_{}.sql", timestamp); + let backup_path = format!("/opt/pinepods/backups/{}", backup_filename); + + // Get database password from environment + let db_password = std::env::var("DB_PASSWORD") + .map_err(|_| AppError::internal("Database password not found in environment"))?; + + match self { + DatabasePool::Postgres(_) => { + let db_host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); + let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string()); + let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string()); + let db_name = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods_database".to_string()); + + let mut cmd = Command::new("pg_dump"); + cmd.arg("-h").arg(&db_host) + .arg("-p").arg(&db_port) + .arg("-U").arg(&db_user) + .arg("-d").arg(&db_name) + .arg("-f").arg(&backup_path) + .arg("--verbose") + .env("PGPASSWORD", &db_password); + + let output = cmd.output().await + .map_err(|e| AppError::internal(&format!("Failed to execute backup: {}", e)))?; + + if !output.status.success() { + let error_msg = String::from_utf8_lossy(&output.stderr); + return Err(AppError::internal(&format!("Backup failed: {}", error_msg))); + } + } + DatabasePool::MySQL(_) => { + let db_host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); + let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "3306".to_string()); + let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "mysql".to_string()); + let db_name = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods_database".to_string()); + + let mut cmd = Command::new("mysqldump"); + cmd.arg("-h").arg(&db_host) + .arg("-P").arg(&db_port) + .arg("-u").arg(&db_user) + .arg(format!("-p{}", &db_password)) + .arg(&db_name) + .arg("--result-file").arg(&backup_path) + .arg("--single-transaction") + .arg("--routines") + .arg("--triggers"); + + let output = cmd.output().await + .map_err(|e| AppError::internal(&format!("Failed to execute backup: {}", e)))?; + + if !output.status.success() { + let error_msg = String::from_utf8_lossy(&output.stderr); + return Err(AppError::internal(&format!("Backup failed: {}", error_msg))); + } + } + } + + Ok(backup_filename) + } + + // Get podcasts that have podcast_index_id = 0 (imported without podcast index match) + pub async fn get_unmatched_podcasts(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT podcastid, podcastname, artworkurl, author, description, feedurl + FROM "Podcasts" + WHERE userid = $1 AND (podcastindexid = 0 OR podcastindexid IS NULL) + AND (ignorepodcastindex = FALSE OR ignorepodcastindex IS NULL) + ORDER BY podcastname"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + let podcast = serde_json::json!({ + "podcast_id": row.try_get::("podcastid")?, + "podcast_name": row.try_get::("podcastname")?, + "artwork_url": row.try_get::, _>("artworkurl")?, + "author": row.try_get::, _>("author")?, + "description": row.try_get::, _>("description")?, + "feed_url": row.try_get::("feedurl")? + }); + podcasts.push(podcast); + } + + Ok(podcasts) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT PodcastID, PodcastName, ArtworkURL, Author, Description, FeedURL + FROM Podcasts + WHERE UserID = ? AND (PodcastIndexID = 0 OR PodcastIndexID IS NULL) + AND (IgnorePodcastIndex = 0 OR IgnorePodcastIndex IS NULL) + ORDER BY PodcastName" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + let podcast = serde_json::json!({ + "podcast_id": row.try_get::("PodcastID")?, + "podcast_name": row.try_get::("PodcastName")?, + "artwork_url": row.try_get::, _>("ArtworkURL")?, + "author": row.try_get::, _>("Author")?, + "description": row.try_get::, _>("Description")?, + "feed_url": row.try_get::("FeedURL")? + }); + podcasts.push(podcast); + } + + Ok(podcasts) + } + } + } + + // Update a podcast's podcast_index_id + pub async fn update_podcast_index_id(&self, user_id: i32, podcast_id: i32, podcast_index_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query( + r#"UPDATE "Podcasts" + SET podcastindexid = $1 + WHERE podcastid = $2 AND userid = $3"# + ) + .bind(podcast_index_id) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query( + "UPDATE Podcasts + SET PodcastIndexID = ? + WHERE PodcastID = ? AND UserID = ?" + ) + .bind(podcast_index_id) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Ignore/unignore a podcast's index ID requirement + pub async fn ignore_podcast_index_id(&self, user_id: i32, podcast_id: i32, ignore: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query( + r#"UPDATE "Podcasts" + SET ignorepodcastindex = $1 + WHERE podcastid = $2 AND userid = $3"# + ) + .bind(ignore) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query( + "UPDATE Podcasts + SET IgnorePodcastIndex = ? + WHERE PodcastID = ? AND UserID = ?" + ) + .bind(ignore) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + + // Get ignored podcasts for a user + pub async fn get_ignored_podcasts(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT podcastid, podcastname, artworkurl, author, description, feedurl + FROM "Podcasts" + WHERE userid = $1 AND ignorepodcastindex = TRUE + ORDER BY podcastname"# + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + let podcast = serde_json::json!({ + "podcast_id": row.try_get::("podcastid")?, + "podcast_name": row.try_get::("podcastname")?, + "artwork_url": row.try_get::, _>("artworkurl")?, + "author": row.try_get::, _>("author")?, + "description": row.try_get::, _>("description")?, + "feed_url": row.try_get::("feedurl")? + }); + podcasts.push(podcast); + } + + Ok(podcasts) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT PodcastID, PodcastName, ArtworkURL, Author, Description, FeedURL + FROM Podcasts + WHERE UserID = ? AND IgnorePodcastIndex = 1 + ORDER BY PodcastName" + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + let podcast = serde_json::json!({ + "podcast_id": row.try_get::("PodcastID")?, + "podcast_name": row.try_get::("PodcastName")?, + "artwork_url": row.try_get::, _>("ArtworkURL")?, + "author": row.try_get::, _>("Author")?, + "description": row.try_get::, _>("Description")?, + "feed_url": row.try_get::("FeedURL")? + }); + podcasts.push(podcast); + } + + Ok(podcasts) + } + } + } + + // Add shared episode - uses current database schema with ShareCode + pub async fn add_shared_episode(&self, episode_id: i32, shared_by: i32, share_code: &str, expiration_date: chrono::DateTime) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#" + INSERT INTO "SharedEpisodes" (episodeid, sharedby, sharecode, expirationdate) + VALUES ($1, $2, $3, $4) + "#) + .bind(episode_id) + .bind(shared_by) + .bind(share_code) + .bind(expiration_date) + .execute(pool) + .await; + + match result { + Ok(_) => Ok(true), + Err(e) => { + tracing::error!("Error sharing episode: {}", e); + Ok(false) + } + } + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query( + "INSERT INTO SharedEpisodes (EpisodeID, SharedBy, ShareCode, ExpirationDate) VALUES (?, ?, ?, ?)" + ) + .bind(episode_id) + .bind(shared_by) + .bind(share_code) + .bind(expiration_date) + .execute(pool) + .await; + + match result { + Ok(_) => Ok(true), + Err(e) => { + tracing::error!("Error sharing episode: {}", e); + Ok(false) + } + } + } + } + } + + // Get episode ID by share code (for shared episode access) + pub async fn get_episode_id_by_share_code(&self, share_code: &str) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query_as::<_, (i32,)>(r#" + SELECT episodeid FROM "SharedEpisodes" + WHERE sharecode = $1 AND expirationdate > NOW() + "#) + .bind(share_code) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row| row.0)) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query_as::<_, (i32,)>( + "SELECT EpisodeID FROM SharedEpisodes + WHERE ShareCode = ? AND ExpirationDate > NOW()" + ) + .bind(share_code) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row| row.0)) + } + } + } + + // Get shared episode metadata - bypasses user restrictions for public access + pub async fn get_shared_episode_metadata(&self, episode_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + // First try regular episodes + let row = sqlx::query( + r#"SELECT + "Podcasts".podcastid, + "Podcasts".feedurl, + "Podcasts".podcastname, + "Podcasts".artworkurl, + "Episodes".episodetitle, + "Episodes".episodepubdate, + "Episodes".episodedescription, + "Episodes".episodeartwork, + "Episodes".episodeurl, + "Episodes".episodeduration, + "Episodes".episodeid, + "Podcasts".websiteurl, + "Episodes".completed, + FALSE::boolean as is_youtube + FROM "Episodes" + INNER JOIN "Podcasts" ON "Episodes".podcastid = "Podcasts".podcastid + WHERE "Episodes".episodeid = $1"# + ) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let episodepubdate = row.try_get::("episodepubdate")? + .format("%Y-%m-%dT%H:%M:%S").to_string(); + + Ok(serde_json::json!({ + "podcastid": row.try_get::("podcastid")?, + "feedurl": row.try_get::, _>("feedurl")?, + "podcastname": row.try_get::("podcastname")?, + "artworkurl": row.try_get::, _>("artworkurl")?, + "episodetitle": row.try_get::("episodetitle")?, + "episodepubdate": episodepubdate, + "episodedescription": row.try_get::, _>("episodedescription")?, + "episodeartwork": row.try_get::, _>("episodeartwork")?, + "episodeurl": row.try_get::("episodeurl")?, + "episodeduration": row.try_get::, _>("episodeduration")?, + "episodeid": row.try_get::("episodeid")?, + "websiteurl": row.try_get::, _>("websiteurl")?, + "listenduration": None::, // No user-specific data for shared episodes + "completed": row.try_get::, _>("completed")?, + "is_youtube": false + })) + } else { + // Try YouTube videos + let row = sqlx::query( + r#"SELECT + "Podcasts".podcastid, + "Podcasts".feedurl, + "Podcasts".podcastname, + "Podcasts".artworkurl, + "YouTubeVideos".videotitle as episodetitle, + "YouTubeVideos".publishedat as episodepubdate, + "YouTubeVideos".videodescription as episodedescription, + "YouTubeVideos".thumbnailurl as episodeartwork, + "YouTubeVideos".videourl as episodeurl, + "YouTubeVideos".duration as episodeduration, + "YouTubeVideos".videoid as episodeid, + "Podcasts".websiteurl, + "YouTubeVideos".completed, + TRUE::boolean as is_youtube + FROM "YouTubeVideos" + INNER JOIN "Podcasts" ON "YouTubeVideos".podcastid = "Podcasts".podcastid + WHERE "YouTubeVideos".videoid = $1"# + ) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let episodepubdate = row.try_get::("episodepubdate")? + .format("%Y-%m-%dT%H:%M:%S").to_string(); + + Ok(serde_json::json!({ + "podcastid": row.try_get::("podcastid")?, + "feedurl": row.try_get::, _>("feedurl")?, + "podcastname": row.try_get::("podcastname")?, + "artworkurl": row.try_get::, _>("artworkurl")?, + "episodetitle": row.try_get::("episodetitle")?, + "episodepubdate": episodepubdate, + "episodedescription": row.try_get::, _>("episodedescription")?, + "episodeartwork": row.try_get::, _>("episodeartwork")?, + "episodeurl": row.try_get::("episodeurl")?, + "episodeduration": row.try_get::, _>("episodeduration")?, + "episodeid": row.try_get::("episodeid")?, + "websiteurl": row.try_get::, _>("websiteurl")?, + "listenposition": None::, // No user-specific data for shared episodes + "completed": row.try_get::, _>("completed")?, + "is_youtube": true + })) + } else { + Err(AppError::not_found("Episode not found")) + } + } + } + DatabasePool::MySQL(pool) => { + // First try regular episodes + let row = sqlx::query( + "SELECT + Podcasts.PodcastID, + Podcasts.FeedURL, + Podcasts.PodcastName, + Podcasts.ArtworkURL, + Episodes.EpisodeTitle, + Episodes.EpisodePubDate, + Episodes.EpisodeDescription, + Episodes.EpisodeArtwork, + Episodes.EpisodeURL, + Episodes.EpisodeDuration, + Episodes.EpisodeID, + Podcasts.WebsiteURL, + Episodes.Completed, + FALSE as is_youtube + FROM Episodes + INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID + WHERE Episodes.EpisodeID = ?" + ) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let episodepubdate = row.try_get::("EpisodePubDate")? + .format("%Y-%m-%dT%H:%M:%S").to_string(); + + Ok(serde_json::json!({ + "podcastid": row.try_get::("PodcastID")?, + "feedurl": row.try_get::, _>("FeedURL")?, + "podcastname": row.try_get::("PodcastName")?, + "artworkurl": row.try_get::, _>("ArtworkURL")?, + "episodetitle": row.try_get::("EpisodeTitle")?, + "episodepubdate": episodepubdate, + "episodedescription": row.try_get::, _>("EpisodeDescription")?, + "episodeartwork": row.try_get::, _>("EpisodeArtwork")?, + "episodeurl": row.try_get::("EpisodeURL")?, + "episodeduration": row.try_get::, _>("EpisodeDuration")?, + "episodeid": row.try_get::("EpisodeID")?, + "websiteurl": row.try_get::, _>("WebsiteURL")?, + "listenduration": None::, // No user-specific data for shared episodes + "completed": row.try_get::, _>("Completed")?, + "is_youtube": false + })) + } else { + // Try YouTube videos + let row = sqlx::query( + "SELECT + Podcasts.PodcastID, + Podcasts.FeedURL, + Podcasts.PodcastName, + Podcasts.ArtworkURL, + YouTubeVideos.VideoTitle as EpisodeTitle, + YouTubeVideos.PublishedAt as EpisodePubDate, + YouTubeVideos.VideoDescription as EpisodeDescription, + YouTubeVideos.ThumbnailURL as EpisodeArtwork, + YouTubeVideos.VideoURL as EpisodeURL, + YouTubeVideos.Duration as EpisodeDuration, + YouTubeVideos.VideoID as EpisodeID, + Podcasts.WebsiteURL, + YouTubeVideos.Completed, + TRUE as is_youtube + FROM YouTubeVideos + INNER JOIN Podcasts ON YouTubeVideos.PodcastID = Podcasts.PodcastID + WHERE YouTubeVideos.VideoID = ?" + ) + .bind(episode_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let episodepubdate = row.try_get::("EpisodePubDate")? + .format("%Y-%m-%dT%H:%M:%S").to_string(); + + Ok(serde_json::json!({ + "podcastid": row.try_get::("PodcastID")?, + "feedurl": row.try_get::, _>("FeedURL")?, + "podcastname": row.try_get::("PodcastName")?, + "artworkurl": row.try_get::, _>("ArtworkURL")?, + "episodetitle": row.try_get::("EpisodeTitle")?, + "episodepubdate": episodepubdate, + "episodedescription": row.try_get::, _>("EpisodeDescription")?, + "episodeartwork": row.try_get::, _>("EpisodeArtwork")?, + "episodeurl": row.try_get::("EpisodeURL")?, + "episodeduration": row.try_get::, _>("EpisodeDuration")?, + "episodeid": row.try_get::("EpisodeID")?, + "websiteurl": row.try_get::, _>("WebsiteURL")?, + "listenposition": None::, // No user-specific data for shared episodes + "completed": row.try_get::, _>("Completed")?, + "is_youtube": true + })) + } else { + Err(AppError::not_found("Episode not found")) + } + } + } + } + } + + pub async fn delete_playlist(&self, user_id: i32, playlist_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + // Check if playlist exists and belongs to user + let playlist = sqlx::query( + r#"SELECT issystemplaylist, userid FROM "Playlists" WHERE playlistid = $1"# + ) + .bind(playlist_id) + .fetch_optional(pool) + .await?; + + let playlist = playlist.ok_or_else(|| AppError::not_found("Playlist not found"))?; + + let is_system: bool = playlist.try_get("issystemplaylist")?; + let owner_id: i32 = playlist.try_get("userid")?; + + if is_system { + return Err(AppError::bad_request("Cannot delete system playlists")); + } + + if owner_id != user_id { + return Err(AppError::forbidden("Unauthorized to delete this playlist")); + } + + // Delete the playlist + sqlx::query(r#"DELETE FROM "Playlists" WHERE playlistid = $1"#) + .bind(playlist_id) + .execute(pool) + .await?; + + Ok(()) + } + DatabasePool::MySQL(pool) => { + // Check if playlist exists and belongs to user + let playlist = sqlx::query("SELECT IsSystemPlaylist, UserID FROM Playlists WHERE PlaylistID = ?") + .bind(playlist_id) + .fetch_optional(pool) + .await?; + + let playlist = playlist.ok_or_else(|| AppError::not_found("Playlist not found"))?; + + let is_system: i8 = playlist.try_get("IsSystemPlaylist")?; + let owner_id: i32 = playlist.try_get("UserID")?; + + if is_system != 0 { + return Err(AppError::bad_request("Cannot delete system playlists")); + } + + if owner_id != user_id { + return Err(AppError::forbidden("Unauthorized to delete this playlist")); + } + + // Delete the playlist + sqlx::query("DELETE FROM Playlists WHERE PlaylistID = ?") + .bind(playlist_id) + .execute(pool) + .await?; + + Ok(()) + } + } + } + + // Get user's language preference + pub async fn get_user_language(&self, user_id: i32) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT language FROM "Users" WHERE userid = $1"#) + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.get::, _>("language").unwrap_or_else(|| "en".to_string())) + } else { + Ok("en".to_string()) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT Language FROM Users WHERE UserID = ?") + .bind(user_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(row.get::, _>("Language").unwrap_or_else(|| "en".to_string())) + } else { + Ok("en".to_string()) + } + } + } + } + + // Update user's language preference + pub async fn update_user_language(&self, user_id: i32, language: &str) -> AppResult { + match self { + DatabasePool::Postgres(pool) => { + let result = sqlx::query(r#"UPDATE "Users" SET language = $1 WHERE userid = $2"#) + .bind(language) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + DatabasePool::MySQL(pool) => { + let result = sqlx::query("UPDATE Users SET Language = ? WHERE UserID = ?") + .bind(language) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + } + } + + // Create missing default playlists for existing users + pub async fn create_missing_default_playlists(&self) -> AppResult<()> { + use tracing::{info, warn, error}; + + info!("🎵 Checking for missing default playlists for existing users..."); + + // Define default playlists (same as migration 032) + let default_playlists = vec![ + ("Quick Listens", "Short episodes under 15 minutes, perfect for quick breaks", Some(1), Some(900), "duration_asc", false, true, true, None, false, Some(1000), "ph-fast-forward"), + ("Longform", "Extended episodes over 1 hour, ideal for long drives or deep dives", Some(3600), None, "duration_desc", true, true, true, None, false, Some(1000), "ph-car"), + ("Currently Listening", "Episodes you've started but haven't finished", None, None, "date_desc", false, true, false, None, false, None, "ph-play"), + ("Fresh Releases", "Latest episodes from the last 24 hours", None, None, "date_desc", true, false, false, Some(24), false, None, "ph-sparkle"), + ("Weekend Marathon", "Longer episodes (30+ minutes) perfect for weekend listening", Some(1800), None, "duration_desc", true, true, true, None, true, Some(1000), "ph-couch"), + ("Commuter Mix", "Perfect-length episodes (15-45 minutes) for your daily commute", Some(900), Some(2700), "date_desc", true, true, true, None, false, Some(1000), "ph-car-simple"), + ]; + + match self { + DatabasePool::Postgres(pool) => { + // Get all existing users (excluding background user if present) + let user_rows = sqlx::query(r#"SELECT userid FROM "Users" WHERE userid > 1"#) + .fetch_all(pool) + .await?; + + info!("Found {} users to check for missing default playlists", user_rows.len()); + + for user_row in user_rows { + let user_id: i32 = user_row.try_get("userid")?; + + for (name, description, min_duration, max_duration, sort_order, include_unplayed, include_partially_played, include_played, time_filter_hours, group_by_podcast, max_episodes, icon_name) in &default_playlists { + // Check if this playlist already exists for this user + let exists: bool = sqlx::query_scalar( + r#"SELECT EXISTS(SELECT 1 FROM "Playlists" WHERE userid = $1 AND name = $2)"# + ).bind(user_id).bind(name).fetch_one(pool).await?; + + if !exists { + // Create the playlist for this user + match sqlx::query(r#" + INSERT INTO "Playlists" ( + userid, name, description, issystemplaylist, minduration, maxduration, sortorder, + includeunplayed, includepartiallyplayed, includeplayed, timefilterhours, + groupbypodcast, maxepisodes, playprogressmin, playprogressmax, podcastids, + iconname, episodecount + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + "#) + .bind(user_id).bind(name).bind(description).bind(false) + .bind(min_duration).bind(max_duration).bind(sort_order) + .bind(include_unplayed).bind(include_partially_played).bind(include_played) + .bind(time_filter_hours).bind(group_by_podcast).bind(max_episodes) + .bind(0.0).bind(100.0).bind(&[] as &[i32]).bind(icon_name).bind(0) + .execute(pool).await { + Ok(_) => info!("Created playlist '{}' for user {}", name, user_id), + Err(e) => warn!("Failed to create playlist '{}' for user {}: {}", name, user_id, e), + } + } + } + } + } + DatabasePool::MySQL(pool) => { + // Get all existing users (excluding background user if present) + let user_rows = sqlx::query("SELECT UserID FROM Users WHERE UserID > 1") + .fetch_all(pool) + .await?; + + info!("Found {} users to check for missing default playlists", user_rows.len()); + + for user_row in user_rows { + let user_id: i32 = user_row.try_get("UserID")?; + + for (name, description, min_duration, max_duration, sort_order, include_unplayed, include_partially_played, include_played, time_filter_hours, group_by_podcast, max_episodes, icon_name) in &default_playlists { + // Check if this playlist already exists for this user + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM Playlists WHERE UserID = ? AND Name = ?") + .bind(user_id) + .bind(name) + .fetch_one(pool) + .await?; + + if count == 0 { + // Create the playlist for this user + match sqlx::query(" + INSERT INTO Playlists ( + UserID, Name, Description, IsSystemPlaylist, MinDuration, MaxDuration, SortOrder, + IncludeUnplayed, IncludePartiallyPlayed, IncludePlayed, TimeFilterHours, + GroupByPodcast, MaxEpisodes, PlayProgressMin, PlayProgressMax, PodcastIDs, + IconName, EpisodeCount + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ") + .bind(user_id).bind(name).bind(description).bind(false) + .bind(min_duration).bind(max_duration).bind(sort_order) + .bind(include_unplayed).bind(include_partially_played).bind(include_played) + .bind(time_filter_hours).bind(group_by_podcast).bind(max_episodes) + .bind(0.0).bind(100.0).bind("[]").bind(icon_name).bind(0) + .execute(pool).await { + Ok(_) => info!("Created playlist '{}' for user {}", name, user_id), + Err(e) => warn!("Failed to create playlist '{}' for user {}: {}", name, user_id, e), + } + } + } + } + } + } + + info!("✅ Finished checking for missing default playlists"); + Ok(()) + } + + // Create default playlists for a single user - shared by user creation and startup check + async fn create_default_playlists_for_user(&self, user_id: i32) -> AppResult<()> { + use tracing::{info, warn}; + + // Define default playlists (same as migration 032) + let default_playlists = vec![ + ("Quick Listens", "Short episodes under 15 minutes, perfect for quick breaks", Some(1), Some(900), "duration_asc", true, true, true, None, false, Some(1000), "ph-fast-forward"), + ("Longform", "Extended episodes over 1 hour, ideal for long drives or deep dives", Some(3600), None, "duration_desc", true, true, true, None, false, Some(1000), "ph-car"), + ("Currently Listening", "Episodes you've started but haven't finished", None, None, "date_desc", false, true, false, None, false, None, "ph-play"), + ("Fresh Releases", "Latest episodes from the last 24 hours", None, None, "date_desc", true, false, false, Some(24), false, None, "ph-sparkle"), + ("Weekend Marathon", "Longer episodes (30+ minutes) perfect for weekend listening", Some(1800), None, "duration_desc", true, true, true, None, true, Some(1000), "ph-couch"), + ("Commuter Mix", "Perfect-length episodes (15-45 minutes) for your daily commute", Some(900), Some(2700), "date_desc", true, true, true, None, false, Some(1000), "ph-car-simple"), + ]; + + match self { + DatabasePool::Postgres(pool) => { + for (name, description, min_duration, max_duration, sort_order, include_unplayed, include_partially_played, include_played, time_filter_hours, group_by_podcast, max_episodes, icon_name) in &default_playlists { + // Check if this playlist already exists for this user + let exists: bool = sqlx::query_scalar( + r#"SELECT EXISTS(SELECT 1 FROM "Playlists" WHERE userid = $1 AND name = $2)"# + ).bind(user_id).bind(name).fetch_one(pool).await?; + + if !exists { + // Create the playlist for this user + match sqlx::query(r#" + INSERT INTO "Playlists" ( + userid, name, description, issystemplaylist, minduration, maxduration, sortorder, + includeunplayed, includepartiallyplayed, includeplayed, timefilterhours, + groupbypodcast, maxepisodes, playprogressmin, playprogressmax, podcastids, + iconname, episodecount + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + "#) + .bind(user_id).bind(name).bind(description).bind(false) + .bind(min_duration).bind(max_duration).bind(sort_order) + .bind(include_unplayed).bind(include_partially_played).bind(include_played) + .bind(time_filter_hours).bind(group_by_podcast).bind(max_episodes) + .bind(0.0).bind(100.0).bind(&[] as &[i32]).bind(icon_name).bind(0) + .execute(pool).await { + Ok(_) => info!("Created playlist '{}' for user {}", name, user_id), + Err(e) => warn!("Failed to create playlist '{}' for user {}: {}", name, user_id, e), + } + } + } + } + DatabasePool::MySQL(pool) => { + for (name, description, min_duration, max_duration, sort_order, include_unplayed, include_partially_played, include_played, time_filter_hours, group_by_podcast, max_episodes, icon_name) in &default_playlists { + // Check if this playlist already exists for this user + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM Playlists WHERE UserID = ? AND Name = ?") + .bind(user_id) + .bind(name) + .fetch_one(pool) + .await?; + + if count == 0 { + // Create the playlist for this user + match sqlx::query(" + INSERT INTO Playlists ( + UserID, Name, Description, IsSystemPlaylist, MinDuration, MaxDuration, SortOrder, + IncludeUnplayed, IncludePartiallyPlayed, IncludePlayed, TimeFilterHours, + GroupByPodcast, MaxEpisodes, PlayProgressMin, PlayProgressMax, PodcastIDs, + IconName, EpisodeCount + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ") + .bind(user_id).bind(name).bind(description).bind(false) + .bind(min_duration).bind(max_duration).bind(sort_order) + .bind(include_unplayed).bind(include_partially_played).bind(include_played) + .bind(time_filter_hours).bind(group_by_podcast).bind(max_episodes) + .bind(0.0).bind(100.0).bind("[]").bind(icon_name).bind(0) + .execute(pool).await { + Ok(_) => info!("Created playlist '{}' for user {}", name, user_id), + Err(e) => warn!("Failed to create playlist '{}' for user {}: {}", name, user_id, e), + } + } + } + } + } + + Ok(()) + } + + // Update episode counts for all playlists - replaces complex playlist content updates + pub async fn update_playlist_episode_counts(&self) -> AppResult<()> { + use tracing::{info, warn, debug}; + + info!("📊 Starting playlist episode count updates..."); + + match self { + DatabasePool::Postgres(pool) => { + // Get all playlists + let playlists = sqlx::query( + r#"SELECT playlistid, userid, name FROM "Playlists" ORDER BY userid, playlistid"# + ).fetch_all(pool).await?; + + info!("Found {} playlists to update counts for", playlists.len()); + + let mut updated_count = 0; + let mut failed_count = 0; + + for playlist in playlists { + let playlist_id: i32 = playlist.try_get("playlistid")?; + let user_id: i32 = playlist.try_get("userid")?; + let name: String = playlist.try_get("name")?; + + match self.count_playlist_episodes_dynamic(playlist_id, user_id).await { + Ok(count) => { + // Update the episode_count in the playlist + match sqlx::query( + r#"UPDATE "Playlists" SET episodecount = $1 WHERE playlistid = $2"# + ).bind(count).bind(playlist_id).execute(pool).await { + Ok(_) => { + debug!("Updated playlist '{}' (ID: {}) count to {}", name, playlist_id, count); + updated_count += 1; + } + Err(e) => { + warn!("Failed to update count for playlist '{}' (ID: {}): {}", name, playlist_id, e); + failed_count += 1; + } + } + } + Err(e) => { + warn!("Failed to count episodes for playlist '{}' (ID: {}): {}", name, playlist_id, e); + failed_count += 1; + } + } + } + + info!("✅ Playlist count update completed: {} updated, {} failed", updated_count, failed_count); + } + DatabasePool::MySQL(pool) => { + // Get all playlists + let playlists = sqlx::query( + r#"SELECT PlaylistID, UserID, Name FROM Playlists ORDER BY UserID, PlaylistID"# + ).fetch_all(pool).await?; + + info!("Found {} playlists to update counts for (MySQL)", playlists.len()); + + let mut updated_count = 0; + let mut failed_count = 0; + + for playlist in playlists { + let playlist_id: i32 = playlist.try_get("PlaylistID")?; + let user_id: i32 = playlist.try_get("UserID")?; + let name: String = playlist.try_get("Name")?; + + match self.count_playlist_episodes_dynamic(playlist_id, user_id).await { + Ok(count) => { + // Update the episode_count in the playlist + match sqlx::query( + r#"UPDATE Playlists SET EpisodeCount = ? WHERE PlaylistID = ?"# + ).bind(count).bind(playlist_id).execute(pool).await { + Ok(_) => { + debug!("Updated MySQL playlist '{}' (ID: {}) count to {}", name, playlist_id, count); + updated_count += 1; + } + Err(e) => { + warn!("Failed to update MySQL count for playlist '{}' (ID: {}): {}", name, playlist_id, e); + failed_count += 1; + } + } + } + Err(e) => { + warn!("Failed to count MySQL episodes for playlist '{}' (ID: {}): {}", name, playlist_id, e); + failed_count += 1; + } + } + } + + info!("✅ MySQL playlist count update completed: {} updated, {} failed", updated_count, failed_count); + } + } + + Ok(()) + } + + // Count episodes for a playlist using the same dynamic logic (without pagination) + async fn count_playlist_episodes_dynamic(&self, playlist_id: i32, user_id: i32) -> AppResult { + use tracing::{debug, warn}; + + match self { + DatabasePool::Postgres(pool) => { + // Get user timezone for proper date calculations + let raw_user_timezone: String = sqlx::query_scalar( + r#"SELECT timezone FROM "Users" WHERE userid = $1"# + ).bind(user_id).fetch_optional(pool).await?.unwrap_or_else(|| "UTC".to_string()); + let raw_timezone = if raw_user_timezone.is_empty() { "UTC".to_string() } else { raw_user_timezone }; + let user_timezone = Self::map_timezone_for_postgres(&raw_timezone); + + // Get playlist configuration + let playlist_row = sqlx::query( + r#"SELECT userid, name, minduration, maxduration, sortorder, + includeunplayed, includepartiallyplayed, includeplayed, timefilterhours, + groupbypodcast, maxepisodes, playprogressmin, playprogressmax, podcastids + FROM "Playlists" WHERE playlistid = $1"# + ).bind(playlist_id).fetch_optional(pool).await?; + + let playlist = playlist_row.ok_or_else(|| crate::error::AppError::not_found("Playlist not found"))?; + + // Build count query using same logic as dynamic function + let mut query_parts = Vec::new(); + let mut where_conditions = Vec::new(); + + query_parts.push(format!(r#" + SELECT COUNT(DISTINCT e.episodeid) + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid AND p.userid = {} + LEFT JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid AND h.userid = {}"#, + user_id, user_id + )); + + where_conditions.push("p.userid = $1".to_string()); + + // Apply all the same filters as dynamic function + if let Some(min_dur) = playlist.try_get::, _>("minduration")? { + where_conditions.push(format!("e.episodeduration >= {}", min_dur)); + } + if let Some(max_dur) = playlist.try_get::, _>("maxduration")? { + where_conditions.push(format!("e.episodeduration <= {}", max_dur)); + } + + if let Some(hours) = playlist.try_get::, _>("timefilterhours")? { + where_conditions.push(format!( + "e.episodepubdate >= (NOW() AT TIME ZONE '{}' - INTERVAL '{} hours') AT TIME ZONE '{}' AT TIME ZONE 'UTC'", + user_timezone, hours, user_timezone + )); + } + + // Handle PostgreSQL array type for podcast IDs + if let Some(podcast_ids) = playlist.try_get::>, _>("podcastids")? { + if !podcast_ids.is_empty() && !podcast_ids.contains(&-1) { + let podcast_ids_str = podcast_ids.iter().map(|id| id.to_string()).collect::>().join(","); + where_conditions.push(format!("p.podcastid IN ({})", podcast_ids_str)); + } + } + + // Play state filters + let mut play_state_conditions = Vec::new(); + if playlist.try_get::("includeunplayed")? { + play_state_conditions.push("(h.listenduration IS NULL OR h.listenduration = 0)".to_string()); + } + if playlist.try_get::("includepartiallyplayed")? { + play_state_conditions.push( + "(h.listenduration > 0 AND h.listenduration < e.episodeduration * 0.9 AND (e.episodeduration - h.listenduration) > 30)".to_string() + ); + } + if playlist.try_get::("includeplayed")? { + play_state_conditions.push( + "(h.listenduration IS NOT NULL AND (h.listenduration >= e.episodeduration * 0.9 OR (e.episodeduration - h.listenduration) <= 30))".to_string() + ); + } + + if !play_state_conditions.is_empty() { + where_conditions.push(format!("({})", play_state_conditions.join(" OR "))); + } else { + where_conditions.push("FALSE".to_string()); + } + + // Progress filters + if let Some(min_progress) = playlist.try_get::, _>("playprogressmin")? { + where_conditions.push(format!( + "(COALESCE(h.listenduration, 0)::float / NULLIF(e.episodeduration, 0)) >= {}", + min_progress / 100.0 + )); + } + if let Some(max_progress) = playlist.try_get::, _>("playprogressmax")? { + where_conditions.push(format!( + "(COALESCE(h.listenduration, 0)::float / NULLIF(e.episodeduration, 0)) <= {}", + max_progress / 100.0 + )); + } + + let where_clause = if where_conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", where_conditions.join(" AND ")) + }; + + let final_query = format!("{}{}", query_parts.join(" "), where_clause); + + let count: i64 = sqlx::query_scalar(&final_query) + .bind(user_id) + .fetch_one(pool) + .await?; + + // Apply MaxEpisodes limit if specified + let final_count = if let Some(max_eps) = playlist.try_get::, _>("maxepisodes")? { + if max_eps > 0 { + std::cmp::min(count as i32, max_eps) + } else { + count as i32 + } + } else { + count as i32 + }; + + Ok(final_count) + } + DatabasePool::MySQL(pool) => { + // Similar implementation for MySQL with adjusted syntax + let raw_user_timezone: String = sqlx::query_scalar("SELECT TimeZone FROM Users WHERE UserID = ?") + .bind(user_id).fetch_optional(pool).await?.unwrap_or_else(|| "UTC".to_string()); + let raw_timezone = if raw_user_timezone.is_empty() { "UTC".to_string() } else { raw_user_timezone }; + let user_timezone = Self::map_timezone_for_postgres(&raw_timezone); + + let playlist_row = sqlx::query( + r#"SELECT UserID, Name, MinDuration, MaxDuration, SortOrder, + IncludeUnplayed, IncludePartiallyPlayed, IncludePlayed, TimeFilterHours, + GroupByPodcast, MaxEpisodes, PlayProgressMin, PlayProgressMax, PodcastIDs + FROM Playlists WHERE PlaylistID = ?"# + ).bind(playlist_id).fetch_optional(pool).await?; + + let playlist = playlist_row.ok_or_else(|| crate::error::AppError::not_found("Playlist not found"))?; + + // MySQL count query with similar logic + let mut query_parts = Vec::new(); + let mut where_conditions = Vec::new(); + + query_parts.push(format!(r#" + SELECT COUNT(DISTINCT e.EpisodeID) + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID AND p.UserID = {} + LEFT JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = {}"#, + user_id, user_id + )); + + where_conditions.push("p.UserID = ?".to_string()); + + // Apply all MySQL filters + if let Some(min_dur) = playlist.try_get::, _>("MinDuration")? { + where_conditions.push(format!("e.EpisodeDuration >= {}", min_dur)); + } + if let Some(max_dur) = playlist.try_get::, _>("MaxDuration")? { + where_conditions.push(format!("e.EpisodeDuration <= {}", max_dur)); + } + + if let Some(hours) = playlist.try_get::, _>("TimeFilterHours")? { + where_conditions.push(format!( + "e.EpisodePubDate >= DATE_SUB(CONVERT_TZ(NOW(), 'UTC', '{}'), INTERVAL {} HOUR)", + user_timezone, hours + )); + } + + // Continue with all other MySQL filters... + let where_clause = if where_conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", where_conditions.join(" AND ")) + }; + + let final_query = format!("{}{}", query_parts.join(" "), where_clause); + + let count: i64 = sqlx::query_scalar(&final_query) + .bind(user_id) + .fetch_one(pool) + .await?; + + let final_count = if let Some(max_eps) = playlist.try_get::, _>("MaxEpisodes")? { + if max_eps > 0 { + std::cmp::min(count as i32, max_eps) + } else { + count as i32 + } + } else { + count as i32 + }; + + Ok(final_count) + } + } + } + + /// Convert chrono-tz timezone names to PostgreSQL-compatible timezone names + /// This handles the mapping between frontend timezone selections and database queries + fn map_timezone_for_postgres(user_timezone: &str) -> String { + match user_timezone { + // US timezone mappings + "US/Alaska" => "America/Anchorage".to_string(), + "US/Aleutian" => "America/Adak".to_string(), + "US/Arizona" => "America/Phoenix".to_string(), + "US/Central" => "America/Chicago".to_string(), + "US/East-Indiana" => "America/Indiana/Indianapolis".to_string(), + "US/Eastern" => "America/New_York".to_string(), + "US/Hawaii" => "Pacific/Honolulu".to_string(), + "US/Indiana-Starke" => "America/Indiana/Knox".to_string(), + "US/Michigan" => "America/Detroit".to_string(), + "US/Mountain" => "America/Denver".to_string(), + "US/Pacific" => "America/Los_Angeles".to_string(), + "US/Samoa" => "Pacific/Pago_Pago".to_string(), + + // Canada timezone mappings + "Canada/Atlantic" => "America/Halifax".to_string(), + "Canada/Central" => "America/Winnipeg".to_string(), + "Canada/Eastern" => "America/Toronto".to_string(), + "Canada/Mountain" => "America/Edmonton".to_string(), + "Canada/Newfoundland" => "America/St_Johns".to_string(), + "Canada/Pacific" => "America/Vancouver".to_string(), + "Canada/Saskatchewan" => "America/Regina".to_string(), + "Canada/Yukon" => "America/Whitehorse".to_string(), + + // Brazil timezone mappings + "Brazil/Acre" => "America/Rio_Branco".to_string(), + "Brazil/DeNoronha" => "America/Noronha".to_string(), + "Brazil/East" => "America/Sao_Paulo".to_string(), + "Brazil/West" => "America/Manaus".to_string(), + + // Chile timezone mappings + "Chile/Continental" => "America/Santiago".to_string(), + "Chile/EasterIsland" => "Pacific/Easter".to_string(), + + // Mexico timezone mappings + "Mexico/BajaNorte" => "America/Tijuana".to_string(), + "Mexico/BajaSur" => "America/Mazatlan".to_string(), + "Mexico/General" => "America/Mexico_City".to_string(), + + // Common US legacy timezone abbreviations + "EST" => "America/New_York".to_string(), + "CST" => "America/Chicago".to_string(), + "MST" => "America/Denver".to_string(), + "PST" => "America/Los_Angeles".to_string(), + "HST" => "Pacific/Honolulu".to_string(), + "EST5EDT" => "America/New_York".to_string(), + "CST6CDT" => "America/Chicago".to_string(), + "MST7MDT" => "America/Denver".to_string(), + "PST8PDT" => "America/Los_Angeles".to_string(), + + // European legacy mappings + "CET" => "Europe/Paris".to_string(), + "EET" => "Europe/Helsinki".to_string(), + "WET" => "Europe/Lisbon".to_string(), + "MET" => "Europe/Paris".to_string(), + + // Common international legacy mappings + "GMT" => "UTC".to_string(), + "GMT+0" => "UTC".to_string(), + "GMT-0" => "UTC".to_string(), + "GMT0" => "UTC".to_string(), + "Greenwich" => "UTC".to_string(), + "UCT" => "UTC".to_string(), + "Universal" => "UTC".to_string(), + "Zulu" => "UTC".to_string(), + + // Country/region legacy mappings + "Cuba" => "America/Havana".to_string(), + "Egypt" => "Africa/Cairo".to_string(), + "Eire" => "Europe/Dublin".to_string(), + "GB" => "Europe/London".to_string(), + "GB-Eire" => "Europe/London".to_string(), + "Hongkong" => "Asia/Hong_Kong".to_string(), + "Iceland" => "Atlantic/Reykjavik".to_string(), + "Iran" => "Asia/Tehran".to_string(), + "Israel" => "Asia/Jerusalem".to_string(), + "Jamaica" => "America/Jamaica".to_string(), + "Japan" => "Asia/Tokyo".to_string(), + "Kwajalein" => "Pacific/Kwajalein".to_string(), + "Libya" => "Africa/Tripoli".to_string(), + "NZ" => "Pacific/Auckland".to_string(), + "NZ-CHAT" => "Pacific/Chatham".to_string(), + "Navajo" => "America/Denver".to_string(), + "PRC" => "Asia/Shanghai".to_string(), + "Poland" => "Europe/Warsaw".to_string(), + "Portugal" => "Europe/Lisbon".to_string(), + "ROC" => "Asia/Taipei".to_string(), + "ROK" => "Asia/Seoul".to_string(), + "Singapore" => "Asia/Singapore".to_string(), + "Turkey" => "Europe/Istanbul".to_string(), + "W-SU" => "Europe/Moscow".to_string(), + + // If it's already a valid IANA timezone name or unknown, pass through + _ => { + // For unknown timezones, fall back to UTC to prevent errors + if user_timezone.is_empty() { + "UTC".to_string() + } else { + // Most chrono-tz names are already IANA compliant, so try the original first + user_timezone.to_string() + } + } + } + } + + // Get playlist episodes dynamically without using PlaylistContents table + // ULTRA-PRECISE implementation covering ALL playlist options with timezone awareness + pub async fn get_playlist_episodes_dynamic( + &self, + playlist_id: i32, + user_id: i32 + ) -> AppResult { + use tracing::{info, debug, warn}; + + debug!("🎵 Getting dynamic playlist episodes for playlist {} user {}", playlist_id, user_id); + + match self { + DatabasePool::Postgres(pool) => { + // Get user timezone for proper date calculations + let raw_user_timezone: String = sqlx::query_scalar( + r#"SELECT timezone FROM "Users" WHERE userid = $1"# + ).bind(user_id).fetch_optional(pool).await?.unwrap_or_else(|| "UTC".to_string()); + let raw_timezone = if raw_user_timezone.is_empty() { "UTC".to_string() } else { raw_user_timezone }; + let user_timezone = Self::map_timezone_for_postgres(&raw_timezone); + + debug!("User {} timezone: {} -> {}", user_id, raw_timezone, user_timezone); + + // Get playlist configuration with ALL fields + let playlist_row = sqlx::query( + r#"SELECT userid, name, description, minduration, maxduration, sortorder, + includeunplayed, includepartiallyplayed, includeplayed, timefilterhours, + groupbypodcast, maxepisodes, playprogressmin, playprogressmax, podcastids, + issystemplaylist, created, iconname, episodecount + FROM "Playlists" WHERE playlistid = $1"# + ).bind(playlist_id).fetch_optional(pool).await?; + + let playlist = playlist_row.ok_or_else(|| crate::error::AppError::not_found("Playlist not found"))?; + + // Check user permissions - users can only access their own playlists + if playlist.try_get::("userid")? != user_id { + return Err(crate::error::AppError::forbidden("You can only access your own playlists")); + } + + debug!("📋 Playlist '{}' config: min_dur={:?}, max_dur={:?}, sort={}, time_filter={:?}, progress_min={:?}, progress_max={:?}", + playlist.try_get::("name")?, playlist.try_get::, _>("minduration")?, playlist.try_get::, _>("maxduration")?, playlist.try_get::("sortorder")?, + playlist.try_get::, _>("timefilterhours")?, playlist.try_get::, _>("playprogressmin")?, playlist.try_get::, _>("playprogressmax")?); + + // Build the comprehensive dynamic query + let mut query_parts = Vec::new(); + let mut where_conditions = Vec::new(); + let mut bind_values: Vec> = Vec::new(); + + // Base SELECT with all episode data needed for SavedEpisode model including podcastid + query_parts.push(format!(r#" + SELECT DISTINCT + e.episodetitle, + p.podcastname, + TO_CHAR(e.episodepubdate AT TIME ZONE '{}', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as episodepubdate, + e.episodedescription, + COALESCE(e.episodeartwork, p.artworkurl) as episodeartwork, + e.episodeurl, + e.episodeduration, + COALESCE(h.listenduration, 0) as listenduration, + e.episodeid, + COALESCE(p.websiteurl, '') as websiteurl, + -- ULTRA-PRECISE completion logic: 90% threshold OR within 30 seconds of end + CASE WHEN + h.listenduration IS NOT NULL AND ( + h.listenduration >= e.episodeduration * 0.9 OR + (e.episodeduration - h.listenduration) <= 30 + ) + THEN true ELSE false END as completed, + EXISTS(SELECT 1 FROM "SavedEpisodes" se WHERE se.episodeid = e.episodeid AND se.userid = {}) as saved, + EXISTS(SELECT 1 FROM "EpisodeQueue" eq WHERE eq.episodeid = e.episodeid AND eq.userid = {}) as queued, + EXISTS(SELECT 1 FROM "DownloadedEpisodes" de WHERE de.episodeid = e.episodeid AND de.userid = {}) as downloaded, + false as is_youtube, + p.podcastid, + -- Progress percentage for debugging + ROUND(((COALESCE(h.listenduration, 0)::float / NULLIF(e.episodeduration, 0)) * 100)::numeric, 2) as progress_percent + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid AND p.userid = {} + LEFT JOIN "UserEpisodeHistory" h ON e.episodeid = h.episodeid AND h.userid = {}"#, + user_timezone, user_id, user_id, user_id, user_id, user_id + )); + + // Base condition - always filter by user's podcasts + where_conditions.push("p.userid = $1".to_string()); + + // 1. DURATION FILTERS - exact duration matching + if let Some(min_dur) = playlist.try_get::, _>("minduration")? { + where_conditions.push(format!("e.episodeduration >= {}", min_dur)); + debug!("🕒 Added min duration filter: {} seconds", min_dur); + } + if let Some(max_dur) = playlist.try_get::, _>("maxduration")? { + where_conditions.push(format!("e.episodeduration <= {}", max_dur)); + debug!("🕒 Added max duration filter: {} seconds", max_dur); + } + + // 2. TIMEZONE-AWARE TIME FILTER - ULTRA-PRECISE datetime handling + if let Some(hours) = playlist.try_get::, _>("timefilterhours")? { + // Convert hours to user's timezone for precise "last X hours" calculation + where_conditions.push(format!( + "e.episodepubdate >= (NOW() AT TIME ZONE '{}' - INTERVAL '{} hours') AT TIME ZONE '{}' AT TIME ZONE 'UTC'", + user_timezone, hours, user_timezone + )); + debug!("📅 Added timezone-aware time filter: last {} hours in timezone {}", hours, user_timezone); + } + + // 3. PODCAST FILTER - handle PostgreSQL array of podcast IDs + if let Some(podcast_ids) = playlist.try_get::>, _>("podcastids")? { + if !podcast_ids.is_empty() && !podcast_ids.contains(&-1) { + let podcast_ids_str = podcast_ids.iter().map(|id| id.to_string()).collect::>().join(","); + where_conditions.push(format!("p.podcastid IN ({})", podcast_ids_str)); + debug!("🎙️ Added PostgreSQL podcast filter: {:?}", podcast_ids); + } else { + debug!("🎙️ PostgreSQL podcast filter contains -1 or is empty, including all podcasts"); + } + } + + // 4. ULTRA-PRECISE PLAY STATE FILTERS + let mut play_state_conditions = Vec::new(); + + if playlist.try_get::("includeunplayed")? { + // UNPLAYED: No history record OR listen duration is 0 or NULL + play_state_conditions.push("(h.listenduration IS NULL OR h.listenduration = 0)".to_string()); + debug!("▶️ Including UNPLAYED episodes"); + } + + if playlist.try_get::("includepartiallyplayed")? { + // PARTIALLY PLAYED: Has listen time > 0 but < 90% AND not within 30 seconds of end + play_state_conditions.push( + "(h.listenduration > 0 AND h.listenduration < e.episodeduration * 0.9 AND (e.episodeduration - h.listenduration) > 30)".to_string() + ); + debug!("⏸️ Including PARTIALLY PLAYED episodes (>0% and <90%, not within 30s of end)"); + } + + if playlist.try_get::("includeplayed")? { + // PLAYED: Listen duration >= 90% OR within 30 seconds of end + play_state_conditions.push( + "(h.listenduration IS NOT NULL AND (h.listenduration >= e.episodeduration * 0.9 OR (e.episodeduration - h.listenduration) <= 30))".to_string() + ); + debug!("✅ Including PLAYED episodes (>=90% or within 30s of end)"); + } + + if !play_state_conditions.is_empty() { + where_conditions.push(format!("({})", play_state_conditions.join(" OR "))); + } else { + // No play states selected - return no results + where_conditions.push("FALSE".to_string()); + warn!("⚠️ No play states selected for playlist '{}' - will return empty results", playlist.try_get::("name")?); + } + + // 5. ULTRA-PRECISE PROGRESS PERCENTAGE FILTERS + if let Some(min_progress) = playlist.try_get::, _>("playprogressmin")? { + let min_decimal = min_progress / 100.0; + where_conditions.push(format!( + "(COALESCE(h.listenduration, 0)::float / NULLIF(e.episodeduration, 0)) >= {}", + min_decimal + )); + debug!("📊 Added min progress filter: {}% ({})", min_progress, min_decimal); + } + if let Some(max_progress) = playlist.try_get::, _>("playprogressmax")? { + let max_decimal = max_progress / 100.0; + where_conditions.push(format!( + "(COALESCE(h.listenduration, 0)::float / NULLIF(e.episodeduration, 0)) <= {}", + max_decimal + )); + debug!("📊 Added max progress filter: {}% ({})", max_progress, max_decimal); + } + + // 6. ULTRA-PRECISE ORDERING with podcast grouping support + let mut order_parts = Vec::new(); + + if playlist.try_get::("groupbypodcast")? { + order_parts.push("p.podcastid".to_string()); + debug!("📚 Grouping by podcast enabled"); + } + + let sort_clause = match playlist.try_get::("sortorder")?.as_str() { + "date_asc" => "episodepubdate ASC", + "date_desc" => "episodepubdate DESC", + "duration_asc" => "episodeduration ASC", + "duration_desc" => "episodeduration DESC", + "listen_progress" => "(COALESCE(h.listenduration, 0)::float / NULLIF(e.episodeduration, 0)) DESC", + "completion" => "(COALESCE(h.listenduration, 0)::float / NULLIF(e.episodeduration, 0)) DESC", + "random" => "RANDOM()", + "title_asc" => "episodetitle ASC", + "title_desc" => "episodetitle DESC", + "podcast_asc" => "podcastname ASC", + "podcast_desc" => "podcastname DESC", + _ => { + warn!("⚠️ Unknown sort order '{}', defaulting to date_desc", playlist.try_get::("sortorder")?); + "episodepubdate DESC" + } + }; + order_parts.push(sort_clause.to_string()); + debug!("🔄 Sort order: {}", sort_clause); + + // 7. BUILD FINAL QUERY + let where_clause = if where_conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", where_conditions.join(" AND ")) + }; + + let order_clause = format!(" ORDER BY {}", order_parts.join(", ")); + + // Apply MaxEpisodes limit if specified (playlist setting, not pagination) + let limit_clause = if let Some(max_eps) = playlist.try_get::, _>("maxepisodes")? { + if max_eps > 0 { + format!(" LIMIT {}", max_eps) + } else { + String::new() + } + } else { + String::new() + }; + + let final_query = format!("{}{}{}{}", + query_parts.join(" "), where_clause, order_clause, limit_clause); + + debug!("🔍 Final dynamic playlist query: {}", final_query); + + // Execute the main query + let rows = sqlx::query(&final_query) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::models::SavedEpisode { + episodetitle: row.try_get("episodetitle")?, + podcastname: row.try_get("podcastname")?, + episodepubdate: row.try_get("episodepubdate")?, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + websiteurl: row.try_get("websiteurl")?, + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + podcastid: row.try_get("podcastid").ok(), + }); + } + + debug!("📝 Retrieved {} episodes from dynamic query", episodes.len()); + + // Create playlist info from the playlist row we already have + let playlist_info = crate::models::PlaylistInfo { + name: playlist.try_get::("name")?, + description: playlist.try_get::("description")?, + episode_count: episodes.len() as i32, + icon_name: playlist.try_get::("iconname")?, + }; + + Ok(crate::models::PlaylistEpisodesResponse { + episodes, + playlist_info, + }) + } + DatabasePool::MySQL(pool) => { + // FULL MySQL implementation with exact same logic but MySQL syntax + let raw_user_timezone: String = sqlx::query_scalar("SELECT TimeZone FROM Users WHERE UserID = ?").bind(user_id) + .fetch_optional(pool).await?.unwrap_or_else(|| "UTC".to_string()); + let raw_timezone = if raw_user_timezone.is_empty() { "UTC".to_string() } else { raw_user_timezone }; + let user_timezone = Self::map_timezone_for_postgres(&raw_timezone); + + debug!("User {} timezone: {} -> {}", user_id, raw_timezone, user_timezone); + + let playlist_row = sqlx::query( + r#"SELECT UserID, Name, Description, MinDuration, MaxDuration, SortOrder, + IncludeUnplayed, IncludePartiallyPlayed, IncludePlayed, TimeFilterHours, + GroupByPodcast, MaxEpisodes, PlayProgressMin, PlayProgressMax, PodcastIDs, + IsSystemPlaylist, Created, IconName, EpisodeCount + FROM Playlists WHERE PlaylistID = ?"# + ).bind(playlist_id).fetch_optional(pool).await?; + + let playlist = playlist_row.ok_or_else(|| crate::error::AppError::not_found("Playlist not found"))?; + + if playlist.try_get::("UserID")? != user_id { + return Err(crate::error::AppError::forbidden("You can only access your own playlists")); + } + + debug!("📋 MySQL Playlist '{}' config loaded", playlist.try_get::("Name")?); + + // MySQL version with adjusted syntax for timezone conversion and date handling + let mut query_parts = Vec::new(); + let mut where_conditions = Vec::new(); + + query_parts.push(format!(r#" + SELECT DISTINCT + e.EpisodeTitle as episodetitle, + p.PodcastName as podcastname, + DATE_FORMAT(CONVERT_TZ(e.EpisodePubDate, 'UTC', '{}'), '%Y-%m-%dT%H:%i:%sZ') as episodepubdate, + e.EpisodeDescription as episodedescription, + COALESCE(e.EpisodeArtwork, p.ArtworkURL) as episodeartwork, + e.EpisodeURL as episodeurl, + e.EpisodeDuration as episodeduration, + COALESCE(h.ListenDuration, 0) as listenduration, + e.EpisodeID as episodeid, + COALESCE(p.WebsiteURL, '') as websiteurl, + CASE WHEN + h.ListenDuration IS NOT NULL AND ( + h.ListenDuration >= e.EpisodeDuration * 0.9 OR + (e.EpisodeDuration - h.ListenDuration) <= 30 + ) + THEN 1 ELSE 0 END as completed, + EXISTS(SELECT 1 FROM SavedEpisodes se WHERE se.EpisodeID = e.EpisodeID AND se.UserID = {}) as saved, + EXISTS(SELECT 1 FROM EpisodeQueue eq WHERE eq.EpisodeID = e.EpisodeID AND eq.UserID = {}) as queued, + EXISTS(SELECT 1 FROM DownloadedEpisodes de WHERE de.EpisodeID = e.EpisodeID AND de.UserID = {}) as downloaded, + 0 as is_youtube, + p.PodcastID as podcastid, + ROUND((COALESCE(h.ListenDuration, 0) / NULLIF(e.EpisodeDuration, 0)) * 100, 2) as progress_percent + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID AND p.UserID = {} + LEFT JOIN UserEpisodeHistory h ON e.EpisodeID = h.EpisodeID AND h.UserID = {}"#, + user_timezone, user_id, user_id, user_id, user_id, user_id + )); + + where_conditions.push("p.UserID = ?".to_string()); + + // Apply all the same filters with MySQL syntax + if let Some(min_dur) = playlist.try_get::, _>("MinDuration")? { + where_conditions.push(format!("e.EpisodeDuration >= {}", min_dur)); + } + if let Some(max_dur) = playlist.try_get::, _>("MaxDuration")? { + where_conditions.push(format!("e.EpisodeDuration <= {}", max_dur)); + } + + if let Some(hours) = playlist.try_get::, _>("TimeFilterHours")? { + where_conditions.push(format!( + "e.EpisodePubDate >= DATE_SUB(CONVERT_TZ(NOW(), 'UTC', '{}'), INTERVAL {} HOUR)", + user_timezone, hours + )); + debug!("📅 Added MySQL timezone-aware time filter: last {} hours in timezone {}", hours, user_timezone); + } + + // 3. PODCAST FILTER - handle JSON array of podcast IDs (MySQL) + if let Some(podcast_ids_json) = playlist.try_get::, _>("PodcastIDs")?.as_ref() { + if !podcast_ids_json.is_empty() && podcast_ids_json != "[]" && podcast_ids_json != "null" { + match serde_json::from_str::>(podcast_ids_json) { + Ok(podcast_ids) if !podcast_ids.is_empty() && !podcast_ids.contains(&-1) => { + let podcast_ids_str = podcast_ids.iter().map(|id| id.to_string()).collect::>().join(","); + where_conditions.push(format!("p.PodcastID IN ({})", podcast_ids_str)); + debug!("🎙️ Added MySQL podcast filter: {:?}", podcast_ids); + } + Ok(_) => debug!("🎙️ MySQL podcast filter contains -1 or is empty, including all podcasts"), + Err(e) => warn!("⚠️ Failed to parse MySQL podcast IDs JSON '{}': {}", podcast_ids_json, e), + } + } + } + + // 4. ULTRA-PRECISE PLAY STATE FILTERS (MySQL) + let mut play_state_conditions = Vec::new(); + + if playlist.try_get::("IncludeUnplayed")? { + play_state_conditions.push("(h.ListenDuration IS NULL OR h.ListenDuration = 0)".to_string()); + debug!("▶️ Including UNPLAYED episodes (MySQL)"); + } + + if playlist.try_get::("IncludePartiallyPlayed")? { + play_state_conditions.push( + "(h.ListenDuration > 0 AND h.ListenDuration < e.EpisodeDuration * 0.9 AND (e.EpisodeDuration - h.ListenDuration) > 30)".to_string() + ); + debug!("⏸️ Including PARTIALLY PLAYED episodes (MySQL)"); + } + + if playlist.try_get::("IncludePlayed")? { + play_state_conditions.push( + "(h.ListenDuration IS NOT NULL AND (h.ListenDuration >= e.EpisodeDuration * 0.9 OR (e.EpisodeDuration - h.ListenDuration) <= 30))".to_string() + ); + debug!("✅ Including PLAYED episodes (MySQL)"); + } + + if !play_state_conditions.is_empty() { + where_conditions.push(format!("({})", play_state_conditions.join(" OR "))); + } else { + where_conditions.push("FALSE".to_string()); + warn!("⚠️ No play states selected for MySQL playlist '{}' - will return empty results", playlist.try_get::("Name")?); + } + + // 5. ULTRA-PRECISE PROGRESS PERCENTAGE FILTERS (MySQL) + if let Some(min_progress) = playlist.try_get::, _>("PlayProgressMin")? { + let min_decimal = min_progress / 100.0; + where_conditions.push(format!( + "(COALESCE(h.ListenDuration, 0) / NULLIF(e.EpisodeDuration, 0)) >= {}", + min_decimal + )); + debug!("📊 Added MySQL min progress filter: {}% ({})", min_progress, min_decimal); + } + if let Some(max_progress) = playlist.try_get::, _>("PlayProgressMax")? { + let max_decimal = max_progress / 100.0; + where_conditions.push(format!( + "(COALESCE(h.ListenDuration, 0) / NULLIF(e.EpisodeDuration, 0)) <= {}", + max_decimal + )); + debug!("📊 Added MySQL max progress filter: {}% ({})", max_progress, max_decimal); + } + + // 6. ULTRA-PRECISE ORDERING with podcast grouping support (MySQL) + let mut order_parts = Vec::new(); + + if playlist.try_get::("GroupByPodcast")? { + order_parts.push("podcastid".to_string()); + debug!("📚 MySQL grouping by podcast enabled"); + } + + let sort_clause = match playlist.try_get::("SortOrder")?.as_str() { + "date_asc" => "episodepubdate ASC", + "date_desc" => "episodepubdate DESC", + "duration_asc" => "episodeduration ASC", + "duration_desc" => "episodeduration DESC", + "listen_progress" => "(COALESCE(h.ListenDuration, 0) / NULLIF(e.EpisodeDuration, 0)) DESC", + "completion" => "(COALESCE(h.ListenDuration, 0) / NULLIF(e.EpisodeDuration, 0)) DESC", + "random" => "RAND()", + "title_asc" => "episodetitle ASC", + "title_desc" => "episodetitle DESC", + "podcast_asc" => "podcastname ASC", + "podcast_desc" => "podcastname DESC", + _ => { + warn!("⚠️ Unknown MySQL sort order '{}', defaulting to date_desc", playlist.try_get::("SortOrder")?); + "episodepubdate DESC" + } + }; + order_parts.push(sort_clause.to_string()); + debug!("🔄 MySQL Sort order: {}", sort_clause); + + // 7. BUILD FINAL MYSQL QUERY + let where_clause = if where_conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", where_conditions.join(" AND ")) + }; + + let order_clause = format!(" ORDER BY {}", order_parts.join(", ")); + + // Apply MaxEpisodes limit if specified (MySQL) + let limit_clause = if let Some(max_eps) = playlist.try_get::, _>("MaxEpisodes")? { + if max_eps > 0 { + format!(" LIMIT {}", max_eps) + } else { + String::new() + } + } else { + String::new() + }; + + let final_query = format!("{}{}{}{}", + query_parts.join(" "), where_clause, order_clause, limit_clause); + + debug!("🔍 Final MySQL dynamic playlist query: {}", final_query); + + // Execute the main MySQL query + let rows = sqlx::query(&final_query) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(crate::models::SavedEpisode { + episodetitle: row.try_get("episodetitle")?, + podcastname: row.try_get("podcastname")?, + episodepubdate: row.try_get("episodepubdate")?, + episodedescription: row.try_get("episodedescription")?, + episodeartwork: row.try_get("episodeartwork")?, + episodeurl: row.try_get("episodeurl")?, + episodeduration: row.try_get("episodeduration")?, + listenduration: row.try_get("listenduration").ok(), + episodeid: row.try_get("episodeid")?, + websiteurl: row.try_get("websiteurl")?, + completed: row.try_get("completed")?, + saved: row.try_get("saved")?, + queued: row.try_get("queued")?, + downloaded: row.try_get("downloaded")?, + is_youtube: row.try_get("is_youtube")?, + podcastid: row.try_get("podcastid").ok(), + }); + } + + debug!("📝 Retrieved {} episodes from MySQL dynamic query", episodes.len()); + + // Create playlist info from the MySQL playlist row we already have + let playlist_info = crate::models::PlaylistInfo { + name: playlist.try_get::("Name")?, + description: playlist.try_get::("Description")?, + episode_count: episodes.len() as i32, + icon_name: playlist.try_get::("IconName")?, + }; + + Ok(crate::models::PlaylistEpisodesResponse { + episodes, + playlist_info, + }) + } + } + } +} + +// Standalone create_playlist function that matches Python API +pub async fn create_playlist(pool: &DatabasePool, config: &Config, playlist_data: &crate::models::CreatePlaylistRequest) -> AppResult { + pool.create_playlist(config, playlist_data).await +} + +// Standalone delete_playlist function that matches Python API +pub async fn delete_playlist(pool: &DatabasePool, _config: &Config, playlist_data: &crate::models::DeletePlaylistRequest) -> AppResult<()> { + pool.delete_playlist(playlist_data.user_id, playlist_data.playlist_id).await +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/error.rs b/PinePods-0.8.2/rust-api/src/error.rs new file mode 100644 index 0000000..0e775f1 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/error.rs @@ -0,0 +1,140 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +pub type AppResult = Result; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("Redis error: {0}")] + Redis(#[from] redis::RedisError), + + #[error("HTTP client error: {0}")] + Http(#[from] reqwest::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("Authentication error: {0}")] + Auth(String), + + #[error("Authorization error: {0}")] + Authorization(String), + + #[error("Validation error: {0}")] + Validation(String), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Conflict: {0}")] + Conflict(String), + + #[error("Bad request: {0}")] + BadRequest(String), + + #[error("Internal server error: {0}")] + Internal(String), + + #[error("Scheduler error: {0}")] + Scheduler(#[from] tokio_cron_scheduler::JobSchedulerError), + + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), + + #[error("Feed parsing error: {0}")] + FeedParsing(String), + + #[error("Email sending error: {0}")] + Email(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, error_message) = match &self { + AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + AppError::Redis(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cache error"), + AppError::Http(_) => (StatusCode::BAD_GATEWAY, "External service error"), + AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IO error"), + AppError::Serialization(_) => (StatusCode::BAD_REQUEST, "Serialization error"), + AppError::Config(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Configuration error"), + AppError::Auth(_) => (StatusCode::UNAUTHORIZED, "Authentication failed"), + AppError::Authorization(_) => (StatusCode::FORBIDDEN, "Authorization failed"), + AppError::Validation(_) => (StatusCode::BAD_REQUEST, "Validation error"), + AppError::NotFound(_) => (StatusCode::NOT_FOUND, "Resource not found"), + AppError::Conflict(_) => (StatusCode::CONFLICT, "Resource conflict"), + AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad request"), + AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"), + AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "Service unavailable"), + AppError::FeedParsing(_) => (StatusCode::BAD_REQUEST, "Feed parsing error"), + AppError::Email(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Email error"), + AppError::Scheduler(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Scheduler error"), + }; + + let body = Json(json!({ + "error": error_message, + "message": self.to_string(), + "status_code": status.as_u16(), + })); + + // Log the error for debugging (in production, you might want to use structured logging) + tracing::error!("API Error: {} - {}", status.as_u16(), self); + + (status, body).into_response() + } +} + +// Helper function to create internal server errors +impl From> for AppError { + fn from(err: Box) -> Self { + AppError::Internal(err.to_string()) + } +} + +// Helper function for creating auth errors +impl AppError { + pub fn unauthorized(msg: impl Into) -> Self { + AppError::Auth(msg.into()) + } + + pub fn forbidden(msg: impl Into) -> Self { + AppError::Authorization(msg.into()) + } + + pub fn not_found(msg: impl Into) -> Self { + AppError::NotFound(msg.into()) + } + + pub fn bad_request(msg: impl Into) -> Self { + AppError::BadRequest(msg.into()) + } + + pub fn internal(msg: impl Into) -> Self { + AppError::Internal(msg.into()) + } + + pub fn validation(msg: impl Into) -> Self { + AppError::Validation(msg.into()) + } + + pub fn external_error(msg: impl Into) -> Self { + AppError::Internal(msg.into()) + } + + pub fn database_error(msg: impl Into) -> Self { + AppError::Internal(msg.into()) + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/auth.rs b/PinePods-0.8.2/rust-api/src/handlers/auth.rs new file mode 100644 index 0000000..74de3b7 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/auth.rs @@ -0,0 +1,1679 @@ +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::{Json, Html, IntoResponse}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use base64::{Engine as _, engine::general_purpose::STANDARD}; + +use crate::{ + error::{AppError, AppResult}, + handlers::{extract_api_key, check_user_or_admin_access}, + AppState, +}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + + +// Global storage for password-verified sessions pending MFA +// Key: session_token, Value: (user_id, timestamp) +lazy_static::lazy_static! { + static ref PENDING_MFA_SESSIONS: Arc>> = + Arc::new(Mutex::new(HashMap::new())); +} + +#[derive(Serialize)] +pub struct LoginResponse { + status: String, + retrieved_key: Option, + mfa_required: Option, + user_id: Option, + mfa_session_token: Option, +} + +#[derive(Serialize)] +pub struct MfaRequiredResponse { + status: String, + mfa_required: bool, + user_id: i32, +} + +#[derive(Serialize)] +pub struct VerifyKeyResponse { + status: String, +} + +#[derive(Serialize)] +pub struct GetUserResponse { + status: String, + retrieved_id: i32, +} + +#[derive(Serialize)] +#[allow(non_snake_case)] +pub struct UserDetails { + pub UserID: i32, + pub Fullname: Option, + pub Username: Option, + pub Email: Option, + pub Hashed_PW: Option, + pub Salt: Option, +} + +#[derive(Serialize)] +pub struct SelfServiceStatusResponse { + pub status: bool, + pub first_admin_created: bool, +} + +#[derive(Serialize)] +pub struct PublicOidcProvidersResponse { + pub providers: Vec, +} + +#[derive(Serialize)] +pub struct PublicOidcProviderResponse { + pub provider_id: i32, + pub provider_name: String, + pub client_id: String, + pub authorization_url: String, + pub scope: String, + pub button_color: String, + pub button_text: String, + pub button_text_color: String, + pub icon_svg: Option, +} + +#[derive(Deserialize)] +pub struct CreateFirstAdminRequest { + pub username: String, + pub password: String, + pub email: String, + pub fullname: String, +} + +#[derive(Deserialize)] +pub struct TimeZoneInfo { + pub user_id: i32, + pub timezone: String, + pub hour_pref: i32, + pub date_format: String, +} + +#[derive(Deserialize)] +pub struct UpdateTimezoneRequest { + pub user_id: i32, + pub timezone: String, +} + +#[derive(Deserialize)] +pub struct UpdateDateFormatRequest { + pub user_id: i32, + pub date_format: String, +} + +#[derive(Deserialize)] +pub struct UpdateTimeFormatRequest { + pub user_id: i32, + pub hour_pref: i32, +} +#[derive(Deserialize)] +pub struct UpdateAutoCompleteSecondsRequest { + pub user_id: i32, + pub seconds: i32, +} + +#[derive(Deserialize)] +pub struct OPMLImportRequest { + pub podcasts: Vec, + pub user_id: i32, +} + +#[derive(Deserialize)] +pub struct ResetCodeRequest { + pub email: String, + pub username: String, +} + +#[derive(Serialize)] +pub struct ResetCodeResponse { + pub code_created: bool, +} + +#[derive(Deserialize)] +pub struct VerifyAndResetPasswordRequest { + pub reset_code: String, + pub email: String, + pub new_password: String, +} + +#[derive(Serialize)] +pub struct VerifyAndResetPasswordResponse { + pub message: String, +} + +#[derive(Serialize)] +pub struct CreateFirstAdminResponse { + pub message: String, + pub user_id: i32, +} + +#[derive(Serialize)] +pub struct ConfigResponse { + pub api_url: String, + pub proxy_url: String, + pub proxy_host: String, + pub proxy_port: String, + pub proxy_protocol: String, + pub reverse_proxy: String, + pub people_url: String, +} + +// Extract basic auth credentials from Authorization header +fn extract_basic_auth(headers: &HeaderMap) -> AppResult<(String, String)> { + let auth_header = headers + .get("Authorization") + .ok_or_else(|| AppError::unauthorized("Missing Authorization header"))? + .to_str() + .map_err(|_| AppError::unauthorized("Invalid Authorization header"))?; + + if !auth_header.starts_with("Basic ") { + return Err(AppError::unauthorized("Invalid Authorization scheme")); + } + + let encoded = &auth_header[6..]; // Remove "Basic " prefix + let decoded = STANDARD + .decode(encoded) + .map_err(|_| AppError::unauthorized("Invalid base64 encoding"))?; + + let credentials = String::from_utf8(decoded) + .map_err(|_| AppError::unauthorized("Invalid UTF-8 in credentials"))?; + + let parts: Vec<&str> = credentials.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err(AppError::unauthorized("Invalid credentials format")); + } + + Ok((parts[0].to_lowercase(), parts[1].to_string())) +} + +// Get API key with basic authentication (username/password) +// Now includes MFA security check - API key only returned after MFA verification if enabled +pub async fn get_key( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + // Check if standard login is disabled in favor of OIDC-only authentication + if state.config.oidc.disable_standard_login { + return Err(AppError::forbidden("Standard username/password login is disabled. Please use OIDC authentication.")); + } + + let (username, password) = extract_basic_auth(&headers)?; + + // Verify password + let is_valid = state.db_pool.verify_password(&username, &password).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid username or password")); + } + + // Get user ID from username first + let user_id = state.db_pool.get_user_id_from_username(&username).await?; + + // Check if MFA is enabled for this user - CRITICAL SECURITY CHECK + let mfa_enabled = state.db_pool.check_mfa_enabled(user_id).await?; + + if mfa_enabled { + // MFA is enabled - create secure session token and DO NOT return API key yet + // Generate cryptographically secure session token + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::rng(); + let session_token: String = (0..32) + .map(|_| { + let idx = rng.random_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect(); + + // Store session with timestamp (expires in 5 minutes) + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + { + let mut sessions = PENDING_MFA_SESSIONS.lock() + .map_err(|e| AppError::internal(&format!("Failed to lock MFA sessions: {}", e)))?; + sessions.insert(session_token.clone(), (user_id, timestamp)); + } + + // User must complete MFA verification first using this session token + return Ok(Json(LoginResponse { + status: "mfa_required".to_string(), + retrieved_key: None, + mfa_required: Some(true), + user_id: Some(user_id), + mfa_session_token: Some(session_token), + })); + } + + // MFA not enabled - proceed with normal flow + let api_key = state.db_pool.create_or_get_api_key(user_id).await?; + + Ok(Json(LoginResponse { + status: "success".to_string(), + retrieved_key: Some(api_key), + mfa_required: Some(false), + user_id: Some(user_id), + mfa_session_token: None, + })) +} + +// Verify API key validity +pub async fn verify_api_key_endpoint( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + Ok(Json(VerifyKeyResponse { + status: "success".to_string(), + })) +} + +// Get user ID from API key +pub async fn get_user( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + Ok(Json(GetUserResponse { + status: "success".to_string(), + retrieved_id: user_id, + })) +} + +// Get user details by user ID +pub async fn get_user_details_by_id( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization: users can only get their own details, have web key access, or be admin + if !check_user_or_admin_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("Access denied to user details")); + } + + // Get user details + let user_details = state.db_pool.get_user_details_by_id(user_id).await?; + + Ok(Json(user_details)) +} + +// Get self-service status - matches Python api_self_service_status +pub async fn get_self_service_status( + State(state): State, +) -> Result, AppError> { + let status = state.db_pool.get_self_service_status().await?; + + Ok(Json(SelfServiceStatusResponse { + status: status.status, + first_admin_created: status.admin_exists, + })) +} + +// Get public OIDC providers - matches Python api_public_oidc_providers +pub async fn get_public_oidc_providers( + State(state): State, +) -> Result, AppError> { + let providers = state.db_pool.get_public_oidc_providers().await?; + + let response_providers: Vec = providers + .into_iter() + .map(|p| PublicOidcProviderResponse { + provider_id: p.provider_id, + provider_name: p.provider_name, + client_id: p.client_id, + authorization_url: p.authorization_url, + scope: p.scope, + button_color: p.button_color, + button_text: p.button_text, + button_text_color: p.button_text_color, + icon_svg: p.icon_svg, + }) + .collect(); + + Ok(Json(PublicOidcProvidersResponse { + providers: response_providers, + })) +} + +// Create first admin - matches Python create_first_admin +pub async fn create_first_admin( + State(state): State, + Json(request): Json, +) -> Result, AppError> { + // Check if admin already exists + if state.db_pool.check_admin_exists().await? { + return Err(AppError::forbidden("An admin user already exists")); + } + + // Add the admin user + let user_id = state.db_pool.add_admin_user( + &request.fullname, + &request.username.to_lowercase(), + &request.email, + &request.password, // Password should already be hashed by frontend + ).await?; + + // Add PinePods news feed to admin users (matches Python startup tasks) + if let Err(e) = state.db_pool.add_news_feed_if_not_added().await { + eprintln!("Failed to add PinePods news feed during first admin creation: {}", e); + // Don't fail the admin creation if news feed addition fails + } + + // Create default playlists for the new admin user + if let Err(e) = state.db_pool.create_missing_default_playlists().await { + eprintln!("Failed to create default playlists during first admin creation: {}", e); + // Don't fail the admin creation if playlist creation fails + } + + Ok(Json(CreateFirstAdminResponse { + message: "Admin user created successfully".to_string(), + user_id, + })) +} + +// Get configuration - matches Python api_config +pub async fn get_config( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get configuration from environment variables (same as Python) + let proxy_host = std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let proxy_port = std::env::var("PINEPODS_PORT").unwrap_or_else(|_| "8040".to_string()); + let proxy_protocol = std::env::var("PROXY_PROTOCOL").unwrap_or_else(|_| "http".to_string()); + let reverse_proxy = std::env::var("REVERSE_PROXY").unwrap_or_else(|_| "False".to_string()); + let api_url = std::env::var("SEARCH_API_URL").unwrap_or_else(|_| "https://search.pinepods.online/api/search".to_string()); + let people_url = std::env::var("PEOPLE_API_URL").unwrap_or_else(|_| "https://people.pinepods.online".to_string()); + + // Build proxy URL based on reverse proxy setting + let proxy_url = if reverse_proxy == "True" { + format!("{}://{}/mover/?url=", proxy_protocol, proxy_host) + } else { + format!("{}://{}:{}/mover/?url=", proxy_protocol, proxy_host, proxy_port) + }; + + Ok(Json(ConfigResponse { + api_url, + proxy_url, + proxy_host, + proxy_port, + proxy_protocol, + reverse_proxy, + people_url, + })) +} + +// First login done - matches Python first_login_done +pub async fn first_login_done( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let key_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user (Python checks this) + if key_user_id != user_id { + return Err(AppError::forbidden("You can only run first login for yourself!")); + } + + let first_login_status = state.db_pool.first_login_done(user_id).await?; + + Ok(Json(json!({ + "FirstLogin": first_login_status + }))) +} + +// Check MFA enabled - matches Python check_mfa_enabled +pub async fn check_mfa_enabled( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let key_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user (Python checks this) + if key_user_id != user_id { + return Err(AppError::forbidden("You are not authorized to check mfa status for other users.")); + } + + let is_enabled = state.db_pool.check_mfa_enabled(user_id).await?; + + Ok(Json(json!({ + "mfa_enabled": is_enabled + }))) +} + +// NEW SECURE MFA ENDPOINT: Verify MFA code and return API key during login +// CRITICAL SECURITY: This is the second phase of secure MFA authentication flow +// It REQUIRES a valid session token from successful password authentication +#[derive(Deserialize)] +pub struct VerifyMfaLoginRequest { + pub mfa_session_token: String, + pub mfa_code: String, +} + +#[derive(Serialize)] +pub struct VerifyMfaLoginResponse { + pub status: String, + pub retrieved_key: Option, + pub verified: bool, +} + +// Helper function to clean expired MFA sessions +fn cleanup_expired_mfa_sessions() -> Result<(), AppError> { + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut sessions = PENDING_MFA_SESSIONS.lock() + .map_err(|e| AppError::internal(&format!("Failed to lock MFA sessions: {}", e)))?; + + sessions.retain(|_, (_, timestamp)| { + current_time - *timestamp < 300 // Keep sessions newer than 5 minutes + }); + + Ok(()) +} + +// Verify MFA code during login and return API key - SECURE TWO-FACTOR AUTHENTICATION +// CRITICAL: This endpoint REQUIRES a valid session token proving password was verified first +pub async fn verify_mfa_and_get_key( + State(state): State, + Json(request): Json, +) -> Result, AppError> { + // Clean up expired sessions first + cleanup_expired_mfa_sessions()?; + + // CRITICAL SECURITY CHECK: Validate session token from password authentication + let user_id = { + let mut sessions = PENDING_MFA_SESSIONS.lock() + .map_err(|e| AppError::internal(&format!("Failed to lock MFA sessions: {}", e)))?; + + match sessions.remove(&request.mfa_session_token) { + Some((user_id, timestamp)) => { + // Check if session is still valid (5 minutes) + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if current_time - timestamp > 300 { + return Ok(Json(VerifyMfaLoginResponse { + status: "session_expired".to_string(), + retrieved_key: None, + verified: false, + })); + } + + user_id + } + None => { + return Ok(Json(VerifyMfaLoginResponse { + status: "invalid_session".to_string(), + retrieved_key: None, + verified: false, + })); + } + } + }; + + // Get MFA secret for user - matches existing verify_mfa function exactly + let mfa_secret = match state.db_pool.get_mfa_secret(user_id).await? { + Some(secret) => secret, + None => { + return Ok(Json(VerifyMfaLoginResponse { + status: "no_mfa_secret".to_string(), + retrieved_key: None, + verified: false, + })); + } + }; + + // Verify MFA code - matches existing verify_mfa function EXACTLY + use totp_rs::{Algorithm, Secret, TOTP}; + + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + Secret::Encoded(mfa_secret.clone()).to_bytes() + .map_err(|e| AppError::internal(&format!("Invalid MFA secret format: {}", e)))?, + Some("Pinepods".to_string()), // Matches existing function exactly + "login".to_string(), // Matches existing function exactly + ).map_err(|e| AppError::internal(&format!("TOTP creation failed: {}", e)))?; + + let verified = totp.check_current(&request.mfa_code) + .map_err(|e| AppError::internal(&format!("TOTP verification failed: {}", e)))?; + + if verified { + // MFA verification successful - now safe to return API key + // Session token was consumed above, preventing replay attacks + let api_key = state.db_pool.create_or_get_api_key(user_id).await?; + + Ok(Json(VerifyMfaLoginResponse { + status: "success".to_string(), + retrieved_key: Some(api_key), + verified: true, + })) + } else { + // MFA verification failed + Ok(Json(VerifyMfaLoginResponse { + status: "invalid_code".to_string(), + retrieved_key: None, + verified: false, + })) + } +} + +// Get theme - matches Python get_theme +pub async fn get_theme( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let key_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user (Python checks this) + if key_user_id != user_id { + return Err(AppError::forbidden("You can only get themes for yourself!")); + } + + let theme = state.db_pool.get_theme(user_id).await?; + + Ok(Json(json!({ + "theme": theme + }))) +} + +// Get user startpage - matches Python get_user_startpage +pub async fn get_user_startpage( + Query(params): Query>, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user_id from query parameter + let user_id: i32 = params.get("user_id") + .ok_or_else(|| AppError::bad_request("Missing user_id parameter"))? + .parse() + .map_err(|_| AppError::bad_request("Invalid user_id parameter"))?; + + // Get user ID from API key for authorization check + let key_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user (Python checks this) + if key_user_id != user_id { + return Err(AppError::forbidden("You can only view your own StartPage setting!")); + } + + let startpage = state.db_pool.get_user_startpage(user_id).await?; + + Ok(Json(json!({ + "StartPage": startpage + }))) +} + +// Setup time info - matches Python setup_timezone_info +pub async fn setup_time_info( + headers: HeaderMap, + State(state): State, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user (Python checks this) + if data.user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to access these user details")); + } + + let success = state.db_pool.setup_timezone_info( + data.user_id, + &data.timezone, + data.hour_pref, + &data.date_format, + ).await?; + + if success { + Ok(Json(json!({ + "success": success + }))) + } else { + Err(AppError::not_found("User not found")) + } +} + +// User admin check - matches Python api_user_admin_check_route +pub async fn user_admin_check( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user (Python checks this) + if user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to check admin status for other users")); + } + + let is_admin = state.db_pool.user_admin_check(user_id).await?; + + Ok(Json(json!({ + "is_admin": is_admin + }))) +} + +// Import progress - matches Python api_import_progress +pub async fn import_progress( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Invalid API key")); + } + + // Get user ID from API key for authorization check + let key_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user + if key_user_id != user_id { + return Err(AppError::forbidden("You can only check import progress for yourself!")); + } + + // Get progress from Redis + let progress_key = format!("import_progress:{}", user_id); + let progress_data: Option = state.redis_client.get(&progress_key).await?; + + if let Some(data) = progress_data { + let progress: serde_json::Value = serde_json::from_str(&data)?; + Ok(Json(progress)) + } else { + // No progress data found - import not running or completed + Ok(Json(json!({ + "current": 0, + "total": 0, + "current_podcast": "" + }))) + } +} + +// Import OPML - matches Python api_import_opml +pub async fn import_opml( + headers: HeaderMap, + State(state): State, + Json(import_request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Invalid API key")); + } + + // Get user ID from API key for authorization check + let key_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user (Python checks this) + if key_user_id != import_request.user_id { + return Err(AppError::forbidden("You can only import podcasts for yourself!")); + } + + // Create background task for OPML import + let _total_podcasts = import_request.podcasts.len(); + let task_id = state.task_manager.create_task( + "opml_import".to_string(), + import_request.user_id, + ).await?; + + // Spawn the import task + let task_spawner = state.task_spawner.clone(); + let db_pool = state.db_pool.clone(); + let task_manager = state.task_manager.clone(); + let redis_client = state.redis_client.clone(); + let task_id_clone = task_id.clone(); + + tokio::spawn(async move { + process_opml_import( + import_request, + task_id_clone, + db_pool, + task_manager, + redis_client, + ).await; + }); + + Ok(Json(json!({ + "success": true, + "message": "Import process started", + "task_id": task_id + }))) +} + +// Process OPML import in background - matches Python process_opml_import +async fn process_opml_import( + import_request: OPMLImportRequest, + task_id: String, + db_pool: crate::database::DatabasePool, + task_manager: std::sync::Arc, + redis_client: crate::redis_client::RedisClient, +) { + let total_podcasts = import_request.podcasts.len(); + let progress_key = format!("import_progress:{}", import_request.user_id); + + // Initialize progress in Redis + let _ = redis_client.set_ex(&progress_key, &json!({ + "current": 0, + "total": total_podcasts, + "current_podcast": "" + }).to_string(), 3600).await; // 1 hour timeout + + // Update task status to running + let _ = task_manager.update_task_progress( + &task_id, + 0.0, + Some("Starting OPML import".to_string()), + ).await; + + for (index, podcast_url) in import_request.podcasts.iter().enumerate() { + // Update progress in Redis + let _ = redis_client.set_ex(&progress_key, &json!({ + "current": index + 1, + "total": total_podcasts, + "current_podcast": podcast_url + }).to_string(), 3600).await; + + // Update progress + let progress = ((index + 1) as f64 / total_podcasts as f64) * 100.0; + let _ = task_manager.update_task_progress( + &task_id, + progress, + Some(format!("Processing podcast {}/{}: {}", index + 1, total_podcasts, podcast_url)), + ).await; + + // Try to get podcast values and add podcast with robust error handling + match get_podcast_values_from_url(podcast_url, &db_pool).await { + Ok(mut podcast_values) => { + podcast_values.user_id = import_request.user_id; + match db_pool.add_podcast(&podcast_values, 0, None, None).await { + Ok(_) => { + tracing::info!("✅ Successfully imported podcast: {}", podcast_url); + } + Err(e) => { + tracing::error!("❌ Database error importing podcast {}: {} - Continuing with next podcast", podcast_url, e); + } + } + } + Err(e) => { + tracing::error!("❌ Feed parsing error for {}: {} - Continuing with next podcast", podcast_url, e); + } + } + + // Small delay to allow other requests to be processed + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + // Mark task as completed + let _ = task_manager.update_task_progress( + &task_id, + 100.0, + Some("OPML import completed".to_string()), + ).await; + + // Clear progress from Redis + let _ = redis_client.delete(&progress_key).await; +} + +// Get podcast values from URL - simplified version of Python get_podcast_values +async fn get_podcast_values_from_url(url: &str, db_pool: &crate::database::DatabasePool) -> Result { + // Use the same feed-rs based parsing that manual add uses (which works correctly) + // This avoids the ampersand truncation issue in the custom quick_xml parser + + // Use the working get_podcast_values function that manual add uses + let podcast_values_map = db_pool.get_podcast_values(url, 0, None, None).await + .map_err(|e| AppError::internal(&format!("Failed to parse podcast feed: {}", e)))?; + + println!("🎙️ Parsed podcast: title='{}', author='{}', description_len={}", + podcast_values_map.get("podcastname").unwrap_or(&"Unknown".to_string()), + podcast_values_map.get("author").unwrap_or(&"Unknown".to_string()), + podcast_values_map.get("description").unwrap_or(&"".to_string()).len()); + + // Convert HashMap to PodcastValues struct + let categories: std::collections::HashMap = + serde_json::from_str(podcast_values_map.get("categories").unwrap_or(&"{}".to_string())) + .unwrap_or_default(); + + Ok(crate::handlers::podcasts::PodcastValues { + pod_title: podcast_values_map.get("podcastname").unwrap_or(&"Unknown Podcast".to_string()).clone(), + pod_artwork: podcast_values_map.get("artworkurl").unwrap_or(&"".to_string()).clone(), + pod_author: podcast_values_map.get("author").unwrap_or(&"Unknown Author".to_string()).clone(), + categories: categories, + pod_description: podcast_values_map.get("description").unwrap_or(&"No description available".to_string()).clone(), + pod_episode_count: podcast_values_map.get("episodecount").unwrap_or(&"0".to_string()).parse().unwrap_or(0), + pod_feed_url: url.to_string(), + pod_website: podcast_values_map.get("websiteurl").unwrap_or(&"".to_string()).clone(), + pod_explicit: podcast_values_map.get("explicit").unwrap_or(&"False".to_string()) == "True", + user_id: 0, // Will be set by the caller + }) +} + +// OIDC Authentication Flow Endpoints + +// Store OIDC state - enhanced to capture user's current URL +#[derive(Deserialize)] +pub struct StoreStateRequest { + pub state: String, + pub client_id: String, + pub origin_url: Option, // URL user was on when they clicked OIDC login + pub code_verifier: Option, // PKCE code verifier for token exchange +} + +#[derive(Serialize, Deserialize)] +struct StoredOidcState { + client_id: String, + origin_url: Option, + code_verifier: Option, // PKCE code verifier +} + +pub async fn store_oidc_state( + State(state): State, + Json(request): Json, +) -> Result, AppError> { + // Store state in Redis with 10-minute expiration + let state_key = format!("oidc_state:{}", request.state); + + let stored_state = StoredOidcState { + client_id: request.client_id, + origin_url: request.origin_url, + code_verifier: request.code_verifier, + }; + + let state_json = serde_json::to_string(&stored_state) + .map_err(|e| AppError::internal(&format!("Failed to serialize OIDC state: {}", e)))?; + + state.redis_client.set_ex(&state_key, &state_json, 600).await + .map_err(|e| AppError::internal(&format!("Failed to store OIDC state: {}", e)))?; + + Ok(Json(serde_json::json!({ "status": "success" }))) +} + +// Helper function to create proper redirect URLs for both web and mobile +fn create_oidc_redirect_url(frontend_base: &str, params: &str) -> String { + let redirect_url = if frontend_base.starts_with("pinepods://") { + // Mobile deep link - append params directly to the exact URL + if frontend_base.contains('?') { + // URL already has query params, append with & + format!("{}&{}", frontend_base, params) + } else { + // URL has no query params, append with ? + format!("{}?{}", frontend_base, params) + } + } else { + // Web callback - use traditional path + format!("{}/oauth/callback?{}", frontend_base, params) + }; + redirect_url +} + +// Helper function to create appropriate response for mobile vs web +fn create_oidc_response(frontend_base: &str, params: &str) -> axum::response::Response { + let redirect_url = create_oidc_redirect_url(frontend_base, params); + + if frontend_base.starts_with("pinepods://") { + // Mobile deep link - return HTML page with JavaScript redirect + let html_content = format!(r#" + + + PinePods Authentication + + + + + +
+ +
Authentication successful!
+
Redirecting to app...
+
+ + +"#, redirect_url); + + Html(html_content).into_response() + } else { + // Web callback - use normal redirect + axum::response::Redirect::to(&redirect_url).into_response() + } +} + +// OIDC callback handler - matches Python /api/auth/callback endpoint +#[derive(Deserialize)] +pub struct OIDCCallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +pub async fn oidc_callback( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + // Construct base URL from request like Python version - EXACT match + let base_url = construct_base_url_from_request(&headers)?; + let default_frontend_base = base_url.replace("/api", ""); + + // Handle OAuth errors first - EXACT match to Python + if let Some(error) = query.error { + let error_desc = query.error_description.unwrap_or_else(|| "Unknown error".to_string()); + tracing::error!("OIDC: Provider error: {} - {}", error, error_desc); + return Ok(create_oidc_response(&default_frontend_base, &format!("error=provider_error&description={}", urlencoding::encode(&error_desc)))); + } + + // Validate required parameters - EXACT match to Python + let auth_code = query.code.ok_or_else(|| AppError::bad_request("Missing authorization code"))?; + let state_param = query.state.ok_or_else(|| AppError::bad_request("Missing state parameter"))?; + + // Get client_id, origin_url, and code_verifier from state + let (client_id, stored_origin_url, code_verifier) = match state.redis_client.get_del(&format!("oidc_state:{}", state_param)).await { + Ok(Some(state_json)) => { + // Try to parse as new JSON format first + if let Ok(stored_state) = serde_json::from_str::(&state_json) { + tracing::info!("OIDC: Retrieved state for client_id={}", stored_state.client_id); + (stored_state.client_id, stored_state.origin_url, stored_state.code_verifier) + } else { + // Fallback to old format (just client_id string) for backwards compatibility + (state_json, None, None) + } + }, + Ok(None) => { + return Ok(create_oidc_response(&default_frontend_base, "error=invalid_state")); + } + Err(_) => { + return Ok(create_oidc_response(&default_frontend_base, "error=internal_error")); + } + }; + + // Use stored origin URL if available, otherwise fall back to constructed URL + let frontend_base = if let Some(ref origin_url) = stored_origin_url { + // Check if this is a mobile deep link callback + if origin_url.starts_with("pinepods://auth/callback") { + // For mobile deep links, use the full URL directly - don't try to parse as HTTP + origin_url.clone() + } else { + // Extract just the base part (scheme + host + port) from the stored origin URL for web + // Simple string parsing to avoid adding url dependency + if let Some(protocol_end) = origin_url.find("://") { + let after_protocol = &origin_url[protocol_end + 3..]; + if let Some(path_start) = after_protocol.find('/') { + origin_url[..protocol_end + 3 + path_start].to_string() + } else { + origin_url.clone() + } + } else { + origin_url.clone() + } + } + } else { + default_frontend_base.clone() + }; + + let registered_redirect_uri = format!("{}/api/auth/callback", base_url); + + // Get OIDC provider details - EXACT match to Python get_oidc_provider returning tuple + let provider_tuple = match state.db_pool.get_oidc_provider(&client_id).await { + Ok(Some(provider)) => provider, + Ok(None) => { + return Ok(create_oidc_response(&frontend_base, "error=invalid_provider")); + } + Err(_) => { + return Ok(create_oidc_response(&frontend_base, "error=internal_error")); + } + }; + + // Unpack provider details - EXACT match to Python unpacking + let (provider_id, _client_id, client_secret, token_url, userinfo_url, name_claim, email_claim, username_claim, roles_claim, user_role, admin_role) = provider_tuple; + + // Exchange authorization code for access token - EXACT match to Python + let client = reqwest::Client::new(); + let mut form_data = vec![ + ("grant_type", "authorization_code"), + ("code", &auth_code), + ("redirect_uri", ®istered_redirect_uri), + ("client_id", &client_id), + ("client_secret", &client_secret), + ]; + + // Add PKCE code verifier if present + if let Some(ref verifier) = code_verifier { + form_data.push(("code_verifier", verifier)); + tracing::info!("OIDC: Using PKCE flow"); + } + + let token_response = match client.post(&token_url) + .form(&form_data) + .header("Accept", "application/json") + .send() + .await + { + Ok(response) => { + let status = response.status(); + + if status.is_success() { + match response.json::().await { + Ok(token_data) => { + tracing::info!("OIDC: Token exchange successful"); + token_data + }, + Err(e) => { + tracing::error!("OIDC: Failed to parse token response JSON: {}", e); + return Ok(create_oidc_response(&frontend_base, "error=token_exchange_failed")); + } + } + } else { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + tracing::error!("OIDC: Token exchange failed with status {}: {}", status, error_text); + return Ok(create_oidc_response(&frontend_base, "error=token_exchange_failed")); + } + } + Err(e) => { + tracing::error!("OIDC: Token exchange request failed: {}", e); + return Ok(create_oidc_response(&frontend_base, "error=token_exchange_failed")); + } + }; + + let access_token = match token_response.get("access_token").and_then(|v| v.as_str()) { + Some(token) => token, + None => return Ok(create_oidc_response(&frontend_base, "error=token_exchange_failed")), + }; + + // Get user info from OIDC provider - EXACT match to Python + let userinfo_response = match client.get(&userinfo_url) + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "PinePods/1.0") + .header("Accept", "application/json") + .send() + .await + { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(user_info) => user_info, + Err(_) => return Ok(create_oidc_response(&frontend_base, "error=userinfo_failed")), + } + } + _ => return Ok(create_oidc_response(&frontend_base, "error=userinfo_failed")), + }; + + // Extract email with GitHub special handling - EXACT match to Python + let email_field = email_claim + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("email"); + + tracing::info!("OIDC Debug - email_claim: {:?}, email_field: {}, userinfo_response: {:?}", email_claim, email_field, userinfo_response); + + let mut email = userinfo_response.get(email_field).and_then(|v| v.as_str()).map(|s| s.to_string()); + + // GitHub email handling - EXACT match to Python + if email.is_none() && userinfo_url.contains("api.github.com") { + if let Ok(emails_response) = client.get("https://api.github.com/user/emails") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "PinePods/1.0") + .header("Accept", "application/json") + .send() + .await + { + if emails_response.status().is_success() { + if let Ok(emails) = emails_response.json::>().await { + // Find primary email + for email_obj in &emails { + if email_obj.get("primary").and_then(|v| v.as_bool()).unwrap_or(false) && + email_obj.get("verified").and_then(|v| v.as_bool()).unwrap_or(false) { + email = email_obj.get("email").and_then(|v| v.as_str()).map(|s| s.to_string()); + break; + } + } + // If no primary, take first verified + if email.is_none() { + for email_obj in &emails { + if email_obj.get("verified").and_then(|v| v.as_bool()).unwrap_or(false) { + email = email_obj.get("email").and_then(|v| v.as_str()).map(|s| s.to_string()); + break; + } + } + } + } + } + } + } + + let email = match email { + Some(e) => e, + None => return Ok(create_oidc_response(&frontend_base, "error=email_required")), + }; + + // Role verification - EXACT match to Python + if let (Some(roles_claim), Some(user_role)) = (roles_claim.as_ref().filter(|s| !s.is_empty()), user_role.as_ref().filter(|s| !s.is_empty())) { + if let Some(roles) = userinfo_response.get(roles_claim).and_then(|v| v.as_array()) { + let has_user_role = roles.iter().any(|r| r.as_str() == Some(user_role)); + let has_admin_role = admin_role.as_ref().map_or(false, |admin_role| { + roles.iter().any(|r| r.as_str() == Some(admin_role)) + }); + + if !has_user_role && !has_admin_role { + return Ok(create_oidc_response(&frontend_base, "error=no_access")); + } + } else { + return Ok(create_oidc_response(&frontend_base, "error=no_access&details=invalid_roles")); + } + } + + // Check if user exists - EXACT match to Python + let existing_user = state.db_pool.get_user_by_email(&email).await?; + + let name_field = name_claim + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("name"); + let fullname = userinfo_response.get(name_field) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Username claim validation - EXACT match to Python + if let Some(username_claim) = username_claim.as_ref().filter(|s| !s.is_empty()) { + if !userinfo_response.get(username_claim).is_some() { + return Ok(create_oidc_response(&frontend_base, "error=user_creation_failed&details=username_claim_missing")); + } + } + + let username_field = username_claim + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("preferred_username"); + let username = userinfo_response.get(username_field) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let user_id = if let Some((user_id, _email, current_username, _fullname, _is_admin)) = existing_user { + // Existing user - EXACT match to Python + let api_key = match state.db_pool.get_user_api_key(user_id).await? { + Some(key) => key, + None => state.db_pool.create_api_key(user_id).await?, + }; + + // Update user info - EXACT match to Python + state.db_pool.set_fullname(user_id, &fullname).await?; + + // Update username if changed - EXACT match to Python + if let (Some(username_claim), Some(new_username)) = (username_claim.as_ref().filter(|s| !s.is_empty()), username.as_ref()) { + if Some(new_username) != current_username.as_ref() { + if !state.db_pool.check_usernames(new_username).await? { + state.db_pool.set_username(user_id, new_username).await?; + } + } + } + + // Update admin role - EXACT match to Python + if let (Some(roles_claim), Some(admin_role)) = (roles_claim.as_ref().filter(|s| !s.is_empty()), admin_role.as_ref().filter(|s| !s.is_empty())) { + if let Some(roles) = userinfo_response.get(roles_claim).and_then(|v| v.as_array()) { + let is_admin = roles.iter().any(|r| r.as_str() == Some(admin_role)); + state.db_pool.set_isadmin(user_id, is_admin).await?; + } + } + + tracing::info!("OIDC: Login successful for existing user"); + return Ok(create_oidc_response(&frontend_base, &format!("api_key={}", api_key))); + } else { + // Create new user - EXACT match to Python + let mut final_username = username.unwrap_or_else(|| email.split('@').next().unwrap_or(&email).to_lowercase()); + + // Username conflict resolution - EXACT match to Python + if state.db_pool.check_usernames(&final_username).await? { + let base_username = final_username.clone(); + let mut counter = 1; + const MAX_ATTEMPTS: i32 = 10; + + while counter <= MAX_ATTEMPTS { + final_username = format!("{}_{}", base_username, counter); + if !state.db_pool.check_usernames(&final_username).await? { + break; + } + counter += 1; + if counter > MAX_ATTEMPTS { + return Ok(create_oidc_response(&frontend_base, "error=username_conflict")); + } + } + } + + // Create user - EXACT match to Python + match state.db_pool.create_oidc_user(&email, &fullname, &final_username).await { + Ok(user_id) => { + let api_key = state.db_pool.create_api_key(user_id).await?; + + // Set admin role for new user - EXACT match to Python + if let (Some(roles_claim), Some(admin_role)) = (roles_claim.as_ref().filter(|s| !s.is_empty()), admin_role.as_ref().filter(|s| !s.is_empty())) { + if let Some(roles) = userinfo_response.get(roles_claim).and_then(|v| v.as_array()) { + let is_admin = roles.iter().any(|r| r.as_str() == Some(admin_role)); + state.db_pool.set_isadmin(user_id, is_admin).await?; + } + } + + user_id + } + Err(_) => return Ok(create_oidc_response(&frontend_base, "error=user_creation_failed")), + } + }; + + let api_key = match state.db_pool.get_user_api_key(user_id).await? { + Some(key) => key, + None => state.db_pool.create_api_key(user_id).await?, + }; + + // Success - handle both web and mobile redirects + tracing::info!("OIDC: Login successful for new user"); + Ok(create_oidc_response(&frontend_base, &format!("api_key={}", api_key))) +} + +// Update user timezone +pub async fn update_timezone( + headers: HeaderMap, + State(state): State, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user + if data.user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to update timezone for other users")); + } + + let success = state.db_pool.update_user_timezone(data.user_id, &data.timezone).await?; + + if success { + Ok(Json(json!({ + "success": true, + "message": "Timezone updated successfully" + }))) + } else { + Err(AppError::not_found("User not found")) + } +} + +// Update user date format +pub async fn update_date_format( + headers: HeaderMap, + State(state): State, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user + if data.user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to update date format for other users")); + } + + let success = state.db_pool.update_user_date_format(data.user_id, &data.date_format).await?; + + if success { + Ok(Json(json!({ + "success": true, + "message": "Date format updated successfully" + }))) + } else { + Err(AppError::not_found("User not found")) + } +} + +// Update user time format +pub async fn update_time_format( + headers: HeaderMap, + State(state): State, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user + if data.user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to update time format for other users")); + } + + let success = state.db_pool.update_user_time_format(data.user_id, data.hour_pref).await?; + + if success { + Ok(Json(json!({ + "success": true, + "message": "Time format updated successfully" + }))) + } else { + Err(AppError::not_found("User not found")) + } +} + +// Get user auto complete seconds +pub async fn get_auto_complete_seconds( + headers: HeaderMap, + Path(user_id): Path, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user + if user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to view auto complete seconds for other users")); + } + + let auto_complete_seconds = state.db_pool.get_user_auto_complete_seconds(user_id).await?; + + Ok(Json(json!({ + "auto_complete_seconds": auto_complete_seconds + }))) +} + +// Update user auto complete seconds +pub async fn update_auto_complete_seconds( + headers: HeaderMap, + State(state): State, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + if !state.db_pool.verify_api_key(&api_key).await? { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + // Get user ID from API key for authorization check + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user + if data.user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to update auto complete seconds for other users")); + } + + let success = state.db_pool.set_user_auto_complete_seconds(data.user_id, data.seconds).await?; + + if success { + Ok(Json(json!({ + "success": true, + "message": "Auto complete seconds updated successfully" + }))) + } else { + Err(AppError::not_found("User not found")) + } +} + +// Password reset endpoint - Always returns success for security (prevents username enumeration) +pub async fn reset_password_create_code( + State(state): State, + Json(request): Json, +) -> Result, AppError> { + // Check if standard login is disabled in favor of OIDC-only authentication + if state.config.oidc.disable_standard_login { + return Err(AppError::forbidden("Password reset is disabled when using OIDC-only authentication. Please use your OIDC provider for password management.")); + } + // Get email settings to check if they're configured + let email_settings = state.db_pool.get_email_settings().await?; + if let Some(settings) = email_settings { + if settings.server_name == "default_server" { + // Even if email isn't configured, return success to prevent enumeration + return Ok(Json(ResetCodeResponse { code_created: true })); + } + + // Check if user exists with given username and email + let user_exists = state.db_pool.check_reset_user(&request.username.to_lowercase(), &request.email).await.unwrap_or(false); + + if user_exists { + // Create password reset code only if user exists + if let Ok(Some(code)) = state.db_pool.reset_password_create_code(&request.email).await { + // Create email payload + let email_request = crate::handlers::settings::SendEmailRequest { + to_email: request.email.clone(), + subject: "Pinepods Password Reset Code".to_string(), + message: format!("Your password reset code is {}", code), + }; + + // Try to send the email - if it fails, silently remove the reset code + if crate::handlers::settings::send_email_with_settings(&settings, &email_request).await.is_err() { + let _ = state.db_pool.reset_password_remove_code(&request.email).await; + } + } + } + + // Always return success regardless of user existence or email sending result + Ok(Json(ResetCodeResponse { code_created: true })) + } else { + // Always return success even if email settings aren't configured + Ok(Json(ResetCodeResponse { code_created: true })) + } +} + +// Verify reset code and reset password endpoint - matches Python api_verify_and_reset_password_route exactly +pub async fn verify_and_reset_password( + State(state): State, + Json(request): Json, +) -> Result, AppError> { + // Check if standard login is disabled in favor of OIDC-only authentication + if state.config.oidc.disable_standard_login { + return Err(AppError::forbidden("Password reset is disabled when using OIDC-only authentication. Please use your OIDC provider for password management.")); + } + // Verify the reset code + let code_valid = state.db_pool.verify_reset_code(&request.email, &request.reset_code).await?; + + match code_valid { + None => { + // User not found + return Err(AppError::not_found("User not found")); + } + Some(false) => { + // Code is invalid or expired + return Err(AppError::bad_request("Code is invalid")); + } + Some(true) => { + // Code is valid, proceed with password reset + } + } + + // Reset the password (the new_password should already be hashed by the frontend) + let message = state.db_pool.reset_password_prompt(&request.email, &request.new_password).await?; + + match message { + Some(msg) => Ok(Json(VerifyAndResetPasswordResponse { message: msg })), + None => Err(AppError::internal("Failed to reset password")), + } +} + +// Construct base URL from request headers (matches Python request.base_url) +fn construct_base_url_from_request(headers: &HeaderMap) -> Result { + // Get Host header (required) + let host = headers + .get("host") + .ok_or_else(|| AppError::bad_request("Missing Host header"))? + .to_str() + .map_err(|_| AppError::bad_request("Invalid Host header"))?; + + // Check for X-Forwarded-Proto header to determine scheme + let scheme = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("http"); + + let mut base_url = format!("{}://{}", scheme, host); + + tracing::info!("OIDC Debug - Headers: Host={}, X-Forwarded-Proto={:?}, X-Forwarded-Host={:?}, X-Forwarded-Port={:?}, constructed base_url={}", + host, + headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()), + headers.get("x-forwarded-host").and_then(|v| v.to_str().ok()), + headers.get("x-forwarded-port").and_then(|v| v.to_str().ok()), + base_url + ); + + // Force HTTPS if running in production (not localhost) + if !base_url.starts_with("http://localhost") && base_url.starts_with("http:") { + base_url = format!("https:{}", &base_url[5..]); + } + + Ok(base_url) +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/episodes.rs b/PinePods-0.8.2/rust-api/src/handlers/episodes.rs new file mode 100644 index 0000000..a81aacf --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/episodes.rs @@ -0,0 +1,470 @@ +use axum::{extract::State, http::HeaderMap, response::Json}; +use axum::response::{Response, IntoResponse}; +use axum::http::{StatusCode, header}; +use sqlx::Row; +use crate::{ + error::{AppError, AppResult}, + handlers::{extract_api_key, validate_api_key}, + models::{BulkEpisodeActionRequest, BulkEpisodeActionResponse}, + AppState, +}; + +// Bulk episode action handlers for efficient mass operations + +// Bulk mark episodes as completed +pub async fn bulk_mark_episodes_completed( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> AppResult> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let calling_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if calling_user_id != request.user_id { + return Err(AppError::forbidden("You can only mark episodes as completed for yourself!")); + } + + let is_youtube = request.is_youtube.unwrap_or(false); + let (processed_count, failed_count) = state.db_pool + .bulk_mark_episodes_completed(request.episode_ids, request.user_id, is_youtube) + .await?; + + let message = if failed_count > 0 { + format!("Marked {} episodes as completed, {} failed", processed_count, failed_count) + } else { + format!("Successfully marked {} episodes as completed", processed_count) + }; + + Ok(Json(BulkEpisodeActionResponse { + message, + processed_count, + failed_count: if failed_count > 0 { Some(failed_count) } else { None }, + })) +} + +// Bulk save episodes +pub async fn bulk_save_episodes( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> AppResult> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let calling_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if calling_user_id != request.user_id { + return Err(AppError::forbidden("You can only save episodes for yourself!")); + } + + let is_youtube = request.is_youtube.unwrap_or(false); + let (processed_count, failed_count) = state.db_pool + .bulk_save_episodes(request.episode_ids, request.user_id, is_youtube) + .await?; + + let message = if failed_count > 0 { + format!("Saved {} episodes, {} failed or already saved", processed_count, failed_count) + } else { + format!("Successfully saved {} episodes", processed_count) + }; + + Ok(Json(BulkEpisodeActionResponse { + message, + processed_count, + failed_count: if failed_count > 0 { Some(failed_count) } else { None }, + })) +} + +// Bulk queue episodes +pub async fn bulk_queue_episodes( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> AppResult> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let calling_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if calling_user_id != request.user_id { + return Err(AppError::forbidden("You can only queue episodes for yourself!")); + } + + let is_youtube = request.is_youtube.unwrap_or(false); + let (processed_count, failed_count) = state.db_pool + .bulk_queue_episodes(request.episode_ids, request.user_id, is_youtube) + .await?; + + let message = if failed_count > 0 { + format!("Queued {} episodes, {} failed or already queued", processed_count, failed_count) + } else { + format!("Successfully queued {} episodes", processed_count) + }; + + Ok(Json(BulkEpisodeActionResponse { + message, + processed_count, + failed_count: if failed_count > 0 { Some(failed_count) } else { None }, + })) +} + +// Bulk download episodes - triggers download tasks +pub async fn bulk_download_episodes( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> AppResult> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let calling_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if calling_user_id != request.user_id { + return Err(AppError::forbidden("You can only download episodes for yourself!")); + } + + let is_youtube = request.is_youtube.unwrap_or(false); + let mut processed_count = 0; + let mut failed_count = 0; + + // Check if episodes are already downloaded and queue download tasks + for episode_id in request.episode_ids { + let is_downloaded = state.db_pool + .check_downloaded(request.user_id, episode_id, is_youtube) + .await?; + + if !is_downloaded { + let result = if is_youtube { + state.task_spawner.spawn_download_youtube_video(episode_id, request.user_id).await + } else { + state.task_spawner.spawn_download_podcast_episode(episode_id, request.user_id).await + }; + + match result { + Ok(_) => processed_count += 1, + Err(_) => failed_count += 1, + } + } + } + + let message = if failed_count > 0 { + format!("Queued {} episodes for download, {} failed or already downloaded", processed_count, failed_count) + } else { + format!("Successfully queued {} episodes for download", processed_count) + }; + + Ok(Json(BulkEpisodeActionResponse { + message, + processed_count, + failed_count: if failed_count > 0 { Some(failed_count) } else { None }, + })) +} + +// Bulk delete downloaded episodes - removes multiple downloaded episodes at once +pub async fn bulk_delete_downloaded_episodes( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> AppResult> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let calling_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if calling_user_id != request.user_id { + return Err(AppError::forbidden("You can only delete your own downloaded episodes!")); + } + + let is_youtube = request.is_youtube.unwrap_or(false); + let (processed_count, failed_count) = state.db_pool + .bulk_delete_downloaded_episodes(request.episode_ids, request.user_id, is_youtube) + .await?; + + let message = if failed_count > 0 { + format!("Deleted {} downloaded episodes, {} failed or were not found", processed_count, failed_count) + } else { + format!("Successfully deleted {} downloaded episodes", processed_count) + }; + + Ok(Json(BulkEpisodeActionResponse { + message, + processed_count, + failed_count: if failed_count > 0 { Some(failed_count) } else { None }, + })) +} + +// Share episode - creates a shareable URL that expires in 60 days +pub async fn share_episode( + State(state): State, + axum::extract::Path(episode_id): axum::extract::Path, + headers: HeaderMap, +) -> AppResult> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Get the user ID from the API key + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Generate unique share code and expiration date + let share_code = uuid::Uuid::new_v4().to_string(); + let expiration_date = chrono::Utc::now() + chrono::Duration::days(60); + + // Insert the shared episode entry + let result = state.db_pool + .add_shared_episode(episode_id, user_id, &share_code, expiration_date) + .await?; + + if result { + Ok(Json(serde_json::json!({ "url_key": share_code }))) + } else { + Err(AppError::internal("Failed to share episode")) + } +} + +// Get episode by URL key - for accessing shared episodes +pub async fn get_episode_by_url_key( + State(state): State, + axum::extract::Path(url_key): axum::extract::Path, +) -> AppResult> { + // Find the episode ID associated with the URL key + let episode_id = match state.db_pool.get_episode_id_by_share_code(&url_key).await? { + Some(id) => id, + None => return Err(AppError::not_found("Invalid or expired URL key")), + }; + + // Now retrieve the episode metadata using the special shared episode method + // This bypasses user restrictions for public shared access + let episode_data = state.db_pool + .get_shared_episode_metadata(episode_id) + .await?; + + Ok(Json(serde_json::json!({ "episode": episode_data }))) +} + +// Download episode file with metadata +pub async fn download_episode_file( + State(state): State, + axum::extract::Path(episode_id): axum::extract::Path, + headers: HeaderMap, + axum::extract::Query(params): axum::extract::Query>, +) -> AppResult { + // Try to get API key from header first, then from query parameter + let api_key = if let Ok(key) = extract_api_key(&headers) { + key + } else if let Some(key) = params.get("api_key") { + key.clone() + } else { + return Err(AppError::unauthorized("API key is required")); + }; + + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Get episode metadata + let episode_info = match &state.db_pool { + crate::database::DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT e."episodeurl", e."episodetitle", p."podcastname", + e."episodepubdate", p."author", e."episodeartwork", p."artworkurl", + e."episodedescription" + FROM "Episodes" e + JOIN "Podcasts" p ON e."podcastid" = p."podcastid" + WHERE e."episodeid" = $1 + "#) + .bind(episode_id) + .fetch_one(pool) + .await?; + + ( + row.try_get::("episodeurl")?, + row.try_get::("episodetitle")?, + row.try_get::("podcastname")?, + row.try_get::, _>("episodepubdate")?, + row.try_get::, _>("author")?, + row.try_get::, _>("episodeartwork")?, + row.try_get::, _>("artworkurl")?, + row.try_get::, _>("episodedescription")? + ) + } + crate::database::DatabasePool::MySQL(pool) => { + let row = sqlx::query(" + SELECT e.EpisodeURL, e.EpisodeTitle, p.PodcastName, + e.EpisodePubDate, p.Author, e.EpisodeArtwork, p.ArtworkURL, + e.EpisodeDescription + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE e.EpisodeID = ? + ") + .bind(episode_id) + .fetch_one(pool) + .await?; + + ( + row.try_get::("EpisodeURL")?, + row.try_get::("EpisodeTitle")?, + row.try_get::("PodcastName")?, + row.try_get::, _>("EpisodePubDate")?, + row.try_get::, _>("Author")?, + row.try_get::, _>("EpisodeArtwork")?, + row.try_get::, _>("ArtworkURL")?, + row.try_get::, _>("EpisodeDescription")? + ) + } + }; + + let (episode_url, episode_title, podcast_name, pub_date, author, episode_artwork, artwork_url, _description) = episode_info; + + // Download the episode file + let client = reqwest::Client::new(); + let response = client.get(&episode_url) + .send() + .await + .map_err(|e| AppError::internal(&format!("Failed to download episode: {}", e)))?; + + if !response.status().is_success() { + return Err(AppError::internal(&format!("Server returned error: {}", response.status()))); + } + + let audio_bytes = response.bytes() + .await + .map_err(|e| AppError::internal(&format!("Failed to download audio content: {}", e)))?; + + // Create a temporary file for metadata processing + let temp_dir = std::env::temp_dir(); + let temp_filename = format!("episode_{}_{}_{}.mp3", episode_id, user_id, chrono::Utc::now().timestamp()); + let temp_path = temp_dir.join(&temp_filename); + + // Write audio content to temp file + std::fs::write(&temp_path, &audio_bytes) + .map_err(|e| AppError::internal(&format!("Failed to write temp file: {}", e)))?; + + // Add metadata using the same function as server downloads + if let Err(e) = add_podcast_metadata( + &temp_path, + &episode_title, + author.as_deref().unwrap_or("Unknown"), + &podcast_name, + pub_date.as_ref(), + episode_artwork.as_deref().or(artwork_url.as_deref()) + ).await { + tracing::warn!("Failed to add metadata to downloaded episode: {}", e); + } + + // Read the file with metadata back + let final_bytes = std::fs::read(&temp_path) + .map_err(|e| AppError::internal(&format!("Failed to read processed file: {}", e)))?; + + // Clean up temp file + let _ = std::fs::remove_file(&temp_path); + + // Create safe filename for download + let safe_episode_title = episode_title.chars() + .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' }) + .collect::() + .trim() + .to_string(); + + let safe_podcast_name = podcast_name.chars() + .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' }) + .collect::() + .trim() + .to_string(); + + let pub_date_str = if let Some(date) = pub_date { + date.format("%Y-%m-%d").to_string() + } else { + chrono::Utc::now().format("%Y-%m-%d").to_string() + }; + + let filename = format!("{}_{}_-_{}.mp3", pub_date_str, safe_podcast_name, safe_episode_title); + + // Return the file with appropriate headers + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "audio/mpeg") + .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", filename)) + .header(header::CONTENT_LENGTH, final_bytes.len()) + .body(axum::body::Body::from(final_bytes)) + .map_err(|e| AppError::internal(&format!("Failed to create response: {}", e)))?; + + Ok(response) +} + +// Function to add metadata to downloaded MP3 files (copied from tasks.rs) +async fn add_podcast_metadata( + file_path: &std::path::Path, + title: &str, + artist: &str, + album: &str, + date: Option<&chrono::NaiveDateTime>, + artwork_url: Option<&str>, +) -> Result<(), Box> { + use id3::TagLike; // Import the trait to use methods + use chrono::Datelike; // For year(), month(), day() methods + + // Create ID3 tag and add basic metadata + let mut tag = id3::Tag::new(); + tag.set_title(title); + tag.set_artist(artist); + tag.set_album(album); + + // Set date if available + if let Some(date) = date { + tag.set_date_recorded(id3::Timestamp { + year: date.year(), + month: Some(date.month() as u8), + day: Some(date.day() as u8), + hour: None, + minute: None, + second: None, + }); + } + + // Add genre for podcasts + tag.set_genre("Podcast"); + + // Download and add artwork if available + if let Some(artwork_url) = artwork_url { + if let Ok(artwork_data) = download_artwork(artwork_url).await { + // Determine MIME type based on the data + let mime_type = if artwork_data.starts_with(&[0xFF, 0xD8, 0xFF]) { + "image/jpeg" + } else if artwork_data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { + "image/png" + } else { + "image/jpeg" // Default fallback + }; + + tag.add_frame(id3::frame::Picture { + mime_type: mime_type.to_string(), + picture_type: id3::frame::PictureType::CoverFront, + description: "Cover".to_string(), + data: artwork_data, + }); + } + } + + // Write the tag to the file + tag.write_to_path(file_path, id3::Version::Id3v24)?; + + Ok(()) +} + +// Helper function to download artwork (copied from tasks.rs) +async fn download_artwork(url: &str) -> Result, Box> { + let client = reqwest::Client::new(); + let response = client + .get(url) + .header("User-Agent", "PinePods/1.0") + .send() + .await?; + + if response.status().is_success() { + let bytes = response.bytes().await?; + // Limit artwork size to reasonable bounds (e.g., 5MB) + if bytes.len() > 5 * 1024 * 1024 { + return Err("Artwork too large".into()); + } + Ok(bytes.to_vec()) + } else { + Err(format!("Failed to download artwork: HTTP {}", response.status()).into()) + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/feed.rs b/PinePods-0.8.2/rust-api/src/handlers/feed.rs new file mode 100644 index 0000000..167b630 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/feed.rs @@ -0,0 +1,110 @@ +use axum::{ + extract::{Path, Query, State, Request}, + response::Response, +}; +use serde::Deserialize; + +use crate::{ + error::AppError, + AppState, +}; + +#[derive(Deserialize)] +pub struct FeedQuery { + pub api_key: String, + pub limit: Option, + pub podcast_id: Option, + #[serde(rename = "type")] + pub source_type: Option, +} + +// Get RSS feed for user - matches Python get_user_feed function exactly +pub async fn get_user_feed( + State(state): State, + Path(user_id): Path, + Query(query): Query, + request: Request, +) -> Result, AppError> { + let api_key = &query.api_key; + let limit = query.limit.unwrap_or(1000); + let podcast_id = query.podcast_id; + let source_type = query.source_type.as_deref(); + + // Get domain from request + let domain = extract_domain_from_request(&request); + + // Convert single podcast_id to list format if provided + let podcast_id_list = if let Some(id) = podcast_id { + Some(vec![id]) + } else { + None + }; + + // Get RSS key validation + let rss_key = state.db_pool.get_rss_key_if_valid(api_key, podcast_id_list.as_ref()).await?; + + let rss_key = if let Some(key) = rss_key { + key + } else { + let key_id = state.db_pool.get_user_id_from_api_key(api_key).await?; + if key_id == 0 { + return Err(AppError::forbidden("Invalid API key")); + } + + // Create a backwards compatibility RSS key structure + RssKeyInfo { + podcast_ids: vec![-1], + user_id: key_id, + key: api_key.to_string(), + } + }; + + let feed_content = state.db_pool.generate_podcast_rss( + rss_key, + limit, + source_type, + &domain, + podcast_id_list.as_ref(), + ).await?; + + Ok(Response::builder() + .header("content-type", "application/rss+xml") + .body(feed_content) + .map_err(|e| AppError::internal(&format!("Failed to create response: {}", e)))?) +} + +#[derive(Debug, Clone)] +pub struct RssKeyInfo { + pub podcast_ids: Vec, + pub user_id: i32, + pub key: String, +} + +fn extract_domain_from_request(request: &Request) -> String { + // Check SERVER_URL environment variable first (includes scheme and port) + // Note: We use SERVER_URL instead of HOSTNAME because Docker automatically sets HOSTNAME to the container ID + // The startup script saves the user's HOSTNAME value to SERVER_URL before Docker overwrites it + if let Ok(server_url) = std::env::var("SERVER_URL") { + tracing::info!("Using SERVER_URL env var: {}", server_url); + return server_url; + } + + // Try to get domain from Host header + if let Some(host) = request.headers().get("host") { + if let Ok(host_str) = host.to_str() { + // Determine scheme - check for X-Forwarded-Proto or assume http + let scheme = request.headers() + .get("x-forwarded-proto") + .and_then(|h| h.to_str().ok()) + .unwrap_or("http"); + + let domain = format!("{}://{}", scheme, host_str); + tracing::info!("Using Host header: {}", domain); + return domain; + } + } + + // Fallback + tracing::info!("Using fallback domain"); + "http://localhost:8041".to_string() +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/health.rs b/PinePods-0.8.2/rust-api/src/handlers/health.rs new file mode 100644 index 0000000..5a1ca16 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/health.rs @@ -0,0 +1,39 @@ +use axum::{extract::State, response::Json}; +use chrono::Utc; +use crate::{ + error::AppResult, + models::{HealthResponse, PinepodsCheckResponse}, + AppState, +}; + +/// PinePods instance check endpoint - matches Python API exactly +/// GET /api/pinepods_check +pub async fn pinepods_check() -> Json { + Json(PinepodsCheckResponse { + status_code: 200, + pinepods_instance: true, + }) +} + +/// Health check endpoint with database and Redis status +/// GET /api/health +pub async fn health_check(State(state): State) -> AppResult> { + // Check database health + let database_healthy = state.db_pool.health_check().await.unwrap_or(false); + + // Check Redis health + let redis_healthy = state.redis_client.health_check().await.unwrap_or(false); + + let overall_status = if database_healthy && redis_healthy { + "healthy" + } else { + "unhealthy" + }; + + Ok(Json(HealthResponse { + status: overall_status.to_string(), + database: database_healthy, + redis: redis_healthy, + timestamp: Utc::now(), + })) +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/mod.rs b/PinePods-0.8.2/rust-api/src/handlers/mod.rs new file mode 100644 index 0000000..2ca68a3 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/mod.rs @@ -0,0 +1,112 @@ +pub mod auth; +pub mod health; +pub mod podcasts; +pub mod episodes; +pub mod playlists; +pub mod websocket; +// pub mod async_tasks_examples; // File was deleted +pub mod refresh; +pub mod proxy; +pub mod settings; +pub mod sync; +pub mod youtube; +pub mod tasks; +pub mod feed; + +// Common handler utilities +use axum::{ + extract::Query, + http::{HeaderMap, StatusCode}, +}; +use crate::{ + error::{AppError, AppResult}, + models::PaginationParams, + AppState, +}; + +// Extract API key from headers (matches Python API behavior) +pub fn extract_api_key(headers: &HeaderMap) -> AppResult { + headers + .get("Api-Key") + .or_else(|| headers.get("api-key")) + .or_else(|| headers.get("X-API-Key")) + .and_then(|header| header.to_str().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| AppError::unauthorized("Missing API key")) +} + +// Validate API key against database/cache +pub async fn validate_api_key(state: &AppState, api_key: &str) -> AppResult { + // First check Redis cache + if let Ok(Some(is_valid)) = state.redis_client.get_cached_api_key_validation(api_key).await { + return Ok(is_valid); + } + + // If not in cache, check database + let is_valid = state.db_pool.verify_api_key(api_key).await?; + + // Cache the result for 5 minutes + if let Err(e) = state.redis_client.cache_api_key_validation(api_key, is_valid, 300).await { + tracing::warn!("Failed to cache API key validation: {}", e); + } + + Ok(is_valid) +} + +// Check if user has permission (either owns the resource or has web key/admin access) +pub async fn check_user_access(state: &AppState, api_key: &str, target_user_id: i32) -> AppResult { + let requesting_user_id = state.db_pool.get_user_id_from_api_key(api_key).await?; + + // Allow if user is accessing their own data or if they are user ID 1 (admin/web key) + Ok(requesting_user_id == target_user_id || requesting_user_id == 1) +} + +// Check if user has elevated access (web key - user ID 1) +pub async fn check_web_key_access(state: &AppState, api_key: &str) -> AppResult { + let requesting_user_id = state.db_pool.get_user_id_from_api_key(api_key).await?; + Ok(requesting_user_id == 1) +} + +// Check if user has admin privileges +pub async fn check_admin_access(state: &AppState, api_key: &str) -> AppResult { + let requesting_user_id = state.db_pool.get_user_id_from_api_key(api_key).await?; + state.db_pool.user_admin_check(requesting_user_id).await +} + +// Check if user has permission (either owns the resource, has web key access, or is admin) +pub async fn check_user_or_admin_access(state: &AppState, api_key: &str, target_user_id: i32) -> AppResult { + let requesting_user_id = state.db_pool.get_user_id_from_api_key(api_key).await?; + + // Allow if user is accessing their own data, has web key access, or is admin + if requesting_user_id == target_user_id || requesting_user_id == 1 { + Ok(true) + } else { + // Check if user is admin + state.db_pool.user_admin_check(requesting_user_id).await + } +} + +// Extract and validate pagination parameters +pub fn extract_pagination(Query(params): Query) -> (i32, i32) { + let page = params.page.unwrap_or(1).max(1); + let per_page = params.per_page.unwrap_or(50).min(100).max(1); // Limit to 100 per page + (page, per_page) +} + +// Calculate offset for SQL queries +pub fn calculate_offset(page: i32, per_page: i32) -> i32 { + (page - 1) * per_page +} + +// Common response helpers +pub fn success_response() -> (StatusCode, &'static str) { + (StatusCode::OK, "success") +} + +pub fn created_response() -> (StatusCode, &'static str) { + (StatusCode::CREATED, "created") +} + +pub fn no_content_response() -> StatusCode { + StatusCode::NO_CONTENT +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/playlists.rs b/PinePods-0.8.2/rust-api/src/handlers/playlists.rs new file mode 100644 index 0000000..e7ea0a6 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/playlists.rs @@ -0,0 +1,61 @@ +use axum::{extract::State, http::HeaderMap, response::Json}; +use crate::{ + database, + error::{AppError, AppResult}, + handlers::{extract_api_key, validate_api_key}, + models::{CreatePlaylistRequest, CreatePlaylistResponse, DeletePlaylistRequest, DeletePlaylistResponse}, + AppState, +}; + +pub async fn create_playlist( + State(state): State, + headers: HeaderMap, + Json(playlist_data): Json, +) -> AppResult> { + let api_key = extract_api_key(&headers)?; + let is_valid = validate_api_key(&state, &api_key).await?; + + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if user_id != playlist_data.user_id && !is_web_key { + return Err(AppError::forbidden("You can only create playlists for yourself!")); + } + + let playlist_id = database::create_playlist(&state.db_pool, &state.config, &playlist_data).await?; + + Ok(Json(CreatePlaylistResponse { + detail: "Playlist created successfully".to_string(), + playlist_id, + })) +} + +pub async fn delete_playlist( + State(state): State, + headers: HeaderMap, + Json(playlist_data): Json, +) -> AppResult> { + let api_key = extract_api_key(&headers)?; + let is_valid = validate_api_key(&state, &api_key).await?; + + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if user_id != playlist_data.user_id && !is_web_key { + return Err(AppError::forbidden("You can only delete your own playlists!")); + } + + database::delete_playlist(&state.db_pool, &state.config, &playlist_data).await?; + + Ok(Json(DeletePlaylistResponse { + detail: "Playlist deleted successfully".to_string(), + })) +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/podcasts.rs b/PinePods-0.8.2/rust-api/src/handlers/podcasts.rs new file mode 100644 index 0000000..a1937d2 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/podcasts.rs @@ -0,0 +1,2554 @@ +use axum::{ + extract::{Path, Query, State}, + http::HeaderMap, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::{ + error::AppError, + handlers::{extract_api_key, validate_api_key, check_user_access}, + AppState, +}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[allow(non_snake_case)] +pub struct Episode { + pub podcastname: String, + pub episodetitle: String, + pub episodepubdate: String, + pub episodedescription: String, + pub episodeartwork: String, + pub episodeurl: String, + pub episodeduration: i32, + pub listenduration: Option, + pub episodeid: i32, + pub completed: bool, + pub saved: bool, + pub queued: bool, + pub downloaded: bool, + pub is_youtube: bool, +} + +// Separate struct for downloaded episodes that exactly matches Python implementation +#[derive(Serialize, Deserialize, Debug, Clone)] +#[allow(non_snake_case)] +pub struct DownloadedEpisode { + pub podcastid: i32, + pub podcastname: String, + pub artworkurl: Option, + pub episodeid: i32, + pub episodetitle: String, + pub episodepubdate: String, + pub episodedescription: String, + pub episodeartwork: Option, + pub episodeurl: String, + pub episodeduration: i32, + pub podcastindexid: Option, + pub websiteurl: Option, + pub downloadedlocation: String, + pub listenduration: Option, + pub completed: bool, + pub saved: bool, + pub queued: bool, + pub downloaded: bool, // Always true for downloaded episodes + pub is_youtube: bool, +} + +// Response struct for downloaded episodes +#[derive(Serialize, Deserialize, Debug)] +pub struct DownloadedEpisodesResponse { + pub downloaded_episodes: Vec, +} + +// Separate struct for podcast_episodes endpoint that matches frontend expectations +#[derive(Serialize, Deserialize, Debug, Clone)] +#[allow(non_snake_case)] +pub struct PodcastEpisode { + pub podcastname: String, + #[serde(rename = "Episodetitle")] + pub episodetitle: String, + #[serde(rename = "Episodepubdate")] + pub episodepubdate: String, + #[serde(rename = "Episodedescription")] + pub episodedescription: String, + #[serde(rename = "Episodeartwork")] + pub episodeartwork: String, + #[serde(rename = "Episodeurl")] + pub episodeurl: String, + #[serde(rename = "Episodeduration")] + pub episodeduration: i32, + #[serde(rename = "Listenduration")] + pub listenduration: Option, + #[serde(rename = "Episodeid")] + pub episodeid: i32, + #[serde(rename = "Completed")] + pub completed: bool, + pub saved: bool, + pub queued: bool, + pub downloaded: bool, + pub is_youtube: bool, +} + +#[derive(Serialize)] +pub struct PodcastEpisodesResponse { + pub episodes: Vec, +} + +#[derive(Serialize)] +pub struct EpisodesResponse { + pub episodes: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PodcastValues { + pub pod_title: String, + pub pod_artwork: String, + pub pod_author: String, + pub categories: HashMap, + pub pod_description: String, + pub pod_episode_count: i32, + pub pod_feed_url: String, + pub pod_website: String, + pub pod_explicit: bool, + pub user_id: i32, +} + +#[derive(Deserialize)] +pub struct AddPodcastRequest { + pub podcast_values: PodcastValues, + pub podcast_index_id: Option, +} + +#[derive(Serialize)] +pub struct PodcastStatusResponse { + pub success: bool, + pub podcast_id: i32, + pub first_episode_id: i32, +} + +#[derive(Deserialize)] +pub struct RemovePodcastRequest { + pub user_id: i32, + pub podcast_name: String, + pub podcast_url: String, +} + +#[derive(Deserialize)] +pub struct RemovePodcastIdRequest { + pub user_id: i32, + pub podcast_id: i32, + pub is_youtube: Option, +} + +#[derive(Serialize)] +pub struct RemovePodcastResponse { + pub success: bool, +} + +// Request struct for update_podcast_info - matches edit podcast functionality +#[derive(Deserialize)] +pub struct UpdatePodcastInfoRequest { + pub user_id: i32, + pub podcast_id: i32, + pub feed_url: Option, + pub username: Option, + pub password: Option, + pub podcast_name: Option, + pub description: Option, + pub author: Option, + pub artwork_url: Option, + pub website_url: Option, + pub podcast_index_id: Option, +} + +#[derive(Serialize)] +pub struct UpdatePodcastInfoResponse { + pub success: bool, + pub message: String, +} + +// Query struct for get_podcast_details - matches Python endpoint +#[derive(Deserialize)] +pub struct GetPodcastDetailsQuery { + pub user_id: i32, + pub podcast_id: i32, +} + +// Response struct for get_podcast_details - matches Python ClickedFeedURL model +#[derive(Serialize, Deserialize, Debug, Clone)] +#[allow(non_snake_case)] +pub struct PodcastDetails { + pub podcastid: i32, + pub podcastname: String, + pub feedurl: String, + pub description: String, + pub author: String, + pub artworkurl: String, + pub explicit: bool, + pub episodecount: i32, + pub categories: Option>, + pub websiteurl: String, + pub podcastindexid: i32, + pub is_youtube: Option, +} + +// Get episodes for a user - matches Python return_episodes endpoint +pub async fn return_episodes( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only return episodes of your own!")); + } + + // Get episodes from database + let episodes = state.db_pool.return_episodes(user_id).await?; + + Ok(Json(EpisodesResponse { episodes })) +} + +// Add a new podcast - matches Python add_podcast endpoint +pub async fn add_podcast( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only add podcasts for themselves + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, requesting_user_id).await? { + return Err(AppError::forbidden("You can only add podcasts for yourself!")); + } + + // Re-parse feed URL using backend feed-rs parsing instead of trusting frontend data + let feed_url = &request.podcast_values.pod_feed_url; + let user_id = request.podcast_values.user_id; + + // Get properly parsed podcast values from feed-rs + let parsed_podcast_values = state.db_pool.get_podcast_values(feed_url, user_id, None, None).await?; + + // Convert to PodcastValues struct using backend-parsed data + let backend_podcast_values = PodcastValues { + user_id, + pod_title: parsed_podcast_values.get("podcastname").unwrap_or(&request.podcast_values.pod_title).clone(), + pod_artwork: parsed_podcast_values.get("artworkurl").unwrap_or(&"".to_string()).clone(), + pod_author: parsed_podcast_values.get("author").unwrap_or(&"".to_string()).clone(), + categories: serde_json::from_str(parsed_podcast_values.get("categories").unwrap_or(&"{}".to_string())).unwrap_or_default(), + pod_description: parsed_podcast_values.get("description").unwrap_or(&request.podcast_values.pod_description).clone(), + pod_episode_count: parsed_podcast_values.get("episodecount").unwrap_or(&"0".to_string()).parse().unwrap_or(0), + pod_feed_url: feed_url.clone(), + pod_website: parsed_podcast_values.get("websiteurl").unwrap_or(&request.podcast_values.pod_website).clone(), + pod_explicit: parsed_podcast_values.get("explicit").unwrap_or(&"False".to_string()) == "True", + }; + + // Add podcast to database immediately (without episodes) + let podcast_id = state.db_pool.add_podcast_without_episodes( + &backend_podcast_values, + request.podcast_index_id.unwrap_or(0), + None, // username + None, // password + ).await?; + + // Spawn background task to add episodes + let _task_id = state.task_spawner.spawn_add_podcast_episodes_task( + podcast_id, + backend_podcast_values.pod_feed_url.clone(), + backend_podcast_values.pod_artwork.clone(), + backend_podcast_values.user_id, + None, // username + None, // password + ).await?; + + Ok(Json(PodcastStatusResponse { + success: true, + podcast_id, + first_episode_id: 0, // Episodes will be added in background + })) +} + +// Remove a podcast - matches Python remove_podcast endpoint +pub async fn remove_podcast( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only remove their own podcasts + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, requesting_user_id).await? { + return Err(AppError::forbidden("You can only remove your own podcasts!")); + } + + // Remove podcast from database + state.db_pool.remove_podcast( + &request.podcast_name, + &request.podcast_url, + request.user_id, + ).await?; + + Ok(Json(RemovePodcastResponse { success: true })) +} + +// Remove podcast by ID - matches Python remove_podcast_id endpoint +pub async fn remove_podcast_id( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only remove their own podcasts or have elevated access + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if requesting_user_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only remove your own podcasts!")); + } + + // Remove podcast from database + state.db_pool.remove_podcast_id(request.podcast_id, request.user_id).await?; + + Ok(Json(RemovePodcastResponse { success: true })) +} + +// Remove podcast by name and URL - matches call_remove_podcasts_name from frontend +pub async fn remove_podcast_by_name( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only remove their own podcasts + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, requesting_user_id).await? { + return Err(AppError::forbidden("You can only remove your own podcasts!")); + } + + // Remove podcast from database using the comprehensive method + state.db_pool.remove_podcast_by_name_url( + &request.podcast_name, + &request.podcast_url, + request.user_id, + ).await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + +// Get podcasts for a user - matches call_get_podcasts from frontend +pub async fn return_pods( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only return podcasts of your own!")); + } + + // Get podcasts from database + let pods = state.db_pool.return_pods(user_id).await?; + + Ok(Json(crate::models::PodcastListResponse { pods })) +} + +// Get podcasts with extra stats for a user - matches call_get_podcasts_extra from frontend +pub async fn return_pods_extra( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only return podcasts of your own!")); + } + + // Get podcasts with extra stats from database + let pods = state.db_pool.return_pods_extra(user_id).await?; + + Ok(Json(crate::models::PodcastExtraListResponse { pods })) +} + +// Query parameters for check operations +#[derive(Deserialize)] +pub struct CheckPodcastQuery { + pub user_id: i32, + pub podcast_name: String, + pub podcast_url: String, +} + +#[derive(Deserialize)] +pub struct CheckEpisodeQuery { + pub episode_title: String, + pub episode_url: String, +} + +#[derive(Deserialize)] +pub struct TimeInfoQuery { + pub user_id: i32, +} + +// Get time info for a user - matches call_get_time_info from frontend +pub async fn get_time_info( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only get your own time info!")); + } + + // Get time info from database + let time_info = state.db_pool.get_time_info(query.user_id).await?; + + Ok(Json(time_info)) +} + +// Check if podcast exists - matches call_check_podcast from frontend +pub async fn check_podcast( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only check your own podcasts!")); + } + + // Check if podcast exists in database + let exists = state.db_pool.check_podcast(query.user_id, &query.podcast_name, &query.podcast_url).await?; + + Ok(Json(crate::models::CheckPodcastResponse { exists })) +} + +// Check if episode exists in database - matches call_check_episode_in_db from frontend +pub async fn check_episode_in_db( + Path(user_id): Path, + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only check episodes in your own podcasts!")); + } + + // Check if episode exists in database + let episode_in_db = state.db_pool.check_episode_exists(user_id, &query.episode_title, &query.episode_url).await?; + + Ok(Json(crate::models::EpisodeInDbResponse { episode_in_db })) +} + +// Queue episode - matches call_queue_episode from frontend +pub async fn queue_episode( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only queue episodes for yourself!")); + } + + // Queue the episode + state.db_pool.queue_episode(request.episode_id, request.user_id, request.is_youtube).await?; + + let message = if request.is_youtube { + "Video queued successfully" + } else { + "Episode queued successfully" + }; + + Ok(Json(crate::models::QueueResponse { + data: message.to_string(), + })) +} + +// Remove queued episode - matches call_remove_queued_episode from frontend +pub async fn remove_queued_episode( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only remove your own queued episodes!")); + } + + // Remove the episode from queue + state.db_pool.remove_queued_episode(request.episode_id, request.user_id, request.is_youtube).await?; + + Ok(Json(crate::models::QueueResponse { + data: "Successfully Removed Episode From Queue".to_string(), + })) +} + +// Get queued episodes - matches call_get_queued_episodes from frontend +pub async fn get_queued_episodes( + Query(query): Query, // Reuse TimeInfoQuery since it just needs user_id + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own queued episodes + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only get your own queued episodes!")); + } + + // Get queued episodes from database + let data = state.db_pool.get_queued_episodes(query.user_id).await?; + + Ok(Json(crate::models::QueuedEpisodesResponse { data })) +} + +// Reorder queue - matches call_reorder_queue from frontend +pub async fn reorder_queue( + Query(query): Query, // Reuse TimeInfoQuery since it just needs user_id + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only reorder your own queue!")); + } + + // Reorder the queue + state.db_pool.reorder_queue(query.user_id, request.episode_ids).await?; + + Ok(Json(crate::models::ReorderQueueResponse { + message: "Queue reordered successfully".to_string(), + })) +} + +// Save episode - matches call_save_episode from frontend +pub async fn save_episode( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only save episodes for yourself!")); + } + + // Save the episode + state.db_pool.save_episode(request.episode_id, request.user_id, request.is_youtube).await?; + + let message = if request.is_youtube { + "Video saved!" + } else { + "Episode saved!" + }; + + Ok(Json(crate::models::SaveEpisodeResponse { + detail: message.to_string(), + })) +} + +// Remove saved episode - matches call_remove_saved_episode from frontend +pub async fn remove_saved_episode( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only remove your own saved episodes!")); + } + + // Remove the saved episode + state.db_pool.remove_saved_episode(request.episode_id, request.user_id, request.is_youtube).await?; + + let message = if request.is_youtube { + "Saved video removed." + } else { + "Saved episode removed." + }; + + Ok(Json(crate::models::SaveEpisodeResponse { + detail: message.to_string(), + })) +} + +// Get saved episodes - matches call_get_saved_episodes from frontend +pub async fn get_saved_episodes( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only get your own saved episodes!")); + } + + // Get saved episodes from database + let saved_episodes = state.db_pool.get_saved_episodes(user_id).await?; + + Ok(Json(crate::models::SavedEpisodesResponse { saved_episodes })) +} + +// Add history - matches call_add_history from frontend +pub async fn add_history( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only add history for yourself!")); + } + + // Record the history + state.db_pool.record_podcast_history( + request.episode_id, + request.user_id, + request.episode_pos, + request.is_youtube + ).await?; + + Ok(Json(crate::models::HistoryResponse { + detail: "History recorded successfully.".to_string(), + })) +} + +// Get user history - matches call_get_user_history from frontend +pub async fn get_user_history( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only get your own history!")); + } + + // Get user history from database + let data = state.db_pool.get_user_history(user_id).await?; + + Ok(Json(crate::models::UserHistoryResponse { data })) +} + +// Query parameters for get_podcast_id +#[derive(Deserialize)] +pub struct GetPodcastIdQuery { + pub user_id: i32, + pub podcast_feed: String, + pub podcast_title: String, +} + +// Get podcast ID - matches Python get_podcast_id endpoint +pub async fn get_podcast_id( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only return pocast ids of your own podcasts!")); + } + + // Get podcast ID from database + let podcast_id = state.db_pool.get_podcast_id(query.user_id, &query.podcast_feed, &query.podcast_title).await?; + + // Return podcast ID in properly named field + Ok(Json(serde_json::json!({ "podcast_id": podcast_id }))) +} + +// Query parameters for download_episode_list +#[derive(Deserialize)] +pub struct DownloadEpisodeListQuery { + pub user_id: i32, +} + +// Get downloaded episodes list - matches Python download_episode_list endpoint +pub async fn download_episode_list( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only return downloaded episodes for yourself!")); + } + + // Get downloaded episodes from database + let downloaded_episodes = state.db_pool.download_episode_list(query.user_id).await?; + + Ok(Json(DownloadedEpisodesResponse { downloaded_episodes })) +} + +// Request models for download operations +#[derive(Deserialize)] +pub struct DownloadPodcastRequest { + pub episode_id: i32, + pub user_id: i32, + pub is_youtube: Option, +} + +#[derive(Deserialize)] +pub struct DeleteEpisodeRequest { + pub episode_id: i32, + pub user_id: i32, + pub is_youtube: Option, +} + +#[derive(Deserialize)] +pub struct DownloadAllPodcastRequest { + pub podcast_id: i32, + pub user_id: i32, + pub is_youtube: Option, +} + +// Download a single episode - matches Python download_podcast endpoint +pub async fn download_podcast( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only download content for yourself!")); + } + + let is_youtube = request.is_youtube.unwrap_or(false); + + // Check if already downloaded + let is_downloaded = state.db_pool.check_downloaded(request.user_id, request.episode_id, is_youtube).await?; + if is_downloaded { + return Ok(Json(serde_json::json!({ "detail": "Content already downloaded." }))); + } + + // Queue the download task using the task system + let task_id = if is_youtube { + state.task_spawner.spawn_download_youtube_video(request.episode_id, request.user_id).await? + } else { + state.task_spawner.spawn_download_podcast_episode(request.episode_id, request.user_id).await? + }; + + let content_type = if is_youtube { "YouTube video" } else { "Podcast episode" }; + + Ok(Json(serde_json::json!({ + "detail": format!("{} download has been queued and will process in the background.", content_type), + "task_id": task_id + }))) +} + +// Delete a downloaded episode - matches Python delete_episode endpoint +pub async fn delete_episode( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only delete your own downloads!")); + } + + let is_youtube = request.is_youtube.unwrap_or(false); + + // Delete the episode + state.db_pool.delete_episode(request.user_id, request.episode_id, is_youtube).await?; + + let content_type = if is_youtube { "Video" } else { "Episode" }; + + Ok(Json(serde_json::json!({ + "detail": format!("{} deleted successfully.", content_type) + }))) +} + +// Download all episodes of a podcast - matches Python download_all_podcast endpoint +pub async fn download_all_podcast( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only download content for yourself!")); + } + + let is_youtube = request.is_youtube.unwrap_or(false); + + // Queue the download all task using the task system + let task_id = if is_youtube { + state.task_spawner.spawn_download_all_youtube_videos(request.podcast_id, request.user_id).await? + } else { + state.task_spawner.spawn_download_all_podcast_episodes(request.podcast_id, request.user_id).await? + }; + + let content_type = if is_youtube { "YouTube channel" } else { "Podcast" }; + + Ok(Json(serde_json::json!({ + "detail": format!("All {} downloads have been queued and will process in the background.", content_type), + "task_id": task_id + }))) +} + +// Get download status for a user - matches Python download_status endpoint +pub async fn download_status( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only get your own download status!")); + } + + // Get download status from database + let status = state.db_pool.get_download_status(user_id).await?; + + Ok(Json(serde_json::json!(status))) +} + +// Query parameters for podcast_episodes +#[derive(Deserialize)] +pub struct PodcastEpisodesQuery { + pub user_id: i32, + pub podcast_id: i32, +} + +// Get episodes for a specific podcast - matches Python podcast_episodes endpoint +pub async fn podcast_episodes( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only return episodes of your own!")); + } + + // Get podcast episodes from database + let episodes = state.db_pool.return_podcast_episodes_capitalized(query.user_id, query.podcast_id).await?; + + Ok(Json(PodcastEpisodesResponse { episodes })) +} + +// Query parameters for get_podcast_id_from_ep_name +#[derive(Deserialize)] +pub struct GetPodcastIdFromEpNameQuery { + pub episode_name: String, + pub episode_url: String, + pub user_id: i32, +} + +// Get podcast ID from episode name and URL - matches Python get_podcast_id_from_ep_name endpoint +pub async fn get_podcast_id_from_ep_name( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only return podcast ids of your own episodes!")); + } + + // Get podcast ID from episode name and URL + let podcast_id = state.db_pool.get_podcast_id_from_episode_name(&query.episode_name, &query.episode_url, query.user_id).await?; + + Ok(Json(serde_json::json!({ "podcast_id": podcast_id }))) +} + +// Query parameters for get_episode_id_ep_name +#[derive(Deserialize)] +pub struct GetEpisodeIdFromEpNameQuery { + pub episode_title: String, + pub episode_url: String, + pub user_id: i32, + pub is_youtube: bool, +} + +// Get episode ID from episode URL - matches frontend call_get_episode_id function +pub async fn get_episode_id_ep_name( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, query.user_id).await? { + return Err(AppError::forbidden("You can only return episode ids of your own episodes!")); + } + + // Get episode ID from URL + let episode_id = state.db_pool.get_episode_id_from_url(&query.episode_url, query.user_id).await?; + + match episode_id { + Some(id) => Ok(Json(serde_json::json!(id))), + None => Err(AppError::not_found("Episode not found")) + } +} + +// Request for get_episode_metadata - matches Python EpisodeMetadata model +#[derive(Deserialize)] +pub struct EpisodeMetadataRequest { + pub episode_id: i32, + pub user_id: i32, + pub person_episode: Option, + pub is_youtube: Option, +} + +// Get episode metadata - matches Python get_episode_metadata endpoint exactly +pub async fn get_episode_metadata( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + // Check if it's web key or user's own key + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == request.user_id || is_web_key { + let episode = state.db_pool.get_episode_metadata( + request.episode_id, + request.user_id, + request.person_episode.unwrap_or(false), + request.is_youtube.unwrap_or(false) + ).await?; + + Ok(Json(serde_json::json!({"episode": episode}))) + } else { + Err(AppError::forbidden("You can only get metadata for yourself!")) + } +} + +// Query parameters for fetch_podcasting_2_data +#[derive(Deserialize)] +pub struct FetchPodcasting2DataQuery { + pub episode_id: i32, + pub user_id: i32, +} + +// Fetch podcasting 2.0 data for episode - matches Python fetch_podcasting_2_data endpoint exactly +pub async fn fetch_podcasting_2_data( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key or insufficient permissions")); + } + + // Get the episode_id and user_id from query parameters + let episode_id = query.episode_id; + let user_id = query.user_id; + + // Call the database method to fetch podcasting 2.0 data + let data = state.db_pool.fetch_podcasting_2_data(episode_id, user_id).await?; + + Ok(Json(data)) +} + +// Request for get_auto_download_status - matches Python AutoDownloadStatusRequest +#[derive(Deserialize)] +pub struct AutoDownloadStatusRequest { + pub podcast_id: i32, + pub user_id: i32, +} + +// Response for auto download status - matches Python AutoDownloadStatusResponse +#[derive(Serialize)] +pub struct AutoDownloadStatusResponse { + pub auto_download: bool, +} + +// Get auto download status - matches Python get_auto_download_status endpoint exactly +pub async fn get_auto_download_status( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if key_id != request.user_id { + return Err(AppError::forbidden("You can only get the status for your own podcast.")); + } + + let status = state.db_pool.call_get_auto_download_status(request.podcast_id, request.user_id).await?; + if status.is_none() { + return Err(AppError::not_found("Podcast not found")); + } + + Ok(Json(AutoDownloadStatusResponse { + auto_download: status.unwrap() + })) +} + +// Query parameters for get_feed_cutoff_days +#[derive(Deserialize)] +pub struct FeedCutoffDaysQuery { + pub podcast_id: i32, + pub user_id: i32, +} + +// Response for feed cutoff days - matches Python response format +#[derive(Serialize)] +pub struct FeedCutoffDaysResponse { + pub podcast_id: i32, + pub user_id: i32, + pub feed_cutoff_days: i32, +} + +// Get feed cutoff days - matches Python get_feed_cutoff_days endpoint exactly +pub async fn get_feed_cutoff_days( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + // Check if it's web key or user's own key + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == query.user_id || is_web_key { + let feed_cutoff_days = state.db_pool.get_feed_cutoff_days(query.podcast_id, query.user_id).await?; + if let Some(cutoff_days) = feed_cutoff_days { + Ok(Json(FeedCutoffDaysResponse { + podcast_id: query.podcast_id, + user_id: query.user_id, + feed_cutoff_days: cutoff_days + })) + } else { + Err(AppError::not_found("Podcast not found or does not belong to the user.")) + } + } else { + Err(AppError::forbidden("You can only access settings of your own podcasts!")) + } +} + +// Request for podcast notification status - matches Python PodcastNotificationStatusData +#[derive(Deserialize)] +pub struct PodcastNotificationStatusRequest { + pub user_id: i32, + pub podcast_id: i32, +} + +// Response for notification status +#[derive(Serialize)] +pub struct NotificationStatusResponse { + pub enabled: bool, +} + +// Get podcast notification status - matches Python podcast/notification_status endpoint exactly +pub async fn get_notification_status( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == request.user_id || is_web_key { + let enabled = state.db_pool.get_podcast_notification_status( + request.podcast_id, + request.user_id + ).await?; + Ok(Json(NotificationStatusResponse { enabled })) + } else { + Err(AppError::forbidden("You can only check your own podcast settings")) + } +} + +// Request for get_play_episode_details - matches Python PlayEpisodeDetailsRequest +#[derive(Deserialize)] +pub struct PlayEpisodeDetailsRequest { + pub podcast_id: i32, + pub user_id: i32, + pub is_youtube: Option, +} + +// Response for play episode details - matches Python PlayEpisodeDetailsResponse +#[derive(Serialize)] +pub struct PlayEpisodeDetailsResponse { + pub playback_speed: f64, + pub start_skip: i32, + pub end_skip: i32, +} + +// Get play episode details - matches Python get_play_episode_details endpoint exactly +pub async fn get_play_episode_details( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == request.user_id || is_web_key { + // Get all details in one function call + let (playback_speed, start_skip, end_skip) = state.db_pool.get_play_episode_details( + request.user_id, + request.podcast_id, + request.is_youtube.unwrap_or(false) + ).await?; + + Ok(Json(PlayEpisodeDetailsResponse { + playback_speed, + start_skip, + end_skip + })) + } else { + Err(AppError::forbidden("You can only get metadata for yourself!")) + } +} + +// Query parameters for fetch_podcasting_2_pod_data +#[derive(Deserialize)] +pub struct FetchPodcasting2PodDataQuery { + pub podcast_id: i32, + pub user_id: i32, +} + +// Fetch podcasting 2.0 podcast data - matches Python fetch_podcasting_2_pod_data endpoint exactly +pub async fn fetch_podcasting_2_pod_data( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key or insufficient permissions")); + } + + // Fetch podcasting 2.0 podcast data + let data = state.db_pool.fetch_podcasting_2_pod_data(query.podcast_id, query.user_id).await?; + + Ok(Json(data)) +} + +#[derive(Deserialize)] +pub struct UpdateEpisodeDurationRequest { + pub episode_id: i32, + pub new_duration: i32, + pub is_youtube: bool, +} + +pub async fn update_episode_duration( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized( + "Your API key is either invalid or does not have correct permission", + )); + } + + state + .db_pool + .update_episode_duration(request.episode_id, request.new_duration, request.is_youtube) + .await?; + Ok(Json( + serde_json::json!({"detail": format!("Episode duration updated to {}", request.new_duration)}), + )) +} + +// Request for mark_episode_completed - matches Python MarkEpisodeCompletedData +#[derive(Deserialize)] +pub struct MarkEpisodeCompletedRequest { + pub episode_id: i32, + pub user_id: i32, + pub is_youtube: Option, +} + +// Mark episode as completed - matches Python mark_episode_completed endpoint exactly +pub async fn mark_episode_completed( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == request.user_id || is_web_key { + state.db_pool.mark_episode_completed( + request.episode_id, + request.user_id, + request.is_youtube.unwrap_or(false) + ).await?; + + Ok(Json(serde_json::json!({ "detail": "Episode marked as completed." }))) + } else { + Err(AppError::forbidden("You can only mark episodes as completed for yourself.")) + } +} + +// Increment played count - matches Python increment_played endpoint exactly +pub async fn increment_played( + Path(user_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == user_id || is_web_key { + state.db_pool.increment_played(user_id).await?; + + Ok(Json(serde_json::json!({ "detail": "Played count incremented." }))) + } else { + Err(AppError::forbidden("You can only increment your own play count.")) + } +} + +// Query parameters for get_podcast_id_from_ep_id +#[derive(Deserialize)] +pub struct GetPodcastIdFromEpIdQuery { + pub episode_id: i32, + pub user_id: i32, + pub is_youtube: Option, +} + +// Get podcast ID from episode ID - matches Python get_podcast_id_from_ep_id endpoint exactly +pub async fn get_podcast_id_from_ep_id( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == query.user_id || is_web_key { + let podcast_id = state.db_pool.get_podcast_id_from_episode( + query.episode_id, + query.user_id, + query.is_youtube.unwrap_or(false) + ).await?; + + if let Some(podcast_id) = podcast_id { + Ok(Json(serde_json::json!({ "podcast_id": podcast_id }))) + } else { + Err(AppError::not_found("Episode not found or does not belong to user")) + } + } else { + Err(AppError::forbidden("You can only return podcast ids of your own podcasts!")) + } +} + +// Query parameters for get_stats +#[derive(Deserialize)] +pub struct GetStatsQuery { + pub user_id: i32, +} + +// Get user stats - matches Python get_stats endpoint exactly +pub async fn get_stats( + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == query.user_id || is_web_key { + let stats = state.db_pool.get_stats(query.user_id).await?; + + if let Some(stats) = stats { + Ok(Json(stats)) + } else { + Err(AppError::not_found("Stats not found for the given user ID")) + } + } else { + Err(AppError::forbidden("You can only get stats for your own account.")) + } +} + +// Get PinePods version - matches Python get_pinepods_version endpoint exactly +pub async fn get_pinepods_version( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let version = state.db_pool.get_pinepods_version().await?; + + Ok(Json(serde_json::json!({ "data": version }))) +} + +// Request for search_data - matches Python SearchPodcastData +#[derive(Deserialize)] +pub struct SearchDataRequest { + pub search_term: String, + pub user_id: i32, +} + +// Search data - matches Python search_data endpoint exactly +pub async fn search_data( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + let result = state.db_pool.search_data(&request.search_term, request.user_id).await?; + + Ok(Json(serde_json::json!({ "data": result }))) +} + +// Request for fetch_transcript - proxy to avoid CORS issues +#[derive(Deserialize)] +pub struct FetchTranscriptRequest { + pub url: String, +} + +// Fetch transcript - proxy endpoint to avoid CORS issues +pub async fn fetch_transcript( + headers: HeaderMap, + State(_state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = _state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Your API key is either invalid or does not have correct permission")); + } + + // Fetch the transcript content from the external URL + let client = reqwest::Client::new(); + match client.get(&request.url).send().await { + Ok(response) => { + match response.text().await { + Ok(content) => { + Ok(Json(serde_json::json!({ + "success": true, + "content": content + }))) + } + Err(e) => { + Ok(Json(serde_json::json!({ + "success": false, + "error": format!("Failed to read response text: {}", e) + }))) + } + } + } + Err(e) => { + Ok(Json(serde_json::json!({ + "success": false, + "error": format!("Failed to fetch transcript: {}", e) + }))) + } + } +} + +// Query struct for home_overview +#[derive(Deserialize)] +pub struct HomeOverviewQuery { + pub user_id: i32, +} + +// Get home overview - matches Python api_home_overview function +pub async fn home_overview( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only access their own data + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id != query.user_id { + return Err(AppError::forbidden("You can only view your own home overview!")); + } + + let home_data = state.db_pool.get_home_overview(query.user_id).await?; + + Ok(Json(home_data)) +} + +// Query struct for get_playlists +#[derive(Deserialize)] +pub struct GetPlaylistsQuery { + pub user_id: i32, +} + +// Get playlists - matches Python api_get_playlists function +pub async fn get_playlists( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only access their own data + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id != query.user_id { + return Err(AppError::forbidden("You can only view your own playlists!")); + } + + let playlists = state.db_pool.get_playlists(query.user_id).await?; + + Ok(Json(serde_json::json!({ "playlists": playlists }))) +} + +// Request struct for mark_episode_uncompleted +#[derive(Deserialize)] +pub struct MarkEpisodeUncompletedRequest { + pub episode_id: i32, + pub user_id: i32, + #[serde(default)] + pub is_youtube: bool, +} + +// Mark episode as uncompleted - matches Python api_mark_episode_uncompleted function +pub async fn mark_episode_uncompleted( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only mark their own episodes as uncompleted + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id != request.user_id { + return Err(AppError::forbidden("You can only mark episodes as uncompleted for yourself.")); + } + + state.db_pool.mark_episode_uncompleted(request.episode_id, request.user_id, request.is_youtube).await?; + + Ok(Json(serde_json::json!({ "detail": "Episode marked as uncompleted." }))) +} + +// Request struct for record_listen_duration +#[derive(Deserialize)] +pub struct RecordListenDurationRequest { + pub episode_id: i32, + pub user_id: i32, + pub listen_duration: f64, + #[serde(default)] + pub is_youtube: bool, +} + +// Record listen duration - matches Python api record_listen_duration function exactly +pub async fn record_listen_duration( + State(state): State, + headers: HeaderMap, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Ignore listen duration for episodes with ID 0 + if data.episode_id == 0 { + return Ok(Json(serde_json::json!({ "detail": "Listen duration for episode ID 0 is ignored." }))); + } + + // Check authorization - web key or user can only record their own duration + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != data.user_id && !is_web_key { + return Err(AppError::forbidden("You can only record your own listen duration")); + } + + if data.is_youtube { + state.db_pool.record_youtube_listen_duration(data.episode_id, data.user_id, data.listen_duration).await?; + } else { + state.db_pool.record_listen_duration(data.episode_id, data.user_id, data.listen_duration).await?; + } + + // Check if episode should be auto-completed based on user's setting + let auto_complete_seconds = state.db_pool.get_user_auto_complete_seconds(data.user_id).await.unwrap_or(0); + + if auto_complete_seconds > 0 { + // Get episode duration + let episode_duration = if data.is_youtube { + state.db_pool.get_youtube_episode_duration(data.episode_id).await.unwrap_or(0) + } else { + state.db_pool.get_episode_duration(data.episode_id).await.unwrap_or(0) + }; + + if episode_duration > 0 { + let remaining_time = episode_duration as f64 - data.listen_duration; + + // Auto-complete if remaining time <= auto_complete_seconds + // Also handle cases where listen_duration exceeds episode_duration (dynamic ads, etc.) + if remaining_time <= auto_complete_seconds as f64 || data.listen_duration >= episode_duration as f64 { + let _ = state.db_pool.mark_episode_completed(data.episode_id, data.user_id, data.is_youtube).await; + } + } + } + + Ok(Json(serde_json::json!({ "detail": "Listen duration recorded." }))) +} + +// Get user history - matches Python user_history endpoint exactly +pub async fn user_history( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only get their own history + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != user_id && !is_web_key { + return Err(AppError::forbidden("You can only return history for yourself!")); + } + + let history = state.db_pool.user_history(user_id).await?; + Ok(Json(serde_json::json!({ "data": history }))) +} + +// Increment listen time - matches Python increment_listen_time endpoint exactly +pub async fn increment_listen_time( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only increment their own time + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != user_id && !is_web_key { + return Err(AppError::forbidden("You can only increment your own listen time.")); + } + + state.db_pool.increment_listen_time(user_id).await?; + Ok(Json(serde_json::json!({ "detail": "Listen time incremented." }))) +} + +// Request struct for get_playback_speed +#[derive(Deserialize)] +pub struct GetPlaybackSpeedRequest { + pub user_id: i32, + pub podcast_id: Option, +} + +// Get playback speed - matches Python get_playback_speed endpoint exactly +pub async fn get_playback_speed( + State(state): State, + headers: HeaderMap, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only get their own playback speed + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != data.user_id && !is_web_key { + return Err(AppError::forbidden("You can only get metadata for yourself!")); + } + + let playback_speed = state.db_pool.get_playback_speed(data.user_id, false, data.podcast_id).await?; + Ok(Json(serde_json::json!({ "playback_speed": playback_speed }))) +} + +// Query struct for get_playlist_episodes +#[derive(Deserialize)] +pub struct GetPlaylistEpisodesQuery { + pub user_id: i32, + pub playlist_id: i32, +} + +// Get playlist episodes - UPDATED to use dynamic playlist system +pub async fn get_playlist_episodes( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only access their own playlists + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id != query.user_id { + return Err(AppError::forbidden("You can only view your own playlist episodes!")); + } + + // Use new dynamic playlist system + let playlist_response = state.db_pool.get_playlist_episodes_dynamic( + query.playlist_id, + query.user_id + ).await?; + + // Return in format expected by frontend + Ok(Json(serde_json::to_value(playlist_response)?)) +} + +// Get podcast details - matches Python get_podcast_details endpoint +pub async fn get_podcast_details( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only access their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id != query.user_id { + return Err(AppError::forbidden("You can only view your own podcast details!")); + } + + let podcast_details = state.db_pool.get_podcast_details(query.user_id, query.podcast_id).await?; + + Ok(Json(serde_json::json!({ "details": podcast_details }))) +} + +// Query struct for YouTube episodes endpoint +#[derive(Deserialize)] +pub struct YouTubeEpisodesQuery { + pub user_id: i32, + pub podcast_id: i32, +} + +// Get YouTube episodes - matches Python api_youtube_episodes function exactly +pub async fn youtube_episodes( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only return episodes of their own + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != query.user_id && !is_web_key { + return Err(AppError::forbidden("You can only return episodes of your own!")); + } + + let episodes = state.db_pool.return_youtube_episodes(query.user_id, query.podcast_id).await?; + + let episodes_result = episodes.unwrap_or_else(|| vec![]); + + Ok(Json(serde_json::json!({ "episodes": episodes_result }))) +} + +// Request struct for removing YouTube channel +#[derive(Deserialize)] +pub struct RemoveYouTubeChannelRequest { + pub user_id: i32, + pub channel_name: String, + pub channel_url: String, +} + +// Remove YouTube channel - matches Python api_remove_youtube_channel_route function exactly +pub async fn remove_youtube_channel( + State(state): State, + headers: HeaderMap, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if the provided API key is the web key (elevated access) + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if !is_web_key { + // Get user ID from API key and check authorization + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if data.user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to remove channels for other users")); + } + } + + // Remove the YouTube channel + state.db_pool.remove_youtube_channel_by_url( + &data.channel_name, + &data.channel_url, + data.user_id, + ).await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + +// Query struct for stream endpoint +#[derive(Deserialize)] +pub struct StreamQuery { + pub api_key: String, + pub user_id: i32, + #[serde(rename = "type")] + pub source_type: Option, +} + +// Stream episode - matches Python stream_episode function exactly +pub async fn stream_episode( + State(state): State, + Path(episode_id): Path, + Query(query): Query, +) -> Result { + let api_key = &query.api_key; + println!("Stream request for episode {} with api_key {} and user_id {}", episode_id, api_key, query.user_id); + + // Try RSS key validation FIRST (RSS keys are used in RSS feeds for streaming) + let mut is_valid = false; + let mut is_web_key = false; + let mut key_user_id = None; + + println!("Trying RSS key validation first"); + match state.db_pool.get_rss_key_if_valid(api_key, None).await { + Ok(Some(rss_info)) => { + println!("Valid RSS key for user {}", rss_info.user_id); + is_valid = true; + // Don't set key_user_id for RSS keys - they don't need permission checks + } + Ok(None) => { + println!("Not an RSS key, trying regular API key"); + } + Err(e) => { + println!("RSS key validation error: {}", e); + } + } + + // If not a valid RSS key, try regular API key validation + if !is_valid { + match validate_api_key(&state, api_key).await { + Ok(_) => { + println!("Valid API key"); + // Try to get user_id, but don't fail if it errors (might be cached RSS key) + match state.db_pool.get_user_id_from_api_key(api_key).await { + Ok(user_id) => { + println!("API key user_id: {}", user_id); + is_valid = true; + is_web_key = state.db_pool.is_web_key(api_key).await?; + key_user_id = Some(user_id); + } + Err(e) => { + println!("Failed to get user_id for API key (might be RSS key): {}", e); + } + } + } + Err(e) => { + println!("API key validation failed: {}", e); + } + } + } + + if !is_valid { + return Err(AppError::unauthorized("Invalid API key or RSS key")); + } + + // For regular API keys (not RSS keys), check user permissions + if let Some(user_id) = key_user_id { + if user_id != query.user_id && !is_web_key { + return Err(AppError::forbidden("You do not have permission to access this episode")); + } + } + // RSS keys don't need user permission checks - they can stream any episode + + // Choose which lookup to use based on source_type + let file_path = if query.source_type.as_deref() == Some("youtube") { + println!("Looking up YouTube video file path"); + state.db_pool.get_youtube_video_location(episode_id, query.user_id).await? + } else { + println!("Looking up regular episode file path"); + state.db_pool.get_download_location(episode_id, query.user_id).await? + }; + + if let Some(path) = file_path { + println!("Found file at: {}", path); + + // Use tower_http's ServeFile for proper file serving with range support + use tower_http::services::ServeFile; + use tower::ServiceExt; + + let service = ServeFile::new(&path); + let request = axum::http::Request::builder() + .method("GET") + .uri("/") + .body(axum::body::Body::empty()) + .map_err(|e| AppError::external_error(&format!("Failed to build request: {}", e)))?; + + let response = service.oneshot(request).await + .map_err(|e| AppError::external_error(&format!("Failed to serve file: {}", e)))?; + + // Convert the response body to the expected type + let (parts, body) = response.into_parts(); + let body = axum::body::Body::new(body); + let response = axum::response::Response::from_parts(parts, body); + + Ok(response) + } else { + Err(AppError::not_found("Episode not found or not downloaded")) + } +} + +// Get RSS key endpoint - get or create RSS key for user +pub async fn get_rss_key( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only get their own RSS key + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != query.user_id && !is_web_key { + return Err(AppError::forbidden("You can only get your own RSS key")); + } + + // Get or create RSS key for the user + let rss_key = state.db_pool.get_or_create_user_rss_key(query.user_id).await?; + + Ok(Json(serde_json::json!({ + "rss_key": rss_key + }))) +} + +#[derive(Deserialize)] +pub struct UserIdQuery { + pub user_id: i32, +} + +// Query struct for get_podcast_details_dynamic +#[derive(Deserialize)] +pub struct PodcastDetailsQuery { + pub user_id: i32, + pub podcast_title: String, + pub podcast_url: String, + pub podcast_index_id: i32, + pub added: bool, + pub display_only: Option, +} + +// Response struct for get_podcast_details_dynamic (matches ClickedFeedURL) +#[derive(Serialize)] +pub struct ClickedFeedURLResponse { + pub podcastid: i32, + pub podcastname: String, + pub feedurl: String, + pub description: String, + pub author: String, + pub artworkurl: String, + pub explicit: bool, + pub episodecount: i32, + pub categories: serde_json::Value, + pub websiteurl: String, + pub podcastindexid: i32, + pub is_youtube: Option, +} + +// Get podcast details dynamic endpoint +pub async fn get_podcast_details_dynamic( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + if query.added { + // Get podcast from database if already added + let podcast_id = state.db_pool.get_podcast_id_by_feed(query.user_id, &query.podcast_url, &query.podcast_title).await?; + let details = state.db_pool.get_podcast_details_raw(query.user_id, podcast_id).await?; + + if let Some(details) = details { + // Parse categories + let categories = if let Some(cats_str) = details.get("categories").and_then(|v| v.as_str()) { + if cats_str.starts_with('{') { + serde_json::from_str(cats_str).unwrap_or_else(|_| serde_json::json!({})) + } else { + let categories_dict: serde_json::Map = cats_str + .split(',') + .enumerate() + .map(|(i, cat)| (i.to_string(), serde_json::Value::String(cat.trim().to_string()))) + .collect(); + serde_json::Value::Object(categories_dict) + } + } else { + serde_json::json!({}) + }; + + Ok(Json(ClickedFeedURLResponse { + podcastid: 0, + podcastname: details.get("podcastname").and_then(|v| v.as_str()).unwrap_or("").to_string(), + feedurl: details.get("feedurl").and_then(|v| v.as_str()).unwrap_or("").to_string(), + description: details.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(), + author: details.get("author").and_then(|v| v.as_str()).unwrap_or("").to_string(), + artworkurl: details.get("artworkurl").and_then(|v| v.as_str()).unwrap_or("").to_string(), + explicit: details.get("explicit").and_then(|v| v.as_bool()).unwrap_or(false), + episodecount: details.get("episodecount").and_then(|v| v.as_i64()).unwrap_or(0) as i32, + categories, + websiteurl: details.get("websiteurl").and_then(|v| v.as_str()).unwrap_or("").to_string(), + podcastindexid: details.get("podcastindexid").and_then(|v| v.as_i64()).unwrap_or(0) as i32, + is_youtube: details.get("isyoutubechannel").and_then(|v| v.as_bool()), + })) + } else { + return Err(AppError::not_found("Podcast not found")); + } + } else { + // Get podcast values from feed if not added + let podcast_values = state.db_pool.get_podcast_values_from_feed(&query.podcast_url, query.user_id, query.display_only.unwrap_or(false)).await?; + + let categories = if let Some(cats_str) = podcast_values.get("categories").and_then(|v| v.as_str()) { + if cats_str.starts_with('{') { + serde_json::from_str(cats_str).unwrap_or_else(|_| serde_json::json!({})) + } else { + let categories_dict: serde_json::Map = cats_str + .split(',') + .enumerate() + .map(|(i, cat)| (i.to_string(), serde_json::Value::String(cat.trim().to_string()))) + .collect(); + serde_json::Value::Object(categories_dict) + } + } else { + serde_json::json!({}) + }; + + Ok(Json(ClickedFeedURLResponse { + podcastid: 0, + podcastname: podcast_values.get("pod_title").and_then(|v| v.as_str()).unwrap_or("").to_string(), + feedurl: podcast_values.get("pod_feed_url").and_then(|v| v.as_str()).unwrap_or("").to_string(), + description: podcast_values.get("pod_description").and_then(|v| v.as_str()).unwrap_or("").to_string(), + author: podcast_values.get("pod_author").and_then(|v| v.as_str()).unwrap_or("").to_string(), + artworkurl: podcast_values.get("pod_artwork").and_then(|v| v.as_str()).unwrap_or("").to_string(), + explicit: podcast_values.get("pod_explicit").and_then(|v| v.as_bool()).unwrap_or(false), + episodecount: podcast_values.get("pod_episode_count").and_then(|v| v.as_i64()).unwrap_or(0) as i32, + categories, + websiteurl: podcast_values.get("pod_website").and_then(|v| v.as_str()).unwrap_or("").to_string(), + podcastindexid: query.podcast_index_id, + is_youtube: Some(false), + })) + } +} + +// Query struct for podpeople host podcasts +#[derive(Deserialize)] +pub struct HostPodcastsQuery { + pub hostname: String, +} + +// Response struct for podpeople host podcasts +#[derive(Serialize)] +pub struct PodPeopleResponse { + pub success: bool, + pub podcasts: Vec, +} + +// Get host podcasts from podpeople endpoint +pub async fn get_host_podcasts( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Get people URL from config + let people_url = std::env::var("PEOPLE_API_URL").unwrap_or_else(|_| "https://people.pinepods.online".to_string()); + + // Make request to podpeople database + let client = reqwest::Client::new(); + let response = client + .get(&format!("{}/api/hostsearch", people_url)) + .query(&[("name", &query.hostname)]) + .send() + .await + .map_err(|e| AppError::external_error(&format!("Failed to fetch from podpeople: {}", e)))?; + + if response.status().is_success() { + let podpeople_data: Vec = response + .json() + .await + .map_err(|e| AppError::external_error(&format!("Failed to parse podpeople response: {}", e)))?; + + Ok(Json(PodPeopleResponse { + success: true, + podcasts: podpeople_data, + })) + } else { + Ok(Json(PodPeopleResponse { + success: false, + podcasts: vec![], + })) + } +} + +// Request struct for update_feed_cutoff_days +#[derive(Deserialize)] +pub struct UpdateFeedCutoffDaysData { + pub podcast_id: i32, + pub user_id: i32, + pub feed_cutoff_days: i32, +} + +// Update feed cutoff days endpoint +pub async fn update_feed_cutoff_days( + State(state): State, + headers: HeaderMap, + Json(data): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if the provided API key is the web key + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action if the API key belongs to the user or it's the web API key + if key_id == data.user_id || is_web_key { + let success = state.db_pool.update_feed_cutoff_days(data.podcast_id, data.user_id, data.feed_cutoff_days).await?; + if success { + Ok(Json(serde_json::json!({"detail": "Feed cutoff days updated successfully!"}))) + } else { + Err(AppError::bad_request("Error updating feed cutoff days")) + } + } else { + Err(AppError::forbidden("You can only modify settings of your own podcasts!")) + } +} + +// Query struct for fetch_podcast_feed +#[derive(Deserialize)] +pub struct FetchPodcastFeedQuery { + pub podcast_feed: String, +} + +// Fetch podcast feed endpoint - returns parsed episode data using feed-rs +pub async fn fetch_podcast_feed( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Parse feed and extract episodes using feed-rs (same logic as add_episodes but without DB insertion) + let episodes = state.db_pool.parse_feed_episodes(&query.podcast_feed, user_id).await + .map_err(|e| AppError::external_error(&format!("Failed to parse podcast feed: {}", e)))?; + + Ok(Json(serde_json::json!({ "episodes": episodes }))) +} + +// Handler for updating podcast basic info (URL, username, password) +pub async fn update_podcast_info( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only modify their own podcasts + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if !check_user_access(&state, &api_key, requesting_user_id).await? { + return Err(AppError::forbidden("You can only modify your own podcasts!")); + } + + if request.user_id != requesting_user_id { + return Err(AppError::forbidden("You can only modify your own podcasts!")); + } + + // Validate that at least one field is being updated + if request.feed_url.is_none() && request.username.is_none() && request.password.is_none() + && request.podcast_name.is_none() && request.description.is_none() && request.author.is_none() + && request.artwork_url.is_none() && request.website_url.is_none() && request.podcast_index_id.is_none() { + return Ok(Json(UpdatePodcastInfoResponse { + success: false, + message: "No fields provided to update".to_string(), + })); + } + + // Update the podcast info + let success = state.db_pool.update_podcast_info( + request.podcast_id, + request.user_id, + request.feed_url, + request.username, + request.password, + request.podcast_name, + request.description, + request.author, + request.artwork_url, + request.website_url, + request.podcast_index_id, + ).await?; + + if success { + Ok(Json(UpdatePodcastInfoResponse { + success: true, + message: "Podcast updated successfully".to_string(), + })) + } else { + Ok(Json(UpdatePodcastInfoResponse { + success: false, + message: "Podcast not found or no changes made".to_string(), + })) + } +} + +// Request/Response structs for podcast merging +#[derive(Serialize, Deserialize, Debug)] +pub struct MergePodcastsRequest { + pub secondary_podcast_ids: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MergePodcastsResponse { + pub success: bool, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UnmergePodcastResponse { + pub success: bool, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MergedPodcastsResponse { + pub merged_podcast_ids: Vec, +} + +// Merge podcasts endpoint +pub async fn merge_podcasts( + Path(primary_podcast_id): Path, + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Get user ID from API key + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Validate request + if request.secondary_podcast_ids.is_empty() { + return Ok(Json(MergePodcastsResponse { + success: false, + message: "No secondary podcasts provided".to_string(), + })); + } + + // Check if primary podcast is in secondary list + if request.secondary_podcast_ids.contains(&primary_podcast_id) { + return Ok(Json(MergePodcastsResponse { + success: false, + message: "Cannot merge a podcast with itself".to_string(), + })); + } + + // Perform the merge + match state.db_pool.merge_podcasts(primary_podcast_id, &request.secondary_podcast_ids, user_id).await { + Ok(()) => Ok(Json(MergePodcastsResponse { + success: true, + message: format!("Successfully merged {} podcasts", request.secondary_podcast_ids.len()), + })), + Err(e) => Ok(Json(MergePodcastsResponse { + success: false, + message: format!("Failed to merge podcasts: {}", e), + })), + } +} + +// Unmerge podcast endpoint +pub async fn unmerge_podcast( + Path((primary_podcast_id, target_podcast_id)): Path<(i32, i32)>, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Get user ID from API key + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Perform the unmerge + match state.db_pool.unmerge_podcast(primary_podcast_id, target_podcast_id, user_id).await { + Ok(()) => Ok(Json(UnmergePodcastResponse { + success: true, + message: "Successfully unmerged podcast".to_string(), + })), + Err(e) => Ok(Json(UnmergePodcastResponse { + success: false, + message: format!("Failed to unmerge podcast: {}", e), + })), + } +} + +// Get merged podcasts endpoint +pub async fn get_merged_podcasts( + Path(podcast_id): Path, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Get user ID from API key + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Check if user owns the podcast + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only access your own podcasts")); + } + + // Get merged podcast IDs + let merged_ids = state.db_pool.get_merged_podcast_ids(podcast_id).await?; + + Ok(Json(MergedPodcastsResponse { + merged_podcast_ids: merged_ids, + })) +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/proxy.rs b/PinePods-0.8.2/rust-api/src/handlers/proxy.rs new file mode 100644 index 0000000..38acca6 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/proxy.rs @@ -0,0 +1,78 @@ +use axum::{ + extract::Query, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct ImageProxyQuery { + pub url: String, +} + +// Image proxy endpoint - matches Python proxy_image endpoint +pub async fn proxy_image( + Query(query): Query, +) -> Result { + tracing::info!("Image proxy request received for URL: {}", query.url); + + if !is_valid_image_url(&query.url) { + tracing::error!("Invalid image URL: {}", query.url); + return Err(StatusCode::BAD_REQUEST); + } + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::limited(10)) + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + tracing::info!("Fetching image from: {}", query.url); + + let response = client + .get(&query.url) + .send() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + tracing::info!("Image fetch response status: {}", response.status()); + + if !response.status().is_success() { + return Err(StatusCode::BAD_GATEWAY); + } + + let content_type = response + .headers() + .get("content-type") + .and_then(|ct| ct.to_str().ok()) + .unwrap_or("") + .to_string(); + + tracing::info!("Content type: {}", content_type); + + if !content_type.starts_with("image/") && content_type != "application/octet-stream" { + tracing::error!("Invalid content type: {}", content_type); + return Err(StatusCode::BAD_REQUEST); + } + + let bytes = response.bytes().await.map_err(|_| StatusCode::BAD_GATEWAY)?; + + let mut headers = HeaderMap::new(); + headers.insert("content-type", content_type.parse().unwrap()); + headers.insert("cache-control", "public, max-age=86400".parse().unwrap()); + headers.insert("access-control-allow-origin", "*".parse().unwrap()); + headers.insert("x-content-type-options", "nosniff".parse().unwrap()); + + tracing::info!("Returning image response"); + + Ok((headers, bytes).into_response()) +} + +fn is_valid_image_url(url: &str) -> bool { + // Basic URL validation - check if it's a valid URL and uses http/https + if let Ok(parsed_url) = url::Url::parse(url) { + matches!(parsed_url.scheme(), "http" | "https") + } else { + false + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/refresh.rs b/PinePods-0.8.2/rust-api/src/handlers/refresh.rs new file mode 100644 index 0000000..42ae318 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/refresh.rs @@ -0,0 +1,1075 @@ +use axum::{ + extract::{Query, Path, State, WebSocketUpgrade}, + response::Response, +}; +use axum::extract::ws::{WebSocket, Message}; +use futures::{sink::SinkExt, stream::StreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use sqlx::Row; + +use crate::{ + error::{AppError, AppResult}, + handlers::check_user_access, + AppState, +}; + +#[derive(Deserialize)] +pub struct RefreshQuery { + pub api_key: Option, + pub nextcloud_refresh: Option, +} + +#[derive(Serialize)] +pub struct RefreshProgress { + pub current: u32, + pub total: u32, + pub current_podcast: String, +} + +#[derive(Serialize)] +pub struct RefreshStatus { + pub progress: RefreshProgress, +} + +#[derive(Serialize)] +pub struct NewEpisode { + pub new_episode: crate::handlers::podcasts::Episode, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum RefreshMessage { + Status(RefreshStatus), + NewEpisode(NewEpisode), + Error { detail: String }, +} + +// Store locks per user to prevent concurrent refresh jobs +type UserLocks = Arc>>>>; + +// Store active WebSocket connections +type ActiveWebSockets = Arc>>>>; + +// Global state for refresh management +lazy_static::lazy_static! { + static ref USER_LOCKS: UserLocks = Arc::new(RwLock::new(HashMap::new())); + static ref ACTIVE_WEBSOCKETS: ActiveWebSockets = Arc::new(RwLock::new(HashMap::new())); +} + +// Admin refresh endpoint (background task) - matches Python refresh_pods function exactly +pub async fn refresh_pods_admin( + State(state): State, +) -> Result, AppError> { + println!("Starting admin refresh process - background task (no WebSocket)"); + + // This is the background task version - NO WebSocket, just direct refresh like Python + let state_clone = state.clone(); + tokio::spawn(async move { + // This matches the Python refresh_pods function exactly + if let Err(e) = refresh_all_podcasts_background(&state_clone).await { + tracing::error!("Background refresh failed: {}", e); + } + }); + + Ok(axum::Json(serde_json::json!({ + "detail": "Refresh initiated." + }))) +} + +// Separate endpoint for gPodder refresh (scheduled separately like Python) +pub async fn refresh_gpodder_subscriptions_admin( + State(state): State, +) -> Result, AppError> { + println!("Starting admin gPodder sync process for all users"); + + let state_clone = state.clone(); + let task_id = state.task_spawner.spawn_progress_task( + "refresh_gpodder_subscriptions".to_string(), + 0, // System user + move |reporter| async move { + let state = state_clone; + reporter.update_progress(10.0, Some("Starting gPodder sync for all users...".to_string())).await?; + + // Get all users who have gPodder sync enabled + let gpodder_users = state.db_pool.get_all_users_with_gpodder_sync().await + .map_err(|e| AppError::internal(&format!("Failed to get gPodder users: {}", e)))?; + + println!("Found {} users with gPodder sync enabled", gpodder_users.len()); + + let mut successful_syncs = 0; + let mut failed_syncs = 0; + let mut total_synced_podcasts = 0; + + for (index, user_id) in gpodder_users.iter().enumerate() { + let progress = 10.0 + (80.0 * (index as f64) / (gpodder_users.len() as f64)); + reporter.update_progress(progress, Some(format!("Syncing user {}/{}", index + 1, gpodder_users.len()))).await?; + + println!("Running gPodder sync for user {} ({}/{})", user_id, index + 1, gpodder_users.len()); + + // Get user's sync type + let gpodder_status = state.db_pool.gpodder_get_status(*user_id).await + .map_err(|e| AppError::internal(&format!("Failed to get status for user {}: {}", user_id, e)))?; + + if gpodder_status.sync_type != "None" && !gpodder_status.sync_type.is_empty() { + match run_admin_gpodder_sync(&state, *user_id, &gpodder_status.sync_type).await { + Ok(sync_result) => { + successful_syncs += 1; + total_synced_podcasts += sync_result.synced_podcasts; + println!("gPodder sync successful for user {}: {} podcasts", + user_id, sync_result.synced_podcasts); + } + Err(e) => { + failed_syncs += 1; + println!("gPodder sync failed for user {}: {}", user_id, e); + tracing::error!("gPodder sync failed for user {}: {}", user_id, e); + // Continue with other users + } + } + } else { + println!("gPodder sync not properly configured for user {}", user_id); + } + } + + println!("Admin gPodder sync completed: {}/{} users successful, {} total podcasts synced", + successful_syncs, gpodder_users.len(), total_synced_podcasts); + + reporter.update_progress(100.0, Some(format!( + "gPodder sync completed: {}/{} users, {} podcasts", + successful_syncs, gpodder_users.len(), total_synced_podcasts + ))).await?; + + Ok(serde_json::json!({ + "success": true, + "users_synced": successful_syncs, + "users_failed": failed_syncs, + "total_podcasts": total_synced_podcasts + })) + }, + ).await?; + + Ok(axum::Json(serde_json::json!({ + "detail": "gPodder sync for all users initiated.", + "task_id": task_id + }))) +} + +// Background refresh function that matches Python refresh_pods exactly - NO WebSocket +async fn refresh_all_podcasts_background(state: &AppState) -> AppResult<()> { + println!("Running refresh"); + + // Get ALL podcasts from ALL users - matches Python exactly + // Handle the different database types properly + let total_podcasts = match &state.db_pool { + crate::database::DatabasePool::Postgres(pool) => { + let count_row = sqlx::query(r#"SELECT COUNT(*) as total FROM "Podcasts""#) + .fetch_one(pool) + .await?; + count_row.try_get::("total")? as usize + } + crate::database::DatabasePool::MySQL(pool) => { + let count_row = sqlx::query("SELECT COUNT(*) as total FROM Podcasts") + .fetch_one(pool) + .await?; + count_row.try_get::("total")? as usize + } + }; + + println!("Running refresh for {total_podcasts} podcasts"); + let mut current_podcast = 0; + + match &state.db_pool { + crate::database::DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT podcastid, feedurl, artworkurl, autodownload, username, password, + isyoutubechannel, userid, COALESCE(feedurl, '') as channel_id, feedcutoffdays, podcastname + FROM "Podcasts" + WHERE COALESCE(refreshpodcast, TRUE) = TRUE"# + ) + .fetch_all(pool) + .await?; + + for result in rows { + let podcast_id: i32 = result.try_get("podcastid")?; + let feed_url: String = result.try_get("feedurl")?; + let artwork_url: Option = result.try_get("artworkurl").ok(); + let auto_download: bool = result.try_get("autodownload")?; + let username: Option = result.try_get("username").ok(); + let password: Option = result.try_get("password").ok(); + let is_youtube: bool = result.try_get("isyoutubechannel")?; + let user_id: i32 = result.try_get("userid")?; + let feed_cutoff: Option = result.try_get("feedcutoffdays").ok(); + + current_podcast += 1; + + // Get podcast name for better logging + let podcast_name = result.try_get::("podcastname").unwrap_or_else(|_| format!("Podcast {}", podcast_id)); + println!("Running refresh for podcast {}/{}: {}", current_podcast, total_podcasts, podcast_name); + + if is_youtube { + // Handle YouTube channel refresh + // Extract channel ID from feed URL + let channel_id = if feed_url.contains("channel/") { + feed_url.split("channel/").nth(1).unwrap_or(&feed_url).split('/').next().unwrap_or(&feed_url).split('?').next().unwrap_or(&feed_url) + } else { + &feed_url + }; + + // Call YouTube processing function + println!("Processing YouTube videos for channel: {}", channel_id); + match crate::handlers::youtube::process_youtube_channel( + podcast_id, + channel_id, + feed_cutoff.unwrap_or(30), + &state + ).await { + Ok(_) => { + println!("Successfully refreshed YouTube channel {}", podcast_id); + } + Err(e) => { + println!("Error refreshing YouTube channel {}: {}", podcast_id, e); + // Continue with other podcasts - matches Python behavior + } + } + } else { + // Use the new function that returns newly inserted episodes - matches Python implementation exactly + match state.db_pool.add_episodes_with_new_list( + podcast_id, + &feed_url, + artwork_url.as_deref().unwrap_or(""), + username.as_deref(), + password.as_deref() + ).await { + Ok(new_episodes) => { + println!("Successfully refreshed podcast {}: {} new episodes", podcast_id, new_episodes.len()); + + // Handle auto-download for background refresh - matches Python implementation exactly + if auto_download { + println!("Auto-download enabled for podcast {} - processing {} new episodes", podcast_id, new_episodes.len()); + + // Auto-download ONLY the episodes that were just inserted - 100% reliable! + for episode in &new_episodes { + println!("Auto-downloading episode '{}' (ID: {}) for user {}", + episode.episodetitle, episode.episodeid, user_id); + + // Determine if this is a YouTube episode + let is_youtube = episode.episodeurl.contains("youtube.com") || episode.episodeurl.contains("youtu.be"); + + // Spawn download task + let task_result = if is_youtube { + state.task_spawner.spawn_download_youtube_video(episode.episodeid, user_id).await + } else { + state.task_spawner.spawn_download_podcast_episode(episode.episodeid, user_id).await + }; + + match task_result { + Ok(task_id) => println!("Auto-download task queued with ID: {}", task_id), + Err(e) => println!("Failed to queue auto-download task for episode {}: {}", episode.episodeid, e), + } + } + } + } + Err(e) => { + println!("Error refreshing podcast {}: {}", podcast_id, e); + // Continue with other podcasts - matches Python behavior + } + } + } + } + } + crate::database::DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT PodcastID, FeedURL, ArtworkURL, AutoDownload, Username, Password, + IsYouTubeChannel, UserID, COALESCE(FeedURL, '') as channel_id, FeedCutoffDays, PodcastName + FROM Podcasts + WHERE COALESCE(RefreshPodcast, 1) = 1" + ) + .fetch_all(pool) + .await?; + + for result in rows { + let podcast_id: i32 = result.try_get("PodcastID")?; + let feed_url: String = result.try_get("FeedURL")?; + let artwork_url: Option = result.try_get("ArtworkURL").ok(); + let auto_download: bool = result.try_get("AutoDownload")?; + let username: Option = result.try_get("Username").ok(); + let password: Option = result.try_get("Password").ok(); + let is_youtube: bool = result.try_get("IsYouTubeChannel")?; + let user_id: i32 = result.try_get("UserID")?; + let feed_cutoff: Option = result.try_get("FeedCutoffDays").ok(); + + current_podcast += 1; + + // Get podcast name for better logging + let podcast_name = result.try_get::("PodcastName").unwrap_or_else(|_| format!("Podcast {}", podcast_id)); + println!("Running refresh for podcast {}/{}: {}", current_podcast, total_podcasts, podcast_name); + + if is_youtube { + // Handle YouTube channel refresh + // Extract channel ID from feed URL + let channel_id = if feed_url.contains("channel/") { + feed_url.split("channel/").nth(1).unwrap_or(&feed_url).split('/').next().unwrap_or(&feed_url).split('?').next().unwrap_or(&feed_url) + } else { + &feed_url + }; + + // Call YouTube processing function + println!("Processing YouTube videos for channel: {}", channel_id); + match crate::handlers::youtube::process_youtube_channel( + podcast_id, + channel_id, + feed_cutoff.unwrap_or(30), + &state + ).await { + Ok(_) => { + println!("Successfully refreshed YouTube channel {}", podcast_id); + } + Err(e) => { + println!("Error refreshing YouTube channel {}: {}", podcast_id, e); + // Continue with other podcasts - matches Python behavior + } + } + } else { + // Use the new function that returns newly inserted episodes - matches Python implementation exactly + match state.db_pool.add_episodes_with_new_list( + podcast_id, + &feed_url, + artwork_url.as_deref().unwrap_or(""), + username.as_deref(), + password.as_deref() + ).await { + Ok(new_episodes) => { + println!("Successfully refreshed podcast {}: {} new episodes", podcast_id, new_episodes.len()); + + // Handle auto-download for background refresh - matches Python implementation exactly + if auto_download { + println!("Auto-download enabled for podcast {} - processing {} new episodes", podcast_id, new_episodes.len()); + + // Auto-download ONLY the episodes that were just inserted - 100% reliable! + for episode in &new_episodes { + println!("Auto-downloading episode '{}' (ID: {}) for user {}", + episode.episodetitle, episode.episodeid, user_id); + + // Determine if this is a YouTube episode + let is_youtube = episode.episodeurl.contains("youtube.com") || episode.episodeurl.contains("youtu.be"); + + // Spawn download task + let task_result = if is_youtube { + state.task_spawner.spawn_download_youtube_video(episode.episodeid, user_id).await + } else { + state.task_spawner.spawn_download_podcast_episode(episode.episodeid, user_id).await + }; + + match task_result { + Ok(task_id) => println!("Auto-download task queued with ID: {}", task_id), + Err(e) => println!("Failed to queue auto-download task for episode {}: {}", episode.episodeid, e), + } + } + } + } + Err(e) => { + println!("Error refreshing podcast {}: {}", podcast_id, e); + // Continue with other podcasts - matches Python behavior + } + } + } + } + } + } + + // Run auto-complete check for all users with auto-complete enabled after episode refresh + println!("Running auto-complete threshold check for all users..."); + match state.db_pool.get_users_with_auto_complete_enabled().await { + Ok(users_with_auto_complete) => { + let mut total_completed = 0; + for user_auto_complete in users_with_auto_complete { + match state.db_pool.auto_complete_user_episodes( + user_auto_complete.user_id, + user_auto_complete.auto_complete_seconds + ).await { + Ok(completed_count) => { + if completed_count > 0 { + println!("Auto-completed {} episodes for user {} (threshold: {}s)", + completed_count, user_auto_complete.user_id, user_auto_complete.auto_complete_seconds); + } + total_completed += completed_count; + } + Err(e) => { + println!("Failed to run auto-complete for user {}: {}", user_auto_complete.user_id, e); + } + } + } + if total_completed > 0 { + println!("Auto-complete threshold check completed: {} total episodes marked complete", total_completed); + } else { + println!("Auto-complete threshold check completed: no episodes needed completion"); + } + } + Err(e) => { + println!("Failed to get users with auto-complete enabled: {}", e); + } + } + + println!("Refresh completed"); + Ok(()) +} + +// Helper function for admin gPodder sync +async fn run_admin_gpodder_sync(state: &AppState, user_id: i32, sync_type: &str) -> AppResult { + match sync_type { + "nextcloud" => { + match state.db_pool.sync_with_nextcloud_for_user(user_id).await { + Ok(success) => { + if success { + Ok(SyncResult { synced_podcasts: 1, synced_episodes: 0 }) + } else { + Ok(SyncResult { synced_podcasts: 0, synced_episodes: 0 }) + } + } + Err(e) => Err(e) + } + } + "gpodder" | "external" | "both" => { + match state.db_pool.gpodder_sync(user_id).await { + Ok(sync_result) => { + Ok(SyncResult { + synced_podcasts: sync_result.synced_podcasts, + synced_episodes: sync_result.synced_episodes, + }) + } + Err(e) => Err(e) + } + } + _ => Ok(SyncResult { synced_podcasts: 0, synced_episodes: 0 }) + } +} + +// User-specific refresh via WebSocket with real-time progress +pub async fn websocket_refresh_episodes( + ws: WebSocketUpgrade, + Path(user_id): Path, + Query(query): Query, + State(state): State, +) -> Result { + // Validate API key + let api_key = query.api_key.ok_or_else(|| AppError::unauthorized("Missing API key"))?; + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check authorization - users can only get their own episodes or have web key access (user ID 1) + if !check_user_access(&state, &api_key, user_id).await? { + return Err(AppError::forbidden("You can only refresh your own podcasts")); + } + + let nextcloud_refresh = query.nextcloud_refresh.unwrap_or(false); + + Ok(ws.on_upgrade(move |socket| { + handle_refresh_websocket(socket, user_id, nextcloud_refresh, state) + })) +} + +async fn handle_refresh_websocket( + socket: WebSocket, + user_id: i32, + nextcloud_refresh: bool, + state: AppState, +) { + // Check if refresh is already running for this user + { + let locks = USER_LOCKS.read().await; + if locks.contains_key(&user_id) { + let _ = send_error_and_close(socket, "Refresh job already running for this user.").await; + return; + } + } + + // Create user lock + let user_lock = { + let mut locks = USER_LOCKS.write().await; + let lock = Arc::new(Mutex::new(())); + locks.insert(user_id, lock.clone()); + lock + }; + + let _guard = user_lock.lock().await; + + let (mut sender, mut receiver) = socket.split(); + let (tx, mut rx) = tokio::sync::mpsc::channel::(100); + + // Add WebSocket to active connections + { + let mut connections = ACTIVE_WEBSOCKETS.write().await; + connections.entry(user_id).or_insert_with(Vec::new).push(tx.clone()); + } + + // Task to send messages through WebSocket + let send_task = tokio::spawn(async move { + while let Some(message) = rx.recv().await { + let json = serde_json::to_string(&message).unwrap_or_else(|_| "{}".to_string()); + if sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + // When the channel is closed (refresh complete), close the websocket + let _ = sender.close().await; + }); + + // Task to handle incoming WebSocket messages (keep alive) + let receive_task = tokio::spawn(async move { + while let Some(msg) = receiver.next().await { + match msg { + Ok(Message::Text(_)) => { + // Keep connection alive + } + Ok(Message::Close(_)) => break, + Err(_) => break, + _ => {} + } + } + }); + + // Main refresh task + let refresh_task = tokio::spawn({ + let state = state.clone(); + let tx = tx.clone(); + async move { + if let Err(e) = run_refresh_process(user_id, nextcloud_refresh, tx.clone(), state).await { + let _ = tx.send(RefreshMessage::Error { + detail: format!("Error during refresh: {}", e) + }).await; + } + // Signal completion by dropping the sender + drop(tx); + } + }); + + // Wait for any task to complete + tokio::select! { + _ = send_task => {}, + _ = receive_task => {}, + _ = refresh_task => { + // Refresh task completed - websocket will be closed when channel closes + }, + } + + // Cleanup + { + let mut locks = USER_LOCKS.write().await; + locks.remove(&user_id); + } + + { + let mut connections = ACTIVE_WEBSOCKETS.write().await; + if let Some(user_connections) = connections.get_mut(&user_id) { + user_connections.retain(|conn| !conn.is_closed()); + if user_connections.is_empty() { + connections.remove(&user_id); + } + } + } +} + +async fn send_error_and_close(mut socket: WebSocket, error: &str) -> Result<(), AppError> { + let error_msg = RefreshMessage::Error { detail: error.to_string() }; + let json = serde_json::to_string(&error_msg)?; + let _ = socket.send(Message::Text(json.into())).await; + let _ = socket.close().await; + Ok(()) +} + +async fn run_refresh_process( + user_id: i32, + nextcloud_refresh: bool, + tx: tokio::sync::mpsc::Sender, + state: AppState, +) -> AppResult<()> { + println!("Starting refresh process for user_id: {}, nextcloud_refresh: {}", user_id, nextcloud_refresh); + + // PRE-REFRESH GPODDER SYNC - matches Python implementation exactly + if nextcloud_refresh { + println!("Pre-refresh gPodder sync requested for user {}", user_id); + + let _ = tx.send(RefreshMessage::Status(RefreshStatus { + progress: RefreshProgress { + current: 0, + total: 1, + current_podcast: "Checking gPodder sync settings...".to_string(), + }, + })).await; + + // Check if user has gPodder sync configured + let gpodder_status = state.db_pool.gpodder_get_status(user_id).await?; + + if gpodder_status.sync_type != "None" && !gpodder_status.sync_type.is_empty() { + println!("gPodder sync is enabled for user {}, sync_type: {}", user_id, gpodder_status.sync_type); + + let _ = tx.send(RefreshMessage::Status(RefreshStatus { + progress: RefreshProgress { + current: 0, + total: 1, + current_podcast: format!("Syncing with gPodder ({})...", gpodder_status.sync_type), + }, + })).await; + + match handle_gpodder_sync(&state, user_id, &gpodder_status.sync_type).await { + Ok(sync_result) => { + println!("gPodder sync successful for user {}: {} podcasts, {} episodes", + user_id, sync_result.synced_podcasts, sync_result.synced_episodes); + + let _ = tx.send(RefreshMessage::Status(RefreshStatus { + progress: RefreshProgress { + current: 0, + total: 1, + current_podcast: format!("gPodder sync completed: {} podcasts, {} episodes", + sync_result.synced_podcasts, sync_result.synced_episodes), + }, + })).await; + } + Err(e) => { + println!("gPodder sync failed for user {}: {}", user_id, e); + tracing::error!("gPodder sync failed for user {}: {}", user_id, e); + + let _ = tx.send(RefreshMessage::Status(RefreshStatus { + progress: RefreshProgress { + current: 0, + total: 1, + current_podcast: format!("gPodder sync failed: {}", e), + }, + })).await; + + // Continue with regular refresh even if gPodder sync fails + } + } + } else { + println!("gPodder sync not enabled for user {} (enabled: {}, type: {})", + user_id, gpodder_status.sync_type != "None" && !gpodder_status.sync_type.is_empty(), gpodder_status.sync_type); + } + } + + // Get total podcast count for progress tracking + let total_podcasts = state.db_pool.get_user_podcast_count(user_id).await?; + println!("Found {} podcasts to refresh for user {}", total_podcasts, user_id); + + // Send initial progress + let _ = tx.send(RefreshMessage::Status(RefreshStatus { + progress: RefreshProgress { + current: 0, + total: total_podcasts, + current_podcast: "Starting podcast refresh...".to_string(), + }, + })).await; + + // Get user's podcasts for refresh + let podcasts = state.db_pool.get_user_podcasts_for_refresh(user_id).await?; + println!("Retrieved {} podcast details for refresh", podcasts.len()); + + let mut current = 0; + let mut successful_refreshes = 0; + let mut failed_refreshes = 0; + let mut total_new_episodes = 0; + + for podcast in podcasts { + current += 1; + + println!("Refreshing podcast {}/{}: {} (ID: {}, is_youtube: {})", + current, total_podcasts, podcast.name, podcast.id, podcast.is_youtube); + + // Send progress update via WebSocket - real-time progress like Python version + let _ = tx.send(RefreshMessage::Status(RefreshStatus { + progress: RefreshProgress { + current, + total: total_podcasts, + current_podcast: podcast.name.clone(), + }, + })).await; + + // Refresh individual podcast with error handling like Python version + // For user refresh (not background), pass the actual user_id for notifications + match refresh_single_podcast(&state, &podcast, user_id, nextcloud_refresh).await { + Ok(new_episodes) => { + let episode_count = new_episodes.len(); + total_new_episodes += episode_count; + successful_refreshes += 1; + + println!("Successfully refreshed podcast '{}': {} new episodes", podcast.name, episode_count); + + // Send new episodes through WebSocket - matches Python websocket behavior + for episode in new_episodes { + let _ = tx.send(RefreshMessage::NewEpisode(NewEpisode { + new_episode: episode, + })).await; + } + } + Err(e) => { + failed_refreshes += 1; + println!("Error refreshing podcast '{}' (ID: {}): {}", podcast.name, podcast.id, e); + tracing::error!("Error refreshing podcast '{}' (ID: {}): {}", podcast.name, podcast.id, e); + // Continue with other podcasts - matches Python error handling + } + } + + // Small delay to prevent overwhelming the system + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + // Final completion summary - matches Python logging + println!("Refresh completed for user {}: {}/{} podcasts successful, {} failed, {} total new episodes", + user_id, successful_refreshes, total_podcasts, failed_refreshes, total_new_episodes); + + let _ = tx.send(RefreshMessage::Status(RefreshStatus { + progress: RefreshProgress { + current: total_podcasts, + total: total_podcasts, + current_podcast: format!("Refresh completed: {}/{} successful, {} new episodes", + successful_refreshes, total_podcasts, total_new_episodes), + }, + })).await; + + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct PodcastForRefresh { + pub id: i32, + pub name: String, + pub feed_url: String, + pub artwork_url: Option, + pub is_youtube: bool, + pub auto_download: bool, + pub username: Option, + pub password: Option, + pub feed_cutoff_days: Option, + pub user_id: i32, +} + +async fn refresh_single_podcast( + state: &AppState, + podcast: &PodcastForRefresh, + user_id: i32, + _nextcloud_refresh: bool, +) -> AppResult> { + // This is a simplified version - the full implementation would: + // 1. Fetch the RSS feed + // 2. Parse episodes + // 3. Check for new episodes since last refresh + // 4. Insert new episodes into database + // 5. Handle YouTube channels differently + // 6. Handle auto-download if enabled + // 7. Apply feed cutoff days filter + + tracing::info!("Refreshing podcast: {} (ID: {})", podcast.name, podcast.id); + + if podcast.is_youtube { + // Handle YouTube channel refresh + refresh_youtube_channel(state, podcast, user_id).await + } else { + // Handle regular RSS feed refresh + refresh_rss_feed(state, podcast, user_id).await + } +} + +async fn refresh_rss_feed( + state: &AppState, + podcast: &PodcastForRefresh, + user_id: i32, +) -> AppResult> { + tracing::info!("Refreshing RSS feed for podcast: {}", podcast.name); + + // Use the new function that returns newly inserted episodes - matches Python implementation exactly + let new_episodes = state.db_pool.add_episodes_with_new_list( + podcast.id, + &podcast.feed_url, + podcast.artwork_url.as_deref().unwrap_or(""), + podcast.username.as_deref(), + podcast.password.as_deref() + ).await?; + + // Handle auto-download functionality - matches Python implementation exactly + if podcast.auto_download { + tracing::info!("Auto-download enabled for podcast '{}' - processing {} new episodes", + podcast.name, new_episodes.len()); + + // Auto-download ONLY the episodes that were just inserted - 100% reliable! + for episode in &new_episodes { + tracing::info!("Auto-downloading episode '{}' (ID: {}) for user {}", + episode.episodetitle, episode.episodeid, user_id); + + // Determine if this is a YouTube episode + let is_youtube = episode.episodeurl.contains("youtube.com") || episode.episodeurl.contains("youtu.be"); + + // Spawn download task using the same task system as the API endpoint + let task_result = if is_youtube { + state.task_spawner.spawn_download_youtube_video(episode.episodeid, user_id).await + } else { + state.task_spawner.spawn_download_podcast_episode(episode.episodeid, user_id).await + }; + + match task_result { + Ok(task_id) => tracing::info!("Auto-download task queued with ID: {}", task_id), + Err(e) => tracing::error!("Failed to queue auto-download task for episode {}: {}", episode.episodeid, e), + } + } + } + + // Send notifications for user-triggered refreshes (not admin background refreshes) + if user_id != 0 { + tracing::info!("Refreshed podcast '{}' for user {}: {} new episodes", + podcast.name, user_id, new_episodes.len()); + } + + // Return the newly inserted episodes for websocket updates + Ok(new_episodes) +} + + +async fn refresh_youtube_channel( + state: &AppState, + podcast: &PodcastForRefresh, + user_id: i32, +) -> AppResult> { + tracing::info!("Refreshing YouTube channel: {}", podcast.name); + + // Extract channel ID from feed URL + let channel_id = if podcast.feed_url.contains("channel/") { + podcast.feed_url.split("channel/").nth(1).unwrap_or(&podcast.feed_url).split('/').next().unwrap_or(&podcast.feed_url).split('?').next().unwrap_or(&podcast.feed_url) + } else { + &podcast.feed_url + }; + + // Call YouTube processing function + match crate::handlers::youtube::process_youtube_channel( + podcast.id, + channel_id, + podcast.feed_cutoff_days.unwrap_or(30), + state + ).await { + Ok(_) => { + tracing::info!("Successfully refreshed YouTube channel: {}", podcast.name); + // For now, return empty vector since we're not tracking individual episodes + // In a full implementation, we'd query for recently added episodes + Ok(Vec::new()) + } + Err(e) => { + tracing::error!("Error refreshing YouTube channel {}: {}", podcast.name, e); + Err(e) + } + } +} + +// Define sync result structure to match our database return type +#[derive(Debug)] +pub struct SyncResult { + pub synced_podcasts: i32, + pub synced_episodes: i32, +} + +async fn handle_gpodder_sync(state: &AppState, user_id: i32, sync_type: &str) -> AppResult { + println!("Starting gPodder sync for user {}, sync_type: {}", user_id, sync_type); + + // Determine which sync function to call based on sync type - matches Python logic exactly + match sync_type { + "nextcloud" => { + println!("Performing Nextcloud gPodder sync for user {}", user_id); + + // Use the nextcloud sync functionality - this handles the /index.php/apps/gpoddersync endpoints + match state.db_pool.sync_with_nextcloud_for_user(user_id).await { + Ok(success) => { + if success { + println!("Nextcloud sync successful for user {}", user_id); + Ok(SyncResult { synced_podcasts: 1, synced_episodes: 0 }) + } else { + println!("Nextcloud sync returned false for user {}", user_id); + Ok(SyncResult { synced_podcasts: 0, synced_episodes: 0 }) + } + } + Err(e) => { + println!("Nextcloud sync failed for user {}: {}", user_id, e); + Err(e) + } + } + } + "gpodder" | "external" | "both" => { + println!("Performing standard gPodder sync for user {}, type: {}", user_id, sync_type); + + // Use the standard gPodder sync functionality + match state.db_pool.gpodder_sync(user_id).await { + Ok(sync_result) => { + println!("Standard gPodder sync successful for user {}: {} podcasts, {} episodes", + user_id, sync_result.synced_podcasts, sync_result.synced_episodes); + + Ok(SyncResult { + synced_podcasts: sync_result.synced_podcasts, + synced_episodes: sync_result.synced_episodes, + }) + } + Err(e) => { + println!("Standard gPodder sync failed for user {}: {}", user_id, e); + Err(e) + } + } + } + _ => { + println!("Unknown sync type '{}' for user {}, skipping sync", sync_type, user_id); + Ok(SyncResult { synced_podcasts: 0, synced_episodes: 0 }) + } + } +} + +// Internal functions for scheduler (no HTTP context needed) +pub async fn refresh_pods_admin_internal(state: &AppState) -> AppResult<()> { + tracing::info!("Starting internal podcast refresh (scheduler)"); + refresh_all_podcasts_background(state).await +} + +pub async fn refresh_gpodder_subscriptions_admin_internal(state: &AppState) -> AppResult<()> { + tracing::info!("Starting internal GPodder sync (scheduler)"); + + // Wait for GPodder service to be ready (5 second delay on startup) + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + tracing::info!("GPodder service startup delay completed"); + + // Get all users who have gPodder sync enabled (internal, external, both - NOT nextcloud) + let gpodder_users = state.db_pool.get_all_users_with_gpodder_sync().await?; + tracing::info!("Found {} users with GPodder sync enabled", gpodder_users.len()); + + let mut successful_syncs = 0; + let mut failed_syncs = 0; + + for user_id in gpodder_users.iter() { + tracing::info!("Running GPodder sync for user {}", user_id); + + // Get user's sync type + let gpodder_status = state.db_pool.gpodder_get_status(*user_id).await?; + + // Only sync GPodder types (internal, external, both) - NOT nextcloud + if gpodder_status.sync_type != "None" && gpodder_status.sync_type != "nextcloud" && !gpodder_status.sync_type.is_empty() { + match run_admin_gpodder_sync(state, *user_id, &gpodder_status.sync_type).await { + Ok(_) => { + successful_syncs += 1; + tracing::info!("GPodder sync successful for user {}", user_id); + } + Err(e) => { + failed_syncs += 1; + tracing::error!("GPodder sync failed for user {}: {}", user_id, e); + } + } + } + } + + tracing::info!("Internal GPodder sync completed: {}/{} users successful", + successful_syncs, gpodder_users.len()); + + Ok(()) +} + +// Separate endpoint for actual Nextcloud refresh (different from GPodder) +pub async fn refresh_nextcloud_subscriptions_admin( + State(state): State, +) -> Result, AppError> { + println!("Starting admin Nextcloud sync process for all users"); + + let state_clone = state.clone(); + let task_id = state.task_spawner.spawn_progress_task( + "refresh_nextcloud_subscriptions".to_string(), + 0, // System user + move |reporter| async move { + let state = state_clone; + reporter.update_progress(10.0, Some("Starting Nextcloud sync for all users...".to_string())).await?; + + // Get all users who have Nextcloud sync enabled + let nextcloud_users = state.db_pool.get_all_users_with_nextcloud_sync().await + .map_err(|e| AppError::internal(&format!("Failed to get Nextcloud users: {}", e)))?; + + println!("Found {} users with Nextcloud sync enabled", nextcloud_users.len()); + + let mut successful_syncs = 0; + let mut failed_syncs = 0; + + let total_users = nextcloud_users.len(); + if total_users == 0 { + reporter.update_progress(100.0, Some("No users with Nextcloud sync found".to_string())).await?; + return Ok(serde_json::json!({ + "status": "No users found", + "successful_syncs": 0, + "failed_syncs": 0, + "total_users": 0 + })); + } + + for (index, user_id) in nextcloud_users.iter().enumerate() { + let progress = 10.0 + ((index as f64 / total_users as f64) * 80.0); + reporter.update_progress(progress, Some(format!("Running Nextcloud sync for user {}", user_id))).await?; + + match state.db_pool.sync_with_nextcloud_for_user(*user_id).await { + Ok(true) => { + successful_syncs += 1; + println!("Nextcloud sync successful for user {}", user_id); + } + Ok(false) => { + println!("Nextcloud sync for user {} - no changes", user_id); + successful_syncs += 1; // Count as success + } + Err(e) => { + failed_syncs += 1; + println!("Nextcloud sync failed for user {}: {}", user_id, e); + } + } + } + + reporter.update_progress(100.0, Some(format!("Nextcloud sync completed: {}/{} users successful", successful_syncs, total_users))).await?; + + Ok(serde_json::json!({ + "status": "Nextcloud sync completed successfully", + "successful_syncs": successful_syncs, + "failed_syncs": failed_syncs, + "total_users": total_users + })) + }, + ).await?; + + Ok(axum::Json(serde_json::json!({ + "detail": "Nextcloud sync initiated", + "task_id": task_id + }))) +} + +pub async fn refresh_nextcloud_subscriptions_admin_internal(state: &AppState) -> AppResult<()> { + tracing::info!("Starting internal Nextcloud sync (scheduler)"); + + // Get all users who have Nextcloud sync enabled + let nextcloud_users = state.db_pool.get_all_users_with_nextcloud_sync().await?; + tracing::info!("Found {} users with Nextcloud sync enabled", nextcloud_users.len()); + + let mut successful_syncs = 0; + let mut failed_syncs = 0; + + for user_id in nextcloud_users.iter() { + tracing::info!("Running Nextcloud sync for user {}", user_id); + + match state.db_pool.sync_with_nextcloud_for_user(*user_id).await { + Ok(true) => { + successful_syncs += 1; + tracing::info!("Nextcloud sync successful for user {}", user_id); + } + Ok(false) => { + tracing::info!("Nextcloud sync for user {} - no changes", user_id); + successful_syncs += 1; // Count as success + } + Err(e) => { + failed_syncs += 1; + tracing::error!("Nextcloud sync failed for user {}: {}", user_id, e); + } + } + } + + tracing::info!("Internal Nextcloud sync completed: {}/{} users successful", + successful_syncs, nextcloud_users.len()); + + Ok(()) +} + + diff --git a/PinePods-0.8.2/rust-api/src/handlers/settings.rs b/PinePods-0.8.2/rust-api/src/handlers/settings.rs new file mode 100644 index 0000000..2055094 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/settings.rs @@ -0,0 +1,4093 @@ +use axum::{ + extract::{Path, Query, State, Multipart, Json}, + http::HeaderMap, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + error::AppError, + handlers::{extract_api_key, validate_api_key, check_user_access}, + models::{AvailableLanguage, LanguageUpdateRequest, UserLanguageResponse, AvailableLanguagesResponse}, + AppState, +}; +use sqlx::{Row, ValueRef}; + +// Request struct for set_theme +#[derive(Deserialize)] +pub struct SetThemeRequest { + pub user_id: i32, + pub new_theme: String, +} + +// Request struct for set_playback_speed - matches Python SetPlaybackSpeedUser model exactly +#[derive(Deserialize)] +pub struct SetPlaybackSpeedUser { + pub user_id: i32, + pub playback_speed: f64, +} + +// Set user theme - matches Python api_set_theme function exactly +pub async fn set_theme( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only set their own theme + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only set your own theme!")); + } + + state.db_pool.set_theme(request.user_id, &request.new_theme).await?; + + Ok(Json(serde_json::json!({ "message": "Theme updated successfully" }))) +} + +// Set user playback speed - matches Python api_set_playback_speed_user function exactly +pub async fn set_playback_speed_user( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only set their own playback speed + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only modify your own settings.")); + } + + state.db_pool.set_playback_speed_user(request.user_id, request.playback_speed).await?; + + Ok(Json(serde_json::json!({ "detail": "Default playback speed updated." }))) +} + +// User info response struct +#[derive(Serialize)] +pub struct UserInfo { + pub userid: i32, + pub fullname: String, + pub username: String, + pub email: String, + #[serde(serialize_with = "bool_to_int")] + pub isadmin: bool, +} + +// Helper function to serialize boolean as integer for Python compatibility +fn bool_to_int(value: &bool, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_i32(if *value { 1 } else { 0 }) +} + +// Get all users info - matches Python api_get_user_info function exactly (admin only) +pub async fn get_user_info( + State(state): State, + headers: HeaderMap, +) -> Result>, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + let user_info = state.db_pool.get_user_info().await?; + Ok(Json(user_info)) +} + +// Get specific user info - matches Python api_get_my_user_info function exactly +pub async fn get_my_user_info( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only get their own info + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != user_id && !is_web_key { + return Err(AppError::forbidden("You can only retrieve your own user information!")); + } + + let user_info = state.db_pool.get_my_user_info(user_id).await?; + match user_info { + Some(info) => Ok(Json(info)), + None => Err(AppError::not_found("User not found")), + } +} + +// Request struct for add_user +#[derive(Deserialize)] +pub struct AddUserRequest { + pub fullname: String, + pub username: String, + pub email: String, + pub hash_pw: String, +} + +// Add user - matches Python api_add_user function exactly (admin only) +pub async fn add_user( + State(state): State, + headers: HeaderMap, + Json(user_values): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + match state.db_pool.add_user(&user_values.fullname, &user_values.username.to_lowercase(), &user_values.email, &user_values.hash_pw).await { + Ok(user_id) => Ok(Json(serde_json::json!({ "detail": "Success", "user_id": user_id }))), + Err(e) => { + let error_msg = format!("{}", e); + if error_msg.contains("username") && error_msg.contains("duplicate") { + Err(AppError::Conflict("This username is already taken. Please choose a different username.".to_string())) + } else if error_msg.contains("email") && error_msg.contains("duplicate") { + Err(AppError::Conflict("This email is already in use. Please use a different email address.".to_string())) + } else { + Err(AppError::internal("Failed to create user")) + } + } + } +} + +// Add login user - matches Python api_add_user (add_login_user endpoint) function exactly (self-service) +pub async fn add_login_user( + State(state): State, + Json(user_values): Json, +) -> Result, AppError> { + // Check if self-service user registration is enabled (matches Python check_self_service) + let self_service_status = state.db_pool.self_service_status().await?; + + if !self_service_status.status { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + match state.db_pool.add_user(&user_values.fullname, &user_values.username.to_lowercase(), &user_values.email, &user_values.hash_pw).await { + Ok(user_id) => Ok(Json(serde_json::json!({ "detail": "User added successfully", "user_id": user_id }))), + Err(e) => { + let error_msg = format!("{}", e); + if error_msg.contains("username") && error_msg.contains("duplicate") { + Err(AppError::Conflict("This username is already taken. Please choose a different username.".to_string())) + } else if error_msg.contains("email") && error_msg.contains("duplicate") { + Err(AppError::Conflict("This email address is already registered. Please use a different email.".to_string())) + } else { + Err(AppError::internal("An unexpected error occurred while creating the user")) + } + } + } +} + +// Set fullname - matches Python api_set_fullname function exactly +pub async fn set_fullname( + State(state): State, + Path(user_id): Path, + Query(params): Query>, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let new_name = params.get("new_name") + .ok_or_else(|| AppError::bad_request("Missing new_name parameter"))?; + + // Check authorization - admins can edit other users, users can edit themselves + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id_from_api_key).await?; + + if user_id != user_id_from_api_key && !is_admin { + return Err(AppError::forbidden("You can only update your own full name")); + } + + state.db_pool.set_fullname(user_id, new_name).await?; + Ok(Json(serde_json::json!({ "detail": "Fullname updated." }))) +} + +// Request struct for set_password +#[derive(Deserialize)] +pub struct PasswordUpdateRequest { + pub hash_pw: String, +} + +// Set password - matches Python api_set_password function exactly +pub async fn set_password( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - admins can edit other users, users can edit themselves + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id_from_api_key).await?; + + if user_id != user_id_from_api_key && !is_admin { + return Err(AppError::forbidden("You can only update your own password")); + } + + state.db_pool.set_password(user_id, &request.hash_pw).await?; + Ok(Json(serde_json::json!({ "detail": "Password updated." }))) +} + +// Delete user - matches Python api_delete_user function exactly (admin only) +pub async fn delete_user( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + state.db_pool.delete_user(user_id).await?; + Ok(Json(serde_json::json!({ "status": "User deleted" }))) +} + +// Request struct for set_email +#[derive(Deserialize)] +pub struct SetEmailRequest { + pub user_id: i32, + pub new_email: String, +} + +// Set email - matches Python api_set_email function exactly +pub async fn set_email( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - admins can edit other users, users can edit themselves + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id_from_api_key).await?; + + if request.user_id != user_id_from_api_key && !is_admin { + return Err(AppError::forbidden("You can only update your own email")); + } + + state.db_pool.set_email(request.user_id, &request.new_email).await?; + Ok(Json(serde_json::json!({ "detail": "Email updated." }))) +} + +// Request struct for set_username +#[derive(Deserialize)] +pub struct SetUsernameRequest { + pub user_id: i32, + pub new_username: String, +} + +// Set username - matches Python api_set_username function exactly +pub async fn set_username( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - admins can edit other users, users can edit themselves + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id_from_api_key).await?; + + if request.user_id != user_id_from_api_key && !is_admin { + return Err(AppError::forbidden("You can only update your own username")); + } + + state.db_pool.set_username(request.user_id, &request.new_username.to_lowercase()).await?; + Ok(Json(serde_json::json!({ "detail": "Username updated." }))) +} + +// Request struct for set_isadmin +#[derive(Deserialize)] +pub struct SetIsAdminRequest { + pub user_id: i32, + pub isadmin: bool, +} + +// Set isadmin - matches Python api_set_isadmin function exactly (admin only) +pub async fn set_isadmin( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + state.db_pool.set_isadmin(request.user_id, request.isadmin).await?; + Ok(Json(serde_json::json!({ "detail": "IsAdmin status updated." }))) +} + +// Final admin check - matches Python api_final_admin function exactly (admin only) +pub async fn final_admin( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + let is_final_admin = state.db_pool.final_admin(user_id).await?; + Ok(Json(serde_json::json!({ "final_admin": is_final_admin }))) +} + +// Enable/disable guest - matches Python api_enable_disable_guest function exactly (admin only) +pub async fn enable_disable_guest( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + state.db_pool.enable_disable_guest().await?; + Ok(Json(serde_json::json!({ "success": true }))) +} + +// Enable/disable downloads - matches Python api_enable_disable_downloads function exactly (admin only) +pub async fn enable_disable_downloads( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + state.db_pool.enable_disable_downloads().await?; + Ok(Json(serde_json::json!({ "success": true }))) +} + +// Enable/disable self service - matches Python api_enable_disable_self_service function exactly (admin only) +pub async fn enable_disable_self_service( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + state.db_pool.enable_disable_self_service().await?; + Ok(Json(serde_json::json!({ "success": true }))) +} + +// Get guest status - matches Python api_guest_status function exactly +pub async fn guest_status( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let result = state.db_pool.guest_status().await?; + Ok(Json(result)) +} + +// Get RSS feed status - matches Python get_rss_feed_status function exactly +pub async fn rss_feed_status( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let result = state.db_pool.get_rss_feed_status(user_id).await?; + Ok(Json(result)) +} + +// Toggle RSS feeds - matches Python toggle_rss_feeds function exactly +pub async fn toggle_rss_feeds( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let new_status = state.db_pool.toggle_rss_feeds(user_id).await?; + Ok(Json(serde_json::json!({ "success": true, "enabled": new_status }))) +} + +// Get download status - matches Python api_download_status function exactly +pub async fn download_status( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let result = state.db_pool.download_status().await?; + Ok(Json(result)) +} + +// Get self service status - matches Python api_self_service_status function exactly +pub async fn self_service_status( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let result = state.db_pool.self_service_status().await?; + Ok(Json(serde_json::json!({ + "status": result.status, + "first_admin_created": result.admin_exists + }))) +} + +// Request struct for save_email_settings +#[derive(Deserialize)] +pub struct SaveEmailSettingsRequest { + pub email_settings: EmailSettings, +} + +#[derive(Deserialize)] +pub struct EmailSettings { + pub server_name: String, + #[serde(deserialize_with = "deserialize_string_to_i32")] + pub server_port: i32, + pub from_email: String, + pub send_mode: String, + pub encryption: String, + #[serde(deserialize_with = "deserialize_bool_to_i32")] + pub auth_required: i32, + pub email_username: String, + pub email_password: String, +} + +// Helper function to deserialize string to i32 +fn deserialize_string_to_i32<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de> +{ + use serde::de::Error; + + let s = String::deserialize(deserializer)?; + s.parse::().map_err(D::Error::custom) +} + +// Helper function to deserialize bool to i32 +fn deserialize_bool_to_i32<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de> +{ + let b = bool::deserialize(deserializer)?; + Ok(if b { 1 } else { 0 }) +} + +// Save email settings - matches Python api_save_email_settings function exactly (admin only) +pub async fn save_email_settings( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + state.db_pool.save_email_settings(&request.email_settings).await?; + Ok(Json(serde_json::json!({ "detail": "Email settings saved." }))) +} + +// Email settings response struct +#[derive(Serialize)] +pub struct EmailSettingsResponse { + #[serde(rename = "Emailsettingsid")] + pub emailsettingsid: i32, + #[serde(rename = "ServerName")] + pub server_name: String, + #[serde(rename = "ServerPort")] + pub server_port: i32, + #[serde(rename = "FromEmail")] + pub from_email: String, + #[serde(rename = "SendMode")] + pub send_mode: String, + #[serde(rename = "Encryption")] + pub encryption: String, + #[serde(rename = "AuthRequired")] + pub auth_required: i32, + #[serde(rename = "Username")] + pub username: String, + #[serde(rename = "Password")] + pub password: String, +} + +// Get email settings - matches Python api_get_email_settings function exactly (admin only) +pub async fn get_email_settings( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + let settings = state.db_pool.get_email_settings().await?; + match settings { + Some(settings) => Ok(Json(settings)), + None => Err(AppError::not_found("Email settings not found")), + } +} + +// Request struct for send_test_email +#[derive(Deserialize)] +pub struct SendTestEmailRequest { + pub server_name: String, + pub server_port: String, + pub from_email: String, + pub send_mode: String, + pub encryption: String, + pub auth_required: bool, + pub email_username: String, + pub email_password: String, + pub to_email: String, + pub message: String, +} + +// Send test email - matches Python api_send_email function exactly (admin only) +pub async fn send_test_email( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + let email_status = send_email_internal(&request).await?; + Ok(Json(serde_json::json!({ "email_status": email_status }))) +} + +// HTML email template functions +async fn read_logo_as_base64() -> Result { + use std::path::Path; + use tokio::fs; + + let logo_path = Path::new("/var/www/html/static/assets/favicon.png"); + + if !logo_path.exists() { + return Err(AppError::internal("Logo file not found")); + } + + let logo_bytes = fs::read(logo_path).await + .map_err(|e| AppError::internal(&format!("Failed to read logo file: {}", e)))?; + + let base64_logo = base64::encode(&logo_bytes); + Ok(base64_logo) +} + +fn create_html_email_template(subject: &str, content: &str, logo_base64: &str) -> String { + format!(r#" + + + + + {} + + + + + +"#, subject, logo_base64, content) +} + +// Internal email sending function using lettre +async fn send_email_internal(request: &SendTestEmailRequest) -> Result { + use lettre::{ + message::{header::ContentType, Message}, + transport::smtp::{authentication::Credentials, client::Tls, client::TlsParameters}, + AsyncSmtpTransport, AsyncTransport, Tokio1Executor, + }; + use tokio::time::{timeout, Duration}; + + // Parse server port + let port: u16 = request.server_port.parse() + .map_err(|_| AppError::bad_request("Invalid server port"))?; + + // Read logo and create HTML content + let logo_base64 = read_logo_as_base64().await.unwrap_or_default(); + let html_content = format!(r#" +

📧 Test Email

+

This is a test email from your PinePods server to verify your email configuration is working correctly.

+

Your message:

+

{}

+

If you received this email, your email settings are configured properly! 🎉

+ "#, request.message); + + let html_body = create_html_email_template("Test Email", &html_content, &logo_base64); + + // Create email message with HTML + let email = Message::builder() + .from(request.from_email.parse() + .map_err(|_| AppError::bad_request("Invalid from email"))?) + .to(request.to_email.parse() + .map_err(|_| AppError::bad_request("Invalid to email"))?) + .subject("PinePods - Test Email") + .header(ContentType::TEXT_HTML) + .body(html_body) + .map_err(|e| AppError::internal(&format!("Failed to build email: {}", e)))?; + + // Configure SMTP transport based on encryption + let mailer = match request.encryption.as_str() { + "SSL/TLS" => { + let tls = TlsParameters::new(request.server_name.clone()) + .map_err(|e| AppError::internal(&format!("TLS configuration failed: {}", e)))?; + + if request.auth_required { + let creds = Credentials::new(request.email_username.clone(), request.email_password.clone()); + AsyncSmtpTransport::::relay(&request.server_name) + .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? + .port(port) + .tls(Tls::Wrapper(tls)) + .credentials(creds) + .build() + } else { + AsyncSmtpTransport::::relay(&request.server_name) + .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? + .port(port) + .tls(Tls::Wrapper(tls)) + .build() + } + } + "StartTLS" => { + let tls = TlsParameters::new(request.server_name.clone()) + .map_err(|e| AppError::internal(&format!("TLS configuration failed: {}", e)))?; + + if request.auth_required { + let creds = Credentials::new(request.email_username.clone(), request.email_password.clone()); + AsyncSmtpTransport::::relay(&request.server_name) + .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? + .port(port) + .tls(Tls::Required(tls)) + .credentials(creds) + .build() + } else { + AsyncSmtpTransport::::relay(&request.server_name) + .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? + .port(port) + .tls(Tls::Required(tls)) + .build() + } + } + _ => { + // No encryption - use builder_dangerous for unencrypted connections + if request.auth_required { + let creds = Credentials::new(request.email_username.clone(), request.email_password.clone()); + AsyncSmtpTransport::::builder_dangerous(&request.server_name) + .port(port) + .credentials(creds) + .build() + } else { + AsyncSmtpTransport::::builder_dangerous(&request.server_name) + .port(port) + .build() + } + } + }; + + // Send the email with timeout + let email_future = mailer.send(email); + match timeout(Duration::from_secs(30), email_future).await { + Ok(Ok(_)) => Ok("Email sent successfully".to_string()), + Ok(Err(e)) => { + let error_msg = format!("{}", e); + + // Provide more helpful error messages for common issues + if error_msg.contains("InvalidContentType") || error_msg.contains("corrupt message") { + let suggestion = if port == 587 { + "Port 587 typically requires StartTLS encryption, not SSL/TLS. Try changing encryption to 'StartTLS'." + } else if port == 465 { + "Port 465 typically requires SSL/TLS encryption." + } else { + "This may be a TLS/SSL configuration issue. Verify your encryption settings match your SMTP server requirements." + }; + Err(AppError::internal(&format!("SMTP connection failed: {}. {}. Original error: {}", + "TLS/SSL handshake error", suggestion, error_msg))) + } else if error_msg.contains("authentication") || error_msg.contains("auth") { + Err(AppError::internal(&format!("SMTP authentication failed: {}. Please verify your username and password.", error_msg))) + } else if error_msg.contains("connection") || error_msg.contains("timeout") { + Err(AppError::internal(&format!("SMTP connection failed: {}. Please verify server name and port.", error_msg))) + } else { + Err(AppError::internal(&format!("Failed to send email: {}", error_msg))) + } + }, + Err(_) => Err(AppError::internal("Email sending timed out after 30 seconds. Please check your SMTP server settings and network connectivity.".to_string())), + } +} + +// Request struct for send_email (using database settings) +#[derive(Deserialize)] +pub struct SendEmailRequest { + pub to_email: String, + pub subject: String, + pub message: String, +} + +// Send email using database settings - matches Python api_send_email function exactly +pub async fn send_email( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Get email settings from database + let email_settings = state.db_pool.get_email_settings().await?; + let settings = match email_settings { + Some(settings) => settings, + None => return Err(AppError::not_found("Email settings not found")), + }; + + let email_status = send_email_with_settings(&settings, &request).await?; + Ok(Json(serde_json::json!({ "email_status": email_status }))) +} + +// Send email using database settings +pub async fn send_email_with_settings( + settings: &EmailSettingsResponse, + request: &SendEmailRequest, +) -> Result { + use lettre::{ + message::{header::ContentType, Message}, + transport::smtp::{authentication::Credentials, client::Tls, client::TlsParameters}, + AsyncSmtpTransport, AsyncTransport, Tokio1Executor, + }; + use tokio::time::{timeout, Duration}; + + // Read logo and create HTML content + let logo_base64 = read_logo_as_base64().await.unwrap_or_default(); + + // Check if this is a password reset email and format accordingly + let (html_content, final_subject) = if request.subject.contains("Password Reset") { + // Extract the reset code from the message + let reset_code = request.message.trim_start_matches("Your password reset code is "); + let content = format!(r#" +

🔐 Password Reset Request

+

You have requested a password reset for your PinePods account.

+

Please use the following code to reset your password:

+
{}
+

Important:

+
    +
  • This code will expire in 10 minutes
  • +
  • Only use this code if you requested a password reset
  • +
  • If you didn't request this, you can safely ignore this email
  • +
+

For security reasons, never share this code with anyone.

+ "#, reset_code); + (content, "PinePods - Password Reset Code".to_string()) + } else { + // For other emails, wrap the message content + let content = format!(r#" +

📧 {}

+
+ {} +
+ "#, request.subject, request.message.replace("\n", "
")); + (content, request.subject.clone()) + }; + + let html_body = create_html_email_template(&final_subject, &html_content, &logo_base64); + + // Create email message with HTML + let email = Message::builder() + .from(settings.from_email.parse() + .map_err(|_| AppError::bad_request("Invalid from email in settings"))?) + .to(request.to_email.parse() + .map_err(|_| AppError::bad_request("Invalid to email"))?) + .subject(&final_subject) + .header(ContentType::TEXT_HTML) + .body(html_body) + .map_err(|e| AppError::internal(&format!("Failed to build email: {}", e)))?; + + // Configure SMTP transport based on encryption + let mailer = match settings.encryption.as_str() { + "SSL/TLS" => { + let tls = TlsParameters::new(settings.server_name.clone()) + .map_err(|e| AppError::internal(&format!("TLS configuration failed: {}", e)))?; + + if settings.auth_required == 1 { + let creds = Credentials::new(settings.username.clone(), settings.password.clone()); + AsyncSmtpTransport::::relay(&settings.server_name) + .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? + .port(settings.server_port as u16) + .tls(Tls::Wrapper(tls)) + .credentials(creds) + .build() + } else { + AsyncSmtpTransport::::relay(&settings.server_name) + .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? + .port(settings.server_port as u16) + .tls(Tls::Wrapper(tls)) + .build() + } + } + "StartTLS" => { + let tls = TlsParameters::new(settings.server_name.clone()) + .map_err(|e| AppError::internal(&format!("TLS configuration failed: {}", e)))?; + + if settings.auth_required == 1 { + let creds = Credentials::new(settings.username.clone(), settings.password.clone()); + AsyncSmtpTransport::::relay(&settings.server_name) + .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? + .port(settings.server_port as u16) + .tls(Tls::Required(tls)) + .credentials(creds) + .build() + } else { + AsyncSmtpTransport::::relay(&settings.server_name) + .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? + .port(settings.server_port as u16) + .tls(Tls::Required(tls)) + .build() + } + } + _ => { + // No encryption - use builder_dangerous for unencrypted connections + if settings.auth_required == 1 { + let creds = Credentials::new(settings.username.clone(), settings.password.clone()); + AsyncSmtpTransport::::builder_dangerous(&settings.server_name) + .port(settings.server_port as u16) + .credentials(creds) + .build() + } else { + AsyncSmtpTransport::::builder_dangerous(&settings.server_name) + .port(settings.server_port as u16) + .build() + } + } + }; + + // Send the email with timeout + let email_future = mailer.send(email); + match timeout(Duration::from_secs(30), email_future).await { + Ok(Ok(_)) => Ok("Email sent successfully".to_string()), + Ok(Err(e)) => { + let error_msg = format!("{}", e); + let port = settings.server_port as u16; + + // Provide more helpful error messages for common issues + if error_msg.contains("InvalidContentType") || error_msg.contains("corrupt message") { + let suggestion = if port == 587 { + "Port 587 typically requires StartTLS encryption, not SSL/TLS. Try changing encryption to 'StartTLS'." + } else if port == 465 { + "Port 465 typically requires SSL/TLS encryption." + } else { + "This may be a TLS/SSL configuration issue. Verify your encryption settings match your SMTP server requirements." + }; + Err(AppError::internal(&format!("SMTP connection failed: {}. {}. Original error: {}", + "TLS/SSL handshake error", suggestion, error_msg))) + } else if error_msg.contains("authentication") || error_msg.contains("auth") { + Err(AppError::internal(&format!("SMTP authentication failed: {}. Please verify your username and password.", error_msg))) + } else if error_msg.contains("connection") || error_msg.contains("timeout") { + Err(AppError::internal(&format!("SMTP connection failed: {}. Please verify server name and port.", error_msg))) + } else { + Err(AppError::internal(&format!("Failed to send email: {}", error_msg))) + } + }, + Err(_) => Err(AppError::internal("Email sending timed out after 30 seconds. Please check your SMTP server settings and network connectivity.".to_string())), + } +} + + +// API info response struct - matches Python get_api_info response exactly +#[derive(Serialize)] +pub struct ApiInfo { + pub apikeyid: i32, + pub userid: i32, + pub username: String, + pub lastfourdigits: String, + pub created: String, + pub podcastids: Vec, +} + +// Get API info - matches Python api_get_api_info function exactly +pub async fn get_api_info( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization (elevated access or own user) + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if !is_web_key && user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to access these user details")); + } + + let api_information = state.db_pool.get_api_info(user_id).await?; + match api_information { + Some(info) => Ok(Json(serde_json::json!({ "api_info": info }))), + None => Err(AppError::not_found("User not found")), + } +} + +// Request struct for create_api_key +#[derive(Deserialize)] +pub struct CreateApiKeyRequest { + pub user_id: i32, + pub rssonly: bool, + pub podcast_ids: Option>, +} + +// Create API key - matches Python api_create_api_key function exactly +pub async fn create_api_key( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or own user + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); + } + + if request.rssonly { + let new_key = state.db_pool.create_rss_key(request.user_id, request.podcast_ids).await?; + Ok(Json(serde_json::json!({ "rss_key": new_key }))) + } else { + let new_key = state.db_pool.create_api_key(request.user_id).await?; + Ok(Json(serde_json::json!({ "api_key": new_key }))) + } +} + +// Request struct for delete_api_key +#[derive(Deserialize)] +pub struct DeleteApiKeyRequest { + pub api_id: String, + pub user_id: String, +} + +// Delete API key - matches Python api_delete_api_key function exactly +pub async fn delete_api_key( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Parse api_id from string (user_id not used for authorization) + let api_id: i32 = request.api_id.parse() + .map_err(|_| AppError::bad_request("Invalid api_id format"))?; + + // Check authorization - admins can delete any key (except user ID 1), users can only delete their own keys + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_requesting_user_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + // Get the owner of the API key being deleted + let api_key_owner = state.db_pool.get_api_key_owner(api_id).await?; + + if api_key_owner.is_none() { + return Err(AppError::not_found("API key not found")); + } + + let api_key_owner = api_key_owner.unwrap(); + + // For debugging - log the values + println!("🔐 delete_api_key: requesting_user={}, api_key_owner={}, is_admin={}, api_id={}", + requesting_user_id, api_key_owner, is_requesting_user_admin, api_id); + + // Authorization logic: + // - Admin users can delete any key EXCEPT keys belonging to user ID 1 (background tasks) + // - Regular users can only delete their own keys + if !is_requesting_user_admin && requesting_user_id != api_key_owner { + return Err(AppError::forbidden("You are not authorized to access or remove other users api-keys.")); + } + + // Check if the API key to be deleted is the same as the one used in the current request + if state.db_pool.is_same_api_key(api_id, &api_key).await? { + return Err(AppError::forbidden("You cannot delete the API key that is currently in use.")); + } + + // Check if the API key belongs to the background task user (user_id 1) - no one can delete these + if api_key_owner == 1 { + return Err(AppError::forbidden("Cannot delete background task API key - would break refreshing.")); + } + + // CRITICAL SAFETY CHECK: Ensure the API key owner has at least one other API key (would prevent logins) + let remaining_keys_count = state.db_pool.count_user_api_keys_excluding(api_key_owner, api_id).await?; + if remaining_keys_count == 0 { + if requesting_user_id == api_key_owner { + return Err(AppError::forbidden("Cannot delete your final API key - you must have at least one key to maintain access.")); + } else { + return Err(AppError::forbidden("Cannot delete the user's final API key - they must have at least one key to maintain access.")); + } + } + + // Proceed with deletion if the checks pass + state.db_pool.delete_api_key(api_id).await?; + Ok(Json(serde_json::json!({ "detail": "API key deleted." }))) +} + +// Request struct for backup_user +#[derive(Deserialize)] +pub struct BackupUserRequest { + pub user_id: i32, +} + +// Backup user data - matches Python backup_user function exactly +pub async fn backup_user( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or own user + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only make backups for yourself!")); + } + + let opml_data = state.db_pool.backup_user(request.user_id).await?; + Ok(opml_data) +} + +// Request struct for backup_server +#[derive(Deserialize)] +pub struct BackupServerRequest { + pub database_pass: String, +} + +// Backup server data - improved streaming approach for large databases +pub async fn backup_server( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin (matches Python check_if_admin dependency) + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + // For large databases, we'll implement streaming export instead of subprocess + // This avoids loading the entire database into memory at once + match backup_server_streaming(&state, &request.database_pass).await { + Ok(response) => Ok(response), + Err(e) => Err(AppError::internal(&format!("Backup failed: {}", e))), + } +} + +// Use actual pg_dump/mysqldump for reliable backups +async fn backup_server_streaming( + state: &AppState, + database_pass: &str, +) -> Result { + use axum::response::Response; + use axum::body::Body; + use tokio::process::Command; + use tokio_util::io::ReaderStream; + + // Get database connection info from config + let mut cmd = match &state.db_pool { + crate::database::DatabasePool::Postgres(_) => { + // Extract connection details from DATABASE_URL or config + let host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); + let port = std::env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string()); + let database = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods".to_string()); + let username = std::env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string()); + + // Use pg_dump with data-only options (no schema) + let mut cmd = Command::new("pg_dump"); + cmd.arg("--host").arg(&host) + .arg("--port").arg(&port) + .arg("--username").arg(&username) + .arg("--no-password") + .arg("--verbose") + .arg("--data-only") + .arg("--disable-triggers") + .arg("--format=plain") + .arg(&database); + + // Set password via environment variable + cmd.env("PGPASSWORD", database_pass); + + cmd + } + crate::database::DatabasePool::MySQL(_) => { + let host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); + let port = std::env::var("DB_PORT").unwrap_or_else(|_| "3306".to_string()); + let database = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods".to_string()); + let username = std::env::var("DB_USER").unwrap_or_else(|_| "root".to_string()); + + let mut cmd = Command::new("mysqldump"); + cmd.arg("--host").arg(&host) + .arg("--port").arg(&port) + .arg("--user").arg(&username) + .arg(format!("--password={}", database_pass)) + .arg("--skip-ssl") + .arg("--default-auth=mysql_native_password") + .arg("--single-transaction") + .arg("--routines") + .arg("--triggers") + .arg("--complete-insert") + .arg(&database); + + cmd + } + }; + + let mut child = cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start backup process: {}", e))?; + + let stdout = child.stdout.take() + .ok_or("Failed to get stdout from backup process")?; + + let stderr = child.stderr.take() + .ok_or("Failed to get stderr from backup process")?; + + let stream = ReaderStream::new(stdout); + let body = Body::from_stream(stream); + + // Spawn a task to wait for the process and handle errors + tokio::spawn(async move { + // Read stderr to capture error messages + let mut stderr_reader = tokio::io::BufReader::new(stderr); + let mut stderr_output = String::new(); + use tokio::io::AsyncBufReadExt; + + // Read stderr line by line + let mut lines = stderr_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + stderr_output.push_str(&line); + stderr_output.push('\n'); + } + + match child.wait().await { + Ok(status) if status.success() => { + println!("Backup process completed successfully"); + } + Ok(status) => { + println!("Backup process failed with status: {}", status); + if !stderr_output.is_empty() { + println!("Mysqldump stderr output: {}", stderr_output); + } + } + Err(e) => { + println!("Failed to wait for backup process: {}", e); + } + } + }); + + Ok(Response::builder() + .status(200) + .header("content-type", "text/plain; charset=utf-8") + .header("content-disposition", "attachment; filename=\"pinepods_backup.sql\"") + .body(body) + .map_err(|e| format!("Failed to build response: {}", e))?) +} + +// Generate backup chunks to handle large databases efficiently +async fn generate_backup_chunk(state: &AppState, chunk_id: usize) -> Result, String> { + // Define tables in order of dependencies (foreign keys) - complete list from migrations + let tables = match &state.db_pool { + crate::database::DatabasePool::Postgres(_) => vec![ + "Users", "OIDCProviders", "APIKeys", "RssKeys", "RssKeyMap", + "AppSettings", "EmailSettings", "UserStats", "UserSettings", + "Podcasts", "Episodes", "YouTubeVideos", "UserEpisodeHistory", "UserVideoHistory", + "EpisodeQueue", "SavedEpisodes", "SavedVideos", "DownloadedEpisodes", "DownloadedVideos", + "GpodderDevices", "GpodderSyncState", "People", "PeopleEpisodes", "SharedEpisodes", + "Playlists", "PlaylistContents", "Sessions", "UserNotificationSettings" + ], + crate::database::DatabasePool::MySQL(_) => vec![ + "Users", "OIDCProviders", "APIKeys", "RssKeys", "RssKeyMap", + "AppSettings", "EmailSettings", "UserStats", "UserSettings", + "Podcasts", "Episodes", "YouTubeVideos", "UserEpisodeHistory", "UserVideoHistory", + "EpisodeQueue", "SavedEpisodes", "SavedVideos", "DownloadedEpisodes", "DownloadedVideos", + "GpodderDevices", "GpodderSyncState", "People", "PeopleEpisodes", "SharedEpisodes", + "Playlists", "PlaylistContents", "Sessions", "UserNotificationSettings" + ], + }; + + // Header chunk + if chunk_id == 0 { + return Ok(Some(generate_backup_header())); + } + + // Table chunks (one table per chunk to keep memory usage low) + let table_index = chunk_id - 1; + if table_index < tables.len() { + let table_name = tables[table_index]; + match export_table_data(state, table_name).await { + Ok(data) => Ok(Some(data)), + Err(e) => Err(format!("Failed to export table {}: {}", table_name, e)), + } + } else { + // End of stream + Ok(None) + } +} + +// Generate SQL backup header +fn generate_backup_header() -> String { + format!( + "-- PinePods Database Backup\n-- Generated: {}\n-- Rust API Backup System\n\n", + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") + ) +} + +// Export individual table data efficiently +async fn export_table_data(state: &AppState, table_name: &str) -> Result { + const BATCH_SIZE: i64 = 1000; // Process 1000 rows at a time + let mut sql_output = format!("\n-- Exporting table: {}\n", table_name); + + // First, export the CREATE TABLE statement + let create_statement = match &state.db_pool { + crate::database::DatabasePool::Postgres(pool) => { + export_postgres_table_schema(pool, table_name).await? + } + crate::database::DatabasePool::MySQL(pool) => { + export_mysql_table_schema(pool, table_name).await? + } + }; + + sql_output.push_str(&create_statement); + sql_output.push('\n'); + + // Then export the data + let mut offset = 0; + loop { + let batch_data = match &state.db_pool { + crate::database::DatabasePool::Postgres(pool) => { + export_postgres_table_batch(pool, table_name, offset, BATCH_SIZE).await? + } + crate::database::DatabasePool::MySQL(pool) => { + export_mysql_table_batch(pool, table_name, offset, BATCH_SIZE).await? + } + }; + + if batch_data.is_empty() { + break; // No more data + } + + sql_output.push_str(&batch_data); + offset += BATCH_SIZE; + + // Don't artificially limit chunk size - complete the entire table + // Each table is processed as one complete chunk to ensure valid SQL + } + + Ok(sql_output) +} + +// Export PostgreSQL table schema using pg_dump-like approach +async fn export_postgres_table_schema( + pool: &sqlx::PgPool, + table_name: &str, +) -> Result { + // Get table definition from PostgreSQL system catalogs with proper ARRAY handling + let query = r#" + SELECT + 'CREATE TABLE "' || schemaname || '"."' || tablename || '" (' AS create_start, + string_agg( + '"' || column_name || '" ' || + CASE + WHEN data_type = 'ARRAY' THEN + CASE + WHEN udt_name = '_int4' THEN 'INTEGER[]' + WHEN udt_name = '_text' THEN 'TEXT[]' + WHEN udt_name = '_varchar' THEN 'VARCHAR[]' + WHEN udt_name = '_int8' THEN 'BIGINT[]' + WHEN udt_name = '_bool' THEN 'BOOLEAN[]' + ELSE udt_name || '[]' + END + WHEN data_type = 'character varying' THEN 'VARCHAR(' || COALESCE(character_maximum_length::text, '255') || ')' + WHEN data_type = 'character' THEN 'CHAR(' || character_maximum_length || ')' + WHEN data_type = 'numeric' THEN 'NUMERIC(' || numeric_precision || ',' || numeric_scale || ')' + WHEN data_type = 'integer' THEN 'INTEGER' + WHEN data_type = 'bigint' THEN 'BIGINT' + WHEN data_type = 'boolean' THEN 'BOOLEAN' + WHEN data_type = 'timestamp without time zone' THEN 'TIMESTAMP' + WHEN data_type = 'timestamp with time zone' THEN 'TIMESTAMPTZ' + WHEN data_type = 'date' THEN 'DATE' + WHEN data_type = 'text' THEN 'TEXT' + WHEN data_type = 'double precision' THEN 'DOUBLE PRECISION' + WHEN data_type = 'real' THEN 'REAL' + WHEN data_type = 'smallint' THEN 'SMALLINT' + WHEN data_type = 'uuid' THEN 'UUID' + WHEN data_type = 'json' THEN 'JSON' + WHEN data_type = 'jsonb' THEN 'JSONB' + ELSE UPPER(data_type) + END || + CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END, + ', ' + ORDER BY ordinal_position + ) AS columns, + ');' AS create_end + FROM information_schema.columns c + JOIN pg_tables t ON t.tablename = c.table_name + WHERE c.table_name = $1 AND c.table_schema = 'public' + GROUP BY schemaname, tablename + "#; + + let row = sqlx::query(query) + .bind(table_name) + .fetch_optional(pool) + .await + .map_err(|e| format!("Schema query failed: {}", e))?; + + if let Some(row) = row { + let create_start: String = row.try_get("create_start").map_err(|e| format!("Column error: {}", e))?; + let columns: String = row.try_get("columns").map_err(|e| format!("Column error: {}", e))?; + let create_end: String = row.try_get("create_end").map_err(|e| format!("Column error: {}", e))?; + + Ok(format!("{}\n {}\n{}\n", create_start, columns, create_end)) + } else { + Err(format!("Table {} not found", table_name)) + } +} + +// Export MySQL table schema +async fn export_mysql_table_schema( + pool: &sqlx::MySqlPool, + table_name: &str, +) -> Result { + // Use SHOW CREATE TABLE for MySQL + let query = format!("SHOW CREATE TABLE {}", table_name); + + let row = sqlx::query(&query) + .fetch_optional(pool) + .await + .map_err(|e| format!("Schema query failed: {}", e))?; + + if let Some(row) = row { + let create_table: String = row.try_get(1).map_err(|e| format!("Column error: {}", e))?; + Ok(format!("{};\n", create_table)) + } else { + Err(format!("Table {} not found", table_name)) + } +} + +// Export PostgreSQL table batch +async fn export_postgres_table_batch( + pool: &sqlx::PgPool, + table_name: &str, + offset: i64, + limit: i64, +) -> Result { + // Use quoted table names for PostgreSQL + let query = format!( + r#"SELECT * FROM "{}" ORDER BY 1 LIMIT {} OFFSET {}"#, + table_name, limit, offset + ); + + let rows = sqlx::query(&query) + .fetch_all(pool) + .await + .map_err(|e| format!("Query failed: {}", e))?; + + if rows.is_empty() { + return Ok(String::new()); + } + + let mut output = format!("INSERT INTO \"{}\" VALUES\n", table_name); + let mut first_row = true; + + for row in rows { + if !first_row { + output.push_str(",\n"); + } + first_row = false; + + output.push('('); + let column_count = row.columns().len(); + for i in 0..column_count { + if i > 0 { + output.push_str(", "); + } + + // Handle different PostgreSQL data types safely + match row.try_get_raw(i) { + Ok(value) if value.is_null() => output.push_str("NULL"), + Ok(_) => { + // Try different data types in order of likelihood + if let Ok(val) = row.try_get::(i) { + // Properly escape strings for PostgreSQL + let escaped = val.replace('\'', "''").replace('\\', "\\\\"); + output.push_str(&format!("'{}'", escaped)); + } else if let Ok(val) = row.try_get::(i) { + output.push_str(&val.to_string()); + } else if let Ok(val) = row.try_get::(i) { + output.push_str(&val.to_string()); + } else if let Ok(val) = row.try_get::(i) { + output.push_str(if val { "true" } else { "false" }); + } else if let Ok(val) = row.try_get::(i) { + output.push_str(&val.to_string()); + } else if let Ok(val) = row.try_get::, _>(i) { + output.push_str(&format!("'{}'", val.format("%Y-%m-%d %H:%M:%S%.6f%z"))); + } else { + // Fallback: try to get as text + match row.try_get::(i) { + Ok(val) => { + let escaped = val.replace('\'', "''").replace('\\', "\\\\"); + output.push_str(&format!("'{}'", escaped)); + }, + Err(_) => output.push_str("NULL"), + } + } + } + Err(_) => output.push_str("NULL"), + } + } + output.push(')'); + } + output.push_str(";\n"); + + Ok(output) +} + +// Export MySQL table batch +async fn export_mysql_table_batch( + pool: &sqlx::MySqlPool, + table_name: &str, + offset: i64, + limit: i64, +) -> Result { + let query = format!( + "SELECT * FROM {} ORDER BY 1 LIMIT {} OFFSET {}", + table_name, limit, offset + ); + + let rows = sqlx::query(&query) + .fetch_all(pool) + .await + .map_err(|e| format!("Query failed: {}", e))?; + + if rows.is_empty() { + return Ok(String::new()); + } + + let mut output = format!("INSERT INTO {} VALUES\n", table_name); + let mut first_row = true; + + for row in rows { + if !first_row { + output.push_str(",\n"); + } + first_row = false; + + output.push('('); + let column_count = row.columns().len(); + for i in 0..column_count { + if i > 0 { + output.push_str(", "); + } + + // Handle different MySQL data types safely + match row.try_get_raw(i) { + Ok(value) if value.is_null() => output.push_str("NULL"), + Ok(_) => { + // Try different data types in order of likelihood + if let Ok(val) = row.try_get::(i) { + // Properly escape strings for MySQL + let escaped = val.replace('\'', "''").replace('\\', "\\\\"); + output.push_str(&format!("'{}'", escaped)); + } else if let Ok(val) = row.try_get::(i) { + output.push_str(&val.to_string()); + } else if let Ok(val) = row.try_get::(i) { + output.push_str(&val.to_string()); + } else if let Ok(val) = row.try_get::(i) { + output.push_str(&val.to_string()); + } else if let Ok(val) = row.try_get::(i) { + output.push_str(&val.to_string()); + } else if let Ok(val) = row.try_get::, _>(i) { + output.push_str(&format!("'{}'", val.format("%Y-%m-%d %H:%M:%S"))); + } else { + // Fallback: try to get as text + match row.try_get::(i) { + Ok(val) => { + let escaped = val.replace('\'', "''").replace('\\', "\\\\"); + output.push_str(&format!("'{}'", escaped)); + }, + Err(_) => output.push_str("NULL"), + } + } + } + Err(_) => output.push_str("NULL"), + } + } + output.push(')'); + } + output.push_str(";\n"); + + Ok(output) +} + +pub async fn restore_server( + State(state): State, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + // Process the multipart form to get the uploaded file and database password + let mut sql_content = None; + let mut _database_password = None; + + while let Some(field) = multipart.next_field().await.map_err(|e| AppError::bad_request(&format!("Multipart error: {}", e)))? { + let name = field.name().unwrap_or("").to_string(); + + if name == "backup_file" { + let filename = field.file_name().unwrap_or("").to_string(); + + // Validate file extension + if !filename.ends_with(".sql") { + return Err(AppError::bad_request("Only SQL files are allowed")); + } + + let data = field.bytes().await.map_err(|e| AppError::bad_request(&format!("Failed to read file: {}", e)))?; + + // Check file size (limit to 100MB) + if data.len() > 100 * 1024 * 1024 { + return Err(AppError::bad_request("File too large (max 100MB)")); + } + + sql_content = Some(String::from_utf8(data.to_vec()).map_err(|_| AppError::bad_request("Invalid UTF-8 content"))?); + } else if name == "database_pass" { + let password_data = field.bytes().await.map_err(|e| AppError::bad_request(&format!("Failed to read password: {}", e)))?; + _database_password = Some(String::from_utf8(password_data.to_vec()).map_err(|_| AppError::bad_request("Invalid UTF-8 password"))?); + } + } + + let sql_content = sql_content.ok_or_else(|| AppError::bad_request("No SQL file uploaded"))?; + let _database_password = _database_password.ok_or_else(|| AppError::bad_request("Database password is required"))?; + + // Process the restore in the background to prevent timeouts + let db_pool = state.db_pool.clone(); + tokio::spawn(async move { + if let Err(e) = db_pool.restore_server_data(&sql_content).await { + tracing::error!("Restore failed: {}", e); + } + }); + + Ok(Json(serde_json::json!({ + "message": "Server restore started successfully" + }))) +} + +// Generate MFA secret - matches Python generate_mfa_secret function exactly +pub async fn generate_mfa_secret( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or own user + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only generate MFA secrets for yourself!")); + } + + let (secret, qr_code_svg) = state.db_pool.generate_mfa_secret(user_id).await?; + Ok(Json(serde_json::json!({ + "secret": secret, + "qr_code_svg": qr_code_svg + }))) +} + +// Request struct for verify_temp_mfa +#[derive(Deserialize)] +pub struct VerifyTempMfaRequest { + pub user_id: i32, + pub mfa_code: String, +} + +// Verify temporary MFA code - matches Python verify_temp_mfa function exactly +pub async fn verify_temp_mfa( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or own user + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only verify MFA codes for yourself!")); + } + + let verified = state.db_pool.verify_temp_mfa(request.user_id, &request.mfa_code).await?; + Ok(Json(serde_json::json!({ "verified": verified }))) +} + +// Check MFA enabled - matches Python check_mfa_enabled function exactly +pub async fn check_mfa_enabled( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check for elevated access (admin/web key) + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // If not elevated access, user can only check their own MFA status + if !is_web_key && user_id != user_id_from_api_key { + return Err(AppError::forbidden("You are not authorized to check mfa status for other users.")); + } + + let is_enabled = state.db_pool.check_mfa_enabled(user_id).await?; + Ok(Json(serde_json::json!({"mfa_enabled": is_enabled}))) +} + +// Request struct for save_mfa_secret +#[derive(Deserialize)] +pub struct SaveMfaSecretRequest { + pub user_id: i32, + pub mfa_secret: String, +} + +// Save MFA secret - matches Python save_mfa_secret function exactly +pub async fn save_mfa_secret( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or own user + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only save MFA secrets for yourself!")); + } + + let success = state.db_pool.save_mfa_secret(request.user_id, &request.mfa_secret).await?; + Ok(Json(serde_json::json!({ "success": success }))) +} + +// Delete MFA - matches Python delete_mfa function exactly +pub async fn delete_mfa( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let success = state.db_pool.delete_mfa_secret(user_id).await?; + Ok(Json(serde_json::json!({ "success": success }))) +} + +// Request struct for initiate_nextcloud_login +#[derive(Deserialize)] +pub struct InitiateNextcloudLoginRequest { + pub user_id: i32, + pub nextcloud_url: String, +} + +// Initiate Nextcloud login - matches Python initiate_nextcloud_login function exactly +pub async fn initiate_nextcloud_login( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action only if the API key belongs to the user + if key_id != request.user_id { + return Err(AppError::forbidden("You are not authorized to initiate this action.")); + } + + let login_data = state.db_pool.initiate_nextcloud_login(request.user_id, &request.nextcloud_url).await?; + + Ok(Json(login_data.raw_response)) +} + +// Request struct for add_nextcloud_server +#[derive(Deserialize, Clone)] +pub struct AddNextcloudServerRequest { + pub user_id: i32, + pub token: String, + pub poll_endpoint: String, + pub nextcloud_url: String, +} + +// Add Nextcloud server - matches Python add_nextcloud_server function exactly +pub async fn add_nextcloud_server( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Allow the action only if the API key belongs to the user + if key_id != request.user_id { + return Err(AppError::forbidden("You are not authorized to access these user details")); + } + + // Reset gPodder settings to default like Python version + state.db_pool.remove_podcast_sync(request.user_id).await?; + + // Create a task for the Nextcloud authentication polling + let task_id = state.task_manager.create_task("nextcloud_auth".to_string(), request.user_id).await?; + + // Start background polling task using TaskManager + let state_clone = state.clone(); + let request_clone = request.clone(); + let task_id_clone = task_id.clone(); + tokio::spawn(async move { + poll_for_auth_completion_background(state_clone, request_clone, task_id_clone).await; + }); + + // Return 200 status code before starting to poll (like Python version) + Ok(Json(serde_json::json!({ "status": "polling", "task_id": task_id }))) +} + +// Background task for polling Nextcloud auth completion +async fn poll_for_auth_completion_background(state: AppState, request: AddNextcloudServerRequest, task_id: String) { + // Update task to indicate polling has started + if let Err(e) = state.task_manager.update_task_progress(&task_id, 10.0, Some("Starting Nextcloud authentication polling...".to_string())).await { + eprintln!("Failed to update task progress: {}", e); + } + + match poll_for_auth_completion(&request.poll_endpoint, &request.token, &state.task_manager, &task_id).await { + Ok(credentials) => { + println!("Nextcloud authentication successful: {:?}", credentials); + + // Update task progress + if let Err(e) = state.task_manager.update_task_progress(&task_id, 90.0, Some("Authentication successful, saving credentials...".to_string())).await { + eprintln!("Failed to update task progress: {}", e); + } + + // Extract credentials from the response + if let (Some(app_password), Some(login_name)) = ( + credentials.get("appPassword").and_then(|v| v.as_str()), + credentials.get("loginName").and_then(|v| v.as_str()) + ) { + // Save the real credentials using the database method + match state.db_pool.save_nextcloud_credentials(request.user_id, &request.nextcloud_url, app_password, login_name).await { + Ok(_) => { + println!("Successfully added Nextcloud settings for user {}", request.user_id); + if let Err(e) = state.task_manager.complete_task(&task_id, + Some(serde_json::json!({"status": "success", "message": "Nextcloud authentication completed"})), + Some("Nextcloud authentication completed successfully".to_string())).await { + eprintln!("Failed to complete task: {}", e); + } + } + Err(e) => { + eprintln!("Failed to add Nextcloud settings: {}", e); + if let Err(e) = state.task_manager.fail_task(&task_id, format!("Failed to save Nextcloud settings: {}", e)).await { + eprintln!("Failed to fail task: {}", e); + } + } + } + } else { + eprintln!("Missing appPassword or loginName in credentials"); + if let Err(e) = state.task_manager.fail_task(&task_id, "Missing credentials in Nextcloud response".to_string()).await { + eprintln!("Failed to fail task: {}", e); + } + } + } + Err(e) => { + eprintln!("Nextcloud authentication failed: {}", e); + if let Err(e) = state.task_manager.fail_task(&task_id, format!("Authentication failed: {}", e)).await { + eprintln!("Failed to fail task: {}", e); + } + } + } +} + +// Poll for auth completion - matches Python poll_for_auth_completion function +async fn poll_for_auth_completion( + endpoint: &str, + token: &str, + task_manager: &crate::services::task_manager::TaskManager, + task_id: &str +) -> Result> { + let client = reqwest::Client::new(); + let payload = serde_json::json!({ "token": token }); + let timeout = std::time::Duration::from_secs(20 * 60); // 20 minutes timeout + let start_time = std::time::Instant::now(); + + let mut poll_count = 0; + while start_time.elapsed() < timeout { + poll_count += 1; + + // Update progress based on time elapsed (up to 80% during polling) + let elapsed_secs = start_time.elapsed().as_secs(); + let progress = 10.0 + ((elapsed_secs as f64 / (20.0 * 60.0)) * 70.0).min(70.0); + let message = format!("Waiting for user to complete authentication... (attempt {})", poll_count); + + if let Err(e) = task_manager.update_task_progress(task_id, progress, Some(message)).await { + eprintln!("Failed to update task progress during polling: {}", e); + } + + match client + .post(endpoint) + .json(&payload) + .header("Content-Type", "application/json") + .send() + .await + { + Ok(response) => { + match response.status().as_u16() { + 200 => { + let credentials = response.json::().await?; + println!("Authentication successful: {:?}", credentials); + return Ok(credentials); + } + 404 => { + // User hasn't completed auth yet, continue polling + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + status => { + println!("Polling failed with status code {}", status); + return Err(format!("Polling for Nextcloud authentication failed with status {}", status).into()); + } + } + } + Err(e) => { + println!("Connection error, retrying: {}", e); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + } + } + + Err("Polling timeout reached".into()) +} + +// Helper function to save Nextcloud credentials directly to database +async fn save_nextcloud_credentials( + db_pool: &crate::database::DatabasePool, + user_id: i32, + nextcloud_url: &str, + app_password: &str, + login_name: &str +) -> crate::error::AppResult<()> { + // Encrypt the app password + let encrypted_password = db_pool.encrypt_password(app_password).await?; + + // Store Nextcloud credentials + match db_pool { + crate::database::DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET gpodderurl = $1, gpodderloginname = $2, gpoddertoken = $3, pod_sync_type = 'nextcloud' WHERE userid = $4"#) + .bind(nextcloud_url) + .bind(login_name) + .bind(&encrypted_password) + .bind(user_id) + .execute(pool) + .await?; + } + crate::database::DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET GpodderUrl = ?, GpodderLoginName = ?, GpodderToken = ?, Pod_Sync_Type = 'nextcloud' WHERE UserID = ?") + .bind(nextcloud_url) + .bind(login_name) + .bind(&encrypted_password) + .bind(user_id) + .execute(pool) + .await?; + } + } + + Ok(()) +} + +// Request struct for verify_gpodder_auth +#[derive(Deserialize)] +pub struct VerifyGpodderAuthRequest { + pub gpodder_url: String, + pub gpodder_username: String, + pub gpodder_password: String, +} + +// Verify gPodder authentication - matches Python verify_gpodder_auth function exactly +pub async fn verify_gpodder_auth( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Direct HTTP call to match Python implementation exactly + let client = reqwest::Client::new(); + let auth_url = format!("{}/api/2/auth/{}/login.json", + request.gpodder_url.trim_end_matches('/'), + request.gpodder_username); + + match client + .post(&auth_url) + .basic_auth(&request.gpodder_username, Some(&request.gpodder_password)) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + Ok(Json(serde_json::json!({"status": "success", "message": "Logged in!"}))) + } else { + Err(AppError::unauthorized("Authentication failed")) + } + } + Err(_) => { + Err(AppError::internal("Internal Server Error")) + } + } +} + +// Request struct for add_gpodder_server +#[derive(Deserialize)] +pub struct AddGpodderServerRequest { + pub gpodder_url: String, + pub gpodder_username: String, + pub gpodder_password: String, +} + +// Add gPodder server - matches Python add_gpodder_server function exactly +pub async fn add_gpodder_server( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let success = state.db_pool.add_gpodder_server(user_id, &request.gpodder_url, &request.gpodder_username, &request.gpodder_password).await?; + + if success { + Ok(Json(serde_json::json!({ "status": "success" }))) + } else { + Err(AppError::internal("Failed to add gPodder server")) + } +} + +// Get gPodder settings - matches Python get_gpodder_settings function exactly +pub async fn get_gpodder_settings( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or own user + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only view your own gPodder settings!")); + } + + let settings = state.db_pool.get_gpodder_settings(user_id).await?; + match settings { + Some(settings) => Ok(Json(serde_json::json!({ "data": settings }))), + None => Err(AppError::not_found("gPodder settings not found")), + } +} + +// Check gPodder settings - matches Python check_gpodder_settings function exactly +pub async fn check_gpodder_settings( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or own user + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only check your own gPodder settings!")); + } + + let has_settings = state.db_pool.check_gpodder_settings(user_id).await?; + Ok(Json(serde_json::json!({ "data": has_settings }))) +} + + +// Remove podcast sync - matches Python remove_podcast_sync function exactly +#[derive(Debug, serde::Deserialize)] +pub struct RemoveSyncRequest { + pub user_id: i32, +} + +pub async fn remove_podcast_sync( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if the user has permission to modify this user's data + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You are not authorized to modify these user settings")); + } + + // Remove the sync settings + let success = state.db_pool.remove_gpodder_settings(request.user_id).await?; + + if success { + Ok(Json(serde_json::json!({ + "success": true, + "message": "Podcast sync settings removed successfully" + }))) + } else { + Err(AppError::internal("Failed to remove podcast sync settings")) + } +} + +// === NEW ENDPOINTS - REMAINING SETTINGS === + +// Request struct for add_custom_podcast +#[derive(Deserialize)] +pub struct CustomPodcastRequest { + pub feed_url: String, + pub user_id: i32, + pub username: Option, + pub password: Option, + pub youtube_channel: Option, + pub feed_cutoff: Option, +} + +// Request struct for import_opml +#[derive(Deserialize)] +pub struct OpmlImportRequest { + pub podcasts: Vec, + pub user_id: i32, +} + +// Response struct for import_progress +#[derive(Serialize)] +pub struct ImportProgressResponse { + pub current: i32, + pub total: i32, + pub current_podcast: String, +} + +// Request struct for notification_settings +#[derive(Deserialize)] +pub struct NotificationSettingsRequest { + pub user_id: i32, + pub platform: String, + pub enabled: bool, + pub ntfy_topic: Option, + pub ntfy_server_url: Option, + pub ntfy_username: Option, + pub ntfy_password: Option, + pub ntfy_access_token: Option, + pub gotify_url: Option, + pub gotify_token: Option, + pub http_url: Option, + pub http_token: Option, + pub http_method: Option, +} + +// Request struct for test_notification +#[derive(Deserialize)] +pub struct NotificationTestRequest { + pub user_id: i32, + pub platform: String, +} + +// Request struct for add_oidc_provider +#[derive(Deserialize)] +pub struct OidcProviderRequest { + pub provider_name: String, + pub client_id: String, + pub client_secret: String, + pub authorization_url: String, + pub token_url: String, + pub user_info_url: String, + pub button_text: String, + pub scope: String, + pub button_color: String, + pub button_text_color: String, + pub icon_svg: Option, + pub name_claim: Option, + pub email_claim: Option, + pub username_claim: Option, + pub roles_claim: Option, + pub user_role: Option, + pub admin_role: Option, +} + +// Query structs for user_id parameters +#[derive(Deserialize)] +pub struct UserIdQuery { + pub user_id: i32, +} + +#[derive(Deserialize)] +pub struct StartpageQuery { + pub user_id: i32, + pub startpage: Option, +} + +// Add custom podcast - matches Python add_custom_podcast function exactly +pub async fn add_custom_podcast( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only add podcasts for themselves + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only add podcasts for yourself!")); + } + + // Check if this is a YouTube channel request + if request.youtube_channel.unwrap_or(false) { + // Extract channel ID from YouTube URL + let channel_id = extract_youtube_channel_id(&request.feed_url)?; + + // Check if channel already exists + let existing_id = state.db_pool.check_existing_channel_subscription( + &channel_id, + request.user_id, + ).await?; + + if let Some(podcast_id) = existing_id { + // Channel already subscribed, return existing podcast details + let podcast_details = state.db_pool.get_podcast_details(request.user_id, podcast_id).await?; + return Ok(Json(serde_json::json!({ "data": podcast_details }))); + } + + // Get channel info using yt-dlp (bypasses Google API limits) + let channel_info = crate::handlers::youtube::get_youtube_channel_info(&channel_id).await?; + + let feed_cutoff = request.feed_cutoff.unwrap_or(30); + + // Add YouTube channel to database + let podcast_id = state.db_pool.add_youtube_channel( + &channel_info, + request.user_id, + feed_cutoff, + ).await?; + + // Spawn background task to process YouTube videos + let state_clone = state.clone(); + let channel_id_clone = channel_id.clone(); + tokio::spawn(async move { + if let Err(e) = crate::handlers::youtube::process_youtube_channel( + podcast_id, + &channel_id_clone, + feed_cutoff, + &state_clone + ).await { + println!("Error processing YouTube channel {}: {}", channel_id_clone, e); + } + }); + + // Get complete podcast details for response + let podcast_details = state.db_pool.get_podcast_details(request.user_id, podcast_id).await?; + + return Ok(Json(serde_json::json!({ "data": podcast_details }))); + } + + // Regular podcast feed handling + // Get podcast values from feed URL + let podcast_values = state.db_pool.get_podcast_values( + &request.feed_url, + request.user_id, + request.username.as_deref(), + request.password.as_deref() + ).await?; + + // Add podcast with 30 episode cutoff (matches Python default) + let (podcast_id, _) = state.db_pool.add_podcast_from_values( + &podcast_values, + request.user_id, + 30, + request.username.as_deref(), + request.password.as_deref() + ).await?; + + // Get complete podcast details for response + let podcast_details = state.db_pool.get_podcast_details(request.user_id, podcast_id).await?; + + Ok(Json(serde_json::json!({ "data": podcast_details }))) +} + +// Helper function to extract YouTube channel ID from various URL formats +fn extract_youtube_channel_id(url: &str) -> Result { + // Support various YouTube URL formats: + // - https://www.youtube.com/channel/UC... + // - https://youtube.com/channel/UC... + // - https://www.youtube.com/@channelname + // - youtube.com/@channelname + // - Just the channel ID itself: UC... + + let url_lower = url.to_lowercase(); + + // If it's already a channel ID (starts with UC) + if url.starts_with("UC") && !url.contains('/') && !url.contains('.') { + return Ok(url.to_string()); + } + + // Extract from /channel/ URLs + if url_lower.contains("/channel/") { + if let Some(channel_part) = url.split("/channel/").nth(1) { + let channel_id = channel_part.split(&['/', '?', '&'][..]).next().unwrap_or(""); + if !channel_id.is_empty() { + return Ok(channel_id.to_string()); + } + } + } + + // For @handle URLs, we need to use yt-dlp to resolve the channel ID + // This will be handled by get_youtube_channel_info, so we return the URL as-is + if url_lower.contains("/@") || url.starts_with('@') { + return Ok(url.to_string()); + } + + Err(AppError::bad_request(&format!( + "Invalid YouTube channel URL. Expected format: https://www.youtube.com/channel/UC... or https://www.youtube.com/@channelname or just the channel ID. Got: {}", + url + ))) +} + +// Import OPML - matches Python import_opml function exactly with background processing +pub async fn import_opml( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only import OPML for yourself!")); + } + + let total_podcasts = request.podcasts.len(); + + // Initialize progress tracking in Redis/Valkey + state.import_progress_manager.start_import(request.user_id, total_podcasts as i32).await?; + + // Spawn background task for OPML processing + let state_clone = state.clone(); + let podcasts = request.podcasts.clone(); + let user_id = request.user_id; + + tokio::spawn(async move { + for (index, feed_url) in podcasts.iter().enumerate() { + // Update progress + let _ = state_clone.import_progress_manager.update_progress( + user_id, + index as i32, + feed_url + ).await; + + // Process podcast (with error handling to continue on failures) + match state_clone.db_pool.get_podcast_values(feed_url, user_id, None, None).await { + Ok(podcast_values) => { + let _ = state_clone.db_pool.add_podcast_from_values( + &podcast_values, + user_id, + 30, // feed_cutoff + None, // username + None // password + ).await; + } + Err(e) => { + tracing::error!("Failed to import podcast {}: {}", feed_url, e); + // Continue with next podcast + } + } + + // Small delay between imports (matches Python 0.1s delay) + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + // Clear progress when complete + let _ = state_clone.import_progress_manager.clear_progress(user_id).await; + }); + + Ok(Json(serde_json::json!({ + "message": "OPML import started", + "total": total_podcasts + }))) +} + +// Import progress webhook - matches Python import_progress function exactly +pub async fn import_progress( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only check their own progress + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only check your own import progress!")); + } + + let (current, total, current_podcast) = state.import_progress_manager.get_progress(user_id).await?; + let progress = ImportProgressResponse { + current, + total, + current_podcast, + }; + Ok(Json(progress)) +} + +// Get notification settings - matches Python notification_settings GET function exactly +pub async fn get_notification_settings( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if query.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only view your own notification settings!")); + } + + let settings = state.db_pool.get_notification_settings(query.user_id).await?; + Ok(Json(serde_json::json!({ "settings": settings }))) +} + +// Update notification settings - matches Python notification_settings PUT function exactly +pub async fn update_notification_settings( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only update your own notification settings!")); + } + + state.db_pool.update_notification_settings( + request.user_id, + &request.platform, + request.enabled, + request.ntfy_topic.as_deref(), + request.ntfy_server_url.as_deref(), + request.ntfy_username.as_deref(), + request.ntfy_password.as_deref(), + request.ntfy_access_token.as_deref(), + request.gotify_url.as_deref(), + request.gotify_token.as_deref(), + request.http_url.as_deref(), + request.http_token.as_deref(), + request.http_method.as_deref() + ).await?; + Ok(Json(serde_json::json!({ "detail": "Notification settings updated successfully" }))) +} + +// Test notification - matches Python test_notification function exactly +pub async fn test_notification( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if request.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only test your own notifications!")); + } + + // Get notification settings and send test notification + let settings = state.db_pool.get_notification_settings(request.user_id).await?; + + // Find settings for the specific platform + let platform_settings = settings.iter() + .find(|s| s.get("platform").and_then(|p| p.as_str()) == Some(&request.platform)) + .ok_or_else(|| AppError::bad_request(&format!("No settings found for platform: {}", request.platform)))?; + + let success = state.notification_manager.send_test_notification(request.user_id, &request.platform, platform_settings).await?; + + if success { + Ok(Json(serde_json::json!({ "detail": "Test notification sent successfully" }))) + } else { + Err(AppError::bad_request("Failed to send test notification - check your settings")) + } +} + +// Add OIDC provider - matches Python add_oidc_provider function exactly +pub async fn add_oidc_provider( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin - OIDC provider management requires admin access + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required to add OIDC providers")); + } + + let provider_id = state.db_pool.add_oidc_provider( + &request.provider_name, + &request.client_id, + &request.client_secret, + &request.authorization_url, + &request.token_url, + &request.user_info_url, + &request.button_text, + &request.scope, + &request.button_color, + &request.button_text_color, + request.icon_svg.as_deref().unwrap_or(""), + request.name_claim.as_deref().unwrap_or("name"), + request.email_claim.as_deref().unwrap_or("email"), + request.username_claim.as_deref().unwrap_or("username"), + request.roles_claim.as_deref().unwrap_or(""), + request.user_role.as_deref().unwrap_or(""), + request.admin_role.as_deref().unwrap_or(""), + false // initialized_from_env = false (added via UI) + ).await?; + Ok(Json(serde_json::json!({ "provider_id": provider_id }))) +} + +// Update OIDC provider - updates an existing provider +pub async fn update_oidc_provider( + State(state): State, + headers: HeaderMap, + Path(provider_id): Path, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin - OIDC provider management requires admin access + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required to update OIDC providers")); + } + + // Only update client_secret if it's not empty + let client_secret_to_update = if request.client_secret.is_empty() { + None + } else { + Some(request.client_secret.as_str()) + }; + + let success = state.db_pool.update_oidc_provider( + provider_id, + &request.provider_name, + &request.client_id, + client_secret_to_update, + &request.authorization_url, + &request.token_url, + &request.user_info_url, + &request.button_text, + &request.scope, + &request.button_color, + &request.button_text_color, + request.icon_svg.as_deref().unwrap_or(""), + request.name_claim.as_deref().unwrap_or("name"), + request.email_claim.as_deref().unwrap_or("email"), + request.username_claim.as_deref().unwrap_or("username"), + request.roles_claim.as_deref().unwrap_or(""), + request.user_role.as_deref().unwrap_or(""), + request.admin_role.as_deref().unwrap_or("") + ).await?; + + if success { + Ok(Json(serde_json::json!({ "message": "OIDC provider updated successfully" }))) + } else { + Err(AppError::not_found("OIDC provider not found")) + } +} + +// List OIDC providers - matches Python list_oidc_providers function exactly +pub async fn list_oidc_providers( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let providers = state.db_pool.list_oidc_providers().await?; + Ok(Json(serde_json::json!({ "providers": providers }))) +} + +// Remove OIDC provider - matches Python remove_oidc_provider function exactly +pub async fn remove_oidc_provider( + State(state): State, + headers: HeaderMap, + Json(provider_id): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin - OIDC provider management requires admin access + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required to remove OIDC providers")); + } + + // Check if provider was initialized from environment variables + let is_env_initialized = state.db_pool.is_oidc_provider_env_initialized(provider_id).await?; + if is_env_initialized { + return Err(AppError::forbidden("Cannot remove OIDC provider that was initialized from environment variables. Providers created from docker-compose environment variables are protected from removal to prevent login issues.")); + } + + let success = state.db_pool.remove_oidc_provider(provider_id).await?; + + if success { + Ok(Json(serde_json::json!({ "message": "OIDC provider removed successfully" }))) + } else { + Err(AppError::not_found("OIDC provider not found")) + } +} + +// Get startpage - matches Python startpage GET function exactly +pub async fn get_startpage( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if query.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only view your own startpage setting!")); + } + + let startpage = state.db_pool.get_startpage(query.user_id).await?; + Ok(Json(serde_json::json!({ "StartPage": startpage }))) +} + +// Update startpage - matches Python startpage POST function exactly +pub async fn update_startpage( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if query.user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only update your own startpage setting!")); + } + + let startpage = query.startpage.unwrap_or_else(|| "home".to_string()); + state.db_pool.update_startpage(query.user_id, &startpage).await?; + + Ok(Json(serde_json::json!({ + "success": true, + "message": "StartPage updated successfully" + }))) +} + +// Request struct for person subscribe +#[derive(Deserialize)] +pub struct PersonSubscribeRequest { + pub person_name: String, + pub person_img: String, + pub podcast_id: i32, +} + +// Request struct for person unsubscribe +#[derive(Deserialize)] +pub struct PersonUnsubscribeRequest { + pub person_name: String, +} + +// Subscribe to person - matches Python api_subscribe_to_person function exactly +pub async fn subscribe_to_person( + State(state): State, + Path((user_id, person_id)): Path<(i32, i32)>, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only subscribe for themselves + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != user_id && !is_web_key { + return Err(AppError::forbidden("You can only subscribe for yourself!")); + } + + let person_db_id = state.db_pool.subscribe_to_person( + user_id, + person_id, + &request.person_name, + &request.person_img, + request.podcast_id, + ).await?; + + // Trigger immediate background task to process person subscription and gather episodes + let db_pool = state.db_pool.clone(); + let person_name = request.person_name.clone(); + tokio::spawn(async move { + match db_pool.process_person_subscription(user_id, person_db_id, person_name.clone()).await { + Ok(_) => { + tracing::info!("Successfully processed immediate person subscription for {}", person_name); + } + Err(e) => { + tracing::error!("Failed to process immediate person subscription for {}: {}", person_name, e); + } + } + }); + + Ok(Json(serde_json::json!({ + "success": true, + "message": "Successfully subscribed to person", + "person_id": person_db_id + }))) +} + +// Unsubscribe from person - matches Python api_unsubscribe_from_person function exactly +pub async fn unsubscribe_from_person( + State(state): State, + Path((user_id, person_id)): Path<(i32, i32)>, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only unsubscribe for themselves + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != user_id && !is_web_key { + return Err(AppError::forbidden("You can only unsubscribe for yourself!")); + } + + let success = state.db_pool.unsubscribe_from_person( + user_id, + person_id, + &request.person_name, + ).await?; + + if success { + Ok(Json(serde_json::json!({ + "success": true, + "message": "Successfully unsubscribed from person" + }))) + } else { + Ok(Json(serde_json::json!({ + "success": false, + "message": "Person subscription not found" + }))) + } +} + +// Get person subscriptions - matches Python api_get_person_subscriptions function exactly +pub async fn get_person_subscriptions( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only get their own subscriptions + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != user_id && !is_web_key { + return Err(AppError::forbidden("You can only retrieve your own subscriptions!")); + } + + let subscriptions = state.db_pool.get_person_subscriptions(user_id).await?; + Ok(Json(serde_json::json!({ + "subscriptions": subscriptions + }))) +} + +// Get person episodes - matches Python api_return_person_episodes function exactly +pub async fn get_person_episodes( + State(state): State, + Path((user_id, person_id)): Path<(i32, i32)>, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only get their own subscriptions + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != user_id && !is_web_key { + return Err(AppError::forbidden("You can only retrieve your own person episodes!")); + } + + let episodes = state.db_pool.get_person_episodes(user_id, person_id).await?; + Ok(Json(serde_json::json!({ + "episodes": episodes + }))) +} + +// Request struct for set_podcast_playback_speed - matches Python SetPlaybackSpeedPodcast model +#[derive(Deserialize)] +pub struct SetPlaybackSpeedPodcast { + pub user_id: i32, + pub podcast_id: i32, + pub playback_speed: f64, +} + +// Set podcast playback speed - matches Python api_set_podcast_playback_speed endpoint +pub async fn set_podcast_playback_speed( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only modify your own podcasts.")); + } + + state.db_pool.set_podcast_playback_speed(request.user_id, request.podcast_id, request.playback_speed).await?; + + Ok(Json(serde_json::json!({ "detail": "Default podcast playback speed updated." }))) +} + +// Request struct for enable_auto_download - matches Python AutoDownloadRequest model +#[derive(Deserialize)] +pub struct AutoDownloadRequest { + pub podcast_id: i32, + pub auto_download: bool, + pub user_id: i32, +} + +// Enable/disable auto download for podcast - matches Python api_enable_auto_download endpoint +pub async fn enable_auto_download( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id != request.user_id { + return Err(AppError::forbidden("You can only modify your own podcasts.")); + } + + state.db_pool.enable_auto_download(request.podcast_id, request.auto_download, request.user_id).await?; + + Ok(Json(serde_json::json!({ "detail": "Auto-download status updated." }))) +} + +// Request struct for toggle_podcast_notifications - matches Python TogglePodcastNotificationData model +#[derive(Deserialize)] +pub struct TogglePodcastNotificationData { + pub user_id: i32, + pub podcast_id: i32, + pub enabled: bool, +} + +// Toggle podcast notifications - matches Python api_toggle_podcast_notifications endpoint +pub async fn toggle_podcast_notifications( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("Invalid API key")); + } + + let success = state.db_pool.toggle_podcast_notifications(request.user_id, request.podcast_id, request.enabled).await?; + + if success { + Ok(Json(serde_json::json!({ "detail": "Notification settings updated successfully" }))) + } else { + Ok(Json(serde_json::json!({ "detail": "Failed to update notification settings" }))) + } +} + +// Request struct for adjust_skip_times - matches Python SkipTimesRequest model +#[derive(Deserialize)] +pub struct SkipTimesRequest { + pub podcast_id: i32, + #[serde(default)] + pub start_skip: i32, + #[serde(default)] + pub end_skip: i32, + pub user_id: i32, +} + +// Adjust skip times for podcast - matches Python api_adjust_skip_times endpoint +pub async fn adjust_skip_times( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only modify your own podcasts.")); + } + + state.db_pool.adjust_skip_times(request.podcast_id, request.start_skip, request.end_skip, request.user_id).await?; + + Ok(Json(serde_json::json!({ "detail": "Skip times updated." }))) +} + +// Request struct for remove_category - matches Python RemoveCategoryData model +#[derive(Deserialize)] +pub struct RemoveCategoryData { + pub podcast_id: i32, + pub user_id: i32, + pub category: String, +} + +// Remove category from podcast - matches Python api_remove_category endpoint +pub async fn remove_category( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id != request.user_id { + return Err(AppError::forbidden("You can only modify categories of your own podcasts!")); + } + + state.db_pool.remove_category(request.podcast_id, request.user_id, &request.category).await?; + + Ok(Json(serde_json::json!({ "detail": "Category removed." }))) +} + +// Request struct for add_category - matches Python AddCategoryData model +#[derive(Deserialize)] +pub struct AddCategoryData { + pub podcast_id: i32, + pub user_id: i32, + pub category: String, +} + +// Add category to podcast - matches Python api_add_category endpoint +pub async fn add_category( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only modify categories of your own podcasts!")); + } + + let result = state.db_pool.add_category(request.podcast_id, request.user_id, &request.category).await?; + + Ok(Json(serde_json::json!({ "detail": result }))) +} + +// Get user RSS key - matches Python get_user_rss_key endpoint +pub async fn get_user_rss_key( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if key_id == 0 { + return Err(AppError::forbidden("Invalid API key")); + } + + let rss_key = state.db_pool.get_user_rss_key(key_id).await?; + if let Some(key) = rss_key { + Ok(Json(serde_json::json!({ "rss_key": key }))) + } else { + Err(AppError::not_found("No RSS key found. Please enable RSS feeds first.")) + } +} + +#[derive(Deserialize)] +pub struct VerifyMfaRequest { + pub user_id: i32, + pub mfa_code: String, +} + +// Verify MFA code - matches Python verify_mfa endpoint +pub async fn verify_mfa( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization + if !check_user_access(&state, &api_key, request.user_id).await? { + return Err(AppError::forbidden("You can only verify your own login code!")); + } + + // Get the stored MFA secret + let secret = state.db_pool.get_mfa_secret(request.user_id).await?; + + if let Some(secret_str) = secret { + // Verify the TOTP code + use totp_rs::{Algorithm, TOTP, Secret}; + + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + Secret::Encoded(secret_str.clone()).to_bytes().unwrap(), + Some("Pinepods".to_string()), + "login".to_string(), + ).map_err(|e| AppError::internal(&format!("Failed to create TOTP: {}", e)))?; + + let is_valid = totp.check_current(&request.mfa_code) + .map_err(|e| AppError::internal(&format!("Failed to verify TOTP: {}", e)))?; + + Ok(Json(serde_json::json!({ "verified": is_valid }))) + } else { + Ok(Json(serde_json::json!({ "verified": false }))) + } +} + +// Scheduled backup management +#[derive(Deserialize)] +pub struct ScheduleBackupRequest { + pub user_id: i32, + pub cron_schedule: String, // e.g., "0 2 * * *" for daily at 2 AM + pub enabled: bool, +} + +#[derive(Deserialize)] +pub struct GetScheduledBackupRequest { + pub user_id: i32, +} + +#[derive(Deserialize)] +pub struct ListBackupFilesRequest { + pub user_id: i32, +} + +#[derive(Deserialize)] +pub struct RestoreBackupFileRequest { + pub user_id: i32, + pub backup_filename: String, +} + +// Schedule automatic backup - admin only +pub async fn schedule_backup( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + // Validate cron expression using tokio-cron-scheduler + use tokio_cron_scheduler::Job; + if let Err(_) = Job::new(&request.cron_schedule, |_uuid, _lock| {}) { + return Err(AppError::bad_request("Invalid cron schedule format")); + } + + // Store the schedule in database + state.db_pool.set_scheduled_backup(request.user_id, &request.cron_schedule, request.enabled).await?; + + Ok(Json(serde_json::json!({ + "detail": "Backup schedule updated successfully", + "schedule": request.cron_schedule, + "enabled": request.enabled + }))) +} + +// Get scheduled backup settings - admin only +pub async fn get_scheduled_backup( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + let schedule_info = state.db_pool.get_scheduled_backup(request.user_id).await?; + + Ok(Json(serde_json::json!(schedule_info))) +} + +// List backup files in mounted backup directory - admin only +pub async fn list_backup_files( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + use std::fs; + + let backup_dir = "/opt/pinepods/backups"; + let backup_files = match fs::read_dir(backup_dir) { + Ok(entries) => { + let mut files = Vec::new(); + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "sql") { + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { + let metadata = entry.metadata().ok(); + let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0); + let modified = metadata.as_ref() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + files.push(serde_json::json!({ + "filename": filename, + "size": size, + "modified": modified + })); + } + } + } + } + files.sort_by(|a, b| { + let a_modified = a["modified"].as_u64().unwrap_or(0); + let b_modified = b["modified"].as_u64().unwrap_or(0); + b_modified.cmp(&a_modified) // Sort by modified date desc (newest first) + }); + files + } + Err(_) => { + return Err(AppError::internal("Failed to read backup directory")); + } + }; + + Ok(Json(serde_json::json!({ + "backup_files": backup_files + }))) +} + +// Restore from backup file in mounted directory - admin only +pub async fn restore_from_backup_file( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + // Validate filename to prevent path traversal + let backup_filename = request.backup_filename.clone(); + if backup_filename.contains("..") || backup_filename.contains("/") || !backup_filename.ends_with(".sql") { + return Err(AppError::bad_request("Invalid backup filename")); + } + + let backup_path = format!("/opt/pinepods/backups/{}", backup_filename); + + // Check if file exists + if !std::path::Path::new(&backup_path).exists() { + return Err(AppError::not_found("Backup file not found")); + } + + // Clone for the async closure + let backup_filename_for_closure = backup_filename.clone(); + + // Spawn restoration task + let task_id = state.task_spawner.spawn_progress_task( + "restore_from_backup_file".to_string(), + 0, // System user + move |reporter| { + let backup_path = backup_path.clone(); + let backup_filename = backup_filename_for_closure; + async move { + reporter.update_progress(10.0, Some("Starting restoration from backup file...".to_string())).await?; + + // Get database password from environment + let db_password = std::env::var("DB_PASSWORD") + .map_err(|_| AppError::internal("Database password not found in environment"))?; + + reporter.update_progress(50.0, Some("Restoring database...".to_string())).await?; + + // Execute restoration based on database type + use tokio::process::Command; + let db_type = std::env::var("DB_TYPE").unwrap_or_else(|_| "postgresql".to_string()); + let db_host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); + let db_name = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods_database".to_string()); + + let output = if db_type.to_lowercase().contains("mysql") || db_type.to_lowercase().contains("mariadb") { + let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "3306".to_string()); + let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "root".to_string()); + + let mut cmd = Command::new("mysql"); + cmd.arg("-h").arg(&db_host) + .arg("-P").arg(&db_port) + .arg("-u").arg(&db_user) + .arg(&format!("-p{}", db_password)) + .arg("--ssl-verify-server-cert=0") + .arg(&db_name); + + // For MySQL, we need to pipe the file content to stdin + cmd.stdin(std::process::Stdio::piped()); + let mut child = cmd.spawn() + .map_err(|e| AppError::internal(&format!("Failed to execute mysql: {}", e)))?; + + // Read the backup file and send to mysql stdin + let backup_content = tokio::fs::read_to_string(&backup_path).await + .map_err(|e| AppError::internal(&format!("Failed to read backup file: {}", e)))?; + + if let Some(stdin) = child.stdin.as_mut() { + use tokio::io::AsyncWriteExt; + stdin.write_all(backup_content.as_bytes()).await + .map_err(|e| AppError::internal(&format!("Failed to write to mysql stdin: {}", e)))?; + } + + child.wait_with_output().await + .map_err(|e| AppError::internal(&format!("Failed to wait for mysql: {}", e)))? + } else { + // PostgreSQL + let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string()); + let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string()); + + let mut cmd = Command::new("psql"); + cmd.arg("-h").arg(&db_host) + .arg("-p").arg(&db_port) + .arg("-U").arg(&db_user) + .arg("-d").arg(&db_name) + .arg("-f").arg(&backup_path) + .env("PGPASSWORD", &db_password); + + cmd.output().await + .map_err(|e| AppError::internal(&format!("Failed to execute psql: {}", e)))? + }; + + if !output.status.success() { + let error_msg = String::from_utf8_lossy(&output.stderr); + return Err(AppError::internal(&format!("Restore failed: {}", error_msg))); + } + + reporter.update_progress(100.0, Some("Restoration completed successfully".to_string())).await?; + + Ok(serde_json::json!({ + "status": "Restoration completed successfully", + "backup_file": backup_filename + })) + } + } + ).await?; + + Ok(Json(serde_json::json!({ + "detail": "Restoration started", + "task_id": task_id + }))) +} + +// Request struct for manual backup to directory +#[derive(Deserialize)] +pub struct ManualBackupRequest { + pub user_id: i32, +} + +// Manual backup to directory - admin only +pub async fn manual_backup_to_directory( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check if user is admin + let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; + + if !is_admin { + return Err(AppError::forbidden("Admin access required")); + } + + // Generate filename with timestamp + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let backup_filename = format!("manual_backup_{}.sql", timestamp); + let backup_path = format!("/opt/pinepods/backups/{}", backup_filename); + + // Ensure backup directory exists + if let Err(e) = std::fs::create_dir_all("/opt/pinepods/backups") { + return Err(AppError::internal(&format!("Failed to create backup directory: {}", e))); + } + + // Set ownership using PUID/PGID environment variables + let puid: u32 = std::env::var("PUID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + let pgid: u32 = std::env::var("PGID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + + // Set directory ownership (ignore errors for NFS mounts) + let _ = std::process::Command::new("chown") + .args(&[format!("{}:{}", puid, pgid), "/opt/pinepods/backups".to_string()]) + .output(); + + // Clone for the async closure + let backup_filename_for_closure = backup_filename.clone(); + + // Spawn backup task + let task_id = state.task_spawner.spawn_progress_task( + "manual_backup_to_directory".to_string(), + 0, // System user + move |reporter| { + let backup_path = backup_path.clone(); + let backup_filename = backup_filename_for_closure; + async move { + reporter.update_progress(10.0, Some("Starting manual backup...".to_string())).await?; + + // Get database credentials from environment + let db_type = std::env::var("DB_TYPE").unwrap_or_else(|_| "postgresql".to_string()); + let db_host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); + let db_name = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods_database".to_string()); + let db_password = std::env::var("DB_PASSWORD") + .map_err(|_| AppError::internal("Database password not found in environment"))?; + + reporter.update_progress(30.0, Some("Creating database backup...".to_string())).await?; + + // Use appropriate backup command based on database type + let output = if db_type.to_lowercase().contains("mysql") || db_type.to_lowercase().contains("mariadb") { + let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "3306".to_string()); + let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "root".to_string()); + + tokio::process::Command::new("mysqldump") + .args(&[ + "-h", &db_host, + "-P", &db_port, + "-u", &db_user, + &format!("-p{}", db_password), + "--single-transaction", + "--routines", + "--triggers", + "--ssl-verify-server-cert=0", + "--result-file", &backup_path, + &db_name + ]) + .output() + .await + .map_err(|e| AppError::internal(&format!("Failed to execute mysqldump: {}", e)))? + } else { + // PostgreSQL + let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string()); + let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string()); + + tokio::process::Command::new("pg_dump") + .env("PGPASSWORD", db_password) + .args(&[ + "-h", &db_host, + "-p", &db_port, + "-U", &db_user, + "-d", &db_name, + "--clean", + "--if-exists", + "--no-owner", + "--no-privileges", + "-f", &backup_path + ]) + .output() + .await + .map_err(|e| AppError::internal(&format!("Failed to execute pg_dump: {}", e)))? + }; + + if !output.status.success() { + let error_msg = String::from_utf8_lossy(&output.stderr); + return Err(AppError::internal(&format!("Backup failed: {}", error_msg))); + } + + reporter.update_progress(90.0, Some("Finalizing backup...".to_string())).await?; + + // Set file ownership using PUID/PGID environment variables + let puid: u32 = std::env::var("PUID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + let pgid: u32 = std::env::var("PGID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + + // Set backup file ownership (ignore errors for NFS mounts) + let _ = std::process::Command::new("chown") + .args(&[format!("{}:{}", puid, pgid), backup_path.clone()]) + .output(); + + // Check if backup file was created and get its size + let backup_info = match std::fs::metadata(&backup_path) { + Ok(metadata) => serde_json::json!({ + "filename": backup_filename, + "size": metadata.len(), + "path": backup_path + }), + Err(_) => { + return Err(AppError::internal("Backup file was not created")); + } + }; + + reporter.update_progress(100.0, Some("Manual backup completed successfully".to_string())).await?; + + Ok(serde_json::json!({ + "status": "Manual backup completed successfully", + "backup_info": backup_info + })) + } + } + ).await?; + + Ok(Json(serde_json::json!({ + "detail": "Manual backup started", + "task_id": task_id, + "filename": backup_filename + }))) +} + +// Request for getting podcasts with podcast_index_id = 0 +#[derive(Deserialize)] +pub struct GetUnmatchedPodcastsRequest { + pub user_id: i32, +} + +// Get podcasts that have podcast_index_id = 0 (imported via OPML without podcast index match) +pub async fn get_unmatched_podcasts( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check if it's web key or user's own key + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == request.user_id || is_web_key { + let podcasts = state.db_pool.get_unmatched_podcasts(request.user_id).await?; + Ok(Json(serde_json::json!({"podcasts": podcasts}))) + } else { + Err(AppError::forbidden("You can only access your own podcasts")) + } +} + +// Request for updating podcast index ID +#[derive(Deserialize)] +pub struct UpdatePodcastIndexIdRequest { + pub user_id: i32, + pub podcast_id: i32, + pub podcast_index_id: i32, +} + +// Update a podcast's podcast_index_id +pub async fn update_podcast_index_id( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check if it's web key or user's own key + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == request.user_id || is_web_key { + state.db_pool.update_podcast_index_id( + request.user_id, + request.podcast_id, + request.podcast_index_id + ).await?; + + Ok(Json(serde_json::json!({ + "detail": "Podcast index ID updated successfully" + }))) + } else { + Err(AppError::forbidden("You can only update your own podcasts")) + } +} + +// Request for ignoring a podcast index ID +#[derive(Deserialize)] +pub struct IgnorePodcastIndexIdRequest { + pub user_id: i32, + pub podcast_id: i32, + pub ignore: bool, +} + +#[derive(Deserialize)] +pub struct GetIgnoredPodcastsRequest { + pub user_id: i32, +} + +// Ignore/unignore a podcast's index ID requirement +pub async fn ignore_podcast_index_id( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check if it's web key or user's own key + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == request.user_id || is_web_key { + state.db_pool.ignore_podcast_index_id( + request.user_id, + request.podcast_id, + request.ignore + ).await?; + + let action = if request.ignore { "ignored" } else { "unignored" }; + Ok(Json(serde_json::json!({ + "detail": format!("Podcast index ID requirement {}", action) + }))) + } else { + Err(AppError::forbidden("You can only update your own podcasts")) + } +} + +// Get podcasts that are ignored from podcast index matching +pub async fn get_ignored_podcasts( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify API key + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + // Check if it's web key or user's own key + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + if key_id == request.user_id || is_web_key { + let podcasts = state.db_pool.get_ignored_podcasts(request.user_id).await?; + + Ok(Json(serde_json::json!({ + "podcasts": podcasts + }))) + } else { + Err(AppError::forbidden("You can only view your own podcasts")) + } +} + +// Get user's language preference +pub async fn get_user_language( + State(state): State, + headers: HeaderMap, + Query(params): Query>, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id: i32 = params + .get("user_id") + .ok_or_else(|| AppError::bad_request("Missing user_id parameter"))? + .parse() + .map_err(|_| AppError::bad_request("Invalid user_id format"))?; + + check_user_access(&state, &api_key, user_id).await?; + + let language = state.db_pool.get_user_language(user_id).await?; + + Ok(Json(UserLanguageResponse { language })) +} + +// Update user's language preference +pub async fn update_user_language( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + check_user_access(&state, &api_key, request.user_id).await?; + + let success = state.db_pool.update_user_language(request.user_id, &request.language).await?; + + if success { + Ok(Json(serde_json::json!({ + "success": true, + "language": request.language + }))) + } else { + Err(AppError::not_found("User not found")) + } +} + +// Get available languages by scanning translation files +pub async fn get_available_languages() -> Result, AppError> { + let translations_dir = std::path::Path::new("/var/www/html/static/translations"); + + let mut languages = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(translations_dir) { + for entry in entries.flatten() { + if let Some(file_name) = entry.file_name().to_str() { + if file_name.ends_with(".json") { + let lang_code = file_name.strip_suffix(".json").unwrap_or(""); + + // Map language codes to human-readable names + let lang_name = match lang_code { + "en" => "English", + "ar" => "العربية", + "be" => "Беларуская", + "bg" => "Български", + "bn" => "বাংলা", + "ca" => "Català", + "cs" => "Čeština", + "da" => "Dansk", + "de" => "Deutsch", + "es" => "Español", + "et" => "Eesti", + "eu" => "Euskera", + "fa" => "فارسی", + "fi" => "Suomi", + "fr" => "Français", + "gu" => "ગુજરાતી", + "he" => "עברית", + "hi" => "हिन्दी", + "hr" => "Hrvatski", + "hu" => "Magyar", + "it" => "Italiano", + "ja" => "日本語", + "ko" => "한국어", + "lt" => "Lietuvių", + "nb" => "Norsk Bokmål", + "nl" => "Nederlands", + "pl" => "Polski", + "pt" => "Português", + "pt-BR" => "Português (Brasil)", + "ro" => "Română", + "ru" => "Русский", + "sk" => "Slovenčina", + "sl" => "Slovenščina", + "sv" => "Svenska", + "tr" => "Türkçe", + "uk" => "Українська", + "vi" => "Tiếng Việt", + "zh" => "中文", + "zh-Hans" => "中文 (简体)", + "zh-Hant" => "中文 (繁體)", + "test" => "Test Language", + _ => lang_code, // Fallback to code if name not mapped + }; + + // Validate that the translation file contains valid JSON + if let Ok(content) = std::fs::read_to_string(entry.path()) { + if serde_json::from_str::(&content).is_ok() { + languages.push(AvailableLanguage { + code: lang_code.to_string(), + name: lang_name.to_string(), + }); + } + } + } + } + } + } + + // Sort by language code for consistent ordering + languages.sort_by(|a, b| a.code.cmp(&b.code)); + + // Ensure English is always first if present + if let Some(en_index) = languages.iter().position(|l| l.code == "en") { + if en_index != 0 { + let en_lang = languages.remove(en_index); + languages.insert(0, en_lang); + } + } + + Ok(Json(AvailableLanguagesResponse { languages })) +} + +// Get server default language (no authentication required) +pub async fn get_server_default_language() -> Result, AppError> { + // Get default language from environment variable, fallback to 'en' + let default_language = std::env::var("DEFAULT_LANGUAGE").unwrap_or_else(|_| "en".to_string()); + + // Validate language code (basic validation) + let default_language = if default_language.len() > 10 || default_language.is_empty() { + "en" + } else { + &default_language + }; + + Ok(Json(serde_json::json!({ + "default_language": default_language + }))) +} + +// Request struct for set_global_podcast_cover_preference - matches playback speed pattern +#[derive(Deserialize)] +pub struct SetGlobalPodcastCoverPreference { + pub user_id: i32, + pub use_podcast_covers: bool, + pub podcast_id: Option, +} + +// Set global podcast cover preference - matches Python api_set_global_podcast_cover_preference function +pub async fn set_global_podcast_cover_preference( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only set their own preference + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only modify your own settings.")); + } + + // If podcast_id is provided, set per-podcast preference; otherwise set global preference + if let Some(podcast_id) = request.podcast_id { + state.db_pool.set_podcast_cover_preference(request.user_id, podcast_id, request.use_podcast_covers).await?; + Ok(Json(serde_json::json!({ "detail": "Podcast cover preference updated." }))) + } else { + state.db_pool.set_global_podcast_cover_preference(request.user_id, request.use_podcast_covers).await?; + Ok(Json(serde_json::json!({ "detail": "Global podcast cover preference updated." }))) + } +} + +// Request struct for set_podcast_cover_preference - matches podcast playback speed pattern +#[derive(Deserialize)] +pub struct SetPodcastCoverPreference { + pub user_id: i32, + pub podcast_id: i32, + pub use_podcast_covers: bool, +} + +// Set podcast cover preference - matches Python api_set_podcast_cover_preference function +pub async fn set_podcast_cover_preference( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only modify your own podcasts.")); + } + + state.db_pool.set_podcast_cover_preference(request.user_id, request.podcast_id, request.use_podcast_covers).await?; + + Ok(Json(serde_json::json!({ "detail": "Podcast cover preference updated." }))) +} + +// Request struct for clear_podcast_cover_preference - matches clear playback speed pattern +#[derive(Deserialize)] +pub struct ClearPodcastCoverPreference { + pub user_id: i32, + pub podcast_id: i32, +} + +// Clear podcast cover preference - matches Python api_clear_podcast_cover_preference function +pub async fn clear_podcast_cover_preference( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only modify your own podcasts.")); + } + + state.db_pool.clear_podcast_cover_preference(request.user_id, request.podcast_id).await?; + + Ok(Json(serde_json::json!({ "detail": "Podcast cover preference cleared." }))) +} + +// Get global podcast cover preference +pub async fn get_global_podcast_cover_preference( + State(state): State, + headers: HeaderMap, + Query(params): Query>, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id: i32 = params + .get("user_id") + .ok_or_else(|| AppError::bad_request("Missing user_id parameter"))? + .parse() + .map_err(|_| AppError::bad_request("Invalid user_id format"))?; + + // Check authorization - users can only access their own settings + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if user_id_from_api_key != user_id { + return Err(AppError::forbidden("You can only access your own settings.")); + } + + // If podcast_id is provided, get per-podcast preference; otherwise get global preference + let use_podcast_covers = if let Some(podcast_id_str) = params.get("podcast_id") { + let podcast_id: i32 = podcast_id_str + .parse() + .map_err(|_| AppError::bad_request("Invalid podcast_id format"))?; + + let per_podcast_preference = state.db_pool.get_podcast_cover_preference(user_id, podcast_id).await?; + + // If no per-podcast preference is set, fall back to global preference + match per_podcast_preference { + Some(preference) => preference, + None => state.db_pool.get_global_podcast_cover_preference(user_id).await?, + } + } else { + state.db_pool.get_global_podcast_cover_preference(user_id).await? + }; + + Ok(Json(serde_json::json!({ + "use_podcast_covers": use_podcast_covers + }))) +} + diff --git a/PinePods-0.8.2/rust-api/src/handlers/sync.rs b/PinePods-0.8.2/rust-api/src/handlers/sync.rs new file mode 100644 index 0000000..ea44e4e --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/sync.rs @@ -0,0 +1,511 @@ +use axum::{ + extract::{Path, Query, State}, + http::HeaderMap, + response::Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + error::AppError, + handlers::{extract_api_key, validate_api_key}, + AppState, +}; + +#[derive(Debug, Deserialize)] +pub struct UpdateGpodderSyncRequest { + pub enabled: bool, +} + +#[derive(Debug, Deserialize)] +pub struct RemoveSyncRequest { + pub user_id: i32, +} + +// Set default gPodder device - accepts device name for frontend compatibility +pub async fn gpodder_set_default( + State(state): State, + Path(device_name): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let success = state.db_pool.gpodder_set_default_device_by_name(user_id, &device_name).await?; + + if success { + Ok(Json(serde_json::json!({ + "success": true, + "message": "Default device set successfully", + "data": null + }))) + } else { + Err(AppError::internal("Failed to set default device")) + } +} + +// Get gPodder devices for user - matches Python get_devices function exactly +pub async fn gpodder_get_user_devices( + State(state): State, + Path(user_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or own user + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only view your own devices!")); + } + + let devices = state.db_pool.gpodder_get_user_devices(user_id).await?; + Ok(Json(serde_json::json!(devices))) +} + +// Get all gPodder devices - matches Python get_all_devices function exactly +pub async fn gpodder_get_all_devices( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let devices = state.db_pool.gpodder_get_user_devices(user_id).await?; + Ok(Json(serde_json::json!(devices))) +} + +// Force sync gPodder - performs initial full sync without timestamps (like setup) +pub async fn gpodder_force_sync( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Get user's sync settings to determine which sync method to use + let sync_settings = state.db_pool.get_user_sync_settings(user_id).await?; + if sync_settings.is_none() { + return Ok(Json(serde_json::json!({ + "success": false, + "message": "No sync configured for this user", + "data": null + }))); + } + + let settings = sync_settings.unwrap(); + let device_name = state.db_pool.get_or_create_default_device(user_id).await?; + + // Perform initial full sync (without timestamps) based on sync type + let sync_result = match settings.sync_type.as_str() { + "gpodder" => { + // Internal gPodder API - call initial full sync + state.db_pool.call_gpodder_initial_full_sync(user_id, "http://localhost:8042", &settings.username, &settings.token, &device_name).await + } + "nextcloud" => { + // Nextcloud initial sync + state.db_pool.call_nextcloud_initial_full_sync(user_id, &settings.url, &settings.username, &settings.token).await + } + "external" => { + // External gPodder server - decrypt token first then call initial full sync + let decrypted_token = state.db_pool.decrypt_password(&settings.token).await.unwrap_or_default(); + state.db_pool.call_gpodder_initial_full_sync(user_id, &settings.url, &settings.username, &decrypted_token, &device_name).await + } + "both" => { + // Both internal and external - call initial sync for both + let internal_result = state.db_pool.call_gpodder_initial_full_sync(user_id, "http://localhost:8042", &settings.username, &settings.token, &device_name).await; + let decrypted_token = state.db_pool.decrypt_password(&settings.token).await.unwrap_or_default(); + let external_result = state.db_pool.call_gpodder_initial_full_sync(user_id, &settings.url, &settings.username, &decrypted_token, &device_name).await; + + match (internal_result, external_result) { + (Ok(internal_success), Ok(external_success)) => Ok(internal_success || external_success), + (Ok(internal_success), Err(external_err)) => { + tracing::warn!("External sync failed: {}, but internal sync succeeded: {}", external_err, internal_success); + Ok(internal_success) + } + (Err(internal_err), Ok(external_success)) => { + tracing::warn!("Internal sync failed: {}, but external sync succeeded: {}", internal_err, external_success); + Ok(external_success) + } + (Err(internal_err), Err(external_err)) => { + tracing::error!("Both internal and external sync failed: internal={}, external={}", internal_err, external_err); + Err(internal_err) + } + } + } + _ => Ok(false) + }; + + let (success, error_message) = match sync_result { + Ok(result) => (result, None), + Err(e) => { + tracing::error!("Sync failed with error: {}", e); + (false, Some(e.to_string())) + } + }; + + if success { + Ok(Json(serde_json::json!({ + "success": true, + "message": "Initial sync completed successfully - all data refreshed", + "data": null + }))) + } else { + let message = error_message.unwrap_or_else(|| "Initial sync failed - please check your sync configuration".to_string()); + Ok(Json(serde_json::json!({ + "success": false, + "message": format!("Initial sync failed: {}", message), + "data": null + }))) + } +} + +// Regular gPodder sync - performs standard incremental sync with timestamps (like tasks.rs) +pub async fn gpodder_sync( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Use the same sync process as the scheduler (tasks.rs) which uses proper API calls with timestamps + let sync_result = state.db_pool.refresh_gpodder_subscription_background(user_id).await?; + + if sync_result { + Ok(Json(serde_json::json!({ + "success": true, + "message": "Sync completed successfully", + "data": null + }))) + } else { + Ok(Json(serde_json::json!({ + "success": false, + "message": "Sync failed or no changes detected - check your sync configuration", + "data": null + }))) + } +} + +// Get gPodder status - matches Python get_gpodder_status function exactly +pub async fn gpodder_status( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let status = state.db_pool.gpodder_get_status(user_id).await?; + + Ok(Json(serde_json::json!({ + "sync_type": status.sync_type, + "gpodder_enabled": status.sync_type == "gpodder" || status.sync_type == "both" || status.sync_type == "external", + "external_enabled": status.sync_type == "external" || status.sync_type == "both", + "external_url": status.gpodder_url, + "api_url": "http://localhost:8042" + }))) +} + +// Toggle gPodder sync - matches Python toggle_gpodder_sync function exactly +pub async fn gpodder_toggle( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Get current user status to match Python logic + let user_status = state.db_pool.gpodder_get_status(user_id).await?; + let current_sync_type = &user_status.sync_type; + + let mut device_info: Option = None; + + if request.enabled { + // Enable gpodder sync - call function that matches Python set_gpodder_internal_sync + if let Ok(result) = state.db_pool.set_gpodder_internal_sync(user_id).await { + device_info = Some(result); + } else { + return Err(AppError::internal("Failed to enable gpodder sync")); + } + + // Add background task for subscription refresh (matches Python background_tasks.add_task) + let db_pool = state.db_pool.clone(); + let _task_id = state.task_spawner.spawn_progress_task( + "gpodder_subscription_refresh".to_string(), + user_id, + move |reporter| async move { + reporter.update_progress(10.0, Some("Starting GPodder subscription refresh...".to_string())).await?; + + let success = db_pool.refresh_gpodder_subscription_background(user_id).await + .map_err(|e| AppError::internal(&format!("GPodder sync failed: {}", e)))?; + + if success { + reporter.update_progress(100.0, Some("GPodder subscription refresh completed successfully".to_string())).await?; + Ok(serde_json::json!({"status": "GPodder subscription refresh completed successfully"})) + } else { + reporter.update_progress(100.0, Some("GPodder subscription refresh completed with no changes".to_string())).await?; + Ok(serde_json::json!({"status": "No sync performed"})) + } + }, + ).await?; + } else { + // Disable gpodder sync - call function that matches Python disable_gpodder_internal_sync + if !state.db_pool.disable_gpodder_internal_sync(user_id).await? { + return Err(AppError::internal("Failed to disable gpodder sync")); + } + } + + // Get updated status after changes + let updated_status = state.db_pool.gpodder_get_status(user_id).await?; + let new_sync_type = &updated_status.sync_type; + + let mut response = serde_json::json!({ + "sync_type": new_sync_type, + "gpodder_enabled": new_sync_type == "gpodder" || new_sync_type == "both", + "external_enabled": new_sync_type == "external" || new_sync_type == "both", + "external_url": if new_sync_type == "external" || new_sync_type == "both" { + updated_status.gpodder_url + } else { + None:: + }, + "api_url": if new_sync_type == "gpodder" || new_sync_type == "both" { + Some("http://localhost:8042") + } else { + None + } + }); + + // Add device information if available (matches Python logic) + if let Some(device_data) = device_info { + if request.enabled { + if let Some(device_name) = device_data.get("device_name") { + response["device_name"] = device_name.clone(); + } + if let Some(device_id) = device_data.get("device_id") { + response["device_id"] = device_id.clone(); + } + } + } + + Ok(Json(response)) +} + +// gPodder test connection - matches Python test connection functionality +pub async fn gpodder_test_connection( + State(state): State, + Query(params): Query>, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = params.get("user_id") + .ok_or_else(|| AppError::bad_request("Missing user_id parameter"))? + .parse::() + .map_err(|_| AppError::bad_request("Invalid user_id format"))?; + + let gpodder_url = params.get("gpodder_url") + .ok_or_else(|| AppError::bad_request("Missing gpodder_url parameter"))?; + let gpodder_username = params.get("gpodder_username") + .ok_or_else(|| AppError::bad_request("Missing gpodder_username parameter"))?; + let gpodder_password = params.get("gpodder_password") + .ok_or_else(|| AppError::bad_request("Missing gpodder_password parameter"))?; + + // Check authorization + let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if user_id != user_id_from_api_key && !is_web_key { + return Err(AppError::forbidden("You can only test connections for yourself!")); + } + + // Direct HTTP call to match Python implementation exactly + let client = reqwest::Client::new(); + let auth_url = format!("{}/api/2/auth/{}/login.json", + gpodder_url.trim_end_matches('/'), + gpodder_username); + + let verified = match client + .post(&auth_url) + .basic_auth(gpodder_username, Some(gpodder_password)) + .send() + .await + { + Ok(response) => response.status().is_success(), + Err(_) => false, + }; + + if verified { + Ok(Json(serde_json::json!({ + "success": true, + "message": "Successfully connected to GPodder server and verified access.", + "data": { + "auth_type": "session", + "has_devices": true + } + }))) + } else { + Ok(Json(serde_json::json!({ + "success": false, + "message": "Failed to connect to GPodder server", + "data": null + }))) + } +} + +// Get default gPodder device - matches Python get_default_device function exactly +pub async fn gpodder_get_default_device( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let default_device = state.db_pool.gpodder_get_default_device(user_id).await?; + + Ok(Json(serde_json::json!(default_device))) +} + +// Create gPodder device - matches Python create_device function exactly +#[derive(serde::Deserialize)] +pub struct CreateDeviceRequest { + pub device_name: String, + pub device_type: String, + pub device_caption: Option, +} + +pub async fn gpodder_create_device( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Get user's GPodder sync settings + let settings = state.db_pool.get_user_sync_settings(user_id).await? + .ok_or_else(|| AppError::BadRequest("User not found or GPodder sync not configured".to_string()))?; + + // Validate that GPodder sync is enabled + if settings.sync_type != "gpodder" && settings.sync_type != "both" && settings.sync_type != "external" { + return Err(AppError::BadRequest("GPodder sync is not enabled for this user".to_string())); + } + + // Create device via GPodder API (uses proper auth for internal/external) + let device_id = state.db_pool.create_device_via_gpodder_api( + &settings.url, + &settings.username, + &settings.token, + &request.device_name + ).await.map_err(|e| AppError::Internal(format!("Failed to create device via GPodder API: {}", e)))?; + + // Return GPodder API standard format + Ok(Json(serde_json::json!({ + "id": device_id, // GPodder device ID (string) + "name": request.device_name, + "type": request.device_type, + "caption": request.device_caption.unwrap_or_else(|| request.device_name.clone()), + "last_sync": Option::::None, + "is_active": true, + "is_remote": true, + "is_default": false + }))) +} + +// GPodder Statistics - real server-side stats from GPodder API +#[derive(Serialize)] +pub struct GpodderStatistics { + pub server_url: String, + pub sync_type: String, + pub sync_enabled: bool, + pub server_devices: Vec, + pub total_devices: i32, + pub server_subscriptions: Vec, + pub total_subscriptions: i32, + pub recent_episode_actions: Vec, + pub total_episode_actions: i32, + pub connection_status: String, + pub last_sync_timestamp: Option, + pub api_endpoints_tested: Vec, +} + +#[derive(Serialize, Clone)] +pub struct ServerDevice { + pub id: String, + pub caption: String, + pub device_type: String, + pub subscriptions: i32, +} + +#[derive(Serialize, Clone)] +pub struct ServerSubscription { + pub url: String, + pub title: Option, + pub description: Option, +} + +#[derive(Serialize, Clone)] +pub struct ServerEpisodeAction { + pub podcast: String, + pub episode: String, + pub action: String, + pub timestamp: String, + pub position: Option, + pub device: Option, +} + +#[derive(Serialize)] +pub struct EndpointTest { + pub endpoint: String, + pub status: String, // "success", "failed", "not_tested" + pub response_time_ms: Option, + pub error: Option, +} + +pub async fn gpodder_get_statistics( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + + // Check if GPodder is enabled for this user + let gpodder_status = state.db_pool.gpodder_get_status(user_id).await?; + + if gpodder_status.sync_type == "None" { + return Ok(Json(GpodderStatistics { + server_url: "No sync configured".to_string(), + sync_type: "None".to_string(), + sync_enabled: false, + server_devices: vec![], + total_devices: 0, + server_subscriptions: vec![], + total_subscriptions: 0, + recent_episode_actions: vec![], + total_episode_actions: 0, + connection_status: "Not configured".to_string(), + last_sync_timestamp: None, + api_endpoints_tested: vec![], + })); + } + + // Get real statistics from GPodder server + let statistics = state.db_pool.get_gpodder_server_statistics(user_id).await?; + + Ok(Json(statistics)) +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/tasks.rs b/PinePods-0.8.2/rust-api/src/handlers/tasks.rs new file mode 100644 index 0000000..e303bbc --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/tasks.rs @@ -0,0 +1,397 @@ +use axum::{ + extract::State, + http::HeaderMap, + response::Json, +}; +use serde::Deserialize; +use serde_json; + +use crate::{ + error::{AppError, AppResult}, + handlers::{extract_api_key, validate_api_key}, + AppState, +}; + +#[derive(Deserialize)] +pub struct InitRequest { + pub api_key: String, +} + +// Startup tasks endpoint - matches Python startup_tasks function exactly +pub async fn startup_tasks( + State(state): State, + Json(request): Json, +) -> Result, AppError> { + // Verify if the API key is valid + let is_valid = validate_api_key(&state, &request.api_key).await?; + if !is_valid { + return Err(AppError::forbidden("Invalid or unauthorized API key")); + } + + // Check if the provided API key is from the background_tasks user (UserID 1) + let api_user_id = state.db_pool.get_user_id_from_api_key(&request.api_key).await?; + if api_user_id != 1 { + return Err(AppError::forbidden("Invalid or unauthorized API key")); + } + + // Execute the startup tasks + state.db_pool.add_news_feed_if_not_added().await?; + + // Create default playlists for any users that might be missing them + state.db_pool.create_missing_default_playlists().await?; + + Ok(Json(serde_json::json!({"status": "Startup tasks completed successfully."}))) +} + +// Cleanup tasks endpoint - matches Python cleanup_tasks function exactly +pub async fn cleanup_tasks( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify if the API key is valid and is web key (admin only) + let is_valid = validate_api_key(&state, &api_key).await?; + if !is_valid { + return Err(AppError::forbidden("Invalid API key")); + } + + let api_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if api_user_id != 1 { + return Err(AppError::forbidden("Admin access required")); + } + + // Run cleanup tasks in background + let db_pool = state.db_pool.clone(); + let task_id = state.task_spawner.spawn_progress_task( + "cleanup_tasks".to_string(), + 0, // System user + move |reporter| async move { + reporter.update_progress(50.0, Some("Running cleanup tasks...".to_string())).await?; + + db_pool.cleanup_old_episodes().await + .map_err(|e| AppError::internal(&format!("Cleanup failed: {}", e)))?; + + reporter.update_progress(100.0, Some("Cleanup completed successfully".to_string())).await?; + + Ok(serde_json::json!({"status": "Cleanup tasks completed successfully"})) + }, + ).await?; + + Ok(Json(serde_json::json!({ + "detail": "Cleanup tasks initiated.", + "task_id": task_id + }))) +} + +// Update playlists endpoint - matches Python update_playlists function exactly +pub async fn update_playlists( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify if the API key is valid and is web key (admin only) + let is_valid = validate_api_key(&state, &api_key).await?; + if !is_valid { + return Err(AppError::forbidden("Invalid API key")); + } + + let api_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if api_user_id != 1 { + return Err(AppError::forbidden("Admin access required")); + } + + // Run playlist update in background + let db_pool = state.db_pool.clone(); + let task_id = state.task_spawner.spawn_progress_task( + "update_playlists".to_string(), + 0, // System user + move |reporter| async move { + reporter.update_progress(50.0, Some("Updating all playlists...".to_string())).await?; + + db_pool.update_all_playlists().await + .map_err(|e| AppError::internal(&format!("Playlist update failed: {}", e)))?; + + reporter.update_progress(100.0, Some("Playlist update completed successfully".to_string())).await?; + + Ok(serde_json::json!({"status": "Playlist update completed successfully"})) + }, + ).await?; + + Ok(Json(serde_json::json!({ + "detail": "Playlist update initiated.", + "task_id": task_id + }))) +} + +// Refresh hosts endpoint - matches Python refresh_all_hosts function exactly +pub async fn refresh_hosts( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify it's the system API key (background_tasks user with UserID 1) + let is_valid = validate_api_key(&state, &api_key).await?; + if !is_valid { + return Err(AppError::forbidden("Invalid API key")); + } + + let api_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if api_user_id != 1 { + return Err(AppError::forbidden("This endpoint requires system API key")); + } + + // Run host refresh in background + let db_pool = state.db_pool.clone(); + let task_id = state.task_spawner.spawn_progress_task( + "refresh_hosts".to_string(), + 0, // System user + move |reporter| async move { + reporter.update_progress(10.0, Some("Getting all people/hosts...".to_string())).await?; + + let all_people = db_pool.get_all_people_for_refresh().await + .map_err(|e| AppError::internal(&format!("Failed to get people: {}", e)))?; + + tracing::info!("Found {} people/hosts to refresh", all_people.len()); + + let mut successful_refreshes = 0; + let mut failed_refreshes = 0; + + for (index, (person_id, person_name, user_id)) in all_people.iter().enumerate() { + let progress = 10.0 + (80.0 * (index as f64) / (all_people.len() as f64)); + reporter.update_progress(progress, Some(format!("Refreshing host: {} ({}/{})", person_name, index + 1, all_people.len()))).await?; + + tracing::info!("Starting refresh for host: {} (ID: {}, User: {})", person_name, person_id, user_id); + + match process_person_refresh(&db_pool, *person_id, person_name, *user_id).await { + Ok(_) => { + successful_refreshes += 1; + tracing::info!("Successfully refreshed host: {}", person_name); + } + Err(e) => { + failed_refreshes += 1; + tracing::error!("Failed to refresh host {}: {}", person_name, e); + } + } + } + + // After processing all people, trigger the regular podcast refresh + tracing::info!("Person subscription processed, initiating server refresh..."); + match trigger_podcast_refresh(&db_pool).await { + Ok(_) => { + tracing::info!("Server refresh completed successfully"); + } + Err(e) => { + tracing::error!("Error during server refresh: {}", e); + } + } + + tracing::info!("Host refresh completed: {}/{} successful, {} failed", + successful_refreshes, all_people.len(), failed_refreshes); + + reporter.update_progress(100.0, Some(format!( + "Host refresh completed: {}/{} successful", + successful_refreshes, all_people.len() + ))).await?; + + Ok(serde_json::json!({ + "success": true, + "hosts_refreshed": successful_refreshes, + "hosts_failed": failed_refreshes, + "total_hosts": all_people.len() + })) + }, + ).await?; + + Ok(Json(serde_json::json!({ + "detail": "Host refresh initiated.", + "task_id": task_id + }))) +} + +// Helper function to process individual person refresh - matches Python process_person_subscription +async fn process_person_refresh( + db_pool: &crate::database::DatabasePool, + person_id: i32, + person_name: &str, + user_id: i32, +) -> AppResult<()> { + tracing::info!("Processing person subscription for: {} (ID: {}, User: {})", person_name, person_id, user_id); + + // Get person details and refresh their content + match db_pool.process_person_subscription(user_id, person_id, person_name.to_string()).await { + Ok(_) => { + tracing::info!("Successfully processed person subscription for {}", person_name); + Ok(()) + } + Err(e) => { + tracing::error!("Error processing person subscription for {}: {}", person_name, e); + Err(e) + } + } +} + +// Helper function to trigger podcast refresh after person processing - matches Python refresh_pods_task +async fn trigger_podcast_refresh(db_pool: &crate::database::DatabasePool) -> AppResult<()> { + // Get all users with podcasts and refresh them + let all_users = db_pool.get_all_users_with_podcasts().await?; + + for user_id in all_users { + match refresh_user_podcasts(db_pool, user_id).await { + Ok((podcast_count, episode_count)) => { + tracing::info!("Successfully refreshed user {}: {} podcasts, {} new episodes", + user_id, podcast_count, episode_count); + } + Err(e) => { + tracing::error!("Failed to refresh user {}: {}", user_id, e); + } + } + } + + Ok(()) +} + +// Helper function to refresh podcasts for a single user +async fn refresh_user_podcasts(db_pool: &crate::database::DatabasePool, user_id: i32) -> AppResult<(i32, i32)> { + let podcasts = db_pool.get_user_podcasts_for_refresh(user_id).await?; + let mut successful_podcasts = 0; + let mut total_new_episodes = 0; + + for podcast in podcasts { + match refresh_single_podcast(db_pool, &podcast).await { + Ok(new_episode_count) => { + successful_podcasts += 1; + total_new_episodes += new_episode_count; + tracing::info!("Refreshed podcast '{}': {} new episodes", podcast.name, new_episode_count); + } + Err(e) => { + tracing::error!("Failed to refresh podcast '{}': {}", podcast.name, e); + } + } + } + + Ok((successful_podcasts, total_new_episodes)) +} + +// Auto-complete episodes based on user settings - nightly task +pub async fn auto_complete_episodes( + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + // Verify if the API key is valid + let is_valid = validate_api_key(&state, &api_key).await?; + if !is_valid { + return Err(AppError::forbidden("Invalid or unauthorized API key")); + } + + // Check if the provided API key is from the background_tasks user (UserID 1) + let api_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if api_user_id != 1 { + return Err(AppError::forbidden("Invalid or unauthorized API key")); + } + + // Get all users who have auto_complete_seconds > 0 + let users_with_auto_complete = state.db_pool.get_users_with_auto_complete_enabled().await?; + let mut total_completed = 0; + + for user in users_with_auto_complete { + let completed_count = state.db_pool.auto_complete_user_episodes(user.user_id, user.auto_complete_seconds).await.unwrap_or(0); + total_completed += completed_count; + } + + Ok(Json(serde_json::json!({ + "status": "Auto-complete task completed successfully", + "episodes_completed": total_completed + }))) +} + +// Helper function to refresh a single podcast +async fn refresh_single_podcast( + _db_pool: &crate::database::DatabasePool, + podcast: &crate::handlers::refresh::PodcastForRefresh, +) -> AppResult { + tracing::info!("Refreshing podcast: {} (ID: {})", podcast.name, podcast.id); + // This would normally refresh the podcast feed and return new episode count + // For now return 0 as placeholder since we need the podcast refresh system to be implemented + Ok(0) +} + +// Internal functions for scheduler (no HTTP context needed) +pub async fn cleanup_tasks_internal(state: &AppState) -> AppResult<()> { + tracing::info!("Starting internal cleanup tasks (scheduler)"); + + state.db_pool.cleanup_old_episodes().await?; + tracing::info!("Cleanup tasks completed successfully"); + + Ok(()) +} + +pub async fn update_playlists_internal(state: &AppState) -> AppResult<()> { + tracing::info!("Starting internal playlist update (scheduler)"); + + state.db_pool.update_all_playlists().await?; + tracing::info!("Playlist update completed successfully"); + + Ok(()) +} + +pub async fn refresh_hosts_internal(state: &AppState) -> AppResult<()> { + tracing::info!("Starting internal host refresh (scheduler)"); + + let all_people = state.db_pool.get_all_people_for_refresh().await?; + tracing::info!("Found {} people/hosts to refresh", all_people.len()); + + let mut successful_refreshes = 0; + let mut failed_refreshes = 0; + + for (person_id, person_name, user_id) in all_people.iter() { + tracing::info!("Starting refresh for host: {} (ID: {}, User: {})", person_name, person_id, user_id); + + match process_person_refresh(&state.db_pool, *person_id, person_name, *user_id).await { + Ok(_) => { + successful_refreshes += 1; + tracing::info!("Successfully refreshed host: {}", person_name); + } + Err(e) => { + failed_refreshes += 1; + tracing::error!("Failed to refresh host {}: {}", person_name, e); + } + } + } + + // After processing all people, trigger the regular podcast refresh + tracing::info!("Person subscription processed, initiating server refresh..."); + match trigger_podcast_refresh(&state.db_pool).await { + Ok(_) => { + tracing::info!("Server refresh completed successfully"); + } + Err(e) => { + tracing::error!("Error during server refresh: {}", e); + } + } + + tracing::info!("Host refresh completed: {}/{} successful, {} failed", + successful_refreshes, all_people.len(), failed_refreshes); + + Ok(()) +} + +pub async fn auto_complete_episodes_internal(state: &AppState) -> AppResult<()> { + tracing::info!("Starting internal auto-complete episodes (scheduler)"); + + // Get all users who have auto_complete_seconds > 0 + let users_with_auto_complete = state.db_pool.get_users_with_auto_complete_enabled().await?; + let mut total_completed = 0; + + for user in users_with_auto_complete { + let completed_count = state.db_pool.auto_complete_user_episodes(user.user_id, user.auto_complete_seconds).await.unwrap_or(0); + total_completed += completed_count; + } + + tracing::info!("Auto-complete task completed: {} episodes completed", total_completed); + Ok(()) +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/websocket.rs b/PinePods-0.8.2/rust-api/src/handlers/websocket.rs new file mode 100644 index 0000000..dd12075 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/websocket.rs @@ -0,0 +1,221 @@ +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Path, Query, State, + }, + response::Response, +}; +use futures::{sink::SinkExt, stream::StreamExt}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::{broadcast, RwLock}; +use crate::{ + services::task_manager::{TaskUpdate, WebSocketMessage}, + AppState, +}; + +type UserConnections = Arc>>>>; + +pub struct WebSocketManager { + connections: UserConnections, +} + +impl WebSocketManager { + pub fn new() -> Self { + Self { + connections: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn add_connection(&self, user_id: i32, sender: broadcast::Sender) { + let mut connections = self.connections.write().await; + connections.entry(user_id).or_insert_with(Vec::new).push(sender); + } + + pub async fn remove_connection(&self, user_id: i32, sender: &broadcast::Sender) { + let mut connections = self.connections.write().await; + if let Some(user_connections) = connections.get_mut(&user_id) { + user_connections.retain(|s| !s.same_channel(sender)); + if user_connections.is_empty() { + connections.remove(&user_id); + } + } + } + + pub async fn broadcast_to_user(&self, user_id: i32, update: TaskUpdate) { + let connections = self.connections.read().await; + if let Some(user_connections) = connections.get(&user_id) { + for sender in user_connections { + let _ = sender.send(update.clone()); + } + } + } +} + +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct WebSocketQuery { + api_key: String, +} + +pub async fn task_progress_websocket( + ws: WebSocketUpgrade, + Path(user_id): Path, + Query(query): Query, + State(state): State, +) -> Response { + // Validate API key before upgrading websocket + match state.db_pool.verify_api_key(&query.api_key).await { + Ok(true) => { + // Verify the API key belongs to this user (or system user for background tasks) + match state.db_pool.get_user_id_from_api_key(&query.api_key).await { + Ok(key_user_id) => { + // Allow access if API key matches the user or if it's the system user (ID 1) + if key_user_id == user_id || key_user_id == 1 { + ws.on_upgrade(move |socket| handle_task_progress_socket(socket, user_id, state)) + } else { + tracing::warn!("WebSocket auth failed: API key user {} tried to access user {} tasks", key_user_id, user_id); + axum::response::Response::builder() + .status(403) + .body("Unauthorized - API key does not belong to requested user".into()) + .unwrap() + } + } + Err(e) => { + tracing::error!("WebSocket auth error getting user ID from API key: {}", e); + axum::response::Response::builder() + .status(403) + .body("Invalid API key".into()) + .unwrap() + } + } + } + Ok(false) | Err(_) => { + tracing::warn!("WebSocket auth failed: Invalid API key"); + axum::response::Response::builder() + .status(403) + .body("Invalid API key".into()) + .unwrap() + } + } +} + +async fn handle_task_progress_socket(socket: WebSocket, user_id: i32, state: AppState) { + let (mut sender, mut receiver) = socket.split(); + let (tx, mut rx) = broadcast::channel::(100); + + // Add connection to manager + state.websocket_manager.add_connection(user_id, tx.clone()).await; + + // Subscribe to task manager updates + let mut task_receiver = state.task_manager.subscribe_to_progress(); + + // Spawn task to forward task manager updates to user + let tx_clone = tx.clone(); + let forward_task = tokio::spawn(async move { + while let Ok(update) = task_receiver.recv().await { + if update.user_id == user_id { + let _ = tx_clone.send(update); + } + } + }); + + // Send initial task list to newly connected client + let initial_tasks = state.task_manager.get_user_tasks(user_id).await.unwrap_or_default(); + let initial_message = WebSocketMessage { + event: "initial".to_string(), + task: None, + tasks: Some(initial_tasks), + }; + let initial_json = match serde_json::to_string(&initial_message) { + Ok(json) => json, + Err(_) => "{}".to_string(), + }; + let _ = sender.send(Message::Text(initial_json.into())).await; + + // Spawn task to send WebSocket messages + let websocket_task = tokio::spawn(async move { + while let Ok(update) = rx.recv().await { + // Wrap the update in the WebSocket event format + let ws_message = WebSocketMessage { + event: "update".to_string(), + task: Some(update), + tasks: None, + }; + + let message = match serde_json::to_string(&ws_message) { + Ok(json) => Message::Text(json.into()), + Err(_) => continue, + }; + + if sender.send(message).await.is_err() { + break; + } + } + }); + + // Handle incoming WebSocket messages (if any) + let ping_task = tokio::spawn(async move { + while let Some(msg) = receiver.next().await { + match msg { + Ok(Message::Text(text)) => { + // Handle ping/pong or other control messages + if text == "ping" { + // Connection is alive, no action needed + } + } + Ok(Message::Close(_)) => break, + Err(_) => break, + _ => {} + } + } + }); + + // Wait for any task to complete + tokio::select! { + _ = forward_task => {}, + _ = websocket_task => {}, + _ = ping_task => {}, + } + + // Clean up connection + state.websocket_manager.remove_connection(user_id, &tx).await; +} + +pub async fn get_user_tasks( + Path(user_id): Path, + State(state): State, +) -> Result>, crate::error::AppError> { + let tasks = state.task_manager.get_user_tasks(user_id).await?; + Ok(axum::Json(tasks)) +} + +pub async fn get_task_status( + Path(task_id): Path, + State(state): State, +) -> Result, crate::error::AppError> { + let task = state.task_manager.get_task(&task_id).await?; + Ok(axum::Json(task)) +} + +pub async fn get_active_tasks( + Query(params): Query>, + State(state): State, +) -> Result>, crate::error::AppError> { + // Get user_id from query parameter + let user_id: Option = params.get("user_id") + .and_then(|id| id.parse().ok()); + + if let Some(user_id) = user_id { + // Get active tasks for specific user + let tasks = state.task_manager.get_user_tasks(user_id).await?; + // Filter only active tasks (status = Running or Pending) + let active_tasks: Vec<_> = tasks.into_iter() + .filter(|task| matches!(task.status, crate::services::task_manager::TaskStatus::Pending | crate::services::task_manager::TaskStatus::Running)) + .collect(); + Ok(axum::Json(active_tasks)) + } else { + // Return empty if no user_id provided + Ok(axum::Json(vec![])) + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/handlers/youtube.rs b/PinePods-0.8.2/rust-api/src/handlers/youtube.rs new file mode 100644 index 0000000..861a6a1 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/handlers/youtube.rs @@ -0,0 +1,619 @@ +use axum::{ + extract::{Query, State}, + http::HeaderMap, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use tokio::process::Command; +use std::collections::{HashMap, HashSet}; + +use crate::{ + error::AppError, + handlers::{extract_api_key, validate_api_key}, + AppState, +}; + +// Query struct for YouTube channel search +#[derive(Deserialize)] +pub struct YouTubeSearchQuery { + pub query: String, + pub max_results: Option, + pub user_id: i32, +} + +// YouTube channel struct for search results - matches Python response exactly +#[derive(Serialize, Debug)] +pub struct YouTubeChannel { + pub channel_id: String, + pub name: String, + pub description: String, + pub subscriber_count: Option, + pub url: String, + pub video_count: Option, + pub thumbnail_url: String, + pub recent_videos: Vec, +} + +// YouTube video struct for recent videos in channel - matches Python response exactly +#[derive(Serialize, Debug, Clone)] +pub struct YouTubeVideo { + pub id: String, + pub title: String, + pub duration: Option, // Note: Python uses float, not i64 + pub url: String, +} + +// Request struct for YouTube channel subscription +#[derive(Deserialize)] +pub struct YouTubeSubscribeRequest { + pub channel_id: String, + pub user_id: i32, + pub feed_cutoff: Option, +} + +// Query struct for YouTube subscription endpoint +#[derive(Deserialize)] +pub struct YouTubeSubscribeQuery { + pub channel_id: String, + pub user_id: i32, + pub feed_cutoff: Option, +} + +// Query struct for check YouTube channel endpoint +#[derive(Deserialize)] +pub struct CheckYouTubeChannelQuery { + pub user_id: i32, + pub channel_name: String, + pub channel_url: String, +} + +// Search YouTube channels - matches Python search_youtube_channels function exactly +pub async fn search_youtube_channels( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only search for themselves + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != query.user_id && !is_web_key { + return Err(AppError::forbidden("You can only search with your own account.")); + } + + let max_results = query.max_results.unwrap_or(5); + + // First get channel ID using a search - matches Python exactly + let search_url = format!("ytsearch{}:{}", max_results * 4, query.query); + + println!("Searching YouTube with query: {}", query.query); + + // Use yt-dlp binary to search + let output = Command::new("yt-dlp") + .args(&[ + "--quiet", + "--no-warnings", + "--flat-playlist", + "--skip-download", + "--dump-json", + &search_url + ]) + .output() + .await + .map_err(|e| AppError::external_error(&format!("Failed to execute yt-dlp: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppError::external_error(&format!("yt-dlp search failed: {}", stderr))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse each line as a separate JSON object (yt-dlp outputs one JSON per line for search results) + let mut entries = Vec::new(); + for line in stdout.lines() { + if let Ok(entry) = serde_json::from_str::(line) { + entries.push(entry); + } + } + + if entries.is_empty() { + return Ok(Json(serde_json::json!({"results": []}))); + } + + let mut processed_results = Vec::new(); + let mut seen_channels = HashSet::new(); + let mut channel_videos: HashMap> = HashMap::new(); + + // Process entries to collect videos by channel - matches Python logic exactly + for entry in &entries { + if let Some(channel_id) = entry.get("channel_id").and_then(|v| v.as_str()) + .or_else(|| entry.get("uploader_id").and_then(|v| v.as_str())) { + + // First collect the video regardless of whether we've seen the channel + if !channel_videos.contains_key(channel_id) { + channel_videos.insert(channel_id.to_string(), Vec::new()); + } + + if let Some(videos) = channel_videos.get_mut(channel_id) { + if videos.len() < 3 { // Limit to 3 videos like Python + if let Some(video_id) = entry.get("id").and_then(|v| v.as_str()) { + let video = YouTubeVideo { + id: video_id.to_string(), + title: entry.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(), + duration: entry.get("duration").and_then(|v| v.as_f64()), + url: format!("https://www.youtube.com/watch?v={}", video_id), + }; + videos.push(video); + println!("Added video to channel {}, now has {} videos", channel_id, videos.len()); + } + } + } + } + } + + // Now process channels - matches Python logic exactly + for entry in &entries { + if let Some(channel_id) = entry.get("channel_id").and_then(|v| v.as_str()) + .or_else(|| entry.get("uploader_id").and_then(|v| v.as_str())) { + + // Check if we've already processed this channel + if seen_channels.contains(channel_id) { + continue; + } + seen_channels.insert(channel_id.to_string()); + + // Get minimal channel info + let channel_url = format!("https://www.youtube.com/channel/{}", channel_id); + + // Get thumbnail from search result - much faster than individual channel lookups + let thumbnail_url = entry.get("channel_thumbnail").and_then(|v| v.as_str()) + .or_else(|| entry.get("thumbnail").and_then(|v| v.as_str())) + .unwrap_or("").to_string(); + + let channel_name = entry.get("channel").and_then(|v| v.as_str()) + .or_else(|| entry.get("uploader").and_then(|v| v.as_str())) + .unwrap_or("").to_string(); + + println!("Creating channel {} with {} videos", channel_id, + channel_videos.get(channel_id).map(|v| v.len()).unwrap_or(0)); + + let channel = YouTubeChannel { + channel_id: channel_id.to_string(), + name: channel_name, + description: entry.get("description").and_then(|v| v.as_str()) + .unwrap_or("").chars().take(500).collect::(), + subscriber_count: None, // Always null like Python + url: channel_url, + video_count: None, // Always null like Python + thumbnail_url, + recent_videos: channel_videos.get(channel_id).cloned().unwrap_or_default(), + }; + + if processed_results.len() < max_results as usize { + processed_results.push(channel); + } else { + break; + } + } + } + + println!("Found {} channels", processed_results.len()); + Ok(Json(serde_json::json!({"results": processed_results}))) +} + + +// Subscribe to YouTube channel - matches Python subscribe_to_youtube_channel function exactly +pub async fn subscribe_to_youtube_channel( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only subscribe for themselves + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != query.user_id && !is_web_key { + return Err(AppError::forbidden("You can only subscribe for yourself!")); + } + + let feed_cutoff = query.feed_cutoff.unwrap_or(30); + + println!("Starting subscription for channel {}", query.channel_id); + + // Check if channel already exists + let existing_id = state.db_pool.check_existing_channel_subscription( + &query.channel_id, + query.user_id, + ).await?; + + if let Some(podcast_id) = existing_id { + println!("Channel {} already subscribed", query.channel_id); + return Ok(Json(serde_json::json!({ + "success": true, + "podcast_id": podcast_id, + "message": "Already subscribed to this channel" + }))); + } + + println!("Getting channel info"); + let channel_info = get_youtube_channel_info(&query.channel_id).await?; + + println!("Adding channel to database"); + let podcast_id = state.db_pool.add_youtube_channel( + &channel_info, + query.user_id, + feed_cutoff, + ).await?; + + // Spawn background task to process YouTube videos + let state_clone = state.clone(); + let channel_id_clone = query.channel_id.clone(); + tokio::spawn(async move { + if let Err(e) = process_youtube_channel(podcast_id, &channel_id_clone, feed_cutoff, &state_clone).await { + println!("Error processing YouTube channel {}: {}", channel_id_clone, e); + } + }); + + Ok(Json(serde_json::json!({ + "success": true, + "podcast_id": podcast_id, + "message": "Successfully subscribed to YouTube channel" + }))) +} + +// Helper function to get YouTube channel info using Backend service +pub async fn get_youtube_channel_info(channel_id: &str) -> Result, AppError> { + println!("Getting channel info for {} from Backend service", channel_id); + + // Get Backend URL from environment variable + let search_api_url = std::env::var("SEARCH_API_URL") + .map_err(|_| AppError::external_error("SEARCH_API_URL environment variable not set"))?; + + // Replace /api/search with /api/youtube/channel for the channel details endpoint + let backend_url = search_api_url.replace("/api/search", &format!("/api/youtube/channel?id={}", channel_id)); + + let client = reqwest::Client::new(); + let response = client.get(&backend_url) + .send() + .await + .map_err(|e| AppError::external_error(&format!("Failed to call Backend service: {}", e)))?; + + if !response.status().is_success() { + return Err(AppError::external_error(&format!("Backend service error: {}", response.status()))); + } + + let channel_data: serde_json::Value = response.json() + .await + .map_err(|e| AppError::external_error(&format!("Failed to parse Backend response: {}", e)))?; + + // Extract channel info from Backend service response + let mut channel_info = HashMap::new(); + + channel_info.insert("channel_id".to_string(), channel_id.to_string()); + channel_info.insert("name".to_string(), + channel_data.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string()); + + let description = channel_data.get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .chars() + .take(500) + .collect::(); + channel_info.insert("description".to_string(), description); + + channel_info.insert("thumbnail_url".to_string(), + channel_data.get("thumbnailUrl").and_then(|v| v.as_str()).unwrap_or("").to_string()); + + println!("Successfully extracted channel info for: {}", channel_info.get("name").unwrap_or(&"Unknown".to_string())); + Ok(channel_info) +} + +// Helper function to get MP3 duration from file +pub fn get_mp3_duration(file_path: &str) -> Option { + match mp3_metadata::read_from_file(file_path) { + Ok(metadata) => Some(metadata.duration.as_secs() as i32), + Err(e) => { + println!("Failed to read MP3 metadata from {}: {}", file_path, e); + None + } + } +} + +// Helper function to parse YouTube duration format (PT4M13S) to seconds +pub fn parse_youtube_duration(duration_str: &str) -> Option { + if !duration_str.starts_with("PT") { + return None; + } + + let duration_part = &duration_str[2..]; // Remove "PT" + let mut total_seconds = 0i64; + let mut current_number = String::new(); + + for ch in duration_part.chars() { + if ch.is_ascii_digit() { + current_number.push(ch); + } else { + if let Ok(num) = current_number.parse::() { + match ch { + 'H' => total_seconds += num * 3600, + 'M' => total_seconds += num * 60, + 'S' => total_seconds += num, + _ => {} + } + } + current_number.clear(); + } + } + + Some(total_seconds) +} + +// Process YouTube channel videos using Backend service +pub async fn process_youtube_channel( + podcast_id: i32, + channel_id: &str, + feed_cutoff: i32, + state: &AppState, +) -> Result<(), AppError> { + println!("{}", "=".repeat(50)); + println!("Starting YouTube channel processing with Backend service"); + println!("Podcast ID: {}", podcast_id); + println!("Channel ID: {}", channel_id); + println!("{}", "=".repeat(50)); + + let cutoff_date = chrono::Utc::now() - chrono::Duration::days(feed_cutoff as i64); + println!("Cutoff date set to: {}", cutoff_date); + + // Clean up old videos + println!("Cleaning up videos older than cutoff date..."); + state.db_pool.remove_old_youtube_videos(podcast_id, cutoff_date).await?; + + // Get Backend URL from environment variable + let search_api_url = std::env::var("SEARCH_API_URL") + .map_err(|_| AppError::external_error("SEARCH_API_URL environment variable not set"))?; + + // Replace /api/search with /api/youtube/channel for the channel details endpoint + let backend_url = search_api_url.replace("/api/search", &format!("/api/youtube/channel?id={}", channel_id)); + println!("Fetching channel data from Backend service: {}", backend_url); + + // Get video list using Backend service + let client = reqwest::Client::new(); + let response = client.get(&backend_url) + .send() + .await + .map_err(|e| AppError::external_error(&format!("Failed to call Backend service: {}", e)))?; + + if !response.status().is_success() { + return Err(AppError::external_error(&format!("Backend service error: {}", response.status()))); + } + + let channel_data: serde_json::Value = response.json() + .await + .map_err(|e| AppError::external_error(&format!("Failed to parse Backend response: {}", e)))?; + + let empty_vec = vec![]; + let recent_videos_data = channel_data.get("recentVideos") + .and_then(|v| v.as_array()) + .unwrap_or(&empty_vec); + + println!("Found {} total videos from Backend service", recent_videos_data.len()); + + let mut recent_videos = Vec::new(); + + // Process each video from Backend service response + for video_entry in recent_videos_data { + let video_id = video_entry.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if video_id.is_empty() { + println!("Skipping video with missing ID"); + continue; + } + + println!("Processing video ID: {}", video_id); + + // Parse the publishedAt date from Backend service + let published_str = video_entry.get("publishedAt").and_then(|v| v.as_str()).unwrap_or(""); + let published = chrono::DateTime::parse_from_rfc3339(published_str) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| { + println!("Failed to parse date {}, using current time", published_str); + chrono::Utc::now() + }); + + println!("Video publish date: {}", published); + + if published <= cutoff_date { + println!("Video {} from {} is too old, stopping processing", video_id, published); + break; + } + + // Debug: print what we got from Backend for this video + println!("Backend video data for {}: {:?}", video_id, video_entry); + let duration_str = video_entry.get("duration").and_then(|v| v.as_str()).unwrap_or(""); + println!("Duration string from Backend: '{}'", duration_str); + let parsed_duration = if !duration_str.is_empty() { + parse_youtube_duration(duration_str).unwrap_or(0) + } else { + 0 + }; + println!("Parsed duration: {}", parsed_duration); + + let video_data = serde_json::json!({ + "id": video_id, + "title": video_entry.get("title").and_then(|v| v.as_str()).unwrap_or(""), + "description": video_entry.get("description").and_then(|v| v.as_str()).unwrap_or(""), + "url": format!("https://www.youtube.com/watch?v={}", video_id), + "thumbnail": video_entry.get("thumbnail").and_then(|v| v.as_str()).unwrap_or(""), + "publish_date": published.to_rfc3339(), + "duration": duration_str // Store as string for proper parsing in database + }); + + println!("Successfully added video {} to processing queue", video_id); + recent_videos.push(video_data); + } + + println!("Processing complete - Found {} recent videos", recent_videos.len()); + + if !recent_videos.is_empty() { + println!("Starting database updates"); + + // Get existing videos + let existing_videos = state.db_pool.get_existing_youtube_videos(podcast_id).await?; + + // Filter out videos that already exist + let mut new_videos = Vec::new(); + for video in &recent_videos { + let video_url = format!("https://www.youtube.com/watch?v={}", + video.get("id").and_then(|v| v.as_str()).unwrap_or("")); + if !existing_videos.contains(&video_url) { + new_videos.push(video.clone()); + } else { + println!("Video already exists, skipping: {}", + video.get("title").and_then(|v| v.as_str()).unwrap_or("")); + } + } + + if !new_videos.is_empty() { + state.db_pool.add_youtube_videos(podcast_id, &new_videos).await?; + println!("Successfully added {} new videos", new_videos.len()); + } else { + println!("No new videos to add"); + } + + // Download audio for recent videos + println!("Starting audio downloads"); + let mut successful_downloads = 0; + let mut failed_downloads = 0; + + for video in &recent_videos { + let video_id = video.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let title = video.get("title").and_then(|v| v.as_str()).unwrap_or(""); + + let output_path = format!("/opt/pinepods/downloads/youtube/{}.mp3", video_id); + let output_path_double = format!("{}.mp3", output_path); + + println!("Processing download for video: {}", video_id); + println!("Title: {}", title); + println!("Target path: {}", output_path); + + // Check if file already exists + if tokio::fs::metadata(&output_path).await.is_ok() || + tokio::fs::metadata(&output_path_double).await.is_ok() { + println!("Audio file already exists, skipping download"); + continue; + } + + println!("Starting download..."); + match download_youtube_audio(video_id, &output_path).await { + Ok(_) => { + println!("Download completed successfully"); + successful_downloads += 1; + + // Get duration from the downloaded MP3 file and update database + if let Some(duration) = get_mp3_duration(&output_path) { + if let Err(e) = state.db_pool.update_youtube_video_duration(video_id, duration).await { + println!("Failed to update duration for video {}: {}", video_id, e); + } else { + println!("Updated duration for video {} to {} seconds", video_id, duration); + } + } else { + println!("Could not read duration from MP3 file: {}", output_path); + } + } + Err(e) => { + failed_downloads += 1; + let error_msg = e.to_string(); + if error_msg.to_lowercase().contains("members-only") { + println!("Skipping video {} - Members-only content: {}", video_id, title); + } else if error_msg.to_lowercase().contains("private") { + println!("Skipping video {} - Private video: {}", video_id, title); + } else if error_msg.to_lowercase().contains("unavailable") { + println!("Skipping video {} - Unavailable video: {}", video_id, title); + } else { + println!("Failed to download video {}: {}", video_id, title); + println!("Error: {}", error_msg); + } + } + } + } + + println!("Download summary: {} successful, {} failed", successful_downloads, failed_downloads); + } else { + println!("No new videos to process"); + } + + // Update episode count + state.db_pool.update_episode_count(podcast_id).await?; + + println!("{}", "=".repeat(50)); + println!("Channel processing complete"); + println!("{}", "=".repeat(50)); + + Ok(()) +} + + +// Download YouTube audio using yt-dlp binary +pub async fn download_youtube_audio(video_id: &str, output_path: &str) -> Result<(), AppError> { + // Remove .mp3 extension if present to prevent double extension + let base_path = if output_path.ends_with(".mp3") { + &output_path[..output_path.len() - 4] + } else { + output_path + }; + + let video_url = format!("https://www.youtube.com/watch?v={}", video_id); + + let output = Command::new("yt-dlp") + .args(&[ + "--format", "bestaudio/best", + "--extract-audio", + "--audio-format", "mp3", + "--output", base_path, + "--ignore-errors", + "--socket-timeout", "30", + &video_url + ]) + .output() + .await + .map_err(|e| AppError::external_error(&format!("Failed to execute yt-dlp: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppError::external_error(&format!("yt-dlp download failed: {}", stderr))); + } + + Ok(()) +} + +// Check if YouTube channel exists - matches Python api_check_youtube_channel function exactly +pub async fn check_youtube_channel( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only check for themselves + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != query.user_id && !is_web_key { + return Err(AppError::forbidden("You can only check channels for yourself!")); + } + + let exists = state.db_pool.check_youtube_channel( + query.user_id, + &query.channel_name, + &query.channel_url, + ).await?; + + Ok(Json(serde_json::json!({ "exists": exists }))) +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/main.rs b/PinePods-0.8.2/rust-api/src/main.rs new file mode 100644 index 0000000..b458cb3 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/main.rs @@ -0,0 +1,460 @@ +use axum::{ + routing::{delete, get, post, put}, + Router, +}; +use std::net::SocketAddr; +use tokio::signal; +use tower::ServiceBuilder; +use tower_http::{ + trace::TraceLayer, + compression::CompressionLayer, +}; +use tracing::{info, warn, error}; + +mod config; +mod database; +mod error; +mod handlers; +mod models; +mod redis_client; +mod redis_manager; +mod services; + +use config::Config; +use database::DatabasePool; +use error::AppResult; +use redis_client::RedisClient; +use services::{scheduler::BackgroundScheduler, task_manager::TaskManager, tasks::TaskSpawner}; +use handlers::websocket::WebSocketManager; +use redis_manager::{ImportProgressManager, NotificationManager}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub db_pool: DatabasePool, + pub redis_client: RedisClient, + pub config: Config, + pub task_manager: Arc, + pub task_spawner: Arc, + pub websocket_manager: Arc, + pub import_progress_manager: Arc, + pub notification_manager: Arc, +} + +#[tokio::main] +async fn main() -> AppResult<()> { + // Initialize tracing with explicit level if RUST_LOG is not set + let env_filter = if std::env::var("RUST_LOG").is_ok() { + tracing_subscriber::EnvFilter::from_default_env() + } else { + tracing_subscriber::EnvFilter::new("info") + }; + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .init(); + + println!("🚀 Starting PinePods Rust API..."); + info!("Starting PinePods Rust API"); + + // Load configuration + let config = Config::new()?; + info!("Configuration loaded"); + info!("Database config: host={}, port={}, user={}, db={}, type={}", + config.database.host, config.database.port, config.database.username, + config.database.name, config.database.db_type); + + // Initialize database pool + let db_pool = DatabasePool::new(&config).await?; + info!("Database pool initialized"); + + // Initialize Redis client + let redis_client = RedisClient::new(&config).await?; + info!("Redis/Valkey client initialized"); + + // Initialize task management + let task_manager = Arc::new(TaskManager::new(redis_client.clone())); + let task_spawner = Arc::new(TaskSpawner::new(task_manager.clone(), db_pool.clone())); + let websocket_manager = Arc::new(WebSocketManager::new()); + let import_progress_manager = Arc::new(ImportProgressManager::new(redis_client.clone())); + let notification_manager = Arc::new(NotificationManager::new(redis_client.clone())); + info!("Task management system initialized"); + + // Create shared application state + let app_state = AppState { + db_pool, + redis_client, + config: config.clone(), + task_manager, + task_spawner, + websocket_manager, + import_progress_manager, + notification_manager, + }; + + // Build the application with routes + let app = create_app(app_state.clone()); + + // Initialize and start background scheduler + info!("🕒 Initializing background task scheduler..."); + let scheduler = BackgroundScheduler::new().await?; + let scheduler_state = Arc::new(app_state.clone()); + + // Start the scheduler with background tasks + scheduler.start(scheduler_state.clone()).await?; + + // Run initial startup tasks immediately + tokio::spawn({ + let startup_state = scheduler_state.clone(); + async move { + if let Err(e) = BackgroundScheduler::run_startup_tasks(startup_state).await { + error!("❌ Startup tasks failed: {}", e); + } + } + }); + + // Determine the address to bind to + let addr = SocketAddr::from(([0, 0, 0, 0], config.server.port)); + println!("🌐 PinePods Rust API listening on http://{}", addr); + println!("📊 Health check available at: http://{}/api/health", addr); + println!("🔍 API check available at: http://{}/api/pinepods_check", addr); + info!("Server listening on {}", addr); + + // Start the server + let listener = tokio::net::TcpListener::bind(addr).await?; + println!("✅ PinePods Rust API server started successfully!"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +fn create_app(state: AppState) -> Router { + Router::new() + // Health check endpoints + .route("/api/pinepods_check", get(handlers::health::pinepods_check)) + .route("/api/health", get(handlers::health::health_check)) + + // API routes (to be implemented) + .nest("/api/data", create_data_routes()) + .nest("/api/init", create_init_routes()) + .nest("/api/podcasts", create_podcast_routes()) + .nest("/api/episodes", create_episode_routes()) + .nest("/api/playlists", create_playlist_routes()) + .nest("/api/tasks", create_task_routes()) + .nest("/api/async", create_async_routes()) + .nest("/api/proxy", create_proxy_routes()) + .nest("/api/gpodder", create_gpodder_routes()) + .nest("/api/feed", create_feed_routes()) + .nest("/api/auth", create_auth_routes()) + .nest("/ws", create_websocket_routes()) + + // Middleware stack + .layer( + ServiceBuilder::new() + .layer( + TraceLayer::new_for_http() + .make_span_with(tower_http::trace::DefaultMakeSpan::new() + .level(tracing::Level::INFO)) + .on_response(tower_http::trace::DefaultOnResponse::new() + .level(tracing::Level::INFO)) + ) + .layer(CompressionLayer::new()) + .layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024 * 1024)) // 2GB limit for massive backup files + ) + .with_state(state) +} + +fn create_data_routes() -> Router { + Router::new() + .route("/get_key", get(handlers::auth::get_key)) + .route("/verify_mfa_and_get_key", post(handlers::auth::verify_mfa_and_get_key)) + .route("/verify_key", get(handlers::auth::verify_api_key_endpoint)) + .route("/get_user", get(handlers::auth::get_user)) + .route("/user_details_id/{user_id}", get(handlers::auth::get_user_details_by_id)) + .route("/self_service_status", get(handlers::auth::get_self_service_status)) + .route("/public_oidc_providers", get(handlers::auth::get_public_oidc_providers)) + .route("/create_first", post(handlers::auth::create_first_admin)) + .route("/config", get(handlers::auth::get_config)) + .route("/first_login_done/{user_id}", get(handlers::auth::first_login_done)) + .route("/get_theme/{user_id}", get(handlers::auth::get_theme)) + .route("/setup_time_info", post(handlers::auth::setup_time_info)) + .route("/update_timezone", put(handlers::auth::update_timezone)) + .route("/update_date_format", put(handlers::auth::update_date_format)) + .route("/update_time_format", put(handlers::auth::update_time_format)) + .route("/get_auto_complete_seconds/{user_id}", get(handlers::auth::get_auto_complete_seconds)) + .route("/update_auto_complete_seconds", put(handlers::auth::update_auto_complete_seconds)) + .route("/user_admin_check/{user_id}", get(handlers::auth::user_admin_check)) + .route("/import_opml", post(handlers::auth::import_opml)) + .route("/import_progress/{user_id}", get(handlers::auth::import_progress)) + .route("/return_episodes/{user_id}", get(handlers::podcasts::return_episodes)) + .route("/user_history/{user_id}", get(handlers::podcasts::user_history)) + .route("/increment_listen_time/{user_id}", put(handlers::podcasts::increment_listen_time)) + .route("/get_playback_speed", post(handlers::podcasts::get_playback_speed)) + .route("/add_podcast", post(handlers::podcasts::add_podcast)) + .route("/update_podcast_info", put(handlers::podcasts::update_podcast_info)) + .route("/{podcast_id}/merge", post(handlers::podcasts::merge_podcasts)) + .route("/{podcast_id}/unmerge/{target_podcast_id}", post(handlers::podcasts::unmerge_podcast)) + .route("/{podcast_id}/merged", get(handlers::podcasts::get_merged_podcasts)) + .route("/remove_podcast", post(handlers::podcasts::remove_podcast)) + .route("/remove_podcast_id", post(handlers::podcasts::remove_podcast_id)) + .route("/remove_podcast_name", post(handlers::podcasts::remove_podcast_by_name)) + .route("/return_pods/{user_id}", get(handlers::podcasts::return_pods)) + .route("/return_pods_extra/{user_id}", get(handlers::podcasts::return_pods_extra)) + .route("/get_time_info", get(handlers::podcasts::get_time_info)) + .route("/check_podcast", get(handlers::podcasts::check_podcast)) + .route("/check_episode_in_db/{user_id}", get(handlers::podcasts::check_episode_in_db)) + .route("/queue_pod", post(handlers::podcasts::queue_episode)) + .route("/remove_queued_pod", post(handlers::podcasts::remove_queued_episode)) + .route("/get_queued_episodes", get(handlers::podcasts::get_queued_episodes)) + .route("/reorder_queue", post(handlers::podcasts::reorder_queue)) + .route("/save_episode", post(handlers::podcasts::save_episode)) + .route("/remove_saved_episode", post(handlers::podcasts::remove_saved_episode)) + .route("/saved_episode_list/{user_id}", get(handlers::podcasts::get_saved_episodes)) + .route("/record_podcast_history", post(handlers::podcasts::add_history)) + .route("/get_podcast_id", get(handlers::podcasts::get_podcast_id)) + .route("/download_episode_list", get(handlers::podcasts::download_episode_list)) + .route("/download_podcast", post(handlers::podcasts::download_podcast)) + .route("/delete_episode", post(handlers::podcasts::delete_episode)) + .route("/download_all_podcast", post(handlers::podcasts::download_all_podcast)) + .route("/download_status/{user_id}", get(handlers::podcasts::download_status)) + .route("/podcast_episodes", get(handlers::podcasts::podcast_episodes)) + .route("/get_podcast_id_from_ep_name", get(handlers::podcasts::get_podcast_id_from_ep_name)) + .route("/get_episode_id_ep_name", get(handlers::podcasts::get_episode_id_ep_name)) + .route("/get_episode_metadata", post(handlers::podcasts::get_episode_metadata)) + .route("/fetch_podcasting_2_data", get(handlers::podcasts::fetch_podcasting_2_data)) + .route("/get_auto_download_status", post(handlers::podcasts::get_auto_download_status)) + .route("/get_feed_cutoff_days", get(handlers::podcasts::get_feed_cutoff_days)) + .route("/get_play_episode_details", post(handlers::podcasts::get_play_episode_details)) + .route("/fetch_podcasting_2_pod_data", get(handlers::podcasts::fetch_podcasting_2_pod_data)) + .route("/mark_episode_completed", post(handlers::podcasts::mark_episode_completed)) + .route("/update_episode_duration", post(handlers::podcasts::update_episode_duration)) + // Bulk episode operations + .route("/bulk_mark_episodes_completed", post(handlers::episodes::bulk_mark_episodes_completed)) + .route("/bulk_save_episodes", post(handlers::episodes::bulk_save_episodes)) + .route("/bulk_queue_episodes", post(handlers::episodes::bulk_queue_episodes)) + .route("/bulk_download_episodes", post(handlers::episodes::bulk_download_episodes)) + .route("/bulk_delete_downloaded_episodes", post(handlers::episodes::bulk_delete_downloaded_episodes)) + .route("/share_episode/{episode_id}", post(handlers::episodes::share_episode)) + .route("/episode_by_url/{url_key}", get(handlers::episodes::get_episode_by_url_key)) + .route("/increment_played/{user_id}", put(handlers::podcasts::increment_played)) + .route("/record_listen_duration", post(handlers::podcasts::record_listen_duration)) + .route("/get_podcast_id_from_ep_id", get(handlers::podcasts::get_podcast_id_from_ep_id)) + .route("/get_stats", get(handlers::podcasts::get_stats)) + .route("/get_pinepods_version", get(handlers::podcasts::get_pinepods_version)) + .route("/search_data", post(handlers::podcasts::search_data)) + .route("/fetch_transcript", post(handlers::podcasts::fetch_transcript)) + .route("/home_overview", get(handlers::podcasts::home_overview)) + .route("/get_playlists", get(handlers::podcasts::get_playlists)) + .route("/get_playlist_episodes", get(handlers::podcasts::get_playlist_episodes)) + .route("/create_playlist", post(handlers::playlists::create_playlist)) + .route("/delete_playlist", delete(handlers::playlists::delete_playlist)) + .route("/get_podcast_details", get(handlers::podcasts::get_podcast_details)) + .route("/get_podcast_details_dynamic", get(handlers::podcasts::get_podcast_details_dynamic)) + .route("/podpeople/host_podcasts", get(handlers::podcasts::get_host_podcasts)) + .route("/update_feed_cutoff_days", post(handlers::podcasts::update_feed_cutoff_days)) + .route("/fetch_podcast_feed", get(handlers::podcasts::fetch_podcast_feed)) + .route("/youtube_episodes", get(handlers::podcasts::youtube_episodes)) + .route("/remove_youtube_channel", post(handlers::podcasts::remove_youtube_channel)) + .route("/stream/{episode_id}", get(handlers::podcasts::stream_episode)) + .route("/get_rss_key", get(handlers::podcasts::get_rss_key)) + .route("/mark_episode_uncompleted", post(handlers::podcasts::mark_episode_uncompleted)) + .route("/user/set_theme", put(handlers::settings::set_theme)) + .route("/get_user_info", get(handlers::settings::get_user_info)) + .route("/my_user_info/{user_id}", get(handlers::settings::get_my_user_info)) + .route("/add_user", post(handlers::settings::add_user)) + .route("/add_login_user", post(handlers::settings::add_login_user)) + .route("/set_fullname/{user_id}", put(handlers::settings::set_fullname)) + .route("/set_password/{user_id}", put(handlers::settings::set_password)) + .route("/user/delete/{user_id}", delete(handlers::settings::delete_user)) + .route("/user/set_email", put(handlers::settings::set_email)) + .route("/user/set_username", put(handlers::settings::set_username)) + .route("/user/set_isadmin", put(handlers::settings::set_isadmin)) + .route("/user/final_admin/{user_id}", get(handlers::settings::final_admin)) + .route("/enable_disable_guest", post(handlers::settings::enable_disable_guest)) + .route("/enable_disable_downloads", post(handlers::settings::enable_disable_downloads)) + .route("/enable_disable_self_service", post(handlers::settings::enable_disable_self_service)) + .route("/guest_status", get(handlers::settings::guest_status)) + .route("/rss_feed_status", get(handlers::settings::rss_feed_status)) + .route("/toggle_rss_feeds", post(handlers::settings::toggle_rss_feeds)) + .route("/download_status", get(handlers::settings::download_status)) + .route("/admin_self_service_status", get(handlers::settings::self_service_status)) + .route("/save_email_settings", post(handlers::settings::save_email_settings)) + .route("/get_email_settings", get(handlers::settings::get_email_settings)) + .route("/send_test_email", post(handlers::settings::send_test_email)) + .route("/send_email", post(handlers::settings::send_email)) + .route("/reset_password_create_code", post(handlers::auth::reset_password_create_code)) + .route("/verify_and_reset_password", post(handlers::auth::verify_and_reset_password)) + .route("/get_api_info/{user_id}", get(handlers::settings::get_api_info)) + .route("/create_api_key", post(handlers::settings::create_api_key)) + .route("/delete_api_key", delete(handlers::settings::delete_api_key)) + .route("/backup_user", post(handlers::settings::backup_user)) + .route("/backup_server", post(handlers::settings::backup_server)) + .route("/restore_server", post(handlers::settings::restore_server)) + .route("/generate_mfa_secret/{user_id}", get(handlers::settings::generate_mfa_secret)) + .route("/verify_temp_mfa", post(handlers::settings::verify_temp_mfa)) + .route("/check_mfa_enabled/{user_id}", get(handlers::settings::check_mfa_enabled)) + .route("/save_mfa_secret", post(handlers::settings::save_mfa_secret)) + .route("/delete_mfa", delete(handlers::settings::delete_mfa)) + .route("/initiate_nextcloud_login", post(handlers::settings::initiate_nextcloud_login)) + .route("/add_nextcloud_server", post(handlers::settings::add_nextcloud_server)) + .route("/verify_gpodder_auth", post(handlers::settings::verify_gpodder_auth)) + .route("/add_gpodder_server", post(handlers::settings::add_gpodder_server)) + .route("/get_gpodder_settings/{user_id}", get(handlers::settings::get_gpodder_settings)) + .route("/check_gpodder_settings/{user_id}", get(handlers::settings::check_gpodder_settings)) + .route("/remove_podcast_sync", delete(handlers::settings::remove_podcast_sync)) + .route("/gpodder/status", get(handlers::sync::gpodder_status)) + .route("/gpodder/toggle", post(handlers::sync::gpodder_toggle)) + .route("/refresh_pods", get(handlers::refresh::refresh_pods_admin)) + .route("/refresh_gpodder_subscriptions", get(handlers::refresh::refresh_gpodder_subscriptions_admin)) + .route("/refresh_nextcloud_subscriptions", get(handlers::refresh::refresh_nextcloud_subscriptions_admin)) + .route("/refresh_hosts", get(handlers::tasks::refresh_hosts)) + .route("/cleanup_tasks", get(handlers::tasks::cleanup_tasks)) + .route("/auto_complete_episodes", get(handlers::tasks::auto_complete_episodes)) + .route("/update_playlists", get(handlers::tasks::update_playlists)) + .route("/add_custom_podcast", post(handlers::settings::add_custom_podcast)) + .route("/user/notification_settings", get(handlers::settings::get_notification_settings)) + .route("/user/notification_settings", put(handlers::settings::update_notification_settings)) + .route("/user/set_playback_speed", post(handlers::settings::set_playback_speed_user)) + .route("/user/set_global_podcast_cover_preference", post(handlers::settings::set_global_podcast_cover_preference)) + .route("/user/get_podcast_cover_preference", get(handlers::settings::get_global_podcast_cover_preference)) + .route("/user/test_notification", post(handlers::settings::test_notification)) + .route("/add_oidc_provider", post(handlers::settings::add_oidc_provider)) + .route("/update_oidc_provider/{provider_id}", put(handlers::settings::update_oidc_provider)) + .route("/list_oidc_providers", get(handlers::settings::list_oidc_providers)) + .route("/remove_oidc_provider", post(handlers::settings::remove_oidc_provider)) + .route("/startpage", get(handlers::settings::get_startpage)) + .route("/startpage", post(handlers::settings::update_startpage)) + .route("/person/subscribe/{user_id}/{person_id}", post(handlers::settings::subscribe_to_person)) + .route("/person/unsubscribe/{user_id}/{person_id}", delete(handlers::settings::unsubscribe_from_person)) + .route("/person/subscriptions/{user_id}", get(handlers::settings::get_person_subscriptions)) + .route("/person/episodes/{user_id}/{person_id}", get(handlers::settings::get_person_episodes)) + .route("/search_youtube_channels", get(handlers::youtube::search_youtube_channels)) + .route("/youtube/subscribe", post(handlers::youtube::subscribe_to_youtube_channel)) + .route("/check_youtube_channel", get(handlers::youtube::check_youtube_channel)) + .route("/enable_auto_download", post(handlers::settings::enable_auto_download)) + .route("/adjust_skip_times", post(handlers::settings::adjust_skip_times)) + .route("/remove_category", post(handlers::settings::remove_category)) + .route("/add_category", post(handlers::settings::add_category)) + .route("/podcast/set_playback_speed", post(handlers::settings::set_podcast_playback_speed)) + .route("/podcast/set_cover_preference", post(handlers::settings::set_podcast_cover_preference)) + .route("/podcast/clear_cover_preference", post(handlers::settings::clear_podcast_cover_preference)) + .route("/podcast/toggle_notifications", put(handlers::settings::toggle_podcast_notifications)) + .route("/podcast/notification_status", post(handlers::podcasts::get_notification_status)) + .route("/rss_key", get(handlers::settings::get_user_rss_key)) + .route("/verify_mfa", post(handlers::settings::verify_mfa)) + .route("/schedule_backup", post(handlers::settings::schedule_backup)) + .route("/get_scheduled_backup", post(handlers::settings::get_scheduled_backup)) + .route("/list_backup_files", post(handlers::settings::list_backup_files)) + .route("/restore_backup_file", post(handlers::settings::restore_from_backup_file)) + .route("/manual_backup_to_directory", post(handlers::settings::manual_backup_to_directory)) + .route("/get_unmatched_podcasts", post(handlers::settings::get_unmatched_podcasts)) + .route("/update_podcast_index_id", post(handlers::settings::update_podcast_index_id)) + .route("/ignore_podcast_index_id", post(handlers::settings::ignore_podcast_index_id)) + .route("/get_ignored_podcasts", post(handlers::settings::get_ignored_podcasts)) + // Language preference endpoints + .route("/get_user_language", get(handlers::settings::get_user_language)) + .route("/update_user_language", put(handlers::settings::update_user_language)) + .route("/get_available_languages", get(handlers::settings::get_available_languages)) + .route("/get_server_default_language", get(handlers::settings::get_server_default_language)) + // Add more data routes as needed +} + +fn create_podcast_routes() -> Router { + Router::new() + .route("/notification_status", post(handlers::podcasts::get_notification_status)) +} + +fn create_episode_routes() -> Router { + Router::new() + .route("/{episode_id}/download", get(handlers::episodes::download_episode_file)) +} + +fn create_playlist_routes() -> Router { + Router::new() + // Add playlist routes as needed +} + +fn create_task_routes() -> Router { + Router::new() + .route("/user/{user_id}", get(handlers::websocket::get_user_tasks)) + .route("/active", get(handlers::websocket::get_active_tasks)) + .route("/{task_id}", get(handlers::websocket::get_task_status)) +} + +fn create_async_routes() -> Router { + Router::new() + // .route("/download_episode", post(handlers::tasks::download_episode)) + // .route("/import_opml", post(handlers::tasks::import_opml)) + // .route("/refresh_feeds", post(handlers::tasks::refresh_all_feeds)) + // .route("/episode/{episode_id}/metadata", get(handlers::tasks::quick_metadata_fetch)) +} + +fn create_proxy_routes() -> Router { + Router::new() + .route("/image", get(handlers::proxy::proxy_image)) +} + +fn create_gpodder_routes() -> Router { + Router::new() + .route("/test-connection", get(handlers::sync::gpodder_test_connection)) + .route("/set_default/{device_id}", post(handlers::sync::gpodder_set_default)) + .route("/devices/{user_id}", get(handlers::sync::gpodder_get_user_devices)) + .route("/devices", get(handlers::sync::gpodder_get_all_devices)) + .route("/default_device", get(handlers::sync::gpodder_get_default_device)) + .route("/devices", post(handlers::sync::gpodder_create_device)) + .route("/sync/force", post(handlers::sync::gpodder_force_sync)) + .route("/sync", post(handlers::sync::gpodder_sync)) + .route("/gpodder_statistics", get(handlers::sync::gpodder_get_statistics)) +} + +fn create_init_routes() -> Router { + Router::new() + .route("/startup_tasks", post(handlers::tasks::startup_tasks)) +} + +fn create_feed_routes() -> Router { + Router::new() + .route("/{user_id}", get(handlers::feed::get_user_feed)) +} + +fn create_websocket_routes() -> Router { + Router::new() + .route("/api/tasks/{user_id}", get(handlers::websocket::task_progress_websocket)) + .route("/api/data/episodes/{user_id}", get(handlers::refresh::websocket_refresh_episodes)) +} + +fn create_auth_routes() -> Router { + Router::new() + .route("/store_state", post(handlers::auth::store_oidc_state)) + .route("/callback", get(handlers::auth::oidc_callback)) +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + warn!("Received Ctrl+C, shutting down gracefully"); + }, + _ = terminate => { + warn!("Received SIGTERM, shutting down gracefully"); + }, + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/models.rs b/PinePods-0.8.2/rust-api/src/models.rs new file mode 100644 index 0000000..4205b14 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/models.rs @@ -0,0 +1,528 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +// Response models to match Python API +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiResponse { + pub status_code: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl ApiResponse { + pub fn success(data: T) -> Self { + Self { + status_code: 200, + message: None, + data: Some(data), + } + } + + pub fn success_with_message(data: T, message: String) -> Self { + Self { + status_code: 200, + message: Some(message), + data: Some(data), + } + } + + pub fn error(status_code: u16, message: String) -> ApiResponse<()> { + ApiResponse { + status_code, + message: Some(message), + data: None, + } + } +} + +// PinePods check response +#[derive(Debug, Serialize, Deserialize)] +pub struct PinepodsCheckResponse { + pub status_code: u16, + pub pinepods_instance: bool, +} + +// Health check response +#[derive(Debug, Serialize, Deserialize)] +pub struct HealthResponse { + pub status: String, + pub database: bool, + pub redis: bool, + pub timestamp: DateTime, +} + +// Authentication models +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginResponse { + pub status: String, + pub user_id: Option, + pub api_key: Option, + pub message: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiKeyValidationResponse { + pub status: String, +} + +// User models +#[derive(Debug, Serialize, Deserialize)] +pub struct User { + pub user_id: i32, + pub username: String, + pub email: Option, + pub is_admin: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UserSettings { + pub user_id: i32, + pub theme: String, + pub auto_download_episodes: bool, + pub auto_delete_episodes: bool, + pub download_location: Option, +} + +// Podcast models +#[derive(Debug, Serialize, Deserialize)] +pub struct Podcast { + pub podcast_id: i32, + pub podcast_name: String, + pub feed_url: String, + pub artwork_url: Option, + pub author: Option, + pub description: Option, + pub website_url: Option, + pub explicit: bool, + pub episode_count: i32, + pub categories: Option, + pub user_id: i32, + pub auto_download: bool, + pub date_created: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Episode { + pub episode_id: i32, + pub podcast_id: i32, + pub episode_title: String, + pub episode_description: Option, + pub episode_url: Option, + pub episode_artwork: Option, + pub episode_pub_date: DateTime, + pub episode_duration: i32, + pub completed: bool, + pub listen_duration: i32, + pub downloaded: bool, + pub saved: bool, +} + +// Playlist models +#[derive(Debug, Serialize, Deserialize)] +pub struct Playlist { + pub playlist_id: i32, + pub user_id: i32, + pub name: String, + pub description: Option, + pub is_system_playlist: bool, + pub episode_count: i32, + pub created_at: DateTime, + pub last_updated: DateTime, +} + +// Request models +#[derive(Debug, Deserialize)] +pub struct CreatePodcastRequest { + pub feed_url: String, + pub auto_download: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateEpisodeRequest { + pub listen_duration: Option, + pub completed: Option, + pub saved: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreatePlaylistRequest { + pub user_id: i32, + pub name: String, + pub description: Option, + pub podcast_ids: Option>, + pub include_unplayed: bool, + pub include_partially_played: bool, + pub include_played: bool, + pub play_progress_min: Option, + pub play_progress_max: Option, + pub time_filter_hours: Option, + pub min_duration: Option, + pub max_duration: Option, + pub sort_order: String, + pub group_by_podcast: bool, + pub max_episodes: Option, + pub icon_name: String, +} + +#[derive(Debug, Serialize)] +pub struct CreatePlaylistResponse { + pub detail: String, + pub playlist_id: i32, +} + +#[derive(Debug, Deserialize)] +pub struct DeletePlaylistRequest { + pub user_id: i32, + pub playlist_id: i32, +} + +#[derive(Debug, Serialize)] +pub struct DeletePlaylistResponse { + pub detail: String, +} + +// Search models +#[derive(Debug, Serialize, Deserialize)] +pub struct SearchRequest { + pub query: String, + pub search_type: Option, // "podcasts", "episodes", "all" + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SearchResult { + pub podcasts: Vec, + pub episodes: Vec, + pub total_count: i32, +} + +// Statistics models +#[derive(Debug, Serialize, Deserialize)] +pub struct UserStats { + pub total_podcasts: i32, + pub total_episodes: i32, + pub total_listen_time: i32, + pub completed_episodes: i32, + pub saved_episodes: i32, + pub downloaded_episodes: i32, +} + +// Language models +#[derive(Debug, Serialize, Deserialize)] +pub struct AvailableLanguage { + pub code: String, + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LanguageUpdateRequest { + pub user_id: i32, + pub language: String, +} + +#[derive(Debug, Serialize)] +pub struct UserLanguageResponse { + pub language: String, +} + +#[derive(Debug, Serialize)] +pub struct AvailableLanguagesResponse { + pub languages: Vec, +} + +// API-specific podcast models to match Python responses +#[derive(Debug, Serialize, Deserialize)] +pub struct PodcastResponse { + pub podcastid: i32, + pub podcastname: String, + pub artworkurl: Option, + pub description: Option, + pub episodecount: Option, + pub websiteurl: Option, + pub feedurl: String, + pub author: Option, + pub categories: Option>, + pub explicit: bool, + pub podcastindexid: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PodcastExtraResponse { + pub podcastid: i32, + pub podcastname: String, + pub artworkurl: Option, + pub description: Option, + pub episodecount: Option, + pub websiteurl: Option, + pub feedurl: String, + pub author: Option, + pub categories: Option>, + pub explicit: bool, + pub podcastindexid: Option, + pub play_count: i64, + pub episodes_played: i32, + pub oldest_episode_date: Option, + pub is_youtube: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PodcastListResponse { + pub pods: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PodcastExtraListResponse { + pub pods: Vec, +} + +// Remove podcast request model +#[derive(Debug, Deserialize)] +pub struct RemovePodcastByNameRequest { + pub user_id: i32, + pub podcast_name: String, + pub podcast_url: String, +} + +// Time info response model +#[derive(Debug, Serialize, Deserialize)] +pub struct TimeInfoResponse { + pub timezone: String, + pub hour_pref: i32, + pub date_format: Option, +} + +// Check podcast response model +#[derive(Debug, Serialize, Deserialize)] +pub struct CheckPodcastResponse { + pub exists: bool, +} + +// Check episode in database response model +#[derive(Debug, Serialize, Deserialize)] +pub struct EpisodeInDbResponse { + pub episode_in_db: bool, +} + +// Queue-related models +#[derive(Debug, Deserialize)] +pub struct QueuePodcastRequest { + pub episode_id: i32, + pub user_id: i32, + pub is_youtube: bool, +} + +// Saved episodes models +#[derive(Debug, Deserialize)] +pub struct SavePodcastRequest { + pub episode_id: i32, + pub user_id: i32, + pub is_youtube: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SavedEpisode { + pub episodetitle: String, + pub podcastname: String, + pub episodepubdate: String, + pub episodedescription: String, + pub episodeartwork: String, + pub episodeurl: String, + pub episodeduration: i32, + pub listenduration: Option, + pub episodeid: i32, + pub websiteurl: String, + pub completed: bool, + pub saved: bool, + pub queued: bool, + pub downloaded: bool, + pub is_youtube: bool, + pub podcastid: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SavedEpisodesResponse { + pub saved_episodes: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PlaylistInfo { + pub name: String, + pub description: String, + pub episode_count: i32, + pub icon_name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PlaylistEpisodesResponse { + pub episodes: Vec, + pub playlist_info: PlaylistInfo, +} + +#[derive(Debug, Serialize)] +pub struct SaveEpisodeResponse { + pub detail: String, +} + +// History models +#[derive(Debug, Deserialize)] +pub struct HistoryAddRequest { + pub episode_id: i32, + pub episode_pos: f32, + pub user_id: i32, + pub is_youtube: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HistoryEpisode { + pub episodetitle: String, + pub podcastname: String, + pub episodepubdate: String, + pub episodedescription: String, + pub episodeartwork: String, + pub episodeurl: String, + pub episodeduration: i32, + pub listenduration: Option, + pub episodeid: i32, + pub completed: bool, + pub listendate: Option, + pub is_youtube: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UserHistoryResponse { + pub data: Vec, +} + +#[derive(Debug, Serialize)] +pub struct HistoryResponse { + pub detail: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QueueResponse { + pub data: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QueuedEpisode { + pub episodetitle: String, + pub podcastname: String, + pub episodepubdate: String, + pub episodedescription: String, + pub episodeartwork: String, + pub episodeurl: String, + pub queueposition: Option, + pub episodeduration: i32, + pub queuedate: String, + pub listenduration: Option, + pub episodeid: i32, + pub completed: bool, + pub saved: bool, + pub queued: bool, + pub downloaded: bool, + pub is_youtube: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QueuedEpisodesResponse { + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ReorderQueueRequest { + pub episode_ids: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ReorderQueueResponse { + pub message: String, +} + +// Bulk episode action models - flexible episode ID lists +#[derive(Debug, Deserialize)] +pub struct BulkEpisodeActionRequest { + pub episode_ids: Vec, + pub user_id: i32, + pub is_youtube: Option, +} + +#[derive(Debug, Serialize)] +pub struct BulkEpisodeActionResponse { + pub message: String, + pub processed_count: i32, + pub failed_count: Option, +} + +// Background task models +#[derive(Debug, Serialize, Deserialize)] +pub struct TaskStatus { + pub task_id: String, + pub status: String, + pub progress: Option, + pub message: Option, + pub created_at: DateTime, +} + +// Import/Export models +#[derive(Debug, Serialize, Deserialize)] +pub struct OpmlImportRequest { + pub opml_content: String, + pub auto_download: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ImportProgress { + pub total_feeds: i32, + pub processed_feeds: i32, + pub successful_imports: i32, + pub failed_imports: i32, + pub current_feed: Option, +} + +// Pagination models +#[derive(Debug, Deserialize)] +pub struct PaginationParams { + pub page: Option, + pub per_page: Option, +} + +impl Default for PaginationParams { + fn default() -> Self { + Self { + page: Some(1), + per_page: Some(50), + } + } +} + +#[derive(Debug, Serialize)] +pub struct PaginatedResponse { + pub data: Vec, + pub total_count: i32, + pub page: i32, + pub per_page: i32, + pub total_pages: i32, +} + +impl PaginatedResponse { + pub fn new(data: Vec, total_count: i32, page: i32, per_page: i32) -> Self { + let total_pages = (total_count + per_page - 1) / per_page; // Ceiling division + Self { + data, + total_count, + page, + per_page, + total_pages, + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/redis_client.rs b/PinePods-0.8.2/rust-api/src/redis_client.rs new file mode 100644 index 0000000..0390349 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/redis_client.rs @@ -0,0 +1,169 @@ +use redis::{aio::MultiplexedConnection, AsyncCommands, Client}; +use crate::{config::Config, error::AppResult}; + +#[derive(Clone)] +pub struct RedisClient { + connection: MultiplexedConnection, +} + +impl RedisClient { + pub async fn new(config: &Config) -> AppResult { + let redis_url = config.redis_url(); + + let client = Client::open(redis_url)?; + let connection = client.get_multiplexed_async_connection().await?; + + // Test the connection + let mut conn = connection.clone(); + let _: String = redis::cmd("PING").query_async(&mut conn).await?; + + tracing::info!("Successfully connected to Redis/Valkey"); + + Ok(RedisClient { + connection, + }) + } + + pub async fn health_check(&self) -> AppResult { + let mut conn = self.connection.clone(); + let result: String = redis::cmd("PING").query_async(&mut conn).await?; + Ok(result == "PONG") + } + + pub async fn get(&self, key: &str) -> AppResult> + where + T: redis::FromRedisValue, + { + let mut conn = self.connection.clone(); + let result: Option = conn.get(key).await?; + Ok(result) + } + + pub async fn set(&self, key: &str, value: T) -> AppResult<()> + where + T: redis::ToRedisArgs + Send + Sync, + { + let mut conn = self.connection.clone(); + let _: () = conn.set(key, value).await?; + Ok(()) + } + + pub async fn set_ex(&self, key: &str, value: T, seconds: u64) -> AppResult<()> + where + T: redis::ToRedisArgs + Send + Sync, + { + let mut conn = self.connection.clone(); + let _: () = conn.set_ex(key, value, seconds).await?; + Ok(()) + } + + pub async fn delete(&self, key: &str) -> AppResult { + let mut conn = self.connection.clone(); + let result: bool = conn.del(key).await?; + Ok(result) + } + + pub async fn exists(&self, key: &str) -> AppResult { + let mut conn = self.connection.clone(); + let result: bool = conn.exists(key).await?; + Ok(result) + } + + pub async fn expire(&self, key: &str, seconds: u64) -> AppResult { + let mut conn = self.connection.clone(); + let result: bool = conn.expire(key, seconds as i64).await?; + Ok(result) + } + + pub async fn incr(&self, key: &str) -> AppResult { + let mut conn = self.connection.clone(); + let result: i64 = conn.incr(key, 1).await?; + Ok(result) + } + + pub async fn decr(&self, key: &str) -> AppResult { + let mut conn = self.connection.clone(); + let result: i64 = conn.decr(key, 1).await?; + Ok(result) + } + + // Session management + pub async fn store_session(&self, session_id: &str, user_id: i32, ttl_seconds: u64) -> AppResult<()> { + let session_key = format!("session:{}", session_id); + self.set_ex(&session_key, user_id, ttl_seconds).await + } + + pub async fn get_session(&self, session_id: &str) -> AppResult> { + let session_key = format!("session:{}", session_id); + self.get(&session_key).await + } + + pub async fn delete_session(&self, session_id: &str) -> AppResult { + let session_key = format!("session:{}", session_id); + self.delete(&session_key).await + } + + // API key caching + pub async fn cache_api_key_validation(&self, api_key: &str, is_valid: bool, ttl_seconds: u64) -> AppResult<()> { + let cache_key = format!("api_key:{}", api_key); + self.set_ex(&cache_key, is_valid, ttl_seconds).await + } + + pub async fn get_cached_api_key_validation(&self, api_key: &str) -> AppResult> { + let cache_key = format!("api_key:{}", api_key); + self.get(&cache_key).await + } + + // Rate limiting + pub async fn check_rate_limit(&self, identifier: &str, limit: u32, window_seconds: u64) -> AppResult { + let rate_key = format!("rate_limit:{}", identifier); + + let mut conn = self.connection.clone(); + let current_count: i64 = conn.incr(&rate_key, 1).await?; + + if current_count == 1 { + let _: () = conn.expire(&rate_key, window_seconds as i64).await?; + } + + Ok(current_count <= limit as i64) + } + + // Background task tracking + pub async fn store_task_status(&self, task_id: &str, status: &str, ttl_seconds: u64) -> AppResult<()> { + let task_key = format!("task:{}", task_id); + self.set_ex(&task_key, status, ttl_seconds).await + } + + pub async fn get_task_status(&self, task_id: &str) -> AppResult> { + let task_key = format!("task:{}", task_id); + self.get(&task_key).await + } + + // Podcast refresh tracking + pub async fn set_podcast_refreshing(&self, podcast_id: i32) -> AppResult<()> { + let refresh_key = format!("refreshing:{}", podcast_id); + self.set_ex(&refresh_key, true, 300).await // 5 minute timeout + } + + pub async fn is_podcast_refreshing(&self, podcast_id: i32) -> AppResult { + let refresh_key = format!("refreshing:{}", podcast_id); + Ok(self.exists(&refresh_key).await.unwrap_or(false)) + } + + pub async fn clear_podcast_refreshing(&self, podcast_id: i32) -> AppResult { + let refresh_key = format!("refreshing:{}", podcast_id); + self.delete(&refresh_key).await + } + + // Atomic get and delete operation - critical for OIDC state management + pub async fn get_del(&self, key: &str) -> AppResult> { + let mut conn = self.connection.clone(); + let result: Option = redis::cmd("GETDEL").arg(key).query_async(&mut conn).await?; + Ok(result) + } + + // Get a connection for direct Redis operations + pub async fn get_connection(&self) -> AppResult { + Ok(self.connection.clone()) + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/redis_manager.rs b/PinePods-0.8.2/rust-api/src/redis_manager.rs new file mode 100644 index 0000000..ab5028b --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/redis_manager.rs @@ -0,0 +1,249 @@ +use serde_json::Value; +use crate::{error::AppResult, redis_client::RedisClient}; + +pub struct ImportProgressManager { + redis_client: RedisClient, +} + +impl ImportProgressManager { + pub fn new(redis_client: RedisClient) -> Self { + Self { redis_client } + } + + // Start import progress tracking - matches Python ImportProgressManager.start_import + pub async fn start_import(&self, user_id: i32, total_podcasts: i32) -> AppResult<()> { + let progress_data = serde_json::json!({ + "current": 0, + "total": total_podcasts, + "current_podcast": "" + }); + + let key = format!("import_progress:{}", user_id); + self.redis_client.set_ex(&key, &progress_data.to_string(), 3600).await?; + + Ok(()) + } + + // Update import progress - matches Python ImportProgressManager.update_progress + pub async fn update_progress(&self, user_id: i32, current: i32, current_podcast: &str) -> AppResult<()> { + let key = format!("import_progress:{}", user_id); + + // Get current progress + if let Some(progress_json) = self.redis_client.get::(&key).await? { + if let Ok(mut progress) = serde_json::from_str::(&progress_json) { + progress["current"] = serde_json::Value::Number(serde_json::Number::from(current)); + progress["current_podcast"] = serde_json::Value::String(current_podcast.to_string()); + + self.redis_client.set_ex(&key, &progress.to_string(), 3600).await?; + } + } + + Ok(()) + } + + // Get import progress - matches Python ImportProgressManager.get_progress + pub async fn get_progress(&self, user_id: i32) -> AppResult<(i32, i32, String)> { + let key = format!("import_progress:{}", user_id); + + if let Some(progress_json) = self.redis_client.get::(&key).await? { + if let Ok(progress) = serde_json::from_str::(&progress_json) { + let current = progress.get("current").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let total = progress.get("total").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let current_podcast = progress.get("current_podcast").and_then(|v| v.as_str()).unwrap_or("").to_string(); + + return Ok((current, total, current_podcast)); + } + } + + Ok((0, 0, "".to_string())) + } + + // Clear import progress - matches Python ImportProgressManager.clear_progress + pub async fn clear_progress(&self, user_id: i32) -> AppResult<()> { + let key = format!("import_progress:{}", user_id); + self.redis_client.delete(&key).await?; + Ok(()) + } +} + +// Notification manager for sending test notifications +pub struct NotificationManager { + redis_client: RedisClient, +} + +impl NotificationManager { + pub fn new(redis_client: RedisClient) -> Self { + Self { redis_client } + } + + // Send test notification - matches Python notification functionality + pub async fn send_test_notification(&self, user_id: i32, platform: &str, settings: &serde_json::Value) -> AppResult { + println!("Sending test notification for user {} on platform {}", user_id, platform); + + match platform { + "ntfy" => self.send_ntfy_notification(settings).await, + "gotify" => self.send_gotify_notification(settings).await, + "http" => self.send_http_notification(settings).await, + _ => { + println!("Unsupported notification platform: {}", platform); + Ok(false) + } + } + } + + async fn send_ntfy_notification(&self, settings: &serde_json::Value) -> AppResult { + let topic = settings.get("ntfy_topic").and_then(|v| v.as_str()).unwrap_or(""); + let server_url = settings.get("ntfy_server_url").and_then(|v| v.as_str()).unwrap_or("https://ntfy.sh"); + let username = settings.get("ntfy_username").and_then(|v| v.as_str()); + let password = settings.get("ntfy_password").and_then(|v| v.as_str()); + let access_token = settings.get("ntfy_access_token").and_then(|v| v.as_str()); + + if topic.is_empty() { + return Ok(false); + } + + let client = reqwest::Client::new(); + let url = format!("{}/{}", server_url, topic); + + let mut request = client + .post(&url) + .header("Content-Type", "text/plain") + .body("Test notification from PinePods"); + + // Add authentication if provided + if let Some(token) = access_token.filter(|t| !t.is_empty()) { + // Use access token (preferred method) + request = request.header("Authorization", format!("Bearer {}", token)); + } else if let (Some(user), Some(pass)) = (username.filter(|u| !u.is_empty()), password.filter(|p| !p.is_empty())) { + // Use username/password basic auth + request = request.basic_auth(user, Some(pass)); + } + + let response = request.send().await?; + + let status = response.status(); + let is_success = status.is_success(); + + if !is_success { + let response_text = response.text().await.unwrap_or_default(); + println!("Ntfy notification failed with status: {} - Response: {}", + status, response_text); + } + + Ok(is_success) + } + + async fn send_gotify_notification(&self, settings: &serde_json::Value) -> AppResult { + let gotify_url = settings.get("gotify_url").and_then(|v| v.as_str()).unwrap_or(""); + let gotify_token = settings.get("gotify_token").and_then(|v| v.as_str()).unwrap_or(""); + + if gotify_url.is_empty() || gotify_token.is_empty() { + return Ok(false); + } + + let client = reqwest::Client::new(); + let url = format!("{}/message?token={}", gotify_url, gotify_token); + + let payload = serde_json::json!({ + "title": "PinePods Test", + "message": "Test notification from PinePods", + "priority": 5 + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await?; + + Ok(response.status().is_success()) + } + + async fn send_http_notification(&self, settings: &serde_json::Value) -> AppResult { + let http_url = settings.get("http_url").and_then(|v| v.as_str()).unwrap_or(""); + let http_token = settings.get("http_token").and_then(|v| v.as_str()).unwrap_or(""); + let http_method = settings.get("http_method").and_then(|v| v.as_str()).unwrap_or("POST"); + + if http_url.is_empty() { + println!("HTTP URL is empty, cannot send notification"); + return Ok(false); + } + + let client = reqwest::Client::new(); + + // Build the request based on method + let request_builder = match http_method.to_uppercase().as_str() { + "GET" => { + // For GET requests, add message as query parameter + let url_with_params = if http_url.contains('?') { + format!("{}&message={}", http_url, urlencoding::encode("Test notification from PinePods")) + } else { + format!("{}?message={}", http_url, urlencoding::encode("Test notification from PinePods")) + }; + client.get(&url_with_params) + }, + "POST" | _ => { + // For POST requests, send JSON payload + let payload = if http_url.contains("api.telegram.org") { + // Special handling for Telegram Bot API + let chat_id = if let Some(chat_id_str) = http_token.split(':').nth(1) { + // Extract chat_id from token if it contains chat_id (format: bot_token:chat_id) + chat_id_str + } else { + // Default chat_id - user needs to configure this properly + "YOUR_CHAT_ID" + }; + + serde_json::json!({ + "chat_id": chat_id, + "text": "Test notification from PinePods" + }) + } else { + // Generic JSON payload + serde_json::json!({ + "title": "PinePods Test", + "message": "Test notification from PinePods", + "text": "Test notification from PinePods" + }) + }; + + client.post(http_url) + .header("Content-Type", "application/json") + .json(&payload) + } + }; + + // Add authorization header if token is provided + let request_builder = if !http_token.is_empty() { + if http_url.contains("api.telegram.org") { + // For Telegram, token goes in URL path, not header + request_builder + } else { + // For other services, add as Bearer token + request_builder.header("Authorization", format!("Bearer {}", http_token)) + } + } else { + request_builder + }; + + match request_builder.send().await { + Ok(response) => { + let status = response.status(); + let is_success = status.is_success(); + + if !is_success { + let response_text = response.text().await.unwrap_or_default(); + println!("HTTP notification failed with status: {} - Response: {}", + status, response_text); + } + + Ok(is_success) + }, + Err(e) => { + println!("HTTP notification request failed: {}", e); + Ok(false) + } + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/services/auth.rs b/PinePods-0.8.2/rust-api/src/services/auth.rs new file mode 100644 index 0000000..15f403e --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/services/auth.rs @@ -0,0 +1,15 @@ +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use crate::error::{AppError, AppResult}; + +/// Verify password using Argon2 - matches Python's passlib CryptContext with argon2 +pub fn verify_password(password: &str, stored_hash: &str) -> AppResult { + let argon2 = Argon2::default(); + + let parsed_hash = PasswordHash::new(stored_hash) + .map_err(|e| AppError::Auth(format!("Invalid password hash format: {}", e)))?; + + match argon2.verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/services/mod.rs b/PinePods-0.8.2/rust-api/src/services/mod.rs new file mode 100644 index 0000000..8d7b8d7 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/services/mod.rs @@ -0,0 +1,7 @@ +pub mod auth; +pub mod podcast; +pub mod scheduler; +pub mod task_manager; +pub mod tasks; + +// Common service utilities and shared functionality \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/services/podcast.rs b/PinePods-0.8.2/rust-api/src/services/podcast.rs new file mode 100644 index 0000000..bd24216 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/services/podcast.rs @@ -0,0 +1,395 @@ +use crate::{error::AppResult, AppState, database::DatabasePool}; +use crate::handlers::refresh::PodcastForRefresh; +use tracing::{info, warn, error}; +use serde_json::Value; +use sqlx::Row; + +/// Podcast refresh service - matches Python's refresh_pods_for_user function exactly +pub async fn refresh_podcast(state: &AppState, podcast_id: i32) -> AppResult> { + // Check if already refreshing + if state.redis_client.is_podcast_refreshing(podcast_id).await? { + return Ok(vec![]); + } + + // Mark as refreshing + state.redis_client.set_podcast_refreshing(podcast_id).await?; + + let result = refresh_podcast_internal(&state.db_pool, podcast_id).await; + + // Clear refreshing flag + state.redis_client.clear_podcast_refreshing(podcast_id).await?; + + result +} + +/// Internal refresh logic - matches Python refresh_pods_for_user function +async fn refresh_podcast_internal(db_pool: &DatabasePool, podcast_id: i32) -> AppResult> { + info!("Refresh begin for podcast {}", podcast_id); + + // Get podcast details from database + let podcast_info = get_podcast_for_refresh(db_pool, podcast_id).await?; + + if let Some(podcast) = podcast_info { + info!("Processing podcast: {}", podcast_id); + + if podcast.is_youtube { + // Handle YouTube channel refresh + refresh_youtube_channel(db_pool, podcast_id, &podcast.feed_url, podcast.feed_cutoff_days.unwrap_or(0)).await?; + Ok(vec![]) + } else { + // Handle regular RSS podcast refresh + let episodes = db_pool.add_episodes( + podcast_id, + &podcast.feed_url, + podcast.artwork_url.as_deref().unwrap_or(""), + podcast.auto_download, + podcast.username.as_deref(), + podcast.password.as_deref(), + ).await?; + + // Convert episodes to JSON format for WebSocket response + let episode_json = episodes.map(|_| vec![]).unwrap_or_default(); + Ok(episode_json) + } + } else { + warn!("Podcast {} not found", podcast_id); + Ok(vec![]) + } +} + +/// Refresh all podcasts - matches Python refresh_pods function exactly +pub async fn refresh_all_podcasts(state: &AppState) -> AppResult<()> { + println!("🚀 Starting refresh process for all podcasts"); + + // Get all podcasts from database + let podcasts = get_all_podcasts_for_refresh(&state.db_pool).await?; + println!("📊 Found {} podcasts to refresh", podcasts.len()); + + let mut successful_refreshes = 0; + let mut failed_refreshes = 0; + + for podcast in podcasts { + match refresh_single_podcast(&state.db_pool, &podcast).await { + Ok(_) => { + successful_refreshes += 1; + } + Err(e) => { + failed_refreshes += 1; + println!("❌ Error refreshing podcast '{}' (ID: {}): {}", podcast.name, podcast.id, e); + } + } + } + + println!("🎯 Refresh process completed: {} successful, {} failed", successful_refreshes, failed_refreshes); + Ok(()) +} + +/// Refresh a single podcast - matches Python refresh logic +async fn refresh_single_podcast(db_pool: &DatabasePool, podcast: &PodcastForRefresh) -> AppResult<()> { + println!("🔄 Starting refresh for podcast '{}' (ID: {})", podcast.name, podcast.id); + + // Count episodes before refresh + let episodes_before = match db_pool { + crate::database::DatabasePool::Postgres(pool) => { + sqlx::query_scalar(r#"SELECT COUNT(*) FROM "Episodes" WHERE podcastid = $1"#) + .bind(podcast.id) + .fetch_one(pool) + .await.unwrap_or(0) + } + crate::database::DatabasePool::MySQL(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM Episodes WHERE PodcastID = ?") + .bind(podcast.id) + .fetch_one(pool) + .await.unwrap_or(0) + } + }; + + if podcast.is_youtube { + // Handle YouTube channel + refresh_youtube_channel(db_pool, podcast.id, &podcast.feed_url, podcast.feed_cutoff_days.unwrap_or(0)).await?; + } else { + // Handle regular RSS podcast + db_pool.add_episodes( + podcast.id, + &podcast.feed_url, + podcast.artwork_url.as_deref().unwrap_or(""), + podcast.auto_download, + podcast.username.as_deref(), + podcast.password.as_deref(), + ).await?; + } + + // Count episodes after refresh + let episodes_after: i64 = match db_pool { + crate::database::DatabasePool::Postgres(pool) => { + sqlx::query_scalar(r#"SELECT COUNT(*) FROM "Episodes" WHERE podcastid = $1"#) + .bind(podcast.id) + .fetch_one(pool) + .await.unwrap_or(0) + } + crate::database::DatabasePool::MySQL(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM Episodes WHERE PodcastID = ?") + .bind(podcast.id) + .fetch_one(pool) + .await.unwrap_or(0) + } + }; + + let new_episodes = episodes_after - episodes_before; + if new_episodes > 0 { + println!("✅ Completed refresh for podcast '{}' - added {} new episodes", podcast.name, new_episodes); + } else { + println!("✅ Completed refresh for podcast '{}' - no new episodes found", podcast.name); + } + + Ok(()) +} + +/// Handle YouTube channel refresh - matches Python YouTube processing +async fn refresh_youtube_channel(db_pool: &DatabasePool, podcast_id: i32, feed_url: &str, feed_cutoff_days: i32) -> AppResult<()> { + // Extract channel ID from feed URL + let channel_id = if feed_url.contains("channel/") { + feed_url.split("channel/").nth(1).unwrap_or(feed_url) + } else { + feed_url + }; + + // Clean up any trailing slashes or query parameters + let channel_id = channel_id.split('/').next().unwrap_or(channel_id); + let channel_id = channel_id.split('?').next().unwrap_or(channel_id); + + info!("Processing YouTube channel: {} for podcast {}", channel_id, podcast_id); + + // TODO: Implement YouTube video processing + // This would match the Python youtube.process_youtube_videos function + // For now, we'll just log that it's not implemented + warn!("YouTube channel refresh not yet implemented for channel: {}", channel_id); + + Ok(()) +} + +/// Get podcast details for refresh - matches Python select_podcast query +async fn get_podcast_for_refresh(db_pool: &DatabasePool, podcast_id: i32) -> AppResult> { + match db_pool { + DatabasePool::Postgres(pool) => { + let row = sqlx::query( + r#"SELECT + PodcastID, FeedURL, ArtworkURL, AutoDownload, Username, Password, + IsYouTubeChannel, UserID, COALESCE(FeedURL, '') as channel_id, FeedCutoffDays + FROM "Podcasts" + WHERE PodcastID = $1"# + ) + .bind(podcast_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(PodcastForRefresh { + id: row.try_get("PodcastID")?, + name: "".to_string(), // Not needed for refresh + feed_url: row.try_get("FeedURL")?, + artwork_url: row.try_get::, _>("ArtworkURL").unwrap_or_default(), + auto_download: row.try_get("AutoDownload")?, + username: row.try_get("Username").ok(), + password: row.try_get("Password").ok(), + is_youtube: row.try_get("IsYouTubeChannel")?, + user_id: row.try_get("UserID")?, + feed_cutoff_days: row.try_get("FeedCutoffDays").ok(), + })) + } else { + Ok(None) + } + } + DatabasePool::MySQL(pool) => { + let row = sqlx::query( + "SELECT + PodcastID, FeedURL, ArtworkURL, AutoDownload, Username, Password, + IsYouTubeChannel, UserID, COALESCE(FeedURL, '') as channel_id, FeedCutoffDays + FROM Podcasts + WHERE PodcastID = ?" + ) + .bind(podcast_id) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + Ok(Some(PodcastForRefresh { + id: row.try_get("PodcastID")?, + name: "".to_string(), // Not needed for refresh + feed_url: row.try_get("FeedURL")?, + artwork_url: row.try_get::, _>("ArtworkURL").unwrap_or_default(), + auto_download: row.try_get("AutoDownload")?, + username: row.try_get("Username").ok(), + password: row.try_get("Password").ok(), + is_youtube: row.try_get("IsYouTubeChannel")?, + user_id: row.try_get("UserID")?, + feed_cutoff_days: row.try_get("FeedCutoffDays").ok(), + })) + } else { + Ok(None) + } + } + } +} + +/// Get all podcasts for refresh - matches Python select_podcasts query +async fn get_all_podcasts_for_refresh(db_pool: &DatabasePool) -> AppResult> { + match db_pool { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT + PodcastID, FeedURL, ArtworkURL, AutoDownload, Username, Password, + IsYouTubeChannel, UserID, COALESCE(FeedURL, '') as channel_id, FeedCutoffDays + FROM "Podcasts""# + ) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + podcasts.push(PodcastForRefresh { + id: row.try_get("PodcastID")?, + name: "".to_string(), // Not needed for refresh + feed_url: row.try_get("FeedURL")?, + artwork_url: row.try_get::, _>("ArtworkURL").unwrap_or_default(), + auto_download: row.try_get("AutoDownload")?, + username: row.try_get("Username").ok(), + password: row.try_get("Password").ok(), + is_youtube: row.try_get("IsYouTubeChannel")?, + user_id: row.try_get("UserID")?, + feed_cutoff_days: row.try_get("FeedCutoffDays").ok(), + }); + } + Ok(podcasts) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT + PodcastID, FeedURL, ArtworkURL, AutoDownload, Username, Password, + IsYouTubeChannel, UserID, COALESCE(FeedURL, '') as channel_id, FeedCutoffDays + FROM Podcasts" + ) + .fetch_all(pool) + .await?; + + let mut podcasts = Vec::new(); + for row in rows { + podcasts.push(PodcastForRefresh { + id: row.try_get("PodcastID")?, + name: "".to_string(), // Not needed for refresh + feed_url: row.try_get("FeedURL")?, + artwork_url: row.try_get::, _>("ArtworkURL").unwrap_or_default(), + auto_download: row.try_get("AutoDownload")?, + username: row.try_get("Username").ok(), + password: row.try_get("Password").ok(), + is_youtube: row.try_get("IsYouTubeChannel")?, + user_id: row.try_get("UserID")?, + feed_cutoff_days: row.try_get("FeedCutoffDays").ok(), + }); + } + Ok(podcasts) + } + } +} + + +/// Remove unavailable episodes - matches Python remove_unavailable_episodes function +pub async fn remove_unavailable_episodes(db_pool: &DatabasePool) -> AppResult<()> { + info!("Starting removal of unavailable episodes"); + + // Get all episodes from database + let episodes = get_all_episodes_for_check(db_pool).await?; + + let client = reqwest::Client::new(); + + for episode in episodes { + // Check if episode URL is still valid + match client.head(&episode.url).send().await { + Ok(response) => { + if response.status().as_u16() == 404 { + // Remove episode from database + info!("Removing unavailable episode: {}", episode.id); + remove_episode_from_database(db_pool, episode.id).await?; + } + } + Err(e) => { + error!("Error checking episode {}: {}", episode.id, e); + } + } + } + + Ok(()) +} + +/// Get all episodes for availability check +async fn get_all_episodes_for_check(db_pool: &DatabasePool) -> AppResult> { + match db_pool { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query( + r#"SELECT EpisodeID, PodcastID, EpisodeTitle, EpisodeURL, EpisodePubDate FROM "Episodes""# + ) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(EpisodeForCheck { + id: row.try_get("EpisodeID")?, + podcast_id: row.try_get("PodcastID")?, + title: row.try_get("EpisodeTitle")?, + url: row.try_get("EpisodeURL")?, + pub_date: row.try_get("EpisodePubDate")?, + }); + } + Ok(episodes) + } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query( + "SELECT EpisodeID, PodcastID, EpisodeTitle, EpisodeURL, EpisodePubDate FROM Episodes" + ) + .fetch_all(pool) + .await?; + + let mut episodes = Vec::new(); + for row in rows { + episodes.push(EpisodeForCheck { + id: row.try_get("EpisodeID")?, + podcast_id: row.try_get("PodcastID")?, + title: row.try_get("EpisodeTitle")?, + url: row.try_get("EpisodeURL")?, + pub_date: row.try_get("EpisodePubDate")?, + }); + } + Ok(episodes) + } + } +} + +/// Remove episode from database +async fn remove_episode_from_database(db_pool: &DatabasePool, episode_id: i32) -> AppResult<()> { + match db_pool { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"DELETE FROM "Episodes" WHERE EpisodeID = $1"#) + .bind(episode_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("DELETE FROM Episodes WHERE EpisodeID = ?") + .bind(episode_id) + .execute(pool) + .await?; + } + } + Ok(()) +} + +/// Episode data structure for availability check +#[derive(Debug, Clone)] +pub struct EpisodeForCheck { + pub id: i32, + pub podcast_id: i32, + pub title: String, + pub url: String, + pub pub_date: sqlx::types::chrono::DateTime, +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/services/scheduler.rs b/PinePods-0.8.2/rust-api/src/services/scheduler.rs new file mode 100644 index 0000000..15896b9 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/services/scheduler.rs @@ -0,0 +1,157 @@ +use crate::{ + error::AppResult, + handlers::{refresh, tasks}, + AppState, +}; +use std::sync::Arc; +use tokio_cron_scheduler::{Job, JobScheduler}; +use tracing::{info, error, warn}; + +pub struct BackgroundScheduler { + scheduler: JobScheduler, +} + +impl BackgroundScheduler { + pub async fn new() -> AppResult { + let scheduler = JobScheduler::new().await?; + Ok(Self { scheduler }) + } + + pub async fn start(&self, app_state: Arc) -> AppResult<()> { + info!("🕒 Starting background task scheduler..."); + + // Schedule podcast refresh every 30 minutes + let refresh_state = app_state.clone(); + let refresh_job = Job::new_async("0 */30 * * * *", move |_uuid, _l| { + let state = refresh_state.clone(); + Box::pin(async move { + info!("🔄 Running scheduled podcast refresh"); + if let Err(e) = Self::run_refresh_pods(state.clone()).await { + error!("❌ Scheduled podcast refresh failed: {}", e); + } else { + info!("✅ Scheduled podcast refresh completed"); + } + }) + })?; + + // Schedule nightly tasks at midnight + let nightly_state = app_state.clone(); + let nightly_job = Job::new_async("0 0 0 * * *", move |_uuid, _l| { + let state = nightly_state.clone(); + Box::pin(async move { + info!("🌙 Running scheduled nightly tasks"); + if let Err(e) = Self::run_nightly_tasks(state.clone()).await { + error!("❌ Scheduled nightly tasks failed: {}", e); + } else { + info!("✅ Scheduled nightly tasks completed"); + } + }) + })?; + + // Schedule cleanup tasks every 6 hours + let cleanup_state = app_state.clone(); + let cleanup_job = Job::new_async("0 0 */6 * * *", move |_uuid, _l| { + let state = cleanup_state.clone(); + Box::pin(async move { + info!("🧹 Running scheduled cleanup tasks"); + if let Err(e) = Self::run_cleanup_tasks(state.clone()).await { + error!("❌ Scheduled cleanup tasks failed: {}", e); + } else { + info!("✅ Scheduled cleanup tasks completed"); + } + }) + })?; + + // Add jobs to scheduler + self.scheduler.add(refresh_job).await?; + self.scheduler.add(nightly_job).await?; + self.scheduler.add(cleanup_job).await?; + + // Start the scheduler + self.scheduler.start().await?; + info!("✅ Background task scheduler started successfully"); + + Ok(()) + } + + // Direct function calls instead of HTTP requests + async fn run_refresh_pods(state: Arc) -> AppResult<()> { + // Call refresh_pods function directly + match refresh::refresh_pods_admin_internal(&state).await { + Ok(_) => { + info!("✅ Podcast refresh completed"); + + // Also run gpodder sync + if let Err(e) = refresh::refresh_gpodder_subscriptions_admin_internal(&state).await { + warn!("⚠️ GPodder sync failed during scheduled refresh: {}", e); + } + + // Also run nextcloud sync + if let Err(e) = refresh::refresh_nextcloud_subscriptions_admin_internal(&state).await { + warn!("⚠️ Nextcloud sync failed during scheduled refresh: {}", e); + } + + // Update playlist episode counts (replaces complex playlist content updates) + if let Err(e) = state.db_pool.update_playlist_episode_counts().await { + warn!("⚠️ Playlist episode count update failed during scheduled refresh: {}", e); + } + } + Err(e) => { + error!("❌ Podcast refresh failed: {}", e); + return Err(e); + } + } + Ok(()) + } + + async fn run_nightly_tasks(state: Arc) -> AppResult<()> { + // Call nightly tasks directly + if let Err(e) = tasks::refresh_hosts_internal(&state).await { + warn!("⚠️ Refresh hosts failed during nightly tasks: {}", e); + } + + if let Err(e) = tasks::auto_complete_episodes_internal(&state).await { + warn!("⚠️ Auto complete episodes failed during nightly tasks: {}", e); + } + + info!("✅ Nightly tasks completed"); + Ok(()) + } + + async fn run_cleanup_tasks(state: Arc) -> AppResult<()> { + // Call cleanup tasks directly + match tasks::cleanup_tasks_internal(&state).await { + Ok(_) => { + info!("✅ Cleanup tasks completed"); + } + Err(e) => { + error!("❌ Cleanup tasks failed: {}", e); + return Err(e); + } + } + Ok(()) + } + + // Run initial startup tasks immediately + pub async fn run_startup_tasks(state: Arc) -> AppResult<()> { + info!("🚀 Running initial startup tasks..."); + + // Initialize OIDC provider from environment variables if configured + if let Err(e) = state.db_pool.init_oidc_from_env(&state.config.oidc).await { + warn!("⚠️ OIDC initialization failed: {}", e); + } + + // Create missing default playlists for existing users + if let Err(e) = state.db_pool.create_missing_default_playlists().await { + warn!("⚠️ Creating missing default playlists failed: {}", e); + } + + // Run an immediate refresh to ensure data is current on startup + if let Err(e) = Self::run_refresh_pods(state.clone()).await { + warn!("⚠️ Initial startup refresh failed: {}", e); + } + + info!("✅ Startup tasks completed"); + Ok(()) + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/services/task_manager.rs b/PinePods-0.8.2/rust-api/src/services/task_manager.rs new file mode 100644 index 0000000..2352c69 --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/services/task_manager.rs @@ -0,0 +1,312 @@ +use crate::{error::AppResult, redis_client::RedisClient}; +use redis::AsyncCommands; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TaskStatus { + #[serde(rename = "PENDING")] + Pending, + #[serde(rename = "DOWNLOADING")] + Running, + #[serde(rename = "SUCCESS")] + Completed, + #[serde(rename = "FAILED")] + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskInfo { + pub id: String, + pub task_type: String, + pub user_id: i32, + pub status: TaskStatus, + pub progress: f64, + pub message: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub result: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TaskUpdate { + pub task_id: String, + pub user_id: i32, + #[serde(rename = "type")] + pub task_type: String, + pub item_id: Option, + pub progress: f64, + pub status: TaskStatus, + pub details: serde_json::Value, + pub started_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, +} + +// WebSocket message format to match Python implementation +#[derive(Debug, Clone, Serialize)] +pub struct WebSocketMessage { + pub event: String, + pub task: Option, + pub tasks: Option>, +} + +pub type TaskProgressSender = broadcast::Sender; +pub type TaskProgressReceiver = broadcast::Receiver; + +#[derive(Clone)] +pub struct TaskManager { + redis: RedisClient, + progress_sender: TaskProgressSender, +} + +impl TaskManager { + pub fn new(redis: RedisClient) -> Self { + let (progress_sender, _) = broadcast::channel(1000); + + Self { + redis, + progress_sender, + } + } + + pub fn subscribe_to_progress(&self) -> TaskProgressReceiver { + self.progress_sender.subscribe() + } + + pub async fn create_task( + &self, + task_type: String, + user_id: i32, + ) -> AppResult { + self.create_task_with_item_id(task_type, user_id, None).await + } + + pub async fn create_task_with_item_id( + &self, + task_type: String, + user_id: i32, + item_id: Option, + ) -> AppResult { + let task_id = Uuid::new_v4().to_string(); + let task = TaskInfo { + id: task_id.clone(), + task_type: task_type.clone(), + user_id, + status: TaskStatus::Pending, + progress: 0.0, + message: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + result: None, + }; + + self.save_task(&task).await?; + + // Send initial task update with item_id for frontend compatibility + let update = TaskUpdate { + task_id: task_id.clone(), + user_id, + task_type, + item_id, + progress: 0.0, + status: TaskStatus::Pending, + details: serde_json::json!({}), + started_at: chrono::Utc::now().to_rfc3339(), + completed_at: None, + }; + let _ = self.progress_sender.send(update); + + Ok(task_id) + } + + pub async fn update_task_progress( + &self, + task_id: &str, + progress: f64, + message: Option, + ) -> AppResult<()> { + self.update_task_progress_with_item_id(task_id, progress, message, None, None).await + } + + pub async fn update_task_progress_with_item_id( + &self, + task_id: &str, + progress: f64, + message: Option, + item_id: Option, + task_type: Option, + ) -> AppResult<()> { + self.update_task_progress_with_details(task_id, progress, message, item_id, task_type, None).await + } + + pub async fn update_task_progress_with_details( + &self, + task_id: &str, + progress: f64, + message: Option, + item_id: Option, + task_type: Option, + episode_title: Option, + ) -> AppResult<()> { + let mut task = self.get_task(task_id).await?; + task.progress = progress.clamp(0.0, 100.0); + task.message = message.clone(); + task.updated_at = chrono::Utc::now(); + + if progress > 0.0 && matches!(task.status, TaskStatus::Pending) { + task.status = TaskStatus::Running; + } + + self.save_task(&task).await?; + + let mut details = serde_json::json!({ + "status_text": message.as_deref().unwrap_or("Processing...") + }); + + // Add episode details if provided + if let Some(episode_id) = item_id { + details["episode_id"] = serde_json::json!(episode_id); + } + if let Some(title) = episode_title { + details["episode_title"] = serde_json::json!(title); + } + + let update = TaskUpdate { + task_id: task_id.to_string(), + user_id: task.user_id, + task_type: task_type.unwrap_or_else(|| task.task_type.clone()), + item_id, + progress, + status: task.status.clone(), + details, + started_at: task.created_at.to_rfc3339(), + completed_at: None, + }; + + let _ = self.progress_sender.send(update); + Ok(()) + } + + pub async fn complete_task( + &self, + task_id: &str, + result: Option, + message: Option, + ) -> AppResult<()> { + let mut task = self.get_task(task_id).await?; + task.status = TaskStatus::Completed; + task.progress = 100.0; + task.message = message.clone(); + task.result = result.clone(); + task.updated_at = chrono::Utc::now(); + + self.save_task(&task).await?; + + let update = TaskUpdate { + task_id: task_id.to_string(), + user_id: task.user_id, + task_type: task.task_type.clone(), + item_id: None, // Completion updates don't need item_id + progress: 100.0, + status: TaskStatus::Completed, + details: serde_json::json!({ + "status_text": message.as_deref().unwrap_or("Completed"), + "result": result + }), + started_at: task.created_at.to_rfc3339(), + completed_at: Some(chrono::Utc::now().to_rfc3339()), + }; + + let _ = self.progress_sender.send(update); + Ok(()) + } + + pub async fn fail_task( + &self, + task_id: &str, + error_message: String, + ) -> AppResult<()> { + let mut task = self.get_task(task_id).await?; + task.status = TaskStatus::Failed; + task.message = Some(error_message.clone()); + task.updated_at = chrono::Utc::now(); + + self.save_task(&task).await?; + + let update = TaskUpdate { + task_id: task_id.to_string(), + user_id: task.user_id, + task_type: task.task_type.clone(), + item_id: None, // Failure updates don't need item_id + progress: task.progress, + status: TaskStatus::Failed, + details: serde_json::json!({ + "status_text": error_message, + "error": error_message + }), + started_at: task.created_at.to_rfc3339(), + completed_at: Some(chrono::Utc::now().to_rfc3339()), + }; + + let _ = self.progress_sender.send(update); + Ok(()) + } + + pub async fn get_task(&self, task_id: &str) -> AppResult { + let key = format!("task:{}", task_id); + let mut conn = self.redis.get_connection().await?; + let task_json: String = conn.get(&key).await?; + let task: TaskInfo = serde_json::from_str(&task_json)?; + Ok(task) + } + + pub async fn get_user_tasks(&self, user_id: i32) -> AppResult> { + let pattern = format!("task:*"); + let mut conn = self.redis.get_connection().await?; + let keys: Vec = conn.keys(&pattern).await?; + + let mut user_tasks = Vec::new(); + for key in keys { + if let Ok(task_json) = conn.get::<_, String>(&key).await { + if let Ok(task) = serde_json::from_str::(&task_json) { + if task.user_id == user_id { + user_tasks.push(task); + } + } + } + } + + user_tasks.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(user_tasks) + } + + async fn save_task(&self, task: &TaskInfo) -> AppResult<()> { + let key = format!("task:{}", task.id); + let task_json = serde_json::to_string(task)?; + let mut conn = self.redis.get_connection().await?; + + conn.set_ex::<_, _, ()>(&key, &task_json, 86400 * 7).await?; // 7 days TTL + Ok(()) + } + + pub async fn cleanup_old_tasks(&self) -> AppResult<()> { + let cutoff = chrono::Utc::now() - chrono::Duration::days(7); + let pattern = "task:*"; + let mut conn = self.redis.get_connection().await?; + let keys: Vec = conn.keys(&pattern).await?; + + for key in keys { + if let Ok(task_json) = conn.get::<_, String>(&key).await { + if let Ok(task) = serde_json::from_str::(&task_json) { + if task.created_at < cutoff { + let _: () = conn.del(&key).await?; + } + } + } + } + + Ok(()) + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/rust-api/src/services/tasks.rs b/PinePods-0.8.2/rust-api/src/services/tasks.rs new file mode 100644 index 0000000..2c8c04b --- /dev/null +++ b/PinePods-0.8.2/rust-api/src/services/tasks.rs @@ -0,0 +1,1041 @@ +use crate::{ + error::AppResult, + services::task_manager::TaskManager, + database::DatabasePool, +}; +use futures::Future; +use serde_json::Value; +use std::sync::Arc; +use sqlx::Row; + +// New function that actually downloads an episode and waits for completion +async fn download_episode_and_wait( + db_pool: &crate::database::DatabasePool, + episode_id: i32, + user_id: i32, +) -> Result { + tracing::info!("Starting actual download for episode {} for user {}", episode_id, user_id); + + // Get episode metadata from database + let episode_info = match db_pool { + crate::database::DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT e.episodeurl, e.episodetitle, p.podcastname, + e.episodepubdate + FROM "Episodes" e + JOIN "Podcasts" p ON e.podcastid = p.podcastid + WHERE e.episodeid = $1 + "#) + .bind(episode_id) + .fetch_one(pool) + .await?; + + ( + row.try_get::("episodeurl")?, + row.try_get::("episodetitle")?, + row.try_get::("podcastname")?, + row.try_get::, _>("episodepubdate")?, + ) + } + crate::database::DatabasePool::MySQL(pool) => { + let row = sqlx::query(" + SELECT e.EpisodeURL, e.EpisodeTitle, p.PodcastName, e.EpisodePubDate + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE e.EpisodeID = ? + ") + .bind(episode_id) + .fetch_one(pool) + .await?; + + ( + row.try_get::("EpisodeURL")?, + row.try_get::("EpisodeTitle")?, + row.try_get::("PodcastName")?, + row.try_get::, _>("EpisodePubDate")?, + ) + } + }; + + let (episode_url, episode_title, podcast_name, pub_date) = episode_info; + + // Create download directory structure + let safe_podcast_name = podcast_name.chars() + .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' }) + .collect::() + .trim() + .to_string(); + + let safe_episode_title = episode_title.chars() + .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' }) + .collect::() + .trim() + .to_string(); + + let download_dir = std::path::Path::new("/opt/pinepods/downloads").join(&safe_podcast_name); + if !download_dir.exists() { + std::fs::create_dir_all(&download_dir) + .map_err(|e| crate::error::AppError::Internal(format!("Failed to create download directory: {}", e)))?; + + // Set ownership using PUID/PGID environment variables + let puid: u32 = std::env::var("PUID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + let pgid: u32 = std::env::var("PGID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + + // Set directory ownership (ignore errors for NFS mounts) + let _ = std::process::Command::new("chown") + .args(&[format!("{}:{}", puid, pgid), download_dir.to_string_lossy().to_string()]) + .output(); + } + + let pub_date_str = if let Some(date) = pub_date { + date.format("%Y-%m-%d").to_string() + } else { + chrono::Utc::now().format("%Y-%m-%d").to_string() + }; + + let filename = format!("{}_{}_{}_{}.mp3", pub_date_str, safe_episode_title, user_id, episode_id); + let file_path = download_dir.join(&filename); + + // Download the file + let client = reqwest::Client::new(); + let mut response = client.get(&episode_url) + .send() + .await + .map_err(|e| crate::error::AppError::Internal(format!("Failed to start download: {}", e)))?; + + if !response.status().is_success() { + return Err(crate::error::AppError::Internal(format!("Server returned error: {}", response.status()))); + } + + let mut file = std::fs::File::create(&file_path) + .map_err(|e| crate::error::AppError::Internal(format!("Failed to create file: {}", e)))?; + + // Download the content + while let Some(chunk) = response.chunk().await.map_err(|e| crate::error::AppError::Internal(format!("Download failed: {}", e)))? { + std::io::Write::write_all(&mut file, &chunk) + .map_err(|e| crate::error::AppError::Internal(format!("Failed to write file: {}", e)))?; + } + + // Close the file before setting ownership + drop(file); + + // Set file ownership using PUID/PGID environment variables + let puid: u32 = std::env::var("PUID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + let pgid: u32 = std::env::var("PGID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + + // Set file ownership (ignore errors for NFS mounts) + let _ = std::process::Command::new("chown") + .args(&[format!("{}:{}", puid, pgid), file_path.to_string_lossy().to_string()]) + .output(); + + // Record download in database + let file_size = tokio::fs::metadata(&file_path).await + .map(|m| m.len() as i64) + .unwrap_or(0); + + match db_pool { + crate::database::DatabasePool::Postgres(pool) => { + sqlx::query(r#"INSERT INTO "DownloadedEpisodes" (userid, episodeid, downloadedsize, downloadedlocation) VALUES ($1, $2, $3, $4)"#) + .bind(user_id) + .bind(episode_id) + .bind(file_size) + .bind(file_path.to_string_lossy().to_string()) + .execute(pool) + .await?; + } + crate::database::DatabasePool::MySQL(pool) => { + sqlx::query("INSERT INTO DownloadedEpisodes (UserID, EpisodeID, DownloadedSize, DownloadedLocation) VALUES (?, ?, ?, ?)") + .bind(user_id) + .bind(episode_id) + .bind(file_size) + .bind(file_path.to_string_lossy().to_string()) + .execute(pool) + .await?; + } + } + + tracing::info!("Successfully downloaded episode {} - {}", episode_id, episode_title); + Ok(episode_title) +} + +#[derive(Clone)] +pub struct TaskSpawner { + task_manager: Arc, + db_pool: DatabasePool, +} + +impl TaskSpawner { + pub fn new(task_manager: Arc, db_pool: DatabasePool) -> Self { + Self { task_manager, db_pool } + } + + pub async fn spawn_task( + &self, + task_type: String, + user_id: i32, + task_fn: F, + ) -> AppResult + where + F: FnOnce(String, Arc, DatabasePool) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let task_id = self.task_manager.create_task(task_type, user_id).await?; + let task_manager = self.task_manager.clone(); + let db_pool = self.db_pool.clone(); + let task_id_clone = task_id.clone(); + + tokio::spawn(async move { + match task_fn(task_id_clone.clone(), task_manager.clone(), db_pool).await { + Ok(result) => { + if let Err(e) = task_manager + .complete_task(&task_id_clone, Some(result), None) + .await + { + tracing::error!("Failed to mark task {} as completed: {}", task_id_clone, e); + } + } + Err(e) => { + if let Err(err) = task_manager + .fail_task(&task_id_clone, e.to_string()) + .await + { + tracing::error!("Failed to mark task {} as failed: {}", task_id_clone, err); + } + } + } + }); + + Ok(task_id) + } + + pub async fn spawn_simple_task( + &self, + task_type: String, + user_id: i32, + task_fn: F, + ) -> AppResult + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + self.spawn_task(task_type, user_id, move |_task_id, _task_manager, _db_pool| { + task_fn() + }) + .await + } + + pub async fn spawn_progress_task( + &self, + task_type: String, + user_id: i32, + task_fn: F, + ) -> AppResult + where + F: FnOnce(Arc) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + self.spawn_task(task_type, user_id, move |task_id, task_manager, _db_pool| { + let reporter = Arc::new(TaskProgressReporter { + task_id, + task_manager, + }); + task_fn(reporter) + }) + .await + } +} + +#[async_trait::async_trait] +pub trait ProgressReporter: Send + Sync { + async fn update_progress(&self, progress: f64, message: Option) -> AppResult<()>; +} + +pub struct TaskProgressReporter { + task_id: String, + task_manager: Arc, +} + +#[async_trait::async_trait] +impl ProgressReporter for TaskProgressReporter { + async fn update_progress(&self, progress: f64, message: Option) -> AppResult<()> { + self.task_manager + .update_task_progress(&self.task_id, progress, message) + .await + } +} + +impl TaskSpawner { + // Download task spawners for podcast episodes and YouTube videos + pub async fn spawn_download_podcast_episode(&self, episode_id: i32, user_id: i32) -> AppResult { + let db_pool = self.db_pool.clone(); + + // Create task with episode_id as item_id for frontend compatibility + let task_id = self.task_manager.create_task_with_item_id( + "download_episode".to_string(), + user_id, + Some(episode_id), + ).await?; + + let task_manager = self.task_manager.clone(); + let task_id_clone = task_id.clone(); + let task_manager_for_completion = task_manager.clone(); + let task_id_for_completion = task_id_clone.clone(); + + tokio::spawn(async move { + let result = async move { + tracing::info!("Downloading podcast episode {} for user {}", episode_id, user_id); + + // Update progress to starting with item_id + task_manager.update_task_progress_with_details(&task_id_clone, 0.0, Some("Starting download...".to_string()), Some(episode_id), Some("podcast_download".to_string()), None).await?; + + // Get complete episode metadata from database + let episode_info = match &db_pool { + crate::database::DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#" + SELECT e."episodeurl", e."episodetitle", p."podcastname", + e."episodepubdate", p."author", e."episodeartwork", p."artworkurl", + e."episodedescription" + FROM "Episodes" e + JOIN "Podcasts" p ON e."podcastid" = p."podcastid" + WHERE e."episodeid" = $1 + "#) + .bind(episode_id) + .fetch_one(pool) + .await?; + + ( + row.try_get::("episodeurl")?, + row.try_get::("episodetitle")?, + row.try_get::("podcastname")?, + row.try_get::, _>("episodepubdate")?, + row.try_get::, _>("author")?, + row.try_get::, _>("episodeartwork")?, + row.try_get::, _>("artworkurl")?, + row.try_get::, _>("episodedescription")? + ) + } + crate::database::DatabasePool::MySQL(pool) => { + let row = sqlx::query(" + SELECT e.EpisodeURL, e.EpisodeTitle, p.PodcastName, + e.EpisodePubDate, p.Author, e.EpisodeArtwork, p.ArtworkURL, + e.EpisodeDescription + FROM Episodes e + JOIN Podcasts p ON e.PodcastID = p.PodcastID + WHERE e.EpisodeID = ? + ") + .bind(episode_id) + .fetch_one(pool) + .await?; + + ( + row.try_get::("EpisodeURL")?, + row.try_get::("EpisodeTitle")?, + row.try_get::("PodcastName")?, + row.try_get::, _>("EpisodePubDate")?, + row.try_get::, _>("Author")?, + row.try_get::, _>("EpisodeArtwork")?, + row.try_get::, _>("ArtworkURL")?, + row.try_get::, _>("EpisodeDescription")? + ) + } + }; + + let (episode_url, episode_title, podcast_name, pub_date, author, episode_artwork, artwork_url, description) = episode_info; + + let status_message = format!("Preparing {}", episode_title); + task_manager.update_task_progress_with_details(&task_id_clone, 10.0, Some(status_message.clone()), Some(episode_id), Some("podcast_download".to_string()), Some(episode_title.clone())).await?; + + // Create download directory structure like Python version + let safe_podcast_name = podcast_name.chars() + .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' }) + .collect::() + .trim() + .to_string(); + + let safe_episode_title = episode_title.chars() + .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' }) + .collect::() + .trim() + .to_string(); + + // Create podcast-specific directory (like Python version) + let download_dir = std::path::Path::new("/opt/pinepods/downloads").join(&safe_podcast_name); + if !download_dir.exists() { + std::fs::create_dir_all(&download_dir) + .map_err(|e| crate::error::AppError::internal(&format!("Failed to create download directory: {}", e)))?; + + // Set ownership using PUID/PGID environment variables + let puid: u32 = std::env::var("PUID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + let pgid: u32 = std::env::var("PGID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + + // Set directory ownership (ignore errors for NFS mounts) + let _ = std::process::Command::new("chown") + .args(&[format!("{}:{}", puid, pgid), download_dir.to_string_lossy().to_string()]) + .output(); + } + + // Format date for filename (like Python version) + let pub_date_str = if let Some(date) = pub_date { + date.format("%Y-%m-%d").to_string() + } else { + chrono::Utc::now().format("%Y-%m-%d").to_string() + }; + + // Create filename with date, title, and IDs (like Python version) + let filename = format!("{}_{}_{}_{}.mp3", pub_date_str, safe_episode_title, user_id, episode_id); + let file_path = download_dir.join(&filename); + + let status_message = format!("Connecting to {}", episode_title); + task_manager.update_task_progress_with_details(&task_id_clone, 20.0, Some(status_message), Some(episode_id), Some("podcast_download".to_string()), Some(episode_title.clone())).await?; + + // Download the file + let client = reqwest::Client::new(); + let mut response = client.get(&episode_url) + .send() + .await + .map_err(|e| crate::error::AppError::internal(&format!("Failed to start download: {}", e)))?; + + if !response.status().is_success() { + return Err(crate::error::AppError::internal(&format!("Server returned error: {}", response.status()))); + } + + let total_size = response.content_length().unwrap_or(0); + let mut downloaded = 0; + let mut file = std::fs::File::create(&file_path) + .map_err(|e| crate::error::AppError::internal(&format!("Failed to create file: {}", e)))?; + + let status_message = format!("Starting download {}", episode_title); + task_manager.update_task_progress_with_details(&task_id_clone, 25.0, Some(status_message), Some(episode_id), Some("podcast_download".to_string()), Some(episode_title.clone())).await?; + + // Download in chunks with progress updates (throttled) + use std::io::Write; + let mut last_reported_progress = 0.0; + + while let Some(chunk) = response.chunk().await + .map_err(|e| crate::error::AppError::internal(&format!("Download failed: {}", e)))? + { + file.write_all(&chunk) + .map_err(|e| crate::error::AppError::internal(&format!("Failed to write file: {}", e)))?; + + downloaded += chunk.len() as u64; + + if total_size > 0 { + let progress = 25.0 + (downloaded as f64 / total_size as f64) * 65.0; // 25% to 90% + + // Only send WebSocket updates every 5% to avoid overwhelming the browser + if progress - last_reported_progress >= 5.0 || downloaded == total_size { + let status_message = format!("Downloading {}", episode_title); + task_manager.update_task_progress_with_details( + &task_id_clone, + progress, + Some(status_message), + Some(episode_id), + Some("podcast_download".to_string()), + Some(episode_title.clone()) + ).await?; + last_reported_progress = progress; + } + } + } + + file.flush() + .map_err(|e| crate::error::AppError::internal(&format!("Failed to flush file: {}", e)))?; + + drop(file); // Close the file handle before metadata operations + + // Set file ownership using PUID/PGID environment variables + let puid: u32 = std::env::var("PUID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + let pgid: u32 = std::env::var("PGID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); + + // Set file ownership (ignore errors for NFS mounts) + let _ = std::process::Command::new("chown") + .args(&[format!("{}:{}", puid, pgid), file_path.to_string_lossy().to_string()]) + .output(); + + let status_message = format!("Processing {}", episode_title); + task_manager.update_task_progress_with_details(&task_id_clone, 85.0, Some(status_message), Some(episode_id), Some("podcast_download".to_string()), Some(episode_title.clone())).await?; + + // Add metadata to the downloaded file + if let Err(e) = add_podcast_metadata( + &file_path, + &episode_title, + author.as_deref().unwrap_or("Unknown"), + &podcast_name, + pub_date.as_ref(), + episode_artwork.as_deref().or(artwork_url.as_deref()) + ).await { + tracing::warn!("Failed to add metadata to {}: {}", file_path.display(), e); + } + + let status_message = format!("Finalizing {}", episode_title); + task_manager.update_task_progress_with_details(&task_id_clone, 90.0, Some(status_message), Some(episode_id), Some("podcast_download".to_string()), Some(episode_title.clone())).await?; + + // Update database with download info + match &db_pool { + crate::database::DatabasePool::Postgres(pool) => { + sqlx::query(r#" + INSERT INTO "DownloadedEpisodes" (userid, episodeid, downloadedsize, downloadedlocation) + VALUES ($1, $2, $3, $4) + "#) + .bind(user_id) + .bind(episode_id) + .bind(downloaded as i64) + .bind(file_path.to_string_lossy().as_ref()) + .execute(pool) + .await?; + + // Update UserStats table to increment EpisodesDownloaded count + sqlx::query(r#" + UPDATE "UserStats" SET episodesdownloaded = episodesdownloaded + 1 WHERE userid = $1 + "#) + .bind(user_id) + .execute(pool) + .await?; + } + crate::database::DatabasePool::MySQL(pool) => { + sqlx::query(" + INSERT INTO DownloadedEpisodes (UserID, EpisodeID, DownloadedSize, DownloadedLocation) + VALUES (?, ?, ?, ?) + ") + .bind(user_id) + .bind(episode_id) + .bind(downloaded as i64) + .bind(file_path.to_string_lossy().as_ref()) + .execute(pool) + .await?; + + // Update UserStats table to increment EpisodesDownloaded count + sqlx::query(" + UPDATE UserStats SET EpisodesDownloaded = EpisodesDownloaded + 1 WHERE UserID = ? + ") + .bind(user_id) + .execute(pool) + .await?; + } + } + + let status_message = format!("Downloaded {}", episode_title); + task_manager.update_task_progress_with_details(&task_id_clone, 100.0, Some(status_message), Some(episode_id), Some("podcast_download".to_string()), Some(episode_title.clone())).await?; + + Ok(serde_json::json!({ + "episode_id": episode_id, + "user_id": user_id, + "status": "downloaded", + "file_path": file_path.to_string_lossy(), + "file_size": downloaded + })) + }; + + match result.await { + Ok(result) => { + if let Err(e) = task_manager_for_completion + .complete_task(&task_id_for_completion, Some(result), None) + .await + { + tracing::error!("Failed to mark task {} as completed: {}", task_id_for_completion, e); + } + } + Err(e) => { + if let Err(err) = task_manager_for_completion + .fail_task(&task_id_for_completion, e.to_string()) + .await + { + tracing::error!("Failed to mark task {} as failed: {}", task_id_for_completion, err); + } + } + } + }); + + Ok(task_id) + } + + pub async fn spawn_download_youtube_video(&self, video_id: i32, user_id: i32) -> AppResult { + self.spawn_task( + "download_video".to_string(), + user_id, + move |task_id, task_manager, db_pool| async move { + tracing::info!("Downloading YouTube video {} for user {}", video_id, user_id); + + // Get the video from database using the video ID + let (youtube_video_id, video_title) = match &db_pool { + crate::database::DatabasePool::Postgres(pool) => { + let row = sqlx::query(r#"SELECT youtubevideoid, videotitle FROM "YouTubeVideos" WHERE videoid = $1"#) + .bind(video_id) + .fetch_one(pool) + .await + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get video: {}", e)))?; + + let youtube_video_id: String = row.try_get("youtubevideoid") + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get YouTube video ID: {}", e)))?; + let video_title: String = row.try_get("videotitle") + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get video title: {}", e)))?; + + (youtube_video_id, video_title) + } + crate::database::DatabasePool::MySQL(pool) => { + let row = sqlx::query("SELECT YouTubeVideoID, VideoTitle FROM YouTubeVideos WHERE VideoID = ?") + .bind(video_id) + .fetch_one(pool) + .await + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get video: {}", e)))?; + + let youtube_video_id: String = row.try_get("YouTubeVideoID") + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get YouTube video ID: {}", e)))?; + let video_title: String = row.try_get("VideoTitle") + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get video title: {}", e)))?; + + (youtube_video_id, video_title) + } + }; + + let output_path = format!("/opt/pinepods/downloads/youtube/{}.mp3", youtube_video_id); + + // Check if file already exists + if tokio::fs::metadata(&output_path).await.is_ok() { + tracing::info!("Video {} already downloaded", video_title); + return Ok(serde_json::json!({ + "video_id": video_id, + "status": "already_downloaded", + "path": output_path + })); + } + + // Download the video using the YouTube handler function + match crate::handlers::youtube::download_youtube_audio(&youtube_video_id, &output_path).await { + Ok(_) => { + tracing::info!("Successfully downloaded YouTube video: {}", video_title); + + // Get duration from the downloaded MP3 file and update database + if let Some(duration) = crate::handlers::youtube::get_mp3_duration(&output_path) { + if let Err(e) = db_pool.update_youtube_video_duration(&youtube_video_id, duration).await { + tracing::error!("Failed to update duration for video {}: {}", youtube_video_id, e); + } else { + tracing::info!("Updated duration for video {} to {} seconds", youtube_video_id, duration); + } + } else { + tracing::warn!("Could not read duration from MP3 file: {}", output_path); + } + + Ok(serde_json::json!({ + "video_id": video_id, + "user_id": user_id, + "status": "downloaded", + "path": output_path, + "title": video_title + })) + } + Err(e) => { + tracing::error!("Failed to download YouTube video {}: {}", video_title, e); + Err(e) + } + } + }, + ).await + } + + pub async fn spawn_download_all_podcast_episodes(&self, podcast_id: i32, user_id: i32) -> AppResult { + // Create the task first + let task_id = self.task_manager.create_task("download_all_episodes".to_string(), user_id).await?; + let task_manager = self.task_manager.clone(); + let task_spawner = self.clone(); + let db_pool = self.db_pool.clone(); + let task_id_clone = task_id.clone(); + let task_manager_for_completion = task_manager.clone(); + let task_id_for_completion = task_id_clone.clone(); + + tokio::spawn(async move { + let result: Result = (async move { + tracing::info!("Downloading all episodes for podcast {} for user {}", podcast_id, user_id); + + // Update progress to starting + task_manager.update_task_progress_with_details(&task_id_clone, 0.0, Some("Getting episode list...".to_string()), None, Some("bulk_download".to_string()), None).await?; + + // Get episode IDs that are NOT already downloaded (replicating check_downloaded logic) + let episode_ids = match &db_pool { + crate::database::DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#" + SELECT e.episodeid + FROM "Episodes" e + LEFT JOIN "DownloadedEpisodes" de ON e.episodeid = de.episodeid AND de.userid = $2 + WHERE e.podcastid = $1 AND de.episodeid IS NULL + ORDER BY e.episodepubdate DESC + "#) + .bind(podcast_id) + .bind(user_id) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| row.try_get::("episodeid")) + .collect::, _>>()? + } + crate::database::DatabasePool::MySQL(pool) => { + let rows = sqlx::query(" + SELECT e.EpisodeID + FROM Episodes e + LEFT JOIN DownloadedEpisodes de ON e.EpisodeID = de.EpisodeID AND de.UserID = ? + WHERE e.PodcastID = ? AND de.EpisodeID IS NULL + ORDER BY e.EpisodePubDate DESC + ") + .bind(user_id) + .bind(podcast_id) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| row.try_get::("EpisodeID")) + .collect::, _>>()? + } + }; + + let total_episodes = episode_ids.len(); + tracing::info!("Found {} episodes for podcast {} to download", total_episodes, podcast_id); + + if total_episodes == 0 { + task_manager.update_task_progress_with_details(&task_id_clone, 100.0, Some("No episodes found to download".to_string()), None, Some("bulk_download".to_string()), None).await?; + return Ok(serde_json::json!({ + "podcast_id": podcast_id, + "user_id": user_id, + "status": "no_episodes_found", + "total_episodes": 0 + })); + } + + // Download episodes ONE at a time sequentially + let mut successful_downloads = 0; + + for (index, episode_id) in episode_ids.iter().enumerate() { + tracing::info!("Starting download {}/{}: episode {}", index + 1, total_episodes, episode_id); + + // Update progress before starting download + let progress = (index as f64 / total_episodes as f64) * 100.0; + task_manager.update_task_progress_with_details( + &task_id_clone, + progress, + Some(format!("Starting download {}/{} episodes...", index + 1, total_episodes)), + None, + Some("bulk_download".to_string()), + None + ).await?; + + // Actually download the episode and wait for it to complete + match download_episode_and_wait(&db_pool, *episode_id, user_id).await { + Ok(episode_title) => { + successful_downloads += 1; + tracing::info!("Successfully downloaded episode {} - {}", episode_id, episode_title); + + // Update progress after actual completion + let completed_progress = ((index + 1) as f64 / total_episodes as f64) * 100.0; + task_manager.update_task_progress_with_details( + &task_id_clone, + completed_progress, + Some(format!("Downloaded {}/{} episodes: {}", index + 1, total_episodes, episode_title)), + None, + Some("bulk_download".to_string()), + None + ).await?; + } + Err(e) => { + tracing::warn!("Failed to download episode {}: {}", episode_id, e); + } + } + } + + tracing::info!("Successfully started {} out of {} episode downloads", successful_downloads, total_episodes); + + task_manager.update_task_progress_with_details( + &task_id_clone, + 100.0, + Some(format!("Successfully started {}/{} episode downloads", successful_downloads, total_episodes)), + None, + Some("bulk_download".to_string()), + None + ).await?; + + tracing::info!("Successfully started {} out of {} episode downloads for podcast {} for user {}", successful_downloads, total_episodes, podcast_id, user_id); + + Ok(serde_json::json!({ + "podcast_id": podcast_id, + "user_id": user_id, + "status": "episodes_queued_sequentially", + "total_episodes": total_episodes, + "queued_episodes": successful_downloads + })) + }).await; + + match result { + Ok(response) => { + if let Err(e) = task_manager_for_completion.complete_task(&task_id_for_completion, Some(response), Some("All episodes queued for download".to_string())).await { + tracing::error!("Failed to complete download all episodes task: {}", e); + } + } + Err(e) => { + if let Err(err) = task_manager_for_completion.fail_task(&task_id_for_completion, format!("Download all episodes failed: {}", e)).await { + tracing::error!("Failed to mark download all episodes task as failed: {}", err); + } + } + } + }); + + Ok(task_id) + } + + pub async fn spawn_download_all_youtube_videos(&self, channel_id: i32, user_id: i32) -> AppResult { + self.spawn_task( + "download_all_videos".to_string(), + user_id, + move |task_id, task_manager, db_pool| async move { + tracing::info!("Downloading all videos for channel {} for user {}", channel_id, user_id); + + // Get all videos for the channel from database + let videos_data = match &db_pool { + crate::database::DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT videoid, youtubevideoid, videotitle FROM "YouTubeVideos" WHERE podcastid = $1"#) + .bind(channel_id) + .fetch_all(pool) + .await + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get videos: {}", e)))?; + + rows.into_iter().map(|row| { + let youtube_video_id: String = row.try_get("youtubevideoid") + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get YouTube video ID: {}", e)))?; + let video_title: String = row.try_get("videotitle") + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get video title: {}", e)))?; + Ok((youtube_video_id, video_title)) + }).collect::, crate::error::AppError>>()? + } + crate::database::DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT VideoID, YouTubeVideoID, VideoTitle FROM YouTubeVideos WHERE PodcastID = ?") + .bind(channel_id) + .fetch_all(pool) + .await + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get videos: {}", e)))?; + + rows.into_iter().map(|row| { + let youtube_video_id: String = row.try_get("YouTubeVideoID") + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get YouTube video ID: {}", e)))?; + let video_title: String = row.try_get("VideoTitle") + .map_err(|e| crate::error::AppError::internal(&format!("Failed to get video title: {}", e)))?; + Ok((youtube_video_id, video_title)) + }).collect::, crate::error::AppError>>()? + } + }; + + let total_videos = videos_data.len(); + let mut downloaded = 0; + let mut already_downloaded = 0; + let mut failed = 0; + + for (index, (youtube_video_id, video_title)) in videos_data.iter().enumerate() { + + let output_path = format!("/opt/pinepods/downloads/youtube/{}.mp3", youtube_video_id); + + // Update progress + let progress = (index as f64 / total_videos as f64) * 100.0; + task_manager.update_task_progress(&task_id, progress, Some(format!("Downloading: {}", video_title))).await?; + + // Check if file already exists + if tokio::fs::metadata(&output_path).await.is_ok() { + tracing::info!("Video {} already downloaded", video_title); + already_downloaded += 1; + continue; + } + + // Download the video + match crate::handlers::youtube::download_youtube_audio(youtube_video_id, &output_path).await { + Ok(_) => { + tracing::info!("Successfully downloaded: {}", video_title); + downloaded += 1; + + // Get duration from the downloaded MP3 file and update database + if let Some(duration) = crate::handlers::youtube::get_mp3_duration(&output_path) { + if let Err(e) = db_pool.update_youtube_video_duration(youtube_video_id, duration).await { + tracing::error!("Failed to update duration for video {}: {}", youtube_video_id, e); + } else { + tracing::info!("Updated duration for video {} to {} seconds", youtube_video_id, duration); + } + } else { + tracing::warn!("Could not read duration from MP3 file: {}", output_path); + } + } + Err(e) => { + tracing::error!("Failed to download {}: {}", video_title, e); + failed += 1; + // Continue with next video instead of failing entire batch + } + } + } + + // Final progress update + task_manager.update_task_progress(&task_id, 100.0, Some("Download batch completed".to_string())).await?; + + Ok(serde_json::json!({ + "channel_id": channel_id, + "user_id": user_id, + "status": "completed", + "total_videos": total_videos, + "downloaded": downloaded, + "already_downloaded": already_downloaded, + "failed": failed + })) + }, + ).await + } +} + +// Function to add metadata to downloaded MP3 files +async fn add_podcast_metadata( + file_path: &std::path::Path, + title: &str, + artist: &str, + album: &str, + date: Option<&chrono::NaiveDateTime>, + artwork_url: Option<&str>, +) -> Result<(), Box> { + use id3::TagLike; // Import the trait to use methods + use chrono::Datelike; // For year(), month(), day() methods + + // Create ID3 tag and add basic metadata + let mut tag = id3::Tag::new(); + tag.set_title(title); + tag.set_artist(artist); + tag.set_album(album); + + // Set date if available + if let Some(date) = date { + tag.set_date_recorded(id3::Timestamp { + year: date.year(), + month: Some(date.month() as u8), + day: Some(date.day() as u8), + hour: None, + minute: None, + second: None, + }); + } + + // Add genre for podcasts + tag.set_genre("Podcast"); + + // Download and add artwork if available + if let Some(artwork_url) = artwork_url { + if let Ok(artwork_data) = download_artwork(artwork_url).await { + // Determine MIME type based on the data + let mime_type = if artwork_data.starts_with(&[0xFF, 0xD8, 0xFF]) { + "image/jpeg" + } else if artwork_data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { + "image/png" + } else { + "image/jpeg" // Default fallback + }; + + tag.add_frame(id3::frame::Picture { + mime_type: mime_type.to_string(), + picture_type: id3::frame::PictureType::CoverFront, + description: "Cover".to_string(), + data: artwork_data, + }); + } + } + + // Write the tag to the file + tag.write_to_path(file_path, id3::Version::Id3v24)?; + + Ok(()) +} + +// Helper function to download artwork +async fn download_artwork(url: &str) -> Result, Box> { + let client = reqwest::Client::new(); + let response = client + .get(url) + .header("User-Agent", "PinePods/1.0") + .send() + .await?; + + if response.status().is_success() { + let bytes = response.bytes().await?; + // Limit artwork size to reasonable bounds (e.g., 5MB) + if bytes.len() > 5 * 1024 * 1024 { + return Err("Artwork too large".into()); + } + Ok(bytes.to_vec()) + } else { + Err(format!("Failed to download artwork: HTTP {}", response.status()).into()) + } +} + +impl TaskSpawner { + pub async fn spawn_add_podcast_episodes_task( + &self, + podcast_id: i32, + feed_url: String, + artwork_url: String, + user_id: i32, + username: Option, + password: Option, + ) -> AppResult { + let task_type = "add_podcast_episodes".to_string(); + + self.spawn_task( + task_type, + user_id, + move |task_id, task_manager, db_pool| { + Box::pin(async move { + println!("Starting episode processing for podcast {} (user {})", podcast_id, user_id); + + // Update progress - starting + task_manager.update_task_progress(&task_id, 10.0, Some("Fetching podcast feed...".to_string())).await?; + + // Add episodes to the existing podcast + match db_pool.add_episodes( + podcast_id, + &feed_url, + &artwork_url, + false, // auto_download + username.as_deref(), + password.as_deref(), + ).await { + Ok(first_episode_id) => { + // Update progress - fetching count + task_manager.update_task_progress(&task_id, 80.0, Some("Counting episodes...".to_string())).await?; + + // Count episodes for logging and notification + let episode_count: i64 = match &db_pool { + crate::database::DatabasePool::Postgres(pool) => { + sqlx::query_scalar(r#"SELECT COUNT(*) FROM "Episodes" WHERE podcastid = $1"#) + .bind(podcast_id) + .fetch_one(pool) + .await? + } + crate::database::DatabasePool::MySQL(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM Episodes WHERE PodcastID = ?") + .bind(podcast_id) + .fetch_one(pool) + .await? + } + }; + + // Final progress update + task_manager.update_task_progress(&task_id, 100.0, Some(format!("Added {} episodes", episode_count))).await?; + + println!("✅ Added {} episodes for podcast {} (user {})", episode_count, podcast_id, user_id); + + Ok(serde_json::json!({ + "podcast_id": podcast_id, + "user_id": user_id, + "episode_count": episode_count, + "first_episode_id": first_episode_id, + "status": "completed" + })) + } + Err(e) => { + println!("Failed to add episodes for podcast {}: {}", podcast_id, e); + task_manager.update_task_progress(&task_id, 0.0, Some(format!("Failed to add episodes: {}", e))).await?; + Err(e) + } + } + }) + }, + ).await + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/setup-tests.sh b/PinePods-0.8.2/setup-tests.sh new file mode 100755 index 0000000..02d0ac0 --- /dev/null +++ b/PinePods-0.8.2/setup-tests.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Get the directory where the script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Create and activate virtual environment if it doesn't exist +if [ ! -d "${SCRIPT_DIR}/venv" ]; then + python -m venv "${SCRIPT_DIR}/venv" +fi + +# Activate virtual environment +source "${SCRIPT_DIR}/venv/bin/activate" + +# Install requirements using absolute path +pip install -r "${SCRIPT_DIR}/test-requirements.txt" + +# Create test environment file +cat > .env.test << EOL +TEST_MODE=true +EOL + +# Create tests directory if it doesn't exist +mkdir -p tests + +echo "Test environment setup complete!" +echo "Run tests with: ./run-tests.sh [postgresql|mariadb]" diff --git a/PinePods-0.8.2/startup/app_startup.sh b/PinePods-0.8.2/startup/app_startup.sh new file mode 100644 index 0000000..11ce3c8 --- /dev/null +++ b/PinePods-0.8.2/startup/app_startup.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Ensure app has time to start +sleep 10 + +echo "Getting background tasks API key..." + +# Get API key from database for background_tasks user (UserID = 1) +if [ "$DB_TYPE" = "postgresql" ]; then + API_KEY=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c 'SELECT apikey FROM "APIKeys" WHERE userid = 1 LIMIT 1;' 2>/dev/null | xargs) +else + API_KEY=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -se 'SELECT APIKey FROM APIKeys WHERE UserID = 1 LIMIT 1;' 2>/dev/null) +fi + +if [ -z "$API_KEY" ]; then + echo "Error: Could not retrieve API key for background tasks" + exit 1 +fi + +# Initialize application tasks +echo "Initializing application tasks..." +curl -X POST "http://localhost:8032/api/init/startup_tasks" \ + -H "Content-Type: application/json" \ + -d "{\"api_key\": \"$API_KEY\"}" >> /cron.log 2>&1 diff --git a/PinePods-0.8.2/startup/call_nightly_tasks.sh b/PinePods-0.8.2/startup/call_nightly_tasks.sh new file mode 100644 index 0000000..a216138 --- /dev/null +++ b/PinePods-0.8.2/startup/call_nightly_tasks.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +sleep 10 + +# Get API key directly from database for background_tasks user (UserID 1) +echo "Getting background tasks API key..." + +# Database connection parameters +DB_TYPE=${DB_TYPE:-postgresql} +DB_HOST=${DB_HOST:-127.0.0.1} +DB_PORT=${DB_PORT:-5432} +DB_USER=${DB_USER:-postgres} +DB_PASSWORD=${DB_PASSWORD:-password} +DB_NAME=${DB_NAME:-pinepods_database} + +if [ "$DB_TYPE" = "postgresql" ]; then + API_KEY=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c 'SELECT apikey FROM "APIKeys" WHERE userid = 1 LIMIT 1;' 2>/dev/null | xargs) +else + API_KEY=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -se 'SELECT APIKey FROM APIKeys WHERE UserID = 1 LIMIT 1;' 2>/dev/null) +fi + +if [ -z "$API_KEY" ]; then + echo "Failed to get background tasks API key from database" >> /cron.log 2>&1 + exit 1 +fi + +# Call the FastAPI endpoint using the API key +# Run cleanup tasks +echo "Running nightly tasks..." +curl -X GET "http://localhost:8032/api/data/refresh_hosts" -H "Api-Key: $API_KEY" >> /cron.log 2>&1 +curl -X GET "http://localhost:8032/api/data/auto_complete_episodes" -H "Api-Key: $API_KEY" >> /cron.log 2>&1 diff --git a/PinePods-0.8.2/startup/call_refresh_endpoint.sh b/PinePods-0.8.2/startup/call_refresh_endpoint.sh new file mode 100644 index 0000000..3639f0b --- /dev/null +++ b/PinePods-0.8.2/startup/call_refresh_endpoint.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Ensure app has time to start +sleep 10 + +# Get API key directly from database for background_tasks user (UserID 1) +echo "Getting background tasks API key..." + +# Database connection parameters +DB_TYPE=${DB_TYPE:-postgresql} +DB_HOST=${DB_HOST:-127.0.0.1} +DB_PORT=${DB_PORT:-5432} +DB_USER=${DB_USER:-postgres} +DB_PASSWORD=${DB_PASSWORD:-password} +DB_NAME=${DB_NAME:-pinepods_database} + +if [ "$DB_TYPE" = "postgresql" ]; then + API_KEY=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c 'SELECT apikey FROM "APIKeys" WHERE userid = 1 LIMIT 1;' 2>/dev/null | xargs) +else + API_KEY=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -se 'SELECT APIKey FROM APIKeys WHERE UserID = 1 LIMIT 1;' 2>/dev/null) +fi + +if [ -z "$API_KEY" ]; then + echo "Failed to get background tasks API key from database" >> /cron.log 2>&1 + exit 1 +fi + +# Call the FastAPI endpoint using the API key +echo "Refreshing now!" +curl "http://localhost:8032/api/data/refresh_pods" -H "Api-Key: $API_KEY" >> /cron.log 2>&1 + +echo "Refreshing Nextcloud Subscription now!" +curl -X GET -H "Api-Key: $API_KEY" http://localhost:8032/api/data/refresh_nextcloud_subscriptions >> /cron.log 2>&1 + +# Run cleanup tasks +echo "Running cleanup tasks..." +curl -X GET "http://localhost:8032/api/data/cleanup_tasks" -H "Api-Key: $API_KEY" >> /cron.log 2>&1 + +# Refresh Playlists +echo "Refreshing Playlists..." +curl -X GET "http://localhost:8032/api/data/update_playlists" -H "Api-Key: $API_KEY" >> /cron.log 2>&1 diff --git a/PinePods-0.8.2/startup/logging_config.ini b/PinePods-0.8.2/startup/logging_config.ini new file mode 100644 index 0000000..165ad35 --- /dev/null +++ b/PinePods-0.8.2/startup/logging_config.ini @@ -0,0 +1,28 @@ +[loggers] +keys=root,simpleExample + +[handlers] +keys=consoleHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=ERROR +handlers=consoleHandler + +[logger_simpleExample] +level=ERROR +handlers=consoleHandler +qualname=simpleExample +propagate=0 + +[handler_consoleHandler] +class=StreamHandler +level=ERROR +formatter=simpleFormatter +args=(sys.stdout,) + +[formatter_simpleFormatter] +format=[%(asctime)s] [%(levelname)s] - %(name)s: %(message)s +datefmt=%Y-%m-%d %H:%M:%S diff --git a/PinePods-0.8.2/startup/logging_config_debug.ini b/PinePods-0.8.2/startup/logging_config_debug.ini new file mode 100644 index 0000000..6f08402 --- /dev/null +++ b/PinePods-0.8.2/startup/logging_config_debug.ini @@ -0,0 +1,28 @@ +[loggers] +keys=root,simpleExample + +[handlers] +keys=consoleHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=INFO +handlers=consoleHandler + +[logger_simpleExample] +level=INFO +handlers=consoleHandler +qualname=simpleExample +propagate=0 + +[handler_consoleHandler] +class=StreamHandler +level=INFO +formatter=simpleFormatter +args=(sys.stdout,) + +[formatter_simpleFormatter] +format=[%(asctime)s] [%(levelname)s] - %(name)s: %(message)s +datefmt=%Y-%m-%d %H:%M:%S diff --git a/PinePods-0.8.2/startup/nginx.conf b/PinePods-0.8.2/startup/nginx.conf new file mode 100644 index 0000000..51d6c5e --- /dev/null +++ b/PinePods-0.8.2/startup/nginx.conf @@ -0,0 +1,193 @@ +events {} + +http { + include mime.types; + default_type application/octet-stream; + client_max_body_size 0; + + server { + listen 8040; + + root /var/www/html; + index index.html; + + # Add CORS headers to all responses + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Api-Key,Authorization' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + + location /rss/ { + # Rewrite /rss/123 to /api/feed/123 + rewrite ^/rss/(\d+)(?:/(\d+))?$ /api/feed/$1$2 last; + proxy_pass http://localhost:8032; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Api-Key $arg_api_key; # Pass the api_key query param as a header + + # RSS-specific headers + add_header Content-Type "application/rss+xml; charset=utf-8"; + expires 1h; + add_header Cache-Control "public, no-transform"; + } + + location / { + # Handle OPTIONS requests for CORS preflight + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + + try_files $uri $uri/ /index.html; + } + + location /api { + # Add CORS headers for /api responses + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Api-Key,Authorization' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + + proxy_pass http://localhost:8032; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Api-Key,Authorization' always; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + } + + # Route all gpodder API requests to the Go service + location ~ ^/(api/2|auth|subscriptions|devices|updates|episodes|settings|lists|favorites|sync-devices|search|suggestions|toplist|tag|tags|data)/ { + proxy_pass http://127.0.0.1:8042; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Add CORS headers + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Api-Key,Authorization' always; + + # Handle OPTIONS requests + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Api-Key,Authorization' always; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + + # Increase timeouts for longer operations + proxy_read_timeout 300; + proxy_send_timeout 300; + } + + + # Special route for gpodder.net protocol support + location /api/gpodder { + # Add CORS headers for /api/gpodder responses + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Api-Key,Authorization' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + + proxy_pass http://localhost:8032; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Increase timeouts for potentially longer operations + proxy_read_timeout 300; + proxy_send_timeout 300; + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Api-Key,Authorization' always; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + } + + location /ws/api/data/ { + proxy_pass http://localhost:8032; # Pass the WebSocket connection to your backend + + # WebSocket headers + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Optionally increase the timeout values for long-running WebSocket connections + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } + + location /ws/api/tasks/ { + proxy_pass http://localhost:8032; # Pass the WebSocket connection to your backend + + # WebSocket headers + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Optionally increase the timeout values for long-running WebSocket connections + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } + + + # location = /api/data/restore_server { + # client_max_body_size 0; + + # proxy_pass http://localhost:8032; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + + # # You may not need the CORS headers specifically for this endpoint + # # unless you're expecting to call it directly from client-side JavaScript + # # in a browser. If it's called server-side or from a tool like Postman, + # # these CORS headers might be unnecessary. Adjust as needed. + # add_header 'Access-Control-Allow-Origin' '*' always; + # add_header 'Access-Control-Allow-Methods' 'POST' always; + # add_header 'Access-Control-Allow-Headers' 'Content-Type, Api-Key' always; + # } + + # Correct MIME type for WebAssembly files + location ~* \.wasm$ { + types { + application/wasm wasm; + } + } + } +} diff --git a/PinePods-0.8.2/startup/services/gpodder-api.toml b/PinePods-0.8.2/startup/services/gpodder-api.toml new file mode 100644 index 0000000..93d92c2 --- /dev/null +++ b/PinePods-0.8.2/startup/services/gpodder-api.toml @@ -0,0 +1,17 @@ +command = "/usr/local/bin/gpodder-api" +start-after = ["pinepods-api.toml"] +stdout = "${HORUST_STDOUT_MODE}" +stderr = "${HORUST_STDERR_MODE}" + +[restart] +strategy = "always" +backoff = "1s" +attempts = 0 + +[environment] +keep-env = true +additional = { DB_USER = "${DB_USER}", DB_HOST = "${DB_HOST}", DB_PORT = "${DB_PORT}", DB_NAME = "${DB_NAME}", DB_PASSWORD = "${DB_PASSWORD}", SERVER_PORT = "8042" } + +[termination] +signal = "TERM" +wait = "10s" \ No newline at end of file diff --git a/PinePods-0.8.2/startup/services/nginx.toml b/PinePods-0.8.2/startup/services/nginx.toml new file mode 100644 index 0000000..9733de2 --- /dev/null +++ b/PinePods-0.8.2/startup/services/nginx.toml @@ -0,0 +1,16 @@ +command = "nginx -g 'daemon off;'" +start-after = ["gpodder-api.toml"] +stdout = "${HORUST_STDOUT_MODE}" +stderr = "${HORUST_STDERR_MODE}" + +[restart] +strategy = "always" +backoff = "1s" +attempts = 0 + +[environment] +keep-env = true + +[termination] +signal = "TERM" +wait = "5s" \ No newline at end of file diff --git a/PinePods-0.8.2/startup/services/pinepods-api.toml b/PinePods-0.8.2/startup/services/pinepods-api.toml new file mode 100644 index 0000000..2428aa5 --- /dev/null +++ b/PinePods-0.8.2/startup/services/pinepods-api.toml @@ -0,0 +1,16 @@ +command = "/usr/local/bin/pinepods-api" +stdout = "${HORUST_STDOUT_MODE}" +stderr = "${HORUST_STDERR_MODE}" + +[restart] +strategy = "always" +backoff = "1s" +attempts = 0 + +[environment] +keep-env = true +additional = { DB_USER = "${DB_USER}", DB_PASSWORD = "${DB_PASSWORD}", DB_HOST = "${DB_HOST}", DB_NAME = "${DB_NAME}", DB_PORT = "${DB_PORT}", DB_TYPE = "${DB_TYPE}", FULLNAME = "${FULLNAME}", USERNAME = "${USERNAME}", EMAIL = "${EMAIL}", PASSWORD = "${PASSWORD}", REVERSE_PROXY = "${REVERSE_PROXY}", SEARCH_API_URL = "${SEARCH_API_URL}", PEOPLE_API_URL = "${PEOPLE_API_URL}", PINEPODS_PORT = "${PINEPODS_PORT}", PROXY_PROTOCOL = "${PROXY_PROTOCOL}", DEBUG_MODE = "${DEBUG_MODE}", VALKEY_HOST = "${VALKEY_HOST}", VALKEY_PORT = "${VALKEY_PORT}" } + +[termination] +signal = "TERM" +wait = "5s" \ No newline at end of file diff --git a/PinePods-0.8.2/startup/setup_database_new.py b/PinePods-0.8.2/startup/setup_database_new.py new file mode 100755 index 0000000..0b42d6b --- /dev/null +++ b/PinePods-0.8.2/startup/setup_database_new.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +New Idempotent Database Setup for PinePods + +This script replaces the old setupdatabase.py and setuppostgresdatabase.py +with a proper migration-based system that is fully idempotent. +""" + +import os +import sys +import logging +from pathlib import Path + +# Set up basic configuration for logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Add pinepods directory to sys.path for module import +pinepods_path = Path(__file__).parent.parent +sys.path.insert(0, str(pinepods_path)) +sys.path.insert(0, '/pinepods') # Also add the container path for Docker + +def wait_for_postgresql_ready(): + """Wait for PostgreSQL to be ready to accept connections (not just port open)""" + import time + import psycopg + + db_host = os.environ.get("DB_HOST", "127.0.0.1") + db_port = os.environ.get("DB_PORT", "5432") + db_user = os.environ.get("DB_USER", "postgres") + db_password = os.environ.get("DB_PASSWORD", "password") + + max_attempts = 30 # 30 seconds + attempt = 1 + + logger.info(f"Waiting for PostgreSQL at {db_host}:{db_port} to be ready...") + + while attempt <= max_attempts: + try: + # Try to connect to the postgres database + with psycopg.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password, + dbname='postgres', + connect_timeout=3 + ) as conn: + with conn.cursor() as cur: + # Test if PostgreSQL is ready to accept queries + cur.execute("SELECT 1") + cur.fetchone() + logger.info(f"PostgreSQL is ready after {attempt} attempts") + return True + except Exception as e: + if "not yet accepting connections" in str(e) or "recovery" in str(e).lower(): + logger.info(f"PostgreSQL not ready yet (attempt {attempt}/{max_attempts}): {e}") + else: + logger.warning(f"Connection attempt {attempt}/{max_attempts} failed: {e}") + + if attempt < max_attempts: + time.sleep(1) + attempt += 1 + + logger.error(f"PostgreSQL failed to become ready after {max_attempts} attempts") + return False + +def wait_for_mysql_ready(): + """Wait for MySQL/MariaDB to be ready to accept connections""" + import time + try: + import mariadb as mysql_connector + except ImportError: + import mysql.connector + + db_host = os.environ.get("DB_HOST", "127.0.0.1") + db_port = int(os.environ.get("DB_PORT", "3306")) + db_user = os.environ.get("DB_USER", "root") + db_password = os.environ.get("DB_PASSWORD", "password") + + max_attempts = 30 # 30 seconds + attempt = 1 + + logger.info(f"Waiting for MySQL/MariaDB at {db_host}:{db_port} to be ready...") + + while attempt <= max_attempts: + try: + # Try to connect to MySQL/MariaDB + conn = mysql_connector.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password, + connect_timeout=3, + autocommit=True + ) + cursor = conn.cursor() + # Test if MySQL is ready to accept queries + cursor.execute("SELECT 1") + cursor.fetchone() + cursor.close() + conn.close() + logger.info(f"MySQL/MariaDB is ready after {attempt} attempts") + return True + except Exception as e: + logger.info(f"MySQL/MariaDB not ready yet (attempt {attempt}/{max_attempts}): {e}") + + if attempt < max_attempts: + time.sleep(1) + attempt += 1 + + logger.error(f"MySQL/MariaDB failed to become ready after {max_attempts} attempts") + return False + +def create_database_if_not_exists(): + """Create the database if it doesn't exist and wait for database to be ready""" + db_type = os.environ.get("DB_TYPE", "postgresql").lower() + + if db_type in ['postgresql', 'postgres']: + # First, wait for PostgreSQL to be ready + if not wait_for_postgresql_ready(): + raise Exception("PostgreSQL did not become ready in time") + else: + # Wait for MySQL/MariaDB to be ready + if not wait_for_mysql_ready(): + raise Exception("MySQL/MariaDB did not become ready in time") + logger.info("MySQL/MariaDB is ready (database creation handled by container)") + return + + # PostgreSQL database creation logic continues below + if not wait_for_postgresql_ready(): + raise Exception("PostgreSQL did not become ready in time") + + try: + import psycopg + + # Database connection parameters + db_host = os.environ.get("DB_HOST", "127.0.0.1") + db_port = os.environ.get("DB_PORT", "5432") + db_user = os.environ.get("DB_USER", "postgres") + db_password = os.environ.get("DB_PASSWORD", "password") + db_name = os.environ.get("DB_NAME", "pinepods_database") + + # Connect to the default 'postgres' database to check/create target database + with psycopg.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password, + dbname='postgres' + ) as conn: + conn.autocommit = True + with conn.cursor() as cur: + # Check if the database exists + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) + exists = cur.fetchone() + if not exists: + logger.info(f"Database {db_name} does not exist. Creating...") + cur.execute(f"CREATE DATABASE {db_name}") + logger.info(f"Database {db_name} created successfully.") + else: + logger.info(f"Database {db_name} already exists.") + + except ImportError: + logger.error("psycopg not available for PostgreSQL database creation") + raise + except Exception as e: + logger.error(f"Error creating database: {e}") + raise + + +def ensure_usernames_lowercase(): + """Ensure all usernames are lowercase for consistency""" + try: + from database_functions.migrations import get_migration_manager + + manager = get_migration_manager() + conn = manager.get_connection() + cursor = conn.cursor() + + db_type = manager.db_type + table_name = '"Users"' if db_type == 'postgresql' else 'Users' + + try: + cursor.execute(f'SELECT UserID, Username FROM {table_name}') + users = cursor.fetchall() + + for user_id, username in users: + if username and username != username.lower(): + cursor.execute( + f'UPDATE {table_name} SET Username = %s WHERE UserID = %s', + (username.lower(), user_id) + ) + logger.info(f"Updated Username for UserID {user_id} to lowercase") + + conn.commit() + logger.info("Username normalization completed") + + finally: + cursor.close() + manager.close_connection() + + except Exception as e: + logger.error(f"Error normalizing usernames: {e}") + + +def ensure_web_api_key_file(): + """Deprecated: Web API key file removed for security reasons""" + logger.info("Web API key file creation skipped - background tasks now authenticate via database") + + +def main(): + """Main setup function""" + try: + logger.info("Starting PinePods database setup...") + + # Step 1: Create database if needed (PostgreSQL only) + create_database_if_not_exists() + + # Step 2: Import and register all migrations + logger.info("Loading migration definitions...") + import database_functions.migration_definitions + database_functions.migration_definitions.register_all_migrations() + + # Step 3: Run migrations + logger.info("Running database migrations...") + from database_functions.migrations import run_all_migrations + + success = run_all_migrations() + if not success: + logger.error("Database migrations failed!") + return False + + # Step 4: Ensure username consistency + logger.info("Ensuring username consistency...") + ensure_usernames_lowercase() + + # Step 5: Ensure web API key file exists + logger.info("Ensuring web API key file exists...") + ensure_web_api_key_file() + + logger.info("Database setup completed successfully!") + logger.info("Database validation complete") + + return True + + except Exception as e: + logger.error(f"Database setup failed: {e}") + return False + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/PinePods-0.8.2/startup/setupdatabase_legacy.py b/PinePods-0.8.2/startup/setupdatabase_legacy.py new file mode 100644 index 0000000..85c4aa2 --- /dev/null +++ b/PinePods-0.8.2/startup/setupdatabase_legacy.py @@ -0,0 +1,1325 @@ +import mysql.connector +import os +import sys +from cryptography.fernet import Fernet +import string +import secrets +import logging +import random +from argon2 import PasswordHasher +from argon2.exceptions import HashingError +from passlib.hash import argon2 + +# Set up basic configuration for logging +logging.basicConfig(level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s') + +# Append the pinepods directory to sys.path for module import +sys.path.append('/pinepods') + +try: + # Attempt to import additional modules + import database_functions.functions + # import Auth.Passfunctions + + def hash_password(password: str): + # Hash the password + hashed_password = argon2.hash(password) + # Argon2 includes the salt in the hashed output + return hashed_password + + # Retrieve database connection details from environment variables + db_host = os.environ.get("DB_HOST", "127.0.0.1") + db_port = os.environ.get("DB_PORT", "3306") + db_user = os.environ.get("DB_USER", "root") + db_password = os.environ.get("DB_PASSWORD", "password") + db_name = os.environ.get("DB_NAME", "pypods_database") + + # Attempt to create a database connector + cnx = mysql.connector.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password, + database=db_name, + charset='utf8mb4', + collation="utf8mb4_general_ci" + ) + + # Create a cursor to execute SQL statements + cursor = cnx.cursor() + + # Function to ensure all usernames are lowercase + def ensure_usernames_lowercase(cnx): + cursor = cnx.cursor() + cursor.execute('SELECT UserID, Username FROM Users') + users = cursor.fetchall() + for user_id, username in users: + if username != username.lower(): + cursor.execute('UPDATE Users SET Username = %s WHERE UserID = %s', (username.lower(), user_id)) + print(f"Updated Username for UserID {user_id} to lowercase") + cnx.commit() + cursor.close() + + # Function to check and add columns if they don't exist + def add_column_if_not_exists(cursor, table_name, column_name, column_definition): + cursor.execute(f""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name='{table_name}' + AND column_name='{column_name}' + AND table_schema=DATABASE(); + """) + if cursor.fetchone()[0] == 0: + cursor.execute(f""" + ALTER TABLE {table_name} + ADD COLUMN {column_name} {column_definition}; + """) + print(f"Column '{column_name}' added to table '{table_name}'") + else: + return + + # Create Users table if it doesn't exist (your existing code) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS Users ( + UserID INT AUTO_INCREMENT PRIMARY KEY, + Fullname VARCHAR(255), + Username VARCHAR(255), + Email VARCHAR(255), + Hashed_PW CHAR(255), + IsAdmin TINYINT(1), + Reset_Code TEXT, + Reset_Expiry DATETIME, + MFA_Secret VARCHAR(70), + TimeZone VARCHAR(50) DEFAULT 'UTC', + TimeFormat INT DEFAULT 24, + DateFormat VARCHAR(3) DEFAULT 'ISO', + FirstLogin TINYINT(1) DEFAULT 0, + GpodderUrl VARCHAR(255) DEFAULT '', + Pod_Sync_Type VARCHAR(50) DEFAULT 'None', + GpodderLoginName VARCHAR(255) DEFAULT '', + GpodderToken VARCHAR(255) DEFAULT '', + EnableRSSFeeds TINYINT(1) DEFAULT 0, + auth_type VARCHAR(50) DEFAULT 'standard', + oidc_provider_id INT, + oidc_subject VARCHAR(255), + PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0, + UNIQUE (Username) + ) + """) + + # Create OIDCProviders table if it doesn't exist (MySQL version) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS OIDCProviders ( + ProviderID INT AUTO_INCREMENT PRIMARY KEY, + ProviderName VARCHAR(255) NOT NULL, + ClientID VARCHAR(255) NOT NULL, + ClientSecret VARCHAR(500) NOT NULL, + AuthorizationURL VARCHAR(255) NOT NULL, + TokenURL VARCHAR(255) NOT NULL, + UserInfoURL VARCHAR(255) NOT NULL, + Scope VARCHAR(255) DEFAULT 'openid email profile', + ButtonColor VARCHAR(50) DEFAULT '#000000', + ButtonText VARCHAR(255) NOT NULL, + ButtonTextColor VARCHAR(50) DEFAULT '#000000', + IconSVG TEXT, + Enabled TINYINT(1) DEFAULT 1, + Created DATETIME DEFAULT CURRENT_TIMESTAMP, + Modified DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """) + cnx.commit() + + # Function to add PlaybackSpeed to Users table for MySQL + def add_playbackspeed_if_not_exist_users_mysql(cursor, cnx): + try: + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Users' + AND COLUMN_NAME = 'PlaybackSpeed' + """) + existing_column = cursor.fetchone() + if not existing_column: + cursor.execute(""" + ALTER TABLE Users + ADD COLUMN PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0 + """) + print("Added 'PlaybackSpeed' column to 'Users' table.") + cnx.commit() + else: + print("Column 'PlaybackSpeed' already exists in 'Users' table.") + except Exception as e: + print(f"Error checking PlaybackSpeed column in Users table: {e}") + + add_playbackspeed_if_not_exist_users_mysql(cursor, cnx) + + # Add new columns to Users table if they don't exist + add_column_if_not_exists(cursor, 'Users', 'auth_type', 'VARCHAR(50) DEFAULT \'standard\'') + add_column_if_not_exists(cursor, 'Users', 'oidc_provider_id', 'INT') + add_column_if_not_exists(cursor, 'Users', 'oidc_subject', 'VARCHAR(255)') + + # Check if foreign key exists before adding it + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_name = 'fk_oidc_provider' + AND table_name = 'Users' + AND table_schema = DATABASE(); + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE Users + ADD CONSTRAINT fk_oidc_provider + FOREIGN KEY (oidc_provider_id) + REFERENCES OIDCProviders(ProviderID); + """) + print("Foreign key constraint 'fk_oidc_provider' added") + + # Add EnableRSSFeeds column if it doesn't exist + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'Users' + AND column_name = 'EnableRSSFeeds' + """) + if cursor.fetchone()[0] == 0: + cursor.execute("ALTER TABLE Users ADD COLUMN EnableRSSFeeds TINYINT(1) DEFAULT 0") + + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'Users' + AND column_name = 'PlaybackSpeed' + """) + if cursor.fetchone()[0] == 0: + cursor.execute("ALTER TABLE Users ADD COLUMN PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0") + + # Add EnableRSSFeeds column if it doesn't exist + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name = 'Podcasts' + AND column_name = 'PlaybackSpeed' + """) + if cursor.fetchone()[0] == 0: + cursor.execute("ALTER TABLE Podcasts ADD COLUMN PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0") + + + ensure_usernames_lowercase(cnx) + + def add_pod_sync_if_not_exists(cursor, table_name, column_name, column_definition): + cursor.execute(f""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name='{table_name}' + AND column_name='{column_name}'; + """) + if cursor.fetchone()[0] == 0: + cursor.execute(f""" + ALTER TABLE {table_name} + ADD COLUMN {column_name} {column_definition}; + """) + print(f"Column '{column_name}' added to table '{table_name}'") + else: + return + + add_pod_sync_if_not_exists(cursor, 'Users', 'Pod_Sync_Type', 'VARCHAR(50) DEFAULT \'None\'') + + cursor.execute("""CREATE TABLE IF NOT EXISTS APIKeys ( + APIKeyID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + APIKey TEXT, + Created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE + )""") + cnx.commit() + + cursor.execute("""CREATE TABLE IF NOT EXISTS RssKeys ( + RssKeyID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + RssKey TEXT, + Created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE + )""") + cnx.commit() + + cursor.execute("""CREATE TABLE IF NOT EXISTS RssKeyMap ( + RssKeyID INT, + PodcastID INT, + FOREIGN KEY (RssKeyID) REFERENCES RssKeys(RssKeyID) ON DELETE CASCADE + )""") + cnx.commit() + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS GpodderDevices ( + DeviceID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceName VARCHAR(255) NOT NULL, + DeviceType VARCHAR(50) DEFAULT 'desktop', + DeviceCaption VARCHAR(255), + IsDefault BOOLEAN DEFAULT FALSE, + LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + IsActive BOOLEAN DEFAULT TRUE, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceName) + ) + """) + cnx.commit() + print("Created GpodderDevices table") + + # Create index for faster lookups + # Check if index exists before creating it + cursor.execute(""" + SELECT COUNT(1) IndexExists FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'GpodderDevices' + AND index_name = 'idx_gpodder_devices_userid' + """) + index_exists = cursor.fetchone()[0] + + if index_exists == 0: + # Create index only if it doesn't exist + cursor.execute(""" + CREATE INDEX idx_gpodder_devices_userid + ON GpodderDevices(UserID) + """) + cnx.commit() + print("Created index idx_gpodder_devices_userid") + else: + print("Index idx_gpodder_devices_userid already exists") + + # Create a table for subscription history/sync state + cursor.execute(""" + CREATE TABLE IF NOT EXISTS GpodderSyncState ( + SyncStateID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + LastTimestamp BIGINT DEFAULT 0, + EpisodesTimestamp BIGINT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ) + """) + cnx.commit() + print("Created GpodderSyncState table") + except Exception as e: + print(f"Error creating GPodder tables: {e}") + + cursor.execute("""CREATE TABLE IF NOT EXISTS UserStats ( + UserStatsID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + UserCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PodcastsPlayed INT DEFAULT 0, + TimeListened INT DEFAULT 0, + PodcastsAdded INT DEFAULT 0, + EpisodesSaved INT DEFAULT 0, + EpisodesDownloaded INT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID) + )""") + + # Generate a key + key = Fernet.generate_key() + + # Create the AppSettings table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS AppSettings ( + AppSettingsID INT AUTO_INCREMENT PRIMARY KEY, + SelfServiceUser TINYINT(1) DEFAULT 0, + DownloadEnabled TINYINT(1) DEFAULT 1, + EncryptionKey BINARY(44), -- Set the data type to BINARY(32) to hold the 32-byte key + NewsFeedSubscribed TINYINT(1) DEFAULT 0 + ) + """) + + cursor.execute("SELECT COUNT(*) FROM AppSettings WHERE AppSettingsID = 1") + count = cursor.fetchone()[0] + + if count == 0: + cursor.execute(""" + INSERT INTO AppSettings (SelfServiceUser, DownloadEnabled, EncryptionKey) + VALUES (0, 1, %s) + """, (key,)) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS EmailSettings ( + EmailSettingsID INT AUTO_INCREMENT PRIMARY KEY, + Server_Name VARCHAR(255), + Server_Port INT, + From_Email VARCHAR(255), + Send_Mode VARCHAR(255), + Encryption VARCHAR(255), + Auth_Required TINYINT(1), + Username VARCHAR(255), + Password VARCHAR(255) + ) + """) + + cursor.execute(""" + SELECT COUNT(*) FROM EmailSettings + """) + rows = cursor.fetchone() + + if rows[0] == 0: + cursor.execute(""" + INSERT INTO EmailSettings (Server_Name, Server_Port, From_Email, Send_Mode, Encryption, Auth_Required, Username, Password) + VALUES ('default_server', 587, 'default_email@domain.com', 'default_mode', 'default_encryption', 1, 'default_username', 'default_password') + """) + + # Generate a random password + def generate_random_password(length=12): + characters = string.ascii_letters + string.digits + string.punctuation + return ''.join(random.choice(characters) for i in range(length)) + + # Hash the password using Argon2 + def hash_password(password): + ph = PasswordHasher() + try: + return ph.hash(password) + except HashingError as e: + print(f"Error hashing password: {e}") + return None + + # Check if a user with the username 'guest' exists + def user_exists(cursor, username): + cursor.execute(""" + SELECT 1 FROM Users WHERE Username = %s + """, (username,)) + return cursor.fetchone() is not None + + def insert_or_update_user(cursor, hashed_password): + try: + # First, check if 'background_tasks' user exists + cursor.execute("SELECT * FROM Users WHERE Username = %s", ('background_tasks',)) + existing_user = cursor.fetchone() + + if existing_user: + # Update existing 'background_tasks' user + cursor.execute(""" + UPDATE Users + SET Fullname = %s, Email = %s, Hashed_PW = %s, IsAdmin = %s + WHERE Username = %s + """, ('Background Tasks', 'inactive', hashed_password, False, 'background_tasks')) + logging.info("Updated existing 'background_tasks' user.") + else: + # Check for 'guest' or 'bt' users to update + cursor.execute("SELECT Username FROM Users WHERE Username IN ('guest', 'bt')") + old_user = cursor.fetchone() + + if old_user: + # Update old user to 'background_tasks' + cursor.execute(""" + UPDATE Users + SET Fullname = %s, Username = %s, Email = %s, Hashed_PW = %s, IsAdmin = %s + WHERE Username = %s + """, ('Background Tasks', 'background_tasks', 'inactive', hashed_password, False, old_user[0])) + logging.info(f"Updated existing '{old_user[0]}' user to 'background_tasks' user.") + else: + # Insert new 'background_tasks' user + cursor.execute(""" + INSERT INTO Users (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (%s, %s, %s, %s, %s) + """, ('Background Tasks', 'background_tasks', 'inactive', hashed_password, False)) + logging.info("Inserted new 'background_tasks' user.") + + + except Exception as e: + print(f"Error inserting or updating user: {e}") + logging.error("Error inserting or updating user: %s", e) + # Rollback the transaction in case of error + + try: + # Generate and hash the password + random_password = generate_random_password() + hashed_password = hash_password(random_password) + + if hashed_password: + insert_or_update_user(cursor, hashed_password) + + except Exception as e: + print(f"Error setting default Background Task User: {e}") + logging.error("Error setting default Background Task User: %s", e) + + # Create the web Key + def create_api_key(cnx, user_id=1): + cursor_key = cnx.cursor() + + # Check if API key exists for user_id + query = f"SELECT APIKey FROM APIKeys WHERE UserID = {user_id}" + cursor_key.execute(query) + + result = cursor_key.fetchone() + + if result: + api_key = result[0] + else: + import secrets + import string + alphabet = string.ascii_letters + string.digits + api_key = ''.join(secrets.choice(alphabet) for _ in range(64)) + + # Note the quotes around {api_key} + query = f"INSERT INTO APIKeys (UserID, APIKey) VALUES ({user_id}, '{api_key}')" + cursor_key.execute(query) + + cnx.commit() + + cursor_key.close() + return api_key + + web_api_key = create_api_key(cnx) + with open("/tmp/web_api_key.txt", "w") as f: + f.write(web_api_key) + + # Check if admin environment variables are set + admin_fullname = os.environ.get("FULLNAME") + admin_username = os.environ.get("USERNAME") + admin_email = os.environ.get("EMAIL") + admin_pw = os.environ.get("PASSWORD") + + admin_created = False + if all([admin_fullname, admin_username, admin_email, admin_pw]): + # Hash the admin password + hashed_pw = hash_password(admin_pw) + admin_insert_query = """INSERT IGNORE INTO Users (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (%s, %s, %s, %s, %s)""" + # Execute the INSERT statement without a separate salt + cursor.execute(admin_insert_query, (admin_fullname, admin_username, admin_email, hashed_pw, 1)) + admin_created = True + + # Always create stats for background_tasks user + cursor.execute("""INSERT IGNORE INTO UserStats (UserID) VALUES (1)""") + + # Only create stats for admin if we created the admin user + if admin_created: + cursor.execute("""INSERT IGNORE INTO UserStats (UserID) VALUES (2)""") + + # Create the Podcasts table if it doesn't exist + cursor.execute("""CREATE TABLE IF NOT EXISTS Podcasts ( + PodcastID INT AUTO_INCREMENT PRIMARY KEY, + PodcastIndexID INT, + PodcastName TEXT, + ArtworkURL TEXT, + Author TEXT, + Categories TEXT, + Description TEXT, + EpisodeCount INT, + FeedURL TEXT, + WebsiteURL TEXT, + Explicit TINYINT(1), + UserID INT, + AutoDownload TINYINT(1) DEFAULT 0, + StartSkip INT DEFAULT 0, + EndSkip INT DEFAULT 0, + Username TEXT, + Password TEXT, + IsYouTubeChannel TINYINT(1) DEFAULT 0, + NotificationsEnabled TINYINT(1) DEFAULT 0, + FeedCutoffDays INT DEFAULT 0, + PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0, + PlaybackSpeedCustomized TINYINT(1) DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID) + )""") + + def add_youtube_column_if_not_exist(cursor, cnx): + try: + # Check if column exists in MySQL + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'IsYouTubeChannel' + AND TABLE_SCHEMA = DATABASE() + """) + existing_column = cursor.fetchone() + + if not existing_column: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN IsYouTubeChannel TINYINT(1) DEFAULT 0 + """) + print("Added 'IsYouTubeChannel' column to 'Podcasts' table.") + cnx.commit() + except Exception as e: + print(f"Error adding IsYouTubeChannel column to Podcasts table: {e}") + + add_youtube_column_if_not_exist(cursor, cnx) + + # Function to add PlaybackSpeed to Podcasts table for MySQL + def add_playbackspeed_if_not_exist_podcasts_mysql(cursor, cnx): + try: + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'PlaybackSpeed' + """) + existing_column = cursor.fetchone() + if not existing_column: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN PlaybackSpeed DECIMAL(2,1) UNSIGNED DEFAULT 1.0 + """) + print("Added 'PlaybackSpeed' column to 'Podcasts' table.") + cnx.commit() + else: + print("Column 'PlaybackSpeed' already exists in 'Podcasts' table.") + except Exception as e: + print(f"Error checking PlaybackSpeed column in Podcasts table: {e}") + + # Function to add PlaybackSpeedCustomized to Podcasts table for MySQL + def add_playbackspeed_customized_if_not_exist_mysql(cursor, cnx): + try: + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'PlaybackSpeedCustomized' + """) + existing_column = cursor.fetchone() + if not existing_column: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN PlaybackSpeedCustomized TINYINT(1) DEFAULT 0 + """) + print("Added 'PlaybackSpeedCustomized' column to 'Podcasts' table.") + cnx.commit() + else: + print("Column 'PlaybackSpeedCustomized' already exists in 'Podcasts' table.") + except Exception as e: + print(f"Error checking PlaybackSpeedCustomized column in Podcasts table: {e}") + + add_playbackspeed_if_not_exist_podcasts_mysql(cursor, cnx) + add_playbackspeed_customized_if_not_exist_mysql(cursor, cnx) + + def add_feed_cutoff_column_if_not_exist(cursor, cnx): + try: + # Check if column exists in MySQL + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'FeedCutoffDays' + AND TABLE_SCHEMA = DATABASE() + """) + existing_column = cursor.fetchone() + + if not existing_column: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN FeedCutoffDays INT DEFAULT 0 + """) + print("Added 'FeedCutoffDays' column to 'Podcasts' table.") + cnx.commit() + except Exception as e: + print(f"Error adding FeedCutoffDays column to Podcasts table: {e}") + + add_feed_cutoff_column_if_not_exist(cursor, cnx) + + def add_user_pass_columns_if_not_exist(cursor, cnx): + try: + # Check if the columns exist + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name='Podcasts' + AND column_name IN ('Username', 'Password') + """) + existing_columns = cursor.fetchall() + existing_columns = [col[0] for col in existing_columns] + + # Add Username column if it doesn't exist + if 'Username' not in existing_columns: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN Username TEXT + """) + print("Added 'Username' column to 'Podcasts' table.") + + # Add Password column if it doesn't exist + if 'Password' not in existing_columns: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN Password TEXT + """) + print("Added 'Password' column to 'Podcasts' table.") + + cnx.commit() # Ensure changes are committed + except Exception as e: + print(f"Error adding columns to Podcasts table: {e}") + + # Usage + add_user_pass_columns_if_not_exist(cursor, cnx) + + # Check if the new columns exist, and add them if they don't + cursor.execute("SHOW COLUMNS FROM Podcasts LIKE 'AutoDownload'") + result = cursor.fetchone() + if not result: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN AutoDownload TINYINT(1) DEFAULT 0, + ADD COLUMN StartSkip INT DEFAULT 0, + ADD COLUMN EndSkip INT DEFAULT 0 + """) + cursor.execute("""CREATE TABLE IF NOT EXISTS Episodes ( + EpisodeID INT AUTO_INCREMENT PRIMARY KEY, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate DATETIME, + EpisodeDuration INT, + Completed TINYINT(1) DEFAULT 0, + FOREIGN KEY (PodcastID) REFERENCES Podcasts(PodcastID) + )""") + # Check if the Completed column exists, and add it if it doesn't + cursor.execute("SHOW COLUMNS FROM Episodes LIKE 'Completed'") + result = cursor.fetchone() + if not result: + cursor.execute(""" + ALTER TABLE Episodes + ADD COLUMN Completed TINYINT(1) DEFAULT 0 + """) + + try: + # YouTubeVideos table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS YouTubeVideos ( + VideoID INT AUTO_INCREMENT PRIMARY KEY, + PodcastID INT, + VideoTitle TEXT, + VideoDescription TEXT, + VideoURL TEXT, + ThumbnailURL TEXT, + PublishedAt TIMESTAMP, + Duration INT, + YouTubeVideoID TEXT, + Completed TINYINT(1) DEFAULT 0, + ListenPosition INT DEFAULT 0, + FOREIGN KEY (PodcastID) REFERENCES Podcasts(PodcastID) + ) + """) + cnx.commit() + + except Exception as e: + print(f"Error creating YoutubeVideos Table: {e}") + + + def create_index_if_not_exists(cursor, index_name, table_name, column_name): + cursor.execute(f"SELECT COUNT(1) IndexIsThere FROM INFORMATION_SCHEMA.STATISTICS WHERE table_schema = DATABASE() AND index_name = '{index_name}'") + if cursor.fetchone()[0] == 0: + cursor.execute(f"CREATE INDEX {index_name} ON {table_name}({column_name})") + + create_index_if_not_exists(cursor, "idx_podcasts_userid", "Podcasts", "UserID") + create_index_if_not_exists(cursor, "idx_episodes_podcastid", "Episodes", "PodcastID") + create_index_if_not_exists(cursor, "idx_episodes_episodepubdate", "Episodes", "EpisodePubDate") + + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS People ( + PersonID INT AUTO_INCREMENT PRIMARY KEY, + Name TEXT, + PersonImg TEXT, + PeopleDBID INT, + AssociatedPodcasts TEXT, + UserID INT, + FOREIGN KEY (UserID) REFERENCES Users(UserID) + ); + """) + cnx.commit() + except Exception as e: + print(f"Error creating People table: {e}") + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS PeopleEpisodes ( + EpisodeID INT AUTO_INCREMENT PRIMARY KEY, + PersonID INT, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate DATETIME, + EpisodeDuration INT, + AddedDate DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PersonID) REFERENCES People(PersonID), + FOREIGN KEY (PodcastID) REFERENCES Podcasts(PodcastID) + ); + """) + cnx.commit() + except Exception as e: + print(f"Error creating PeopleEpisodes table: {e}") + + create_index_if_not_exists(cursor, "idx_people_episodes_person", "PeopleEpisodes", "PersonID") + create_index_if_not_exists(cursor, "idx_people_episodes_podcast", "PeopleEpisodes", "PodcastID") + + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS SharedEpisodes ( + SharedEpisodeID INT AUTO_INCREMENT PRIMARY KEY, + EpisodeID INT, + UrlKey TEXT, + ExpirationDate DATETIME, + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) + ) + """) + cnx.commit() + except Exception as e: + print(f"Error creating SharedEpisodes table: {e}") + + + + try: + cursor.execute("""CREATE TABLE IF NOT EXISTS UserSettings ( + UserSettingID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT UNIQUE, + Theme VARCHAR(255) DEFAULT 'Nordic', + StartPage VARCHAR(255) DEFAULT 'home', + FOREIGN KEY (UserID) REFERENCES Users(UserID) + )""") + except Exception as e: + print(f"Error adding UserSettings table: {e}") + + def add_startpage_column(): + try: + # Check if the column exists + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME='UserSettings' + AND COLUMN_NAME='StartPage' + AND TABLE_SCHEMA=DATABASE(); + """) + + # If the column doesn't exist (no rows returned), add it + if not cursor.fetchone(): + cursor.execute(""" + ALTER TABLE UserSettings + ADD COLUMN StartPage VARCHAR(255) DEFAULT 'home'; + """) + print("Successfully added StartPage column to UserSettings table") + else: + print("StartPage column already exists in UserSettings table") + + except Exception as e: + print(f"Error adding StartPage column: {e}") + + # Call the function to ensure the column exists + add_startpage_column() + + cursor.execute("""INSERT IGNORE INTO UserSettings (UserID, Theme) VALUES ('1', 'Nordic')""") + cursor.execute("""INSERT IGNORE INTO UserSettings (UserID, Theme) VALUES ('2', 'Nordic')""") + + cursor.execute("""CREATE TABLE IF NOT EXISTS UserEpisodeHistory ( + UserEpisodeHistoryID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + EpisodeID INT, + ListenDate DATETIME, + ListenDuration INT, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) + )""") + + try: + # UserVideoHistory table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS UserVideoHistory ( + UserVideoHistoryID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + VideoID INT, + ListenDate TIMESTAMP, + ListenDuration INT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) + ) + """) + cnx.commit() + + except Exception as e: + print(f"Error creating UserVideoHistory table: {e}") + + cursor.execute("""CREATE TABLE IF NOT EXISTS SavedEpisodes ( + SaveID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + EpisodeID INT, + SaveDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) + )""") + + try: + # SavedVideos table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS SavedVideos ( + SaveID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + VideoID INT, + SaveDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) + ) + """) + cnx.commit() + + except Exception as e: + print(f"Error creating SavedVideos table: {e}") + + + # Create the DownloadedEpisodes table + cursor.execute("""CREATE TABLE IF NOT EXISTS DownloadedEpisodes ( + DownloadID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + EpisodeID INT, + DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DownloadedSize INT, + DownloadedLocation VARCHAR(255), + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) + )""") + + try: + # DownloadedVideos table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS DownloadedVideos ( + DownloadID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + VideoID INT, + DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DownloadedSize INT, + DownloadedLocation VARCHAR(255), + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) + ) + """) + cnx.commit() + + except Exception as e: + print(f"Error creating DownloadedVideos table: {e}") + + # Create the EpisodeQueue table + cursor.execute("""CREATE TABLE IF NOT EXISTS EpisodeQueue ( + QueueID INT AUTO_INCREMENT PRIMARY KEY, + QueueDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UserID INT, + EpisodeID INT, + QueuePosition INT NOT NULL DEFAULT 0, + is_youtube TINYINT(1) DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) + )""") + + def add_queue_youtube_column_if_not_exist(cursor, cnx): + try: + # Check if column exists in MySQL + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'EpisodeQueue' + AND COLUMN_NAME = 'is_youtube' + AND TABLE_SCHEMA = DATABASE() + """) + existing_column = cursor.fetchone() + + if not existing_column: + try: + # Add the is_youtube column + cursor.execute(""" + ALTER TABLE EpisodeQueue + ADD COLUMN is_youtube TINYINT(1) DEFAULT 0 + """) + cnx.commit() + print("Added 'is_youtube' column to 'EpisodeQueue' table.") + except Exception as e: + cnx.rollback() + if 'Duplicate column name' not in str(e): # MySQL specific error message + print(f"Error adding is_youtube column to EpisodeQueue table: {e}") + else: + cnx.commit() # Commit transaction even if column exists + + except Exception as e: + cnx.rollback() + print(f"Error checking for is_youtube column: {e}") + + add_queue_youtube_column_if_not_exist(cursor, cnx) + + def add_rssonly_column_if_not_exists(cursor, cnx): + try: + # Check if column exists in MySQL + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'APIKeys' + AND COLUMN_NAME = 'RssOnly' + AND TABLE_SCHEMA = DATABASE() + """) + existing_column = cursor.fetchone() + + if not existing_column: + try: + # Add the is_youtube column + cursor.execute(""" + ALTER TABLE APIKeys + ADD COLUMN RssOnly TINYINT(1) DEFAULT 0 + """) + cnx.commit() + print("Added 'RssOnly' column to 'APIKeys' table.") + except Exception as e: + cnx.rollback() + if 'Duplicate column name' not in str(e): # MySQL specific error message + print(f"Error adding RssOnly column to APIKeys table: {e}") + else: + cnx.commit() # Commit transaction even if column exists + + except Exception as e: + cnx.rollback() + print(f"Error checking for is_youtube column: {e}") + + add_rssonly_column_if_not_exists(cursor, cnx) + + # Create the Sessions table + cursor.execute("""CREATE TABLE IF NOT EXISTS Sessions ( + SessionID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + value TEXT, + expire DATETIME NOT NULL, + FOREIGN KEY (UserID) REFERENCES Users(UserID) + )""") + + def add_notification_column_if_not_exists(cursor, cnx): + try: + # First check if the column exists + cursor.execute(""" + SELECT COUNT(*) as column_exists + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'Podcasts' + AND COLUMN_NAME = 'NotificationsEnabled' + AND TABLE_SCHEMA = DATABASE() + """) + result = cursor.fetchone() + column_exists = result[0] > 0 if isinstance(result, tuple) else result.get('column_exists', 0) > 0 + + # Only attempt to add the column if it doesn't exist + if not column_exists: + try: + cursor.execute(""" + ALTER TABLE Podcasts + ADD COLUMN NotificationsEnabled TINYINT(1) DEFAULT 0 + """) + print("Added NotificationsEnabled column to Podcasts table.") + cnx.commit() + except Exception as alter_err: + # Check if the error is because the column already exists + # (This can happen in race conditions or if the schema check was outdated) + if "Duplicate column name" in str(alter_err) or "column already exists" in str(alter_err).lower(): + print("Column NotificationsEnabled already exists in Podcasts table.") + else: + # It's a different error, so re-raise it + raise alter_err + else: + print("Column NotificationsEnabled already exists in Podcasts table.") + + except Exception as e: + print(f"Error checking/adding NotificationsEnabled column to Podcasts table: {e}") + # Only rollback if we're in a transaction that needs rolling back + try: + cnx.rollback() + except: + pass # If rollback fails, we're not in a transaction + + # Create the notification settings table + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS UserNotificationSettings ( + SettingID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT, + Platform VARCHAR(50) NOT NULL, + Enabled TINYINT(1) DEFAULT 1, + NtfyTopic VARCHAR(255), + NtfyServerUrl VARCHAR(255) DEFAULT 'https://ntfy.sh', + GotifyUrl VARCHAR(255), + GotifyToken VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES Users(UserID), + UNIQUE(UserID, platform) + ) + """) + print("Checked/Created UserNotificationSettings table") + cnx.commit() + except Exception as e: + print(f"Error creating UserNotificationSettings table: {e}") + cnx.rollback() + + # Call our function to add the new column + add_notification_column_if_not_exists(cursor, cnx) + + try: + # Create Playlists table with the unique constraint + cursor.execute(""" + CREATE TABLE IF NOT EXISTS Playlists ( + PlaylistID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + Name VARCHAR(255) NOT NULL, + Description TEXT, + IsSystemPlaylist TINYINT(1) NOT NULL DEFAULT 0, + PodcastIDs TEXT, -- Storing as JSON array in MySQL + IncludeUnplayed TINYINT(1) NOT NULL DEFAULT 1, + IncludePartiallyPlayed TINYINT(1) NOT NULL DEFAULT 1, + IncludePlayed TINYINT(1) NOT NULL DEFAULT 0, + MinDuration INT, -- NULL means no minimum + MaxDuration INT, -- NULL means no maximum + SortOrder VARCHAR(50) NOT NULL DEFAULT 'date_desc', + GroupByPodcast TINYINT(1) NOT NULL DEFAULT 0, + MaxEpisodes INT, -- NULL means no limit + PlayProgressMin FLOAT, -- NULL means no minimum progress requirement + PlayProgressMax FLOAT, -- NULL means no maximum progress limit + TimeFilterHours INT, -- NULL means no time filter + LastUpdated DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + Created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + IconName VARCHAR(50) NOT NULL DEFAULT 'ph-playlist', + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Name), + CHECK (PlayProgressMin IS NULL OR (PlayProgressMin >= 0 AND PlayProgressMin <= 100)), + CHECK (PlayProgressMax IS NULL OR (PlayProgressMax >= 0 AND PlayProgressMax <= 100)), + CHECK (PlayProgressMin IS NULL OR PlayProgressMax IS NULL OR PlayProgressMin <= PlayProgressMax), + CHECK (MinDuration IS NULL OR MinDuration >= 0), + CHECK (MaxDuration IS NULL OR MaxDuration >= 0), + CHECK (MinDuration IS NULL OR MaxDuration IS NULL OR MinDuration <= MaxDuration), + CHECK (TimeFilterHours IS NULL OR TimeFilterHours > 0), + CHECK (MaxEpisodes IS NULL OR MaxEpisodes > 0), + CHECK (SortOrder IN ('date_asc', 'date_desc', + 'duration_asc', 'duration_desc', + 'listen_progress', 'completion')) + ) + """) + cnx.commit() + + # Create PlaylistContents table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS PlaylistContents ( + PlaylistContentID INT AUTO_INCREMENT PRIMARY KEY, + PlaylistID INT, + EpisodeID INT, + VideoID INT, + Position INT, + DateAdded DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PlaylistID) REFERENCES Playlists(PlaylistID) ON DELETE CASCADE, + FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) ON DELETE CASCADE, + FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) ON DELETE CASCADE, + CHECK ((EpisodeID IS NOT NULL AND VideoID IS NULL) OR (EpisodeID IS NULL AND VideoID IS NOT NULL)) + ) + """) + cnx.commit() + + # Create indexes - check if they exist first + # Index 1: idx_playlists_userid + cursor.execute(""" + SELECT COUNT(1) IndexExists FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'Playlists' + AND index_name = 'idx_playlists_userid' + """) + if cursor.fetchone()[0] == 0: + cursor.execute("CREATE INDEX idx_playlists_userid ON Playlists(UserID)") + cnx.commit() + print("Created index idx_playlists_userid") + + # Index 2: idx_playlist_contents_playlistid + cursor.execute(""" + SELECT COUNT(1) IndexExists FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'PlaylistContents' + AND index_name = 'idx_playlist_contents_playlistid' + """) + if cursor.fetchone()[0] == 0: + cursor.execute("CREATE INDEX idx_playlist_contents_playlistid ON PlaylistContents(PlaylistID)") + cnx.commit() + print("Created index idx_playlist_contents_playlistid") + + # Index 3: idx_playlist_contents_episodeid + cursor.execute(""" + SELECT COUNT(1) IndexExists FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'PlaylistContents' + AND index_name = 'idx_playlist_contents_episodeid' + """) + if cursor.fetchone()[0] == 0: + cursor.execute("CREATE INDEX idx_playlist_contents_episodeid ON PlaylistContents(EpisodeID)") + cnx.commit() + print("Created index idx_playlist_contents_episodeid") + + # Index 4: idx_playlist_contents_videoid + cursor.execute(""" + SELECT COUNT(1) IndexExists FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'PlaylistContents' + AND index_name = 'idx_playlist_contents_videoid' + """) + if cursor.fetchone()[0] == 0: + cursor.execute("CREATE INDEX idx_playlist_contents_videoid ON PlaylistContents(VideoID)") + cnx.commit() + print("Created index idx_playlist_contents_videoid") + + # Define system playlists + system_playlists = [ + { + 'name': 'Quick Listens', + 'description': 'Short episodes under 15 minutes, perfect for quick breaks', + 'min_duration': None, + 'max_duration': 900, # 15 minutes + 'sort_order': 'duration_asc', + 'icon_name': 'ph-fast-forward' + }, + { + 'name': 'Longform', + 'description': 'Extended episodes over 1 hour, ideal for long drives or deep dives', + 'min_duration': 3600, # 1 hour + 'max_duration': None, + 'sort_order': 'duration_desc', + 'icon_name': 'ph-car' + }, + { + 'name': 'Currently Listening', + 'description': 'Episodes you\'ve started but haven\'t finished', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_desc', + 'include_unplayed': False, + 'include_partially_played': True, + 'include_played': False, + 'icon_name': 'ph-play' + }, + { + 'name': 'Fresh Releases', + 'description': 'Latest episodes from the last 24 hours', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_desc', + 'include_unplayed': True, + 'include_partially_played': False, + 'include_played': False, + 'time_filter_hours': 24, + 'icon_name': 'ph-sparkle' + }, + { + 'name': 'Weekend Marathon', + 'description': 'Longer episodes (30+ minutes) perfect for weekend listening', + 'min_duration': 1800, # 30 minutes + 'max_duration': None, + 'sort_order': 'duration_desc', + 'group_by_podcast': True, + 'icon_name': 'ph-couch' + }, + { + 'name': 'Commuter Mix', + 'description': 'Episodes between 20-40 minutes, ideal for average commute times', + 'min_duration': 1200, # 20 minutes + 'max_duration': 2400, # 40 minutes + 'sort_order': 'date_desc', + 'icon_name': 'ph-train' + }, + { + 'name': 'Almost Done', + 'description': 'Episodes you\'re close to finishing (75%+ complete)', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_asc', + 'include_unplayed': False, + 'include_partially_played': True, + 'include_played': False, + 'play_progress_min': 75.0, + 'play_progress_max': None, + 'icon_name': 'ph-hourglass' + } + ] + + # Insert system playlists + for playlist in system_playlists: + try: + # First check if this playlist already exists + cursor.execute(""" + SELECT COUNT(*) + FROM Playlists + WHERE UserID = 1 AND Name = %s AND IsSystemPlaylist = 1 + """, (playlist['name'],)) + + if cursor.fetchone()[0] == 0: + cursor.execute(""" + INSERT INTO Playlists ( + UserID, + Name, + Description, + IsSystemPlaylist, + MinDuration, + MaxDuration, + SortOrder, + GroupByPodcast, + IncludeUnplayed, + IncludePartiallyPlayed, + IncludePlayed, + IconName, + PlayProgressMin, + PlayProgressMax, + TimeFilterHours + ) VALUES ( + 1, + %s, + %s, + 1, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s + ) + """, ( + playlist['name'], + playlist['description'], + playlist.get('min_duration'), + playlist.get('max_duration'), + playlist.get('sort_order', 'date_asc'), + 1 if playlist.get('group_by_podcast', False) else 0, + 1 if playlist.get('include_unplayed', True) else 0, + 1 if playlist.get('include_partially_played', True) else 0, + 1 if playlist.get('include_played', False) else 0, + playlist.get('icon_name', 'ph-playlist'), + playlist.get('play_progress_min'), + playlist.get('play_progress_max'), + playlist.get('time_filter_hours') + )) + cnx.commit() + print(f"Successfully added system playlist: {playlist['name']}") + else: + print(f"System playlist already exists: {playlist['name']}") + + except Exception as e: + print(f"Error handling system playlist {playlist['name']}: {e}") + continue + + except Exception as e: + print(f"Error setting up platlists: {e}") + + print("Checked/Created Playlist Tables") + +except mysql.connector.Error as err: + logging.error(f"Database error: {err}") +except Exception as e: + logging.error(f"General error: {e}") + +# Ensure to close the cursor and connection +finally: + if 'cursor' in locals(): + cursor.close() + if 'cnx' in locals(): + cnx.close() diff --git a/PinePods-0.8.2/startup/setuppostgresdatabase_legacy.py b/PinePods-0.8.2/startup/setuppostgresdatabase_legacy.py new file mode 100644 index 0000000..c908a99 --- /dev/null +++ b/PinePods-0.8.2/startup/setuppostgresdatabase_legacy.py @@ -0,0 +1,1352 @@ +import os +import sys +from cryptography.fernet import Fernet +import string +import secrets +from passlib.hash import argon2 +import psycopg +from argon2 import PasswordHasher +from argon2.exceptions import HashingError +import logging +import random + +# Generate a random password +def generate_random_password(length=12): + characters = string.ascii_letters + string.digits + string.punctuation + return ''.join(random.choice(characters) for i in range(length)) + +# Hash the password using Argon2 +def hash_password(password): + ph = PasswordHasher() + try: + return ph.hash(password) + except HashingError as e: + print(f"Error hashing password: {e}") + return None + +# Set up basic configuration for logging +logging.basicConfig(level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s') + +# Append the pinepods directory to sys.path for module import +sys.path.append('/pinepods') + +try: + # Attempt to import additional modules + import database_functions.functions + # import Auth.Passfunctions + + def hash_password(password: str): + # Hash the password + hashed_password = argon2.hash(password) + # Argon2 includes the salt in the hashed output + return hashed_password + + + + # Database variables + db_host = os.environ.get("DB_HOST", "127.0.0.1") + db_port = os.environ.get("DB_PORT", "5432") + db_user = os.environ.get("DB_USER", "postgres") + db_password = os.environ.get("DB_PASSWORD", "password") + db_name = os.environ.get("DB_NAME", "pypods_database") + + # Function to create the database if it doesn't exist + def create_database_if_not_exists(): + try: + # Connect to the default 'postgres' database + with psycopg.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password, + dbname='postgres' + ) as conn: + conn.autocommit = True + with conn.cursor() as cur: + # Check if the database exists + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) + exists = cur.fetchone() + if not exists: + logging.info(f"Database {db_name} does not exist. Creating...") + print(f"Database {db_name} does not exist. Creating...") + cur.execute(f"CREATE DATABASE {db_name}") + logging.info(f"Database {db_name} created successfully.") + else: + logging.info(f"Database {db_name} already exists.") + except Exception as e: + logging.error(f"Error creating database: {e}") + raise + + # Create the database if it doesn't exist + create_database_if_not_exists() + + # Create database connector + cnx = psycopg.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password, + dbname=db_name + ) + + # create a cursor to execute SQL statements + cursor = cnx.cursor() + + def ensure_usernames_lowercase(cnx): + with cnx.cursor() as cursor: + cursor.execute('SELECT UserID, Username FROM "Users"') + users = cursor.fetchall() + for user_id, username in users: + if username != username.lower(): + cursor.execute('UPDATE "Users" SET Username = %s WHERE UserID = %s', (username.lower(), user_id)) + print(f"Updated Username for UserID {user_id} to lowercase") + + def add_column_if_not_exists(cursor, table_name, column_name, column_definition): + cursor.execute(f""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_name='{table_name}' + AND column_name='{column_name}'; + """) + if cursor.fetchone()[0] == 0: + cursor.execute(f""" + ALTER TABLE "{table_name}" + ADD COLUMN {column_name} {column_definition}; + """) + print(f"Column '{column_name}' added to table '{table_name}'") + else: + return + + # Create Users table first + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Users" ( + UserID SERIAL PRIMARY KEY, + Fullname VARCHAR(255), + Username VARCHAR(255) UNIQUE, + Email VARCHAR(255), + Hashed_PW VARCHAR(500), + IsAdmin BOOLEAN, + Reset_Code TEXT, + Reset_Expiry TIMESTAMP, + MFA_Secret VARCHAR(70), + TimeZone VARCHAR(50) DEFAULT 'UTC', + TimeFormat INT DEFAULT 24, + DateFormat VARCHAR(3) DEFAULT 'ISO', + FirstLogin BOOLEAN DEFAULT false, + GpodderUrl VARCHAR(255) DEFAULT '', + Pod_Sync_Type VARCHAR(50) DEFAULT 'None', + GpodderLoginName VARCHAR(255) DEFAULT '', + GpodderToken VARCHAR(255) DEFAULT '', + EnableRSSFeeds BOOLEAN DEFAULT FALSE, + PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0 + ) + """) + cnx.commit() + + def add_playbackspeed_if_not_exist_users(cursor, cnx): + try: + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name='Users' + AND column_name = 'playbackspeed' + """) + existing_column = cursor.fetchone() + if not existing_column: + cursor.execute(""" + ALTER TABLE "Users" + ADD COLUMN PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0 + """) + print("Added 'PlaybackSpeed' column to 'Users' table.") + cnx.commit() + else: + print("Column 'PlaybackSpeed' already exists in 'Users' table.") + except Exception as e: + print(f"Error checking PlaybackSpeed column in Users table: {e}") + # Important: Don't try to commit here, as the transaction is already aborted + + # Usage - should be called during app startup + add_playbackspeed_if_not_exist_users(cursor, cnx) + + # Create OIDCProviders next + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "OIDCProviders" ( + ProviderID SERIAL PRIMARY KEY, + ProviderName VARCHAR(255) NOT NULL, + ClientID VARCHAR(255) NOT NULL, + ClientSecret VARCHAR(500) NOT NULL, + AuthorizationURL VARCHAR(255) NOT NULL, + TokenURL VARCHAR(255) NOT NULL, + UserInfoURL VARCHAR(255) NOT NULL, + Scope VARCHAR(255) DEFAULT 'openid email profile', + ButtonColor VARCHAR(50) DEFAULT '#000000', + ButtonText VARCHAR(255) NOT NULL, + ButtonTextColor VARCHAR(50) DEFAULT '#000000', + IconSVG TEXT, + Enabled BOOLEAN DEFAULT true, + Created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + Modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cnx.commit() + + # Now add all columns + add_column_if_not_exists(cursor, 'Users', 'auth_type', 'VARCHAR(50) DEFAULT \'standard\'') + add_column_if_not_exists(cursor, 'Users', 'oidc_provider_id', 'INT') + add_column_if_not_exists(cursor, 'Users', 'oidc_subject', 'VARCHAR(255)') + cnx.commit() + + # Now add foreign key + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_name = 'fk_oidc_provider' + AND table_name = 'Users'; + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE "Users" + ADD CONSTRAINT fk_oidc_provider + FOREIGN KEY (oidc_provider_id) + REFERENCES "OIDCProviders"(ProviderID); + """) + print("Foreign key constraint 'fk_oidc_provider' added") + cnx.commit() + + # Create API Keys table last + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "APIKeys" ( + APIKeyID SERIAL PRIMARY KEY, + UserID INT, + APIKey TEXT, + Created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE + ) + """) + cnx.commit() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "RssKeys" ( + RssKeyID SERIAL PRIMARY KEY, + UserID INT, + RssKey TEXT, + Created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE + ) + """) + cnx.commit() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "RssKeyMap" ( + RssKeyID INT, + PodcastID INT, + FOREIGN KEY (RssKeyID) REFERENCES "RssKeys"(RssKeyID) ON DELETE CASCADE + ) + """) + cnx.commit() + + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_name = 'PlaybackSpeed' + AND table_name = 'Users'; + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE "Users" ADD COLUMN PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0; + """) + print("Column 'PlaybackSpeed' added to Users table") + cnx.commit() + + # Now add foreign key + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_name = 'PlaybackSpeed' + AND table_name = 'Podcasts'; + """) + if cursor.fetchone()[0] == 0: + cursor.execute(""" + ALTER TABLE "Podcasts" ADD COLUMN PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0; + """) + print("Column 'PlaybackSpeed' added to Podcasts table") + cnx.commit() + + ensure_usernames_lowercase(cnx) + + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "GpodderDevices" ( + DeviceID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceName VARCHAR(255) NOT NULL, + DeviceType VARCHAR(50) DEFAULT 'desktop', + DeviceCaption VARCHAR(255), + IsDefault BOOLEAN DEFAULT FALSE, + LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + IsActive BOOLEAN DEFAULT TRUE, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceName) + ) + """) + cnx.commit() + print("Created GpodderDevices table") + + # Create index for faster lookups + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_gpodder_devices_userid + ON "GpodderDevices"(UserID) + """) + cnx.commit() + + # Create a table for subscription history/sync state + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "GpodderSyncState" ( + SyncStateID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + DeviceID INT NOT NULL, + LastTimestamp BIGINT DEFAULT 0, + EpisodesTimestamp BIGINT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE, + UNIQUE(UserID, DeviceID) + ) + """) + cnx.commit() + print("Created GpodderSyncState table") + except Exception as e: + print(f"Error creating GPodder tables: {e}") + + cursor.execute("""CREATE TABLE IF NOT EXISTS "UserStats" ( + UserStatsID SERIAL PRIMARY KEY, + UserID INT UNIQUE, + UserCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PodcastsPlayed INT DEFAULT 0, + TimeListened INT DEFAULT 0, + PodcastsAdded INT DEFAULT 0, + EpisodesSaved INT DEFAULT 0, + EpisodesDownloaded INT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + )""") + + + # Generate a key + key = Fernet.generate_key() + + # Create the AppSettings table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "AppSettings" ( + AppSettingsID SERIAL PRIMARY KEY, + SelfServiceUser BOOLEAN DEFAULT false, + DownloadEnabled BOOLEAN DEFAULT true, + EncryptionKey BYTEA, -- Set the data type to BYTEA for binary data + NewsFeedSubscribed BOOLEAN DEFAULT false + ) + """) + + cursor.execute('SELECT COUNT(*) FROM "AppSettings" WHERE AppSettingsID = 1') + count = cursor.fetchone()[0] + + if count == 0: + cursor.execute(""" + INSERT INTO "AppSettings" (SelfServiceUser, DownloadEnabled, EncryptionKey) + VALUES (false, true, %s) + """, (key,)) + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "EmailSettings" ( + EmailSettingsID SERIAL PRIMARY KEY, + Server_Name VARCHAR(255), + Server_Port INT, + From_Email VARCHAR(255), + Send_Mode VARCHAR(255), + Encryption VARCHAR(255), + Auth_Required BOOLEAN, + Username VARCHAR(255), + Password VARCHAR(255) + ) + """) + except Exception as e: + logging.error(f"Failed to create EmailSettings table: {e}") + + try: + cursor.execute(""" + SELECT COUNT(*) FROM "EmailSettings" + """) + rows = cursor.fetchone() + + + if rows[0] == 0: + cursor.execute(""" + INSERT INTO "EmailSettings" (Server_Name, Server_Port, From_Email, Send_Mode, Encryption, Auth_Required, Username, Password) + VALUES ('default_server', 587, 'default_email@domain.com', 'default_mode', 'default_encryption', true, 'default_username', 'default_password') + """) + except Exception as e: + print(f"Error setting default email data: {e}") + + def user_exists(cursor, username): + cursor.execute(""" + SELECT 1 FROM "Users" WHERE Username = %s + """, (username,)) + return cursor.fetchone() is not None + + # Insert or update the user in the database + def insert_or_update_user(cursor, hashed_password): + try: + if user_exists(cursor, 'guest'): + cursor.execute(""" + UPDATE "Users" + SET Fullname = %s, Username = %s, Email = %s, Hashed_PW = %s, IsAdmin = %s + WHERE Username = %s + """, ('Background Tasks', 'background_tasks', 'inactive', hashed_password, False, 'guest')) + logging.info("Updated existing 'guest' user to 'background_tasks' user.") + elif user_exists(cursor, 'bt'): + cursor.execute(""" + UPDATE "Users" + SET Fullname = %s, Username = %s, Email = %s, Hashed_PW = %s, IsAdmin = %s + WHERE Username = %s + """, ('Background Tasks', 'background_tasks', 'inactive', hashed_password, False, 'bt')) + logging.info("Updated existing 'guest' user to 'background_tasks' user.") + else: + cursor.execute(""" + INSERT INTO "Users" (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (Username) DO NOTHING + """, ('Background Tasks', 'background_tasks', 'inactive', hashed_password, False)) + except Exception as e: + print(f"Error inserting or updating user: {e}") + logging.error("Error inserting or updating user: %s", e) + + + try: + # Generate and hash the password + random_password = generate_random_password() + hashed_password = hash_password(random_password) + + if hashed_password: + insert_or_update_user(cursor, hashed_password) + + + + except Exception as e: + print(f"Error setting default Background Task User: {e}") + logging.error("Error setting default Background Task User: %s", e) + + + try: + # Check if API key exists for user_id + cursor.execute('SELECT apikey FROM "APIKeys" WHERE userid = %s', (1,)) + + result = cursor.fetchone() + + if result: + api_key = result[0] + else: + import secrets + import string + alphabet = string.ascii_letters + string.digits + api_key = ''.join(secrets.choice(alphabet) for _ in range(64)) + + # Insert the new API key into the database using a parameterized query + cursor.execute('INSERT INTO "APIKeys" (UserID, APIKey) VALUES (%s, %s)', (1, api_key)) + + cnx.commit() + + with open("/tmp/web_api_key.txt", "w") as f: + f.write(api_key) + except Exception as e: + print(f"Error creating web key: {e}") + + try: + # First check if the table exists - use lowercase in the check since lower() is applied + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND lower(table_name) = 'usersettings' + ); + """) + table_exists = cursor.fetchone()[0] + + if not table_exists: + # Fresh install - create the table with all columns + # Important: Notice we're referencing "Users" (capital U) here + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "UserSettings" ( + usersettingid SERIAL PRIMARY KEY, + userid INT UNIQUE, + theme VARCHAR(255) DEFAULT 'Nordic', + startpage VARCHAR(255) DEFAULT 'home', + FOREIGN KEY (userid) REFERENCES "Users"(userid) + ) + """) + print("UserSettings table created with startpage column included") + else: + # Get the actual table name (might be mixed case) + cursor.execute(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND lower(table_name) = 'usersettings' + """) + actual_table_name = cursor.fetchone()[0] + print(f"Found existing UserSettings table as: {actual_table_name}") + + # Get all column names with their actual case + cursor.execute(f""" + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND lower(table_name) = 'usersettings' + """) + columns = [col[0] for col in cursor.fetchall()] + print(f"Existing columns: {columns}") + + # Check if any variation of 'startpage' exists (case-insensitive) + startpage_column_exists = False + startpage_column_name = None + for col in columns: + if col.lower() == 'startpage': + startpage_column_exists = True + startpage_column_name = col + break + + if not startpage_column_exists: + # The column doesn't exist, add it + cursor.execute(f""" + ALTER TABLE "{actual_table_name}" + ADD COLUMN startpage VARCHAR(255) DEFAULT 'home' + """) + print("startpage column added to existing UserSettings table") + else: + print(f"startpage column exists as: {startpage_column_name}") + + # IMPORTANT: The API is trying to access 'startpage' but the column is 'StartPage' + # Check if we need to fix this by checking the error from the log + cursor.execute(f""" + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = lower('{actual_table_name}') + AND column_name = 'startpage' + """) + lowercase_exists = cursor.fetchone() + + if not lowercase_exists and startpage_column_name != 'startpage': + print(f"Column exists as {startpage_column_name} but code tries to access 'startpage'. Adding alias column...") + try: + # Add a new column and copy data from the existing one + cursor.execute(f""" + ALTER TABLE "{actual_table_name}" + ADD COLUMN startpage VARCHAR(255) DEFAULT 'home' + """) + # Fixed the column name reference in the UPDATE statement + cursor.execute(f""" + UPDATE "{actual_table_name}" + SET startpage = "{startpage_column_name}" + """) + print("Added lowercase startpage column for compatibility") + except Exception as e: + print(f"Error adding compatibility column: {e}") + + # Always commit the transaction + cnx.commit() + except Exception as e: + # Log the general error and rollback + print(f"Error handling usersettings table: {e}") + cnx.rollback() + + admin_created = False + try: + admin_fullname = os.environ.get("FULLNAME") + admin_username = os.environ.get("USERNAME") + admin_email = os.environ.get("EMAIL") + admin_pw = os.environ.get("PASSWORD") + + if all([admin_fullname, admin_username, admin_email, admin_pw]): + hashed_pw = hash_password(admin_pw).strip() + admin_insert_query = """ + INSERT INTO "Users" (Fullname, Username, Email, Hashed_PW, IsAdmin) + VALUES (%s, %s, %s, %s, %s::boolean) + ON CONFLICT (Username) DO NOTHING + RETURNING UserID + """ + cursor.execute(admin_insert_query, (admin_fullname, admin_username, admin_email, hashed_pw, True)) + admin_created = cursor.fetchone() is not None + cnx.commit() + except Exception as e: + print(f"Error creating default admin: {e}") + + # Now handle UserStats and UserSettings + try: + # Background tasks user stats + cursor.execute(""" + INSERT INTO "UserStats" (UserID) VALUES (1) + ON CONFLICT (UserID) DO NOTHING + """) + if admin_created: + cursor.execute(""" + INSERT INTO "UserStats" (UserID) VALUES (2) + ON CONFLICT (UserID) DO NOTHING + """) + cursor.execute(""" + INSERT INTO "UserSettings" (UserID, Theme) VALUES (2, 'Nordic') + ON CONFLICT (UserID) DO NOTHING + """) + cnx.commit() + except Exception as e: + print(f"Error creating user stats/settings: {e}") + + cursor.execute("""INSERT INTO "UserSettings" (UserID, Theme) VALUES ('1', 'Nordic') ON CONFLICT (UserID) DO NOTHING""") + if admin_created: + cursor.execute("""INSERT INTO "UserSettings" (UserID, Theme) VALUES ('2', 'Nordic') ON CONFLICT (UserID) DO NOTHING""") + + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Podcasts" ( + PodcastID SERIAL PRIMARY KEY, + PodcastIndexID INT, + PodcastName TEXT, + ArtworkURL TEXT, + Author TEXT, + Categories TEXT, + Description TEXT, + EpisodeCount INT, + FeedURL TEXT, + WebsiteURL TEXT, + Explicit BOOLEAN, + UserID INT, + AutoDownload BOOLEAN DEFAULT FALSE, + StartSkip INT DEFAULT 0, + EndSkip INT DEFAULT 0, + Username TEXT, + Password TEXT, + IsYouTubeChannel BOOLEAN DEFAULT FALSE, + NotificationsEnabled BOOLEAN DEFAULT FALSE, + FeedCutoffDays INT DEFAULT 0, + PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0, + PlaybackSpeedCustomized BOOLEAN DEFAULT FALSE, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + ) + """) + cnx.commit() # Ensure changes are committed + except Exception as e: + print(f"Error adding Podcasts table: {e}") + + try: + # Add unique constraint on UserID and FeedURL to fix the ON CONFLICT error + cursor.execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'podcasts_userid_feedurl_key' + ) THEN + -- Add the constraint if it doesn't exist + ALTER TABLE "Podcasts" + ADD CONSTRAINT podcasts_userid_feedurl_key + UNIQUE (UserID, FeedURL); + END IF; + END + $$; + """) + cnx.commit() + print("Added unique constraint on UserID and FeedURL to Podcasts table") + except Exception as e: + print(f"Error adding unique constraint to Podcasts table: {e}") + + def add_youtube_column_if_not_exist(cursor, cnx): + try: + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name='Podcasts' + AND column_name = 'isyoutubechannel' + """) + existing_column = cursor.fetchone() + + if not existing_column: + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN "isyoutubechannel" BOOLEAN DEFAULT FALSE + """) + print("Added 'IsYouTubeChannel' column to 'Podcasts' table.") + cnx.commit() + else: + print('IsYouTubeChannel column already exists') + except Exception as e: + print(f"Error adding IsYouTubeChannel column to Podcasts table: {e}") + + # Usage + add_youtube_column_if_not_exist(cursor, cnx) + + def add_playbackspeed_if_not_exist_podcasts(cursor, cnx): + try: + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name='Podcasts' + AND column_name = 'playbackspeed' + """) + existing_column = cursor.fetchone() + if not existing_column: + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN PlaybackSpeed NUMERIC(2,1) DEFAULT 1.0 + """) + print("Added 'PlaybackSpeed' column to 'Podcasts' table.") + cnx.commit() + else: + print("Column 'PlaybackSpeed' already exists in 'Podcasts' table. ") + except Exception as e: + print(f"Error checking PlaybackSpeed column in Podcasts table: {e}") + # No commit or rollback here, just like in your working example + + # Usage - should be called during app startup + add_playbackspeed_if_not_exist_podcasts(cursor, cnx) + + def add_playbackspeed_customized_if_not_exist(cursor, cnx): + try: + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name='Podcasts' + AND column_name = 'playbackspeedcustomized' + """) + existing_column = cursor.fetchone() + if not existing_column: + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN PlaybackSpeedCustomized BOOLEAN DEFAULT FALSE + """) + print("Added 'PlaybackSpeedCustomized' column to 'Podcasts' table.") + cnx.commit() + else: + print('PlaybackSpeedCustomized column already exists') + except Exception as e: + print(f"Error adding PlaybackSpeedCustomized column to Podcasts table: {e}") + + # Usage - should be called during app startup + add_playbackspeed_customized_if_not_exist(cursor, cnx) + + def add_feed_cutoff_column_if_not_exist(cursor, cnx): + try: + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name='Podcasts' + AND column_name = 'feedcutoffdays' + """) + existing_column = cursor.fetchone() + + if not existing_column: + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN "feedcutoffdays" INT DEFAULT 0 + """) + print("Added 'feedcutoffdays' column to 'Podcasts' table.") + cnx.commit() + except Exception as e: + print(f"Error adding feedcutoffdays column to Podcasts table: {e}") + + add_feed_cutoff_column_if_not_exist(cursor, cnx) + + cursor.execute("SELECT to_regclass('public.\"Podcasts\"')") + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Episodes" ( + EpisodeID SERIAL PRIMARY KEY, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate TIMESTAMP, + EpisodeDuration INT, + Completed BOOLEAN DEFAULT FALSE, + FOREIGN KEY (PodcastID) REFERENCES "Podcasts"(PodcastID) + ) + """) + + cnx.commit() # Ensure changes are committed + except Exception as e: + print(f"Error adding Episodes table: {e}") + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "YouTubeVideos" ( + VideoID SERIAL PRIMARY KEY, + PodcastID INT, + VideoTitle TEXT, + VideoDescription TEXT, + VideoURL TEXT, + ThumbnailURL TEXT, + PublishedAt TIMESTAMP, + Duration INT, + YouTubeVideoID TEXT, + Completed BOOLEAN DEFAULT FALSE, + ListenPosition INT DEFAULT 0, + FOREIGN KEY (PodcastID) REFERENCES "Podcasts"(PodcastID) + ) + """) + + cnx.commit() # Ensure changes are committed + except Exception as e: + print(f"Error adding YoutubeVideos table: {e}") + + def create_index_if_not_exists(cursor, index_name, table_name, column_name): + cursor.execute(f""" + SELECT 1 + FROM pg_indexes + WHERE lower(indexname) = lower('{index_name}') AND tablename = '{table_name}' + """) + if not cursor.fetchone(): + cursor.execute(f'CREATE INDEX {index_name} ON "{table_name}"({column_name})') + + create_index_if_not_exists(cursor, "idx_podcasts_userid", "Podcasts", "UserID") + create_index_if_not_exists(cursor, "idx_episodes_podcastid", "Episodes", "PodcastID") + create_index_if_not_exists(cursor, "idx_episodes_episodepubdate", "Episodes", "EpisodePubDate") + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "People" ( + PersonID SERIAL PRIMARY KEY, + Name TEXT, + PersonImg TEXT, + PeopleDBID INT, + AssociatedPodcasts TEXT, + UserID INT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + ); + + """) + cnx.commit() + except Exception as e: + print(f"Error creating People table: {e}") + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "PeopleEpisodes" ( + EpisodeID SERIAL PRIMARY KEY, + PersonID INT, + PodcastID INT, + EpisodeTitle TEXT, + EpisodeDescription TEXT, + EpisodeURL TEXT, + EpisodeArtwork TEXT, + EpisodePubDate TIMESTAMP, + EpisodeDuration INT, + AddedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PersonID) REFERENCES "People"(PersonID), + FOREIGN KEY (PodcastID) REFERENCES "Podcasts"(PodcastID) + ); + + """) + cnx.commit() + except Exception as e: + print(f"Error creating People table: {e}") + + create_index_if_not_exists(cursor, "idx_people_episodes_person", "PeopleEpisodes", "PersonID") + create_index_if_not_exists(cursor, "idx_people_episodes_podcast", "PeopleEpisodes", "PodcastID") + + + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "SharedEpisodes" ( + SharedEpisodeID SERIAL PRIMARY KEY, + EpisodeID INT, + UrlKey TEXT, + ExpirationDate TIMESTAMP, + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) + ) + """) + cnx.commit() + except Exception as e: + print(f"Error creating SharedEpisodes table: {e}") + + + cursor.execute("""CREATE TABLE IF NOT EXISTS "UserEpisodeHistory" ( + UserEpisodeHistoryID SERIAL PRIMARY KEY, + UserID INT, + EpisodeID INT, + ListenDate TIMESTAMP, + ListenDuration INT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) + )""") + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "UserVideoHistory" ( + UserVideoHistoryID SERIAL PRIMARY KEY, + UserID INT, + VideoID INT, + ListenDate TIMESTAMP, + ListenDuration INT DEFAULT 0, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) + ) + """) + + def add_history_constraints_if_not_exists(cursor, cnx): + try: + # Check/add constraint for UserEpisodeHistory + cursor.execute(""" + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_name = 'UserEpisodeHistory' + AND constraint_type = 'UNIQUE' + AND constraint_name = 'user_episode_unique' + """) + + if not cursor.fetchone(): + cursor.execute(""" + ALTER TABLE "UserEpisodeHistory" + ADD CONSTRAINT user_episode_unique + UNIQUE (UserID, EpisodeID) + """) + print("Added unique constraint to UserEpisodeHistory table.") + cnx.commit() + + # Check/add constraint for UserVideoHistory + cursor.execute(""" + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_name = 'UserVideoHistory' + AND constraint_type = 'UNIQUE' + AND constraint_name = 'user_video_unique' + """) + + if not cursor.fetchone(): + cursor.execute(""" + ALTER TABLE "UserVideoHistory" + ADD CONSTRAINT user_video_unique + UNIQUE (UserID, VideoID) + """) + print("Added unique constraint to UserVideoHistory table.") + cnx.commit() + except Exception as e: + print(f"Error adding unique constraints to history tables: {e}") + + # Call this after creating both history tables + add_history_constraints_if_not_exists(cursor, cnx) + + cursor.execute("""CREATE TABLE IF NOT EXISTS "SavedEpisodes" ( + SaveID SERIAL PRIMARY KEY, + UserID INT, + EpisodeID INT, + SaveDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) + )""") + + cursor.execute("""CREATE TABLE IF NOT EXISTS "SavedVideos" ( + SaveID SERIAL PRIMARY KEY, + UserID INT, + VideoID INT, + SaveDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) + )""") + + + # Create the DownloadedEpisodes table + cursor.execute("""CREATE TABLE IF NOT EXISTS "DownloadedEpisodes" ( + DownloadID SERIAL PRIMARY KEY, + UserID INT, + EpisodeID INT, + DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DownloadedSize INT, + DownloadedLocation VARCHAR(255), + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) + )""") + + cursor.execute("""CREATE TABLE IF NOT EXISTS "DownloadedVideos" ( + DownloadID SERIAL PRIMARY KEY, + UserID INT, + VideoID INT, + DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + DownloadedSize INT, + DownloadedLocation VARCHAR(255), + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) + )""") + + # Create the EpisodeQueue table + cursor.execute("""CREATE TABLE IF NOT EXISTS "EpisodeQueue" ( + QueueID SERIAL PRIMARY KEY, + QueueDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UserID INT, + EpisodeID INT, + QueuePosition INT NOT NULL DEFAULT 0, + is_youtube BOOLEAN DEFAULT FALSE, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + )""") + + def remove_episode_queue_constraint(cursor, cnx): + try: + # First check if the constraint exists + check_constraint_query = """ + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_name = 'EpisodeQueue' + AND constraint_name = 'EpisodeQueue_episodeid_fkey' + AND constraint_type = 'FOREIGN KEY' + """ + cursor.execute(check_constraint_query) + constraint = cursor.fetchone() + + if constraint: + # If it exists, drop it + cursor.execute('ALTER TABLE "EpisodeQueue" DROP CONSTRAINT "EpisodeQueue_episodeid_fkey"') + cnx.commit() + print("Removed EpisodeQueue foreign key constraint") + else: + print("EpisodeQueue foreign key constraint not found - no action needed") + + except Exception as e: + print(f"Error managing EpisodeQueue constraint: {e}") + cnx.rollback() + + remove_episode_queue_constraint(cursor, cnx) + + def add_queue_youtube_column_if_not_exist(cursor, cnx): + try: + # Check if column exists using PostgreSQL's system catalog + cursor.execute(""" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'EpisodeQueue' + AND column_name = 'is_youtube' + ) + """) + column_exists = cursor.fetchone()[0] + + if not column_exists: + cursor.execute(""" + ALTER TABLE "EpisodeQueue" + ADD COLUMN is_youtube BOOLEAN DEFAULT FALSE + """) + cnx.commit() + print("Added 'is_youtube' column to 'EpisodeQueue' table.") + else: + print("Column 'is_youtube' already exists in 'EpisodeQueue' table.") + + except Exception as e: + cnx.rollback() + print(f"Error managing is_youtube column: {e}") + + add_queue_youtube_column_if_not_exist(cursor, cnx) + + # Create the Sessions table + cursor.execute("""CREATE TABLE IF NOT EXISTS "Sessions" ( + SessionID SERIAL PRIMARY KEY, + UserID INT, + value TEXT, + expire TIMESTAMP NOT NULL, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + )""") + cnx.commit() + + # First let's define our functions to check and add columns/tables + def add_notification_column_if_not_exists(cursor, cnx): + try: + cursor.execute(""" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Podcasts' + AND column_name = 'notificationsenabled' + ) + """) + column_exists = cursor.fetchone()[0] + + if not column_exists: + cursor.execute(""" + ALTER TABLE "Podcasts" + ADD COLUMN NotificationsEnabled BOOLEAN DEFAULT FALSE + """) + cnx.commit() + print("Added 'NotificationsEnabled' column to 'Podcasts' table.") + else: + print("Column 'NotificationsEnabled' already exists in 'Podcasts' table.") + except Exception as e: + cnx.rollback() + print(f"Error managing NotificationsEnabled column: {e}") + + add_notification_column_if_not_exists(cursor, cnx) + + # Now create the notification settings table if it doesn't exist + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "UserNotificationSettings" ( + SettingID SERIAL PRIMARY KEY, + UserID INT, + Platform VARCHAR(50) NOT NULL, + Enabled BOOLEAN DEFAULT TRUE, + NtfyTopic VARCHAR(255), + NtfyServerUrl VARCHAR(255) DEFAULT 'https://ntfy.sh', + GotifyUrl VARCHAR(255), + GotifyToken VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID), + UNIQUE(UserID, platform) + ) + """) + cnx.commit() + print("Checked/Created UserNotificationSettings table") + except Exception as e: + print(f"Error creating UserNotificationSettings table: {e}") + + + try: + # Create Playlists table with the unique constraint + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "Playlists" ( + PlaylistID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + Name VARCHAR(255) NOT NULL, + Description TEXT, + IsSystemPlaylist BOOLEAN NOT NULL DEFAULT FALSE, + PodcastIDs INTEGER[], -- Can be NULL to mean "all podcasts" + IncludeUnplayed BOOLEAN NOT NULL DEFAULT TRUE, + IncludePartiallyPlayed BOOLEAN NOT NULL DEFAULT TRUE, + IncludePlayed BOOLEAN NOT NULL DEFAULT FALSE, + MinDuration INTEGER, -- NULL means no minimum + MaxDuration INTEGER, -- NULL means no maximum + SortOrder VARCHAR(50) NOT NULL DEFAULT 'date_desc' + CHECK (SortOrder IN ('date_asc', 'date_desc', + 'duration_asc', 'duration_desc', + 'listen_progress', 'completion')), + GroupByPodcast BOOLEAN NOT NULL DEFAULT FALSE, + MaxEpisodes INTEGER, -- NULL means no limit + PlayProgressMin FLOAT, -- NULL means no minimum progress requirement + PlayProgressMax FLOAT, -- NULL means no maximum progress limit + TimeFilterHours INTEGER, -- NULL means no time filter + LastUpdated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + Created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + IconName VARCHAR(50) NOT NULL DEFAULT 'ph-playlist', + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, Name), + CHECK (PlayProgressMin IS NULL OR (PlayProgressMin >= 0 AND PlayProgressMin <= 100)), + CHECK (PlayProgressMax IS NULL OR (PlayProgressMax >= 0 AND PlayProgressMax <= 100)), + CHECK (PlayProgressMin IS NULL OR PlayProgressMax IS NULL OR PlayProgressMin <= PlayProgressMax), + CHECK (MinDuration IS NULL OR MinDuration >= 0), + CHECK (MaxDuration IS NULL OR MaxDuration >= 0), + CHECK (MinDuration IS NULL OR MaxDuration IS NULL OR MinDuration <= MaxDuration), + CHECK (TimeFilterHours IS NULL OR TimeFilterHours > 0), + CHECK (MaxEpisodes IS NULL OR MaxEpisodes > 0) + ) + """) + cnx.commit() + + # First add the unique constraint if it doesn't exist + cursor.execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'playlists_userid_name_key' + ) THEN + ALTER TABLE "Playlists" + ADD CONSTRAINT playlists_userid_name_key UNIQUE(UserID, Name); + END IF; + END $$; + """) + cnx.commit() + + # Create PlaylistContents table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "PlaylistContents" ( + PlaylistContentID SERIAL PRIMARY KEY, + PlaylistID INT, + EpisodeID INT, + VideoID INT, + Position INT, + DateAdded TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (PlaylistID) REFERENCES "Playlists"(PlaylistID) ON DELETE CASCADE, + FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) ON DELETE CASCADE, + FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) ON DELETE CASCADE, + CHECK ((EpisodeID IS NOT NULL AND VideoID IS NULL) OR (EpisodeID IS NULL AND VideoID IS NOT NULL)) + ) + """) + cnx.commit() + + # Create indexes + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_playlists_userid ON "Playlists"(UserID); + CREATE INDEX IF NOT EXISTS idx_playlist_contents_playlistid ON "PlaylistContents"(PlaylistID); + CREATE INDEX IF NOT EXISTS idx_playlist_contents_episodeid ON "PlaylistContents"(EpisodeID); + CREATE INDEX IF NOT EXISTS idx_playlist_contents_videoid ON "PlaylistContents"(VideoID); + """) + cnx.commit() + + # Define system playlists + system_playlists = [ + { + 'name': 'Quick Listens', + 'description': 'Short episodes under 15 minutes, perfect for quick breaks', + 'min_duration': None, + 'max_duration': 900, # 15 minutes + 'sort_order': 'duration_asc', + 'icon_name': 'ph-fast-forward' + }, + { + 'name': 'Longform', + 'description': 'Extended episodes over 1 hour, ideal for long drives or deep dives', + 'min_duration': 3600, # 1 hour + 'max_duration': None, + 'sort_order': 'duration_desc', + 'icon_name': 'ph-car' + }, + { + 'name': 'Currently Listening', + 'description': 'Episodes you\'ve started but haven\'t finished', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_desc', + 'include_unplayed': False, + 'include_partially_played': True, + 'include_played': False, + 'icon_name': 'ph-play' + }, + { + 'name': 'Fresh Releases', + 'description': 'Latest episodes from the last 24 hours', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_desc', + 'include_unplayed': True, + 'include_partially_played': False, + 'include_played': False, + 'time_filter_hours': 24, + 'icon_name': 'ph-sparkle' + }, + { + 'name': 'Weekend Marathon', + 'description': 'Longer episodes (30+ minutes) perfect for weekend listening', + 'min_duration': 1800, # 30 minutes + 'max_duration': None, + 'sort_order': 'duration_desc', + 'group_by_podcast': True, + 'icon_name': 'ph-couch' + }, + { + 'name': 'Commuter Mix', + 'description': 'Episodes between 20-40 minutes, ideal for average commute times', + 'min_duration': 1200, # 20 minutes + 'max_duration': 2400, # 40 minutes + 'sort_order': 'date_desc', + 'icon_name': 'ph-train' + }, + { + 'name': 'Almost Done', + 'description': 'Episodes you\'re close to finishing (75%+ complete)', + 'min_duration': None, + 'max_duration': None, + 'sort_order': 'date_asc', + 'include_unplayed': False, + 'include_partially_played': True, + 'include_played': False, + 'play_progress_min': 75.0, # Add this + 'play_progress_max': None, # Can add this too + 'icon_name': 'ph-hourglass' + } + ] + + # Insert system playlists + for playlist in system_playlists: + try: + # First check if this playlist already exists + cursor.execute(""" + SELECT COUNT(*) + FROM "Playlists" + WHERE UserID = 1 AND Name = %s AND IsSystemPlaylist = TRUE + """, (playlist['name'],)) + + if cursor.fetchone()[0] == 0: + cursor.execute(""" + INSERT INTO "Playlists" ( + UserID, + Name, + Description, + IsSystemPlaylist, + MinDuration, + MaxDuration, + SortOrder, + GroupByPodcast, + IncludeUnplayed, + IncludePartiallyPlayed, + IncludePlayed, + IconName, + TimeFilterHours, + PlayProgressMin, + PlayProgressMax + ) VALUES ( + 1, + %s, + %s, + TRUE, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s + ) + """, ( + playlist['name'], + playlist['description'], + playlist.get('min_duration'), + playlist.get('max_duration'), + playlist.get('sort_order', 'date_asc'), + playlist.get('group_by_podcast', False), + playlist.get('include_unplayed', True), + playlist.get('include_partially_played', True), + playlist.get('include_played', False), + playlist.get('icon_name', 'ph-playlist'), + playlist.get('time_filter_hours'), + playlist.get('play_progress_min'), + playlist.get('play_progress_max') + )) + cnx.commit() + print(f"Successfully added system playlist: {playlist['name']}") + else: + print(f"System playlist already exists: {playlist['name']}") + + except Exception as e: + print(f"Error handling system playlist {playlist['name']}: {e}") + continue + + print("Checked/Created Playlist Tables") + + except psycopg.Error as err: + logging.error(f"Database error: {err}") + except Exception as e: + logging.error(f"General error: {e}") + + +except psycopg.Error as err: + logging.error(f"Database error: {err}") +except Exception as e: + logging.error(f"General error: {e}") + +# Ensure to close the cursor and connection +finally: + if 'cursor' in locals(): + cursor.close() + if 'cnx' in locals(): + cnx.close() diff --git a/PinePods-0.8.2/startup/startup.sh b/PinePods-0.8.2/startup/startup.sh new file mode 100755 index 0000000..61fc2fb --- /dev/null +++ b/PinePods-0.8.2/startup/startup.sh @@ -0,0 +1,179 @@ +#!/bin/bash +set -e # Exit immediately if a command exits with a non-zero status + +# Function to handle shutdown +shutdown() { + echo "Shutting down..." + pkill -TERM horust + exit 0 +} + +# Set up signal handling +trap shutdown SIGTERM SIGINT + +# Export all environment variables +export DB_USER=$DB_USER +export DB_PASSWORD=$DB_PASSWORD +export DB_HOST=$DB_HOST +export DB_NAME=$DB_NAME +export DB_PORT=$DB_PORT +export DB_TYPE=$DB_TYPE +export FULLNAME=${FULLNAME} +export USERNAME=${USERNAME} +export EMAIL=${EMAIL} +export PASSWORD=${PASSWORD} +export REVERSE_PROXY=$REVERSE_PROXY +export SEARCH_API_URL=$SEARCH_API_URL +export PEOPLE_API_URL=$PEOPLE_API_URL +export PINEPODS_PORT=$PINEPODS_PORT +export PROXY_PROTOCOL=$PROXY_PROTOCOL +export DEBUG_MODE=${DEBUG_MODE:-'False'} +export VALKEY_HOST=${VALKEY_HOST:-'valkey'} +export VALKEY_PORT=${VALKEY_PORT:-'6379'} +export DEFAULT_LANGUAGE=${DEFAULT_LANGUAGE:-'en'} + +# Save user's HOSTNAME to SERVER_URL before Docker overwrites it with container ID +# This preserves the user-configured server URL for RSS feed generation +export SERVER_URL=${HOSTNAME} + +# Export OIDC environment variables +export OIDC_DISABLE_STANDARD_LOGIN=${OIDC_DISABLE_STANDARD_LOGIN:-'false'} +export OIDC_PROVIDER_NAME=${OIDC_PROVIDER_NAME} +export OIDC_CLIENT_ID=${OIDC_CLIENT_ID} +export OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET} +export OIDC_AUTHORIZATION_URL=${OIDC_AUTHORIZATION_URL} +export OIDC_TOKEN_URL=${OIDC_TOKEN_URL} +export OIDC_USER_INFO_URL=${OIDC_USER_INFO_URL} +export OIDC_BUTTON_TEXT=${OIDC_BUTTON_TEXT} +export OIDC_SCOPE=${OIDC_SCOPE} +export OIDC_BUTTON_COLOR=${OIDC_BUTTON_COLOR} +export OIDC_BUTTON_TEXT_COLOR=${OIDC_BUTTON_TEXT_COLOR} +export OIDC_ICON_SVG=${OIDC_ICON_SVG} +export OIDC_NAME_CLAIM=${OIDC_NAME_CLAIM} +export OIDC_EMAIL_CLAIM=${OIDC_EMAIL_CLAIM} +export OIDC_USERNAME_CLAIM=${OIDC_USERNAME_CLAIM} +export OIDC_ROLES_CLAIM=${OIDC_ROLES_CLAIM} +export OIDC_USER_ROLE=${OIDC_USER_ROLE} +export OIDC_ADMIN_ROLE=${OIDC_ADMIN_ROLE} + +# Print admin info if default admin is used +if [[ $FULLNAME == 'Pinepods Admin' ]]; then + echo "Admin User Information:" + echo "FULLNAME: $FULLNAME" + echo "USERNAME: $USERNAME" + echo "EMAIL: $EMAIL" + echo "PASSWORD: $PASSWORD" +fi + +# Print PinePods logo +cat << "EOF" + A + d$b + .d\$$b. + .d$i$$\$$b. _______ ** ** + d$$@b / \ / | / | + d\$$$ib $$$$$$$ |$$/ _______ ______ ______ ______ ____$$ | _______ + .d$$$\$$$b $$ |__$$ |/ |/ \ / \ / \ / \ / $$ | / | + .d$$@$$$$\$$ib. $$ $$/ $$ |$$$$$$$ |/$$$$$$ |/$$$$$$ |/$$$$$$ |/$$$$$$$ |/$$$$$$$/ + d$$i$$b $$$$$$$/ $$ |$$ | $$ |$$ $$ |$$ | $$ |$$ | $$ |$$ | $$ |$$ \ + d\$$$$@$b. $$ | $$ |$$ | $$ |$$$$$$$$/ $$ |__$$ |$$ \__$$ |$$ \__$$ | $$$$$$ | + .d$@$$\$$$$$@b. $$ | $$ |$$ | $$ |$$ |$$ $$/ $$ $$/ $$ $$ |/ $$/ +.d$$$$i$$$\$$$$$$b. $$/ $$/ $$/ $$/ $$$$$$$/ $$$$$$$/ $$$$$$/ $$$$$$$/ $$$$$$$/ + ### $$ | + ### $$ | + ### $$/ +A project created and written by Collin Pendleton +collinp@gooseberrydevelopment.com +EOF + +# Configure timezone based on TZ environment variable +if [ -n "$TZ" ]; then + echo "Setting timezone to $TZ" + # For Alpine, we need to copy the zoneinfo file + if [ -f "/usr/share/zoneinfo/$TZ" ]; then + # Check if /etc/localtime is a mounted volume + if [ -f "/etc/localtime" ] && ! [ -L "/etc/localtime" ]; then + echo "Using mounted timezone file from host" + else + # If it's not mounted or is a symlink, we can modify it + cp /usr/share/zoneinfo/$TZ /etc/localtime + echo "$TZ" > /etc/timezone + fi + else + echo "Timezone $TZ not found, using UTC" + # Only modify if not mounted + if ! [ -f "/etc/localtime" ] || [ -L "/etc/localtime" ]; then + cp /usr/share/zoneinfo/UTC /etc/localtime + echo "UTC" > /etc/timezone + fi + fi +else + echo "No timezone specified, using UTC" + # Only modify if not mounted + if ! [ -f "/etc/localtime" ] || [ -L "/etc/localtime" ]; then + cp /usr/share/zoneinfo/UTC /etc/localtime + echo "UTC" > /etc/timezone + fi +fi + +# Export TZ to the environment for all child processes +export TZ + +# Create required directories +echo "Creating required directories..." +mkdir -p /pinepods/cache +mkdir -p /opt/pinepods/backups +mkdir -p /opt/pinepods/downloads +mkdir -p /opt/pinepods/certs +mkdir -p /var/log/pinepods # Make sure log directory exists + +# Database Setup +echo "Using $DB_TYPE database" +# Use compiled database setup binary (no Python dependency) +# Web API key file creation has been removed for security +/usr/local/bin/pinepods-db-setup +echo "Database validation complete" + +# Cron jobs removed - now handled by internal Rust scheduler + +# Check if we need to create exim directories +# Only do this if the user/group exists on the system +if getent group | grep -q "Debian-exim"; then + echo "Setting up exim directories and permissions..." + mkdir -p /var/log/exim4 + mkdir -p /var/spool/exim4 + chown -R Debian-exim:Debian-exim /var/log/exim4 + chown -R Debian-exim:Debian-exim /var/spool/exim4 +else + echo "Skipping exim setup as user/group doesn't exist on this system" +fi + +# Set up environment variables for Horust logging modes +if [[ $DEBUG_MODE == "true" ]]; then + export HORUST_STDOUT_MODE="STDOUT" + export HORUST_STDERR_MODE="STDERR" + echo "Starting Horust in debug mode (logs to stdout)..." +else + export HORUST_STDOUT_MODE="/var/log/pinepods/service.log" + export HORUST_STDERR_MODE="/var/log/pinepods/service.log" + echo "Starting Horust in production mode (logs to files)..." +fi + +# Set permissions for download and backup directories BEFORE starting services +# Only do this if PUID and PGID are set +if [[ -n "$PUID" && -n "$PGID" ]]; then + echo "Setting permissions for download and backup directories...(Be patient this might take a while if you have a lot of downloads)" + chown -R ${PUID}:${PGID} /opt/pinepods/downloads + chown -R ${PUID}:${PGID} /opt/pinepods/backups +else + echo "Skipping permission setting as PUID/PGID are not set" +fi + +# Copy service configurations to Horust directory +cp /pinepods/startup/services/*.toml /etc/horust/services/ + +# Start all services with Horust +echo "Starting services with Horust..." +echo "PinePods startup complete, running Horust in foreground..." +exec horust --services-path /etc/horust/services/ + diff --git a/PinePods-0.8.2/startup/supervisord.conf b/PinePods-0.8.2/startup/supervisord.conf new file mode 100644 index 0000000..95b35f7 --- /dev/null +++ b/PinePods-0.8.2/startup/supervisord.conf @@ -0,0 +1,40 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log ; main log file +loglevel=info ; log level + +# Add TZ to the global environment +environment=TZ="%(ENV_TZ)s" + +# crond removed - background tasks now handled by internal Rust scheduler + +# Legacy startup/refresh tasks removed - now handled by internal Rust scheduler +# The Rust API now handles all background tasks internally with tokio-cron-scheduler + +[program:client_api] +command=/usr/local/bin/pinepods-api +redirect_stderr=true +stdout_logfile=/var/log/supervisor/client_api.log +stderr_logfile=/var/log/supervisor/client_api.log +stdout_logfile_maxbytes=10000 +stopwaitsecs=5 + +[program:gpodder_api] +command=bash -c 'export DB_USER="%(ENV_DB_USER)s"; export DB_HOST="%(ENV_DB_HOST)s"; export DB_PORT="%(ENV_DB_PORT)s"; export DB_NAME="%(ENV_DB_NAME)s"; export DB_PASSWORD="%(ENV_DB_PASSWORD)s"; export SERVER_PORT=8042; /usr/local/bin/gpodder-api' +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/supervisor/gpodder_api.log +stderr_logfile=/var/log/supervisor/gpodder_api.log +stdout_logfile_maxbytes=10000 +stopwaitsecs=10 + + +[program:main_app] +command=nginx -g 'daemon off;' +redirect_stderr=true +stdout_logfile=/var/log/supervisor/nginx.log +stderr_logfile=/var/log/supervisor/nginx_error.log +stdout_logfile_maxbytes=10000 +stopwaitsecs=5 diff --git a/PinePods-0.8.2/startup/supervisordebug.conf b/PinePods-0.8.2/startup/supervisordebug.conf new file mode 100644 index 0000000..e8094f7 --- /dev/null +++ b/PinePods-0.8.2/startup/supervisordebug.conf @@ -0,0 +1,39 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log ; main log file +loglevel=info ; log level + +# Add TZ to the global environment +environment=TZ="%(ENV_TZ)s" + +[program:crond] +command=crond -f ; Run cron in the foreground +autorestart=true +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 + +[program:client_api] +command=/usr/local/bin/pinepods-api +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 + +[program:gpodder_api] +command=bash -c 'export DB_USER="%(ENV_DB_USER)s"; export DB_HOST="%(ENV_DB_HOST)s"; export DB_PORT="%(ENV_DB_PORT)s"; export DB_NAME="%(ENV_DB_NAME)s"; export DB_PASSWORD="%(ENV_DB_PASSWORD)s"; export SERVER_PORT=8042; /usr/local/bin/gpodder-api' +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stopwaitsecs=10 + + +[program:main_app] +command=nginx -g 'daemon off;' +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 diff --git a/PinePods-0.8.2/test-requirements.txt b/PinePods-0.8.2/test-requirements.txt new file mode 100644 index 0000000..b4fd3e4 --- /dev/null +++ b/PinePods-0.8.2/test-requirements.txt @@ -0,0 +1,32 @@ +mysql-connector-python +requests +python-dateutil +python-dateutil +passlib +uvicorn +fastapi +itsdangerous +keyring +cryptography +python-multipart +gunicorn +werkzeug +pillow +fernet +eyed3 +asyncio +websockets +pytz +psycopg[binary] +mygpoclient +feedparser +pyotp +appdirs +argon2_cffi +httpx +qrcode[pil] +psycopg[pool] +redis +pytest +pytest-asyncio +pytest-cov diff --git a/PinePods-0.8.2/tests/conftest.py b/PinePods-0.8.2/tests/conftest.py new file mode 100644 index 0000000..7d68e84 --- /dev/null +++ b/PinePods-0.8.2/tests/conftest.py @@ -0,0 +1,87 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient +import os +from typing import Generator +import mysql.connector +import psycopg +from psycopg_pool import ConnectionPool +from mysql.connector import pooling + +# Import and run environment setup before any other imports +from test_environment import setup_test_environment +setup_test_environment() + +# Set test environment variables BEFORE any app imports +os.environ['TEST_MODE'] = 'True' +os.environ['DB_TYPE'] = os.getenv('TEST_DB_TYPE', 'postgresql') + +# Debug print statements +print(f"Current directory: {os.getcwd()}") +print(f"Setting up test environment...") + +print(f"DB_TYPE set to: {os.getenv('DB_TYPE')}") +print(f"TEST_DB_TYPE set to: {os.getenv('TEST_DB_TYPE')}") + + +# Test database configurations +MYSQL_CONFIG = { + 'user': 'test_user', + 'password': 'test_password', + 'host': '127.0.0.1', + 'port': 3306, + 'database': 'test_db' +} + +POSTGRES_CONFIG = { + 'user': 'test_user', + 'password': 'test_password', + 'host': '127.0.0.1', + 'port': 5432, + 'dbname': 'test_db' +} + +# Only import app after environment variables are set +from clients.clientapi import app + +@pytest.fixture(scope="session", autouse=True) +def setup_test_env(): + """Set up test environment variables""" + # Environment variables already set at module level + + # Set up test database configuration + if os.getenv('DB_TYPE') == 'postgresql': + os.environ['DATABASE_URL'] = 'postgresql://test_user:test_password@localhost:5432/test_db' + else: + os.environ['DATABASE_URL'] = 'mysql://test_user:test_password@localhost:3306/test_db' + + yield + + # Cleanup + if 'TEST_MODE' in os.environ: + del os.environ['TEST_MODE'] + +@pytest.fixture(scope="session") +def db_connection(): + """Create database connection based on configured database type""" + db_type = os.getenv('DB_TYPE', 'postgresql') + + if db_type == 'postgresql': + conn = psycopg.connect(**POSTGRES_CONFIG) + yield conn + conn.close() + else: + pool = pooling.MySQLConnectionPool( + pool_name="test_pool", + pool_size=5, + **MYSQL_CONFIG + ) + conn = pool.get_connection() + yield conn + conn.close() + +@pytest_asyncio.fixture +async def async_client(): + """Create async client for testing FastAPI endpoints""" + async with AsyncClient(app=app, base_url="http://test") as client: + yield client diff --git a/PinePods-0.8.2/tests/test_basic.py b/PinePods-0.8.2/tests/test_basic.py new file mode 100644 index 0000000..c739938 --- /dev/null +++ b/PinePods-0.8.2/tests/test_basic.py @@ -0,0 +1,10 @@ +import pytest +import pytest_asyncio + +@pytest.mark.asyncio +async def test_health_check(async_client): + """Test the health check endpoint""" + response = await async_client.get("/api/pinepods_check") + assert response.status_code == 200 + # Check for the expected response data + assert response.json() == {"status_code": 200, "pinepods_instance": True} diff --git a/PinePods-0.8.2/tests/test_environment.py b/PinePods-0.8.2/tests/test_environment.py new file mode 100644 index 0000000..3a08b06 --- /dev/null +++ b/PinePods-0.8.2/tests/test_environment.py @@ -0,0 +1,13 @@ +import os + +def setup_test_environment(): + """Set up test environment variables for database configuration""" + os.environ['DB_TYPE'] = os.getenv('TEST_DB_TYPE', 'postgresql') + os.environ['DB_HOST'] = '127.0.0.1' + os.environ['DB_PORT'] = '5432' if os.getenv('DB_TYPE') == 'postgresql' else '3306' + os.environ['DB_USER'] = 'test_user' + os.environ['DB_PASSWORD'] = 'test_password' + os.environ['DB_NAME'] = 'test_db' + os.environ['TEST_MODE'] = 'True' + os.environ['SEARCH_API_URL'] = 'https://search.pinepods.online/api/search' + os.environ['PEOPLE_API_URL'] = 'https://people.pinepods.online/api/hosts' diff --git a/PinePods-0.8.2/tests/test_podcast.py b/PinePods-0.8.2/tests/test_podcast.py new file mode 100644 index 0000000..27856d2 --- /dev/null +++ b/PinePods-0.8.2/tests/test_podcast.py @@ -0,0 +1,124 @@ +import pytest +import pytest_asyncio +import os + +# Use consistent environment variables +DB_USER = os.environ.get("DB_USER", "test_user") +DB_PASSWORD = os.environ.get("DB_PASSWORD", "test_password") +DB_HOST = os.environ.get("DB_HOST", "127.0.0.1") +DB_PORT = os.environ.get("DB_PORT", "5432") +DB_NAME = os.environ.get("DB_NAME", "test_db") + +# Read the API key from the file +def get_admin_api_key(): + try: + with open("/tmp/web_api_key.txt", "r") as f: + return f.read().strip() + except FileNotFoundError: + raise RuntimeError("API key file not found. Ensure database setup has completed.") + +# Get the API key once at module level +ADMIN_API_KEY = get_admin_api_key() + +@pytest.mark.asyncio +async def test_pinepods_check(async_client): + """Test the basic health check endpoint""" + response = await async_client.get("/api/pinepods_check") + assert response.status_code == 200 + assert response.json() == {"status_code": 200, "pinepods_instance": True} + +@pytest.mark.asyncio +async def test_verify_api_key(async_client): + """Test API key verification with admin web key""" + response = await async_client.get( + "/api/data/verify_key", + headers={"Api-Key": ADMIN_API_KEY} + ) + assert response.status_code == 200 + assert response.json() == {"status": "success"} + +@pytest.mark.asyncio +async def test_get_podcast_details_dynamic(async_client): + """Test fetching dynamic podcast details from the feed""" + params = { + "user_id": 1, # Admin user ID is typically 1 + "podcast_title": "PinePods News", + "podcast_url": "https://news.pinepods.online/feed.xml", + "added": False, + "display_only": False + } + response = await async_client.get( + "/api/data/get_podcast_details_dynamic", + params=params, + headers={"Api-Key": ADMIN_API_KEY} + ) + assert response.status_code == 200 + data = response.json() + assert data["podcast_title"] == "Pinepods News Feed" + assert data["podcast_url"] == "https://news.pinepods.online/feed.xml" + + +@pytest.mark.asyncio +async def test_add_podcast(async_client): + """Test adding a podcast to the database""" + # Mock the database functions + import database_functions.functions + + # Store original function + original_add_podcast = database_functions.functions.add_podcast + + # Mock the add_podcast function to return expected values + def mock_add_podcast(*args, **kwargs): + return (1, 1) # Return a tuple of (podcast_id, first_episode_id) + + # Patch the function + database_functions.functions.add_podcast = mock_add_podcast + + try: + # First get the podcast details + params = { + "user_id": 1, + "podcast_title": "PinePods News", + "podcast_url": "https://news.pinepods.online/feed.xml", + "added": False, + "display_only": False + } + details_response = await async_client.get( + "/api/data/get_podcast_details_dynamic", + params=params, + headers={"Api-Key": ADMIN_API_KEY} + ) + podcast_details = details_response.json() + + # Then add the podcast + add_request = { + "podcast_values": { + "pod_title": podcast_details["podcast_title"], + "pod_artwork": podcast_details["podcast_artwork"], + "pod_author": podcast_details["podcast_author"], + "categories": podcast_details["podcast_categories"], + "pod_description": podcast_details["podcast_description"], + "pod_episode_count": podcast_details["podcast_episode_count"], + "pod_feed_url": podcast_details["podcast_url"], + "pod_website": podcast_details["podcast_link"], + "pod_explicit": podcast_details["podcast_explicit"], + "user_id": 1 + }, + "podcast_index_id": 0 + } + + response = await async_client.post( + "/api/data/add_podcast", + json=add_request, + headers={"Api-Key": ADMIN_API_KEY} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "podcast_id" in data + assert "first_episode_id" in data + + finally: + # Restore original function + database_functions.functions.add_podcast = original_add_podcast diff --git a/PinePods-0.8.2/validate_db.py b/PinePods-0.8.2/validate_db.py new file mode 100644 index 0000000..7067d8f --- /dev/null +++ b/PinePods-0.8.2/validate_db.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +Simple wrapper for database validation that reads from environment variables +""" + +import os +import sys +import subprocess +import psycopg +def main(): + # Get database config from environment variables (same as the app uses) + db_type = os.environ.get('DB_TYPE', 'postgresql') + db_host = os.environ.get('DB_HOST', 'localhost') + db_port = os.environ.get('DB_PORT', '5432' if db_type == 'postgresql' else '3306') + db_user = os.environ.get('DB_USER', 'postgres' if db_type == 'postgresql' else 'root') + db_password = os.environ.get('DB_PASSWORD', '') + db_name = os.environ.get('DB_NAME', 'pinepods_database') + + if not db_password: + print("Error: DB_PASSWORD environment variable is required") + sys.exit(1) + + # Build command + cmd = [ + sys.executable, + 'database_functions/validate_database.py', + '--db-type', db_type, + '--db-host', db_host, + '--db-port', db_port, + '--db-user', db_user, + '--db-password', db_password, + '--db-name', db_name + ] + + # Add verbose flag if requested + if '--verbose' in sys.argv or '-v' in sys.argv: + cmd.append('--verbose') + + print(f"Validating {db_type} database: {db_user}@{db_host}:{db_port}/{db_name}") + print("Running database validation...") + print() + + # Run the validator + result = subprocess.run(cmd) + sys.exit(result.returncode) + +if __name__ == '__main__': + main() diff --git a/PinePods-0.8.2/web/.cargo/config.toml b/PinePods-0.8.2/web/.cargo/config.toml new file mode 100644 index 0000000..2981b36 --- /dev/null +++ b/PinePods-0.8.2/web/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +rustflags = ["--cfg=web_sys_unstable_apis", "--cfg=getrandom_backend=\"wasm_js\""] \ No newline at end of file diff --git a/PinePods-0.8.2/web/Cargo.lock b/PinePods-0.8.2/web/Cargo.lock new file mode 100644 index 0000000..2bb4305 --- /dev/null +++ b/PinePods-0.8.2/web/Cargo.lock @@ -0,0 +1,3245 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + +[[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 = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "anymap" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33954243bd79057c2de7338850b85983a44588021f8a5fee574a8888c6de4344" + +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.1", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers 0.3.0", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atom_syndication" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8100e46ff92eb85bf6dc2930c73f2a4f7176393c84a9446b3d501e1b354e7b34" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "diligent-date-parser" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" +dependencies = [ + "chrono", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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.1", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gloo" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ce6f2dfa9f57f15b848efa2aade5e1850dc72986b87a2b0752d44ca08f4967" +dependencies = [ + "gloo-console-timer", + "gloo-events 0.1.2", + "gloo-file 0.1.0", + "gloo-timers 0.2.6", +] + +[[package]] +name = "gloo" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d" +dependencies = [ + "gloo-console 0.2.3", + "gloo-dialogs 0.1.1", + "gloo-events 0.1.2", + "gloo-file 0.2.3", + "gloo-history 0.1.5", + "gloo-net 0.3.1", + "gloo-render 0.1.1", + "gloo-storage 0.2.2", + "gloo-timers 0.2.6", + "gloo-utils 0.1.7", + "gloo-worker 0.2.1", +] + +[[package]] +name = "gloo" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.4.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.4.0", +] + +[[package]] +name = "gloo" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.5.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.5.0", +] + +[[package]] +name = "gloo-console" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console-timer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b48675544b29ac03402c6dffc31a912f716e38d19f7e74b78b7e900ec3c941ea" +dependencies = [ + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f9fecfe46b5dc3cc46f58e98ba580cc714f2c93860796d002eb3527a465ef49" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "futures-channel", + "gloo-events 0.2.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f" +dependencies = [ + "gloo-events 0.1.2", + "gloo-utils 0.1.7", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom 0.2.16", + "gloo-events 0.2.0", + "gloo-utils 0.2.0", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.1.7", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 1.3.1", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a" +dependencies = [ + "anymap2", + "bincode", + "gloo-console 0.2.3", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + +[[package]] +name = "htmlentity" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd54ae4f69adcc1a43637dcff230852832c3ad50df31a90e0cb5f001dd441359" +dependencies = [ + "anyhow", + "lazy_static", + "thiserror", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "i18nrs" +version = "0.1.7" +source = "git+https://github.com/madeofpendletonwool/i18n-rs#c0e755729b3c8ff220d8417fb126972fa0665a46" +dependencies = [ + "serde_json", + "web-sys", + "yew 0.21.0", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "implicit-clone" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" +dependencies = [ + "implicit-clone-derive", + "indexmap 2.11.4", +] + +[[package]] +name = "implicit-clone-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.176" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +dependencies = [ + "value-bag", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror", +] + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.1", +] + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488" +dependencies = [ + "futures", + "gloo 0.8.1", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + +[[package]] +name = "rss" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" +dependencies = [ + "atom_syndication", + "derive_builder", + "never", + "quick-xml", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.1", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[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 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.0.4", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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 2.0.106", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "value-bag" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "serde_json", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web" +version = "0.1.0" +dependencies = [ + "ammonia", + "anyhow", + "argon2", + "async-std", + "base64", + "chrono", + "chrono-tz", + "data-encoding", + "futures", + "futures-util", + "getrandom 0.3.4", + "gloo 0.11.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-net 0.6.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "htmlentity", + "i18nrs", + "js-sys", + "log", + "md5", + "percent-encoding", + "pulldown-cmark", + "rand 0.9.2", + "regex", + "rss", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_json", + "serde_with", + "url", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew 0.21.0", + "yew-router", + "yewdux", + "yewtil", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "windows-core" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yew" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d5154faef86dddd2eb333d4755ea5643787d20aca683e58759b0e53351409f" +dependencies = [ + "anyhow", + "anymap", + "bincode", + "cfg-if", + "cfg-match", + "console_error_panic_hook", + "gloo 0.2.1", + "http 0.2.12", + "indexmap 1.9.3", + "js-sys", + "log", + "ryu", + "serde", + "serde_json", + "slab", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro 0.18.0", +] + +[[package]] +name = "yew" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo 0.10.0", + "implicit-clone", + "indexmap 2.11.4", + "js-sys", + "prokio", + "rustversion", + "serde", + "slab", + "thiserror", + "tokio", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro 0.21.0", +] + +[[package]] +name = "yew-macro" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6e23bfe3dc3933fbe9592d149c9985f3047d08c637a884b9344c21e56e092ef" +dependencies = [ + "boolinator", + "lazy_static", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "yew-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" +dependencies = [ + "boolinator", + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "yew-router" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca1d5052c96e6762b4d6209a8aded597758d442e6c479995faf0c7b5538e0c6" +dependencies = [ + "gloo 0.10.0", + "js-sys", + "route-recognizer", + "serde", + "serde_urlencoded", + "tracing", + "urlencoding", + "wasm-bindgen", + "web-sys", + "yew 0.21.0", + "yew-router-macro", +] + +[[package]] +name = "yew-router-macro" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "yewdux" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8030a7de50678c07c038dcb96a42f1e8a7c4cc5610451fbee0c676aa7df42967" +dependencies = [ + "log", + "serde", + "serde_json", + "slab", + "thiserror", + "wasm-bindgen", + "web-sys", + "yew 0.21.0", + "yewdux-macros", +] + +[[package]] +name = "yewdux-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ac6ccd84a49bbce44610d44eb6686a1266337d0cd3aeadb5564ab76a2819f0" +dependencies = [ + "darling 0.20.11", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "yewtil" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543663ac49cd613df079282a1d8bdbdebdad6e02bac229f870fd4237b5d9aaa" +dependencies = [ + "log", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew 0.18.0", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/PinePods-0.8.2/web/Cargo.toml b/PinePods-0.8.2/web/Cargo.toml new file mode 100644 index 0000000..8812766 --- /dev/null +++ b/PinePods-0.8.2/web/Cargo.toml @@ -0,0 +1,103 @@ +[package] +name = "web" +version = "0.1.0" +edition = "2021" +rust-version = "1.89" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +#trunk = "0.18.0" +#yew = { git = "https://github.com/yewstack/yew/", features = ["csr"] } +yew = { version = "0.21.0", features = ["csr"] } +#yew = { "0.21.0", features = ["csr"] } +web-sys = { version = "0.3.82", features = [ + "CssStyleDeclaration", + "DomTokenList", + "HtmlSelectElement", + "HtmlAudioElement", + "DomRect", + "Element", + "HtmlAnchorElement", + "FileReader", + "PopStateEvent", + "Blob", + "Document", + "Element", + "NodeList", + "Window", + "XmlHttpRequest", + "DomParser", + "SupportedType", + "Performance", + "PerformanceNavigation", + "DragEvent", + "MutationObserver", + "MutationObserverInit", + "DataTransfer", + "TouchEvent", + "TouchList", + "CacheStorage", + "Cache", + "Touch", + "Clipboard", + "Navigator", + "MediaMetadata", + "MediaSession", + "MediaSessionAction", + "Permissions", + "MediaPositionState", + "MediaSessionPlaybackState", + "Crypto", + "MouseEvent", + "MouseEventInit", + "CustomEvent", +] } +log = "0.4.28" +wasm-bindgen = "0.2.105" +yew-router = { version = "0.18.0" } +serde = { version = "1.0.225", features = ["derive"] } +gloo-net = { version = "0.6.0", features = ["websocket"] } +gloo = "0.11.0" +anyhow = { version = "1.0.99", features = [] } +wasm-bindgen-futures = "0.4.55" +gloo-timers = "0.3.0" +base64 = "0.22.1" +yewdux = "0.11.0" +rss = "2.0.12" +chrono = "0.4.42" +serde_json = "1.0.145" +yewtil = "0.4.0" +gloo-utils = "0.2.0" +gloo-events = "0.2.0" +md5 = "0.8.0" +ammonia = "4.1.1" +pulldown-cmark = "0.13.0" +async-std = "1.13.2" +argon2 = { version = "0.5.3", features = ["std", "password-hash"] } +getrandom = { version = "0.3.4", features = ["wasm_js"] } +rand = "0.9.2" +regex = "1.12.2" +js-sys = "0.3.82" +percent-encoding = "2.3.2" +data-encoding = "2.9.0" +url = "2.5.7" +serde-wasm-bindgen = "0.6.5" +chrono-tz = "0.10.4" +futures = "0.3.31" +futures-util = "0.3.31" +gloo-file = "0.3.0" +urlencoding = "2.1.3" +serde_with = "3.15.1" +htmlentity = "1.3.2" +i18nrs = { git = "https://github.com/madeofpendletonwool/i18n-rs", features = ["yew"] } + +[features] +default = [] +server_build = [] + +[profile.release] +lto = true + +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/PinePods-0.8.2/web/Trunk.toml b/PinePods-0.8.2/web/Trunk.toml new file mode 100644 index 0000000..d497551 --- /dev/null +++ b/PinePods-0.8.2/web/Trunk.toml @@ -0,0 +1,16 @@ +[build] +target = "index.html" +dist = "dist" +release = true + +# [watch] +# ignore = ["dist/**"] + +# [serve] +# Add any serve-specific configurations here if needed + +[clean] +dist = "dist" + +# Specify the required Trunk version if needed +# trunk-version = "^0.19.0" diff --git a/PinePods-0.8.2/web/build.bat b/PinePods-0.8.2/web/build.bat new file mode 100644 index 0000000..99153fe --- /dev/null +++ b/PinePods-0.8.2/web/build.bat @@ -0,0 +1,3 @@ +@echo off +set RUSTFLAGS=--cfg=web_sys_unstable_apis --cfg getrandom_backend="wasm_js" +trunk build --features server_build \ No newline at end of file diff --git a/PinePods-0.8.2/web/build.ps1 b/PinePods-0.8.2/web/build.ps1 new file mode 100644 index 0000000..c21bf16 --- /dev/null +++ b/PinePods-0.8.2/web/build.ps1 @@ -0,0 +1,2 @@ +$env:RUSTFLAGS="--cfg=web_sys_unstable_apis --cfg=getrandom_backend=`"wasm_js`"" +trunk build --features server_build \ No newline at end of file diff --git a/PinePods-0.8.2/web/build.sh b/PinePods-0.8.2/web/build.sh new file mode 100755 index 0000000..d4c5363 --- /dev/null +++ b/PinePods-0.8.2/web/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd "$(dirname "$0")" +export RUSTFLAGS="--cfg=web_sys_unstable_apis --cfg getrandom_backend=\"wasm_js\"" +trunk build --features server_build \ No newline at end of file diff --git a/PinePods-0.8.2/web/dev-info.md b/PinePods-0.8.2/web/dev-info.md new file mode 100644 index 0000000..b8a8416 --- /dev/null +++ b/PinePods-0.8.2/web/dev-info.md @@ -0,0 +1,13 @@ +Needed pre-reqs + +Rust obvs +``` +rustup target add wasm32-unknown-unknown + +cargo install --locked trunk +``` + +Run with +``` +trunk serve --open +``` \ No newline at end of file diff --git a/PinePods-0.8.2/web/index.html b/PinePods-0.8.2/web/index.html new file mode 100644 index 0000000..d5aaa20 --- /dev/null +++ b/PinePods-0.8.2/web/index.html @@ -0,0 +1,40 @@ + + + + + + Pinepods + + + + + + + + + + + + + + + + diff --git a/PinePods-0.8.2/web/src-tauri/.gitignore b/PinePods-0.8.2/web/src-tauri/.gitignore new file mode 100644 index 0000000..222806c --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +keystore.properties \ No newline at end of file diff --git a/PinePods-0.8.2/web/src-tauri/Cargo.lock b/PinePods-0.8.2/web/src-tauri/Cargo.lock new file mode 100644 index 0000000..4f48d69 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/Cargo.lock @@ -0,0 +1,4918 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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 = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "directories", + "dirs", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tokio", + "ureq", + "warp", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.3", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" +dependencies = [ + "serde", +] + +[[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.16", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.5", +] + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.1.3", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.9.3", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.3", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dlopen2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6d81016d6c977deefb2ef8d8290da019e27cc26167e102185da528e6c0ab38" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.5", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.3", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.11.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +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 = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", + "serde", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.3", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.11.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags 2.9.3", + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.16", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.3", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate 2.0.2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.9.3", + "block2 0.6.1", + "libc", + "objc2 0.6.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.3", + "dispatch2", + "objc2 0.6.2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.9.3", + "dispatch2", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +dependencies = [ + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.9.3", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.3", + "block2 0.6.1", + "libc", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a" +dependencies = [ + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.9.3", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.3", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-security" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" +dependencies = [ + "bitflags 2.9.3", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" +dependencies = [ + "bitflags 2.9.3", + "block2 0.6.1", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.11.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.3", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.16", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[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 = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[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-untagged" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[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 2.0.106", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.0", + "schemars 0.9.0", + "schemars 1.0.4", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics", + "foreign-types", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.9.3", + "block2 0.6.1", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bceb52453e507c505b330afe3398510e87f428ea42b6e76ecb6bd63b15965b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.3", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.16", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a924b6c50fe83193f0f8b14072afa7c25b7a72752a2a73d9549b463f5fe91a38" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.5", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c1fe64c74cc40f90848281a90058a6db931eb400b60205840e09801ee30f190" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.106", + "tauri-utils", + "thiserror 2.0.16", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260c5d2eb036b76206b9fca20b7be3614cfd21046c5396f7959e0e64a4b07f2f" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.16", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.16", + "toml 0.9.5", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" +dependencies = [ + "embed-resource", + "toml 0.9.5", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", +] + +[[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 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap 2.11.0", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.11.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.11.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.3", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.16", + "windows-sys 0.59.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "warp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d06d9202adc1f15d709c4f4a2069be5428aa912cc025d6f268ac441ab066b0" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project", + "scoped-tls", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-util", + "tower-service", + "tracing", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +dependencies = [ + "thiserror 2.0.16", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" +dependencies = [ + "windows-link 0.1.3", +] + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[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_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[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_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.3", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wry" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +dependencies = [ + "base64 0.22.1", + "block2 0.6.1", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.16", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/PinePods-0.8.2/web/src-tauri/Cargo.toml b/PinePods-0.8.2/web/src-tauri/Cargo.toml new file mode 100644 index 0000000..e7ebf7f --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "app" +version = "0.1.0" +description = "Pinepods-tauri" +authors = ["Gooseberry Development"] +license = "" +repository = "" +default-run = "app" +edition = "2021" +rust-version = "1.89" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[build-dependencies] +tauri-build = { version = "2.5.1", features = [] } + +[dependencies] +serde_json = "1.0.145" +serde = { version = "1.0.228", features = ["derive"] } +tauri = { version = "2.9.2", features = ["tray-icon"] } +directories = "6.0.0" +dirs = "6.0.0" +# reqwest = { version = "0.12.5", features = ["blocking", "json"] } +tokio = { version = "1.48.0", features = ["full"] } +warp = { version = "0.4.2", features = ["server"] } +ureq = "=3.1.2" + + +[features] +# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. +# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. +# DO NOT REMOVE!! +custom-protocol = ["tauri/custom-protocol"] + +[lib] +name = "app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] diff --git a/PinePods-0.8.2/web/src-tauri/build.rs b/PinePods-0.8.2/web/src-tauri/build.rs new file mode 100644 index 0000000..795b9b7 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/PinePods-0.8.2/web/src-tauri/change-version.py b/PinePods-0.8.2/web/src-tauri/change-version.py new file mode 100644 index 0000000..cd8631a --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/change-version.py @@ -0,0 +1,22 @@ +import json +import sys + +def update_version(file_path, new_version): + with open(file_path, 'r') as file: + config = json.load(file) + + # Update the version at the root level + config['version'] = new_version + + with open(file_path, 'w') as file: + json.dump(config, file, indent=2) + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: update_version.py ") + sys.exit(1) + + file_path = sys.argv[1] + new_version = sys.argv[2] + + update_version(file_path, new_version) diff --git a/PinePods-0.8.2/web/src-tauri/com.gooseberrydevelopment.pinepods.metainfo.xml b/PinePods-0.8.2/web/src-tauri/com.gooseberrydevelopment.pinepods.metainfo.xml new file mode 100644 index 0000000..9ceca19 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/com.gooseberrydevelopment.pinepods.metainfo.xml @@ -0,0 +1,51 @@ + + + com.gooseberrydevelopment.pinepods + CC0-1.0 + GPL-3.0-only + + Pinepods + A complete, self-hosted podcast management system + + + Gooseberry Development + + + +

+ PinePods is a Rust based podcast management system that manages podcasts with multi-user support and relies on a central database with clients to connect to it. It's browser based and your podcasts and settings follow you from device to device due to everything being stored on the server. You can subscribe to podcasts and even hosts for podcasts with the help of the PodPeopleDB. It works on mobile devices and can also sync with a Nextcloud server or gpodder compatible sync server so you can use external apps like Antennapod as well! NOTE: This is the client edition of Pinepods. You must have a server to connect to in order to use this app. +

+

+ Instructions for setting up your own self-hosted server can be found on the main repo +

+
+ com.gooseberrydevelopment.pinepods.desktop + + + + https://raw.githubusercontent.com/madeofpendletonwool/PinePods/3c84f982bc3d320e2d4ebe6ce2788f5fd656f7b5/images/screenshots/homethemed.png + Main interface of Pinepods + + + https://github.com/madeofpendletonwool/PinePods + https://github.com/madeofpendletonwool/PinePods/issues + https://github.com/sponsors/madeofpendletonwool + + + + +

I'm excited to announce PinePods 0.7.6, fully showing the power of the new notification system, things like episode downloads will now display percentages as they download in a brand new alert component that's been added. A worker system for background tasks that can be easily monitored on a per user basis so we can now get status of tasks as they execute was added back in version 0.7.4. Now we're starting to see what can be done with it. At this point I am confident Pinepods is the ultimate podcast archival tool. In the near future I will continue to migrate tasks over to this new system which will allow for even greater visibility into exactly what the server is doing. Think monitoring status of Nextcloud sync tasks etc. + +In addition this update further improves some findings after the previous 0.7.4 update as it was a big one!

+
+
+
+ + 360 + + + 548 + + + +
diff --git a/PinePods-0.8.2/web/src-tauri/gen/schemas/acl-manifests.json b/PinePods-0.8.2/web/src-tauri/gen/schemas/acl-manifests.json new file mode 100644 index 0000000..43da9ef --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/gen/schemas/acl-manifests.json @@ -0,0 +1 @@ +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/PinePods-0.8.2/web/src-tauri/gen/schemas/android-schema.json b/PinePods-0.8.2/web/src-tauri/gen/schemas/android-schema.json new file mode 100644 index 0000000..f1cad26 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/gen/schemas/android-schema.json @@ -0,0 +1,1756 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nOn multiwebview windows, prefer [`Self::webviews`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThis is only required when using on multiwebview contexts, by default all child webviews of a window that matches [`Self::windows`] are linked.\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set which includes:\n- 'core:path:default'\n- 'core:event:default'\n- 'core:window:default'\n- 'core:webview:default'\n- 'core:app:default'\n- 'core:image:default'\n- 'core:resources:default'\n- 'core:menu:default'\n- 'core:tray:default'\n", + "type": "string", + "const": "core:default" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:app:default" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide" + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show" + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon" + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name" + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme" + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version" + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version" + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide" + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show" + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon" + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name" + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme" + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version" + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:event:default" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit" + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to" + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen" + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten" + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit" + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to" + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen" + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:image:default" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes" + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path" + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new" + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba" + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size" + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes" + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path" + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new" + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba" + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:menu:default" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append" + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default" + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get" + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert" + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked" + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled" + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items" + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new" + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup" + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend" + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove" + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at" + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator" + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu" + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp" + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu" + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp" + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked" + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled" + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon" + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text" + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text" + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append" + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default" + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get" + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert" + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked" + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled" + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items" + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new" + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup" + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend" + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove" + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at" + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator" + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu" + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp" + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu" + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp" + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked" + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled" + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon" + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text" + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:path:default" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename" + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname" + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname" + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute" + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join" + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize" + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve" + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory" + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename" + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname" + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname" + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute" + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join" + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize" + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve" + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:resources:default" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close" + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:tray:default" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id" + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new" + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id" + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon" + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template" + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu" + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click" + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path" + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title" + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip" + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible" + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id" + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new" + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id" + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon" + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template" + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu" + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click" + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path" + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title" + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip" + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:webview:default" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data" + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview" + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window" + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews" + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools" + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print" + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent" + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus" + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position" + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size" + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom" + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close" + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide" + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position" + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show" + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size" + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data" + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview" + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window" + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews" + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools" + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print" + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent" + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus" + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position" + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size" + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom" + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close" + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide" + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position" + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show" + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:window:default" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors" + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close" + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create" + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor" + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position" + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy" + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows" + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide" + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position" + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size" + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize" + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable" + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated" + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled" + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused" + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen" + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable" + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized" + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable" + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized" + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable" + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible" + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize" + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize" + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point" + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position" + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size" + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor" + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention" + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor" + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom" + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top" + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable" + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected" + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab" + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon" + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position" + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible" + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations" + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects" + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled" + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus" + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen" + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon" + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events" + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size" + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable" + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size" + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable" + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position" + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar" + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable" + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow" + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size" + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints" + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar" + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme" + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title" + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style" + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces" + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show" + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging" + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging" + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme" + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title" + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize" + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize" + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize" + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors" + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center" + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close" + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create" + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor" + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position" + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy" + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows" + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide" + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position" + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size" + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize" + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable" + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated" + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled" + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused" + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen" + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable" + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized" + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable" + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized" + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable" + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible" + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize" + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize" + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point" + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position" + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size" + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor" + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention" + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor" + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom" + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top" + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable" + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected" + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab" + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon" + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position" + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible" + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations" + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects" + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled" + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus" + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen" + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon" + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events" + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size" + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable" + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size" + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable" + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position" + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar" + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable" + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow" + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size" + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints" + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar" + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme" + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title" + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style" + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces" + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show" + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging" + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging" + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme" + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title" + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize" + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize" + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize" + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/web/src-tauri/gen/schemas/capabilities.json b/PinePods-0.8.2/web/src-tauri/gen/schemas/capabilities.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/gen/schemas/capabilities.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/PinePods-0.8.2/web/src-tauri/gen/schemas/desktop-schema.json b/PinePods-0.8.2/web/src-tauri/gen/schemas/desktop-schema.json new file mode 100644 index 0000000..260dbe0 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/gen/schemas/desktop-schema.json @@ -0,0 +1,2244 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/web/src-tauri/gen/schemas/linux-schema.json b/PinePods-0.8.2/web/src-tauri/gen/schemas/linux-schema.json new file mode 100644 index 0000000..260dbe0 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/gen/schemas/linux-schema.json @@ -0,0 +1,2244 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/web/src-tauri/gen/schemas/mobile-schema.json b/PinePods-0.8.2/web/src-tauri/gen/schemas/mobile-schema.json new file mode 100644 index 0000000..f1cad26 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/gen/schemas/mobile-schema.json @@ -0,0 +1,1756 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nOn multiwebview windows, prefer [`Self::webviews`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThis is only required when using on multiwebview contexts, by default all child webviews of a window that matches [`Self::windows`] are linked.\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set which includes:\n- 'core:path:default'\n- 'core:event:default'\n- 'core:window:default'\n- 'core:webview:default'\n- 'core:app:default'\n- 'core:image:default'\n- 'core:resources:default'\n- 'core:menu:default'\n- 'core:tray:default'\n", + "type": "string", + "const": "core:default" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:app:default" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide" + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show" + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon" + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name" + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme" + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version" + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version" + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide" + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show" + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon" + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name" + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme" + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version" + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:event:default" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit" + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to" + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen" + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten" + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit" + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to" + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen" + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:image:default" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes" + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path" + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new" + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba" + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size" + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes" + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path" + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new" + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba" + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:menu:default" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append" + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default" + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get" + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert" + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked" + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled" + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items" + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new" + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup" + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend" + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove" + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at" + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator" + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu" + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp" + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu" + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp" + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked" + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled" + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon" + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text" + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text" + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append" + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default" + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get" + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert" + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked" + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled" + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items" + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new" + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup" + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend" + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove" + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at" + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator" + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu" + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp" + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu" + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp" + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked" + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled" + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon" + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text" + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:path:default" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename" + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname" + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname" + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute" + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join" + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize" + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve" + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory" + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename" + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname" + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname" + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute" + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join" + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize" + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve" + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:resources:default" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close" + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:tray:default" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id" + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new" + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id" + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon" + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template" + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu" + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click" + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path" + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title" + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip" + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible" + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id" + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new" + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id" + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon" + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template" + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu" + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click" + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path" + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title" + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip" + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:webview:default" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data" + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview" + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window" + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews" + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools" + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print" + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent" + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus" + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position" + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size" + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom" + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close" + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide" + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position" + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show" + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size" + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data" + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview" + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window" + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews" + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools" + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print" + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent" + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus" + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position" + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size" + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom" + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close" + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide" + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position" + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show" + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size" + }, + { + "description": "Default permissions for the plugin.", + "type": "string", + "const": "core:window:default" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors" + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close" + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create" + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor" + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position" + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy" + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows" + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide" + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position" + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size" + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize" + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable" + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated" + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled" + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused" + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen" + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable" + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized" + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable" + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized" + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable" + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible" + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize" + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize" + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point" + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position" + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size" + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor" + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention" + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor" + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom" + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top" + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable" + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected" + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab" + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon" + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position" + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible" + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations" + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects" + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled" + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus" + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen" + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon" + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events" + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size" + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable" + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size" + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable" + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position" + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar" + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable" + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow" + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size" + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints" + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar" + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme" + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title" + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style" + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces" + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show" + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging" + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging" + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme" + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title" + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize" + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize" + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize" + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors" + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center" + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close" + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create" + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor" + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position" + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy" + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows" + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide" + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position" + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size" + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize" + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable" + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated" + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled" + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused" + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen" + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable" + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized" + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable" + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized" + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable" + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible" + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize" + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize" + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point" + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position" + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size" + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor" + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention" + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor" + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom" + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top" + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable" + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected" + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab" + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon" + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position" + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible" + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations" + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects" + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled" + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus" + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen" + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon" + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events" + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size" + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable" + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size" + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable" + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position" + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar" + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable" + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow" + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size" + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints" + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar" + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme" + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title" + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style" + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces" + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show" + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging" + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging" + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme" + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title" + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize" + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize" + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize" + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/PinePods-0.8.2/web/src-tauri/icons/128x128.png b/PinePods-0.8.2/web/src-tauri/icons/128x128.png new file mode 100644 index 0000000..9c92fc0 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/128x128.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/128x128@2x.png b/PinePods-0.8.2/web/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..ffbd648 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/128x128@2x.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/256x256.png b/PinePods-0.8.2/web/src-tauri/icons/256x256.png new file mode 100644 index 0000000..84d73f9 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/256x256.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/32x32.png b/PinePods-0.8.2/web/src-tauri/icons/32x32.png new file mode 100644 index 0000000..07e1897 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/32x32.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square1024x1024.png b/PinePods-0.8.2/web/src-tauri/icons/Square1024x1024.png new file mode 100644 index 0000000..990d957 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square1024x1024.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square107x107Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..cb33aae Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square107x107Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square142x142Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..e28fafb Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square142x142Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square150x150Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..e1ae8e0 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square150x150Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square284x284Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..987bace Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square284x284Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square30x30Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..778deb5 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square30x30Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square310x310Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..478c447 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square310x310Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square44x44Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..a1785df Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square44x44Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square512x512.png b/PinePods-0.8.2/web/src-tauri/icons/Square512x512.png new file mode 100644 index 0000000..4536639 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square512x512.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square71x71Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..46e5da1 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square71x71Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/Square89x89Logo.png b/PinePods-0.8.2/web/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..9fe4d82 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/Square89x89Logo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/StoreLogo.png b/PinePods-0.8.2/web/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..6a80a61 Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/StoreLogo.png differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/icon.icns b/PinePods-0.8.2/web/src-tauri/icons/icon.icns new file mode 100644 index 0000000..4aca92a Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/icon.icns differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/icon.ico b/PinePods-0.8.2/web/src-tauri/icons/icon.ico new file mode 100644 index 0000000..600f2ad Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/icon.ico differ diff --git a/PinePods-0.8.2/web/src-tauri/icons/icon.png b/PinePods-0.8.2/web/src-tauri/icons/icon.png new file mode 100644 index 0000000..f7e997e Binary files /dev/null and b/PinePods-0.8.2/web/src-tauri/icons/icon.png differ diff --git a/PinePods-0.8.2/web/src-tauri/src/lib.rs b/PinePods-0.8.2/web/src-tauri/src/lib.rs new file mode 100644 index 0000000..e242329 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/src/lib.rs @@ -0,0 +1,563 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use directories::ProjectDirs; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::copy; +use std::io::Write; +use std::path::PathBuf; +use tauri::command; + +fn deserialize_categories<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + use std::fmt; + + struct CategoriesVisitor; + + impl<'de> Visitor<'de> for CategoriesVisitor { + type Value = HashMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string or a map") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + // Convert comma-separated string to HashMap + let mut map = HashMap::new(); + if !value.is_empty() && value != "{}" { + for (i, category) in value.split(',').enumerate() { + map.insert(i.to_string(), category.trim().to_string()); + } + } + Ok(map) + } + + fn visit_map(self, mut map: M) -> Result + where + M: de::MapAccess<'de>, + { + let mut categories = HashMap::new(); + while let Some((key, value)) = map.next_entry()? { + categories.insert(key, value); + } + Ok(categories) + } + } + + deserializer.deserialize_any(CategoriesVisitor) +} + +// Define the structure for the file entries +#[derive(Serialize, Deserialize)] +struct FileEntry { + path: String, +} + +// Function to list directory contents +#[command] +async fn list_dir(path: String) -> Result, String> { + let home_dir = dirs::home_dir().ok_or("Cannot find home directory")?; + let target_path = if path == "~" { + home_dir + } else { + PathBuf::from(path) + }; + + let mut entries = Vec::new(); + for entry in fs::read_dir(target_path).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + entries.push(FileEntry { + path: entry.path().display().to_string(), + }); + } + + Ok(entries) +} + +fn get_project_dirs() -> Result { + ProjectDirs::from("com", "gooseberrydevelopment", "pinepods") + .ok_or_else(|| "Cannot determine project directories".to_string()) +} + +#[command] +fn get_app_dir() -> Result { + let proj_dirs = get_project_dirs()?; + let app_dir = proj_dirs.data_dir(); + if !app_dir.exists() { + fs::create_dir_all(app_dir).map_err(|e| e.to_string())?; + } + Ok(app_dir.display().to_string()) +} + +#[command] +async fn download_file(url: String, filename: String) -> Result<(), String> { + println!( + "Starting download_file with url: {}, filename: {}", + url, filename + ); + let proj_dirs = get_project_dirs()?; + let app_dir: PathBuf = proj_dirs.data_dir().to_path_buf(); + println!("App dir path: {:?}", app_dir); + if !app_dir.exists() { + println!("Creating app directory"); + fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?; + } + + let url = url.clone(); + let filename = filename.clone(); + + // Use tokio::task::spawn_blocking for blocking operations + tokio::task::spawn_blocking(move || { + let agent = ureq::Agent::config_builder() + .max_redirects(20) + .build() + .new_agent(); + + let mut response = agent.get(&url).call().map_err(|e| e.to_string())?; + let mut reader = response.body_mut().with_config().reader(); // Alternative approach + let mut file = File::create(app_dir.join(&filename)).map_err(|e| e.to_string())?; + copy(&mut reader, &mut file).map_err(|e| e.to_string())?; + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Serialize)] +#[serde(default)] +pub struct EpisodeInfo { + pub episodetitle: String, + pub podcastname: String, + pub podcastid: i32, + pub podcastindexid: Option, + pub feedurl: String, // This field exists in the response + pub episodepubdate: String, + pub episodedescription: String, + pub episodeartwork: String, + pub episodeurl: String, + pub episodeduration: i32, + pub listenduration: Option, + pub episodeid: i32, + pub completed: bool, + pub is_queued: bool, + pub is_saved: bool, + pub is_downloaded: bool, + pub downloadedlocation: Option, + pub is_youtube: bool, +} + +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Serialize)] +#[serde(default)] +pub struct EpisodeDownload { + pub episodetitle: String, + pub podcastname: String, + pub episodepubdate: String, + pub episodedescription: String, + pub episodeartwork: String, + pub episodeurl: String, + pub episodeduration: i32, + pub listenduration: Option, + pub episodeid: i32, + pub downloadedlocation: Option, + pub podcastid: i32, + pub podcastindexid: Option, + pub completed: bool, + pub queued: bool, + pub saved: bool, + pub downloaded: bool, + pub is_youtube: bool, +} + +#[command] +async fn update_local_db(mut episode_info: EpisodeInfo) -> Result<(), String> { + let proj_dirs = get_project_dirs().map_err(|e| e.to_string())?; + let db_path = proj_dirs.data_dir().join("local_episodes.json"); + + // Calculate the downloaded location + let download_dir = proj_dirs + .data_dir() + .join(format!("episode_{}.mp3", episode_info.episodeid)); + episode_info.downloadedlocation = Some(download_dir.to_string_lossy().into_owned()); + + let mut episodes = if db_path.exists() { + let data = std::fs::read_to_string(&db_path).map_err(|e| e.to_string())?; + serde_json::from_str::>(&data).map_err(|e| e.to_string())? + } else { + Vec::new() + }; + + // Check if episode already exists before adding + if !episodes.iter().any(|ep| ep.episodeid == episode_info.episodeid) { + episodes.push(episode_info); + } + + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&db_path) + .map_err(|e| e.to_string())?; + serde_json::to_writer(file, &episodes).map_err(|e| e.to_string())?; + + Ok(()) +} + +#[command] +async fn remove_multiple_from_local_db(episode_ids: Vec) -> Result<(), String> { + let proj_dirs = get_project_dirs().map_err(|e| e.to_string())?; + let db_path = proj_dirs.data_dir().join("local_episodes.json"); + + let mut episodes = if db_path.exists() { + let data = std::fs::read_to_string(&db_path).map_err(|e| e.to_string())?; + serde_json::from_str::>(&data).map_err(|e| e.to_string())? + } else { + return Ok(()); // No episodes to remove if file doesn't exist + }; + + // Remove episodes with matching IDs + episodes.retain(|episode| !episode_ids.contains(&episode.episodeid)); + + // Write updated episodes back to file + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&db_path) + .map_err(|e| e.to_string())?; + serde_json::to_writer(file, &episodes).map_err(|e| e.to_string())?; + + // Delete the audio files and artwork for each episode + for episodeid in episode_ids { + let audio_file_path = proj_dirs + .data_dir() + .join(format!("episode_{}.mp3", episodeid)); + let artwork_file_path = proj_dirs + .data_dir() + .join(format!("artwork_{}.jpg", episodeid)); + + if audio_file_path.exists() { + std::fs::remove_file(audio_file_path).map_err(|e| e.to_string())?; + } + + if artwork_file_path.exists() { + std::fs::remove_file(artwork_file_path).map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +#[command] +async fn remove_from_local_db(episodeid: i32) -> Result<(), String> { + let proj_dirs = get_project_dirs().map_err(|e| e.to_string())?; + let db_path = proj_dirs.data_dir().join("local_episodes.json"); + + let mut episodes = if db_path.exists() { + let data = std::fs::read_to_string(&db_path).map_err(|e| e.to_string())?; + serde_json::from_str::>(&data).map_err(|e| e.to_string())? + } else { + return Ok(()); // No episodes to remove if file doesn't exist + }; + + episodes.retain(|episode| episode.episodeid != episodeid); + + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&db_path) + .map_err(|e| e.to_string())?; + serde_json::to_writer(file, &episodes).map_err(|e| e.to_string())?; + + // Delete the audio file and artwork + let audio_file_path = proj_dirs + .data_dir() + .join(format!("episode_{}.mp3", episodeid)); + let artwork_file_path = proj_dirs + .data_dir() + .join(format!("artwork_{}.jpg", episodeid)); + + if audio_file_path.exists() { + std::fs::remove_file(audio_file_path).map_err(|e| e.to_string())?; + } + + if artwork_file_path.exists() { + std::fs::remove_file(artwork_file_path).map_err(|e| e.to_string())?; + } + + Ok(()) +} + +#[command] +async fn deduplicate_local_episodes() -> Result<(), String> { + let proj_dirs = get_project_dirs().map_err(|e| e.to_string())?; + let db_path = proj_dirs.data_dir().join("local_episodes.json"); + + if !db_path.exists() { + return Ok(()); + } + + let data = std::fs::read_to_string(&db_path).map_err(|e| e.to_string())?; + let episodes = match serde_json::from_str::>(&data) { + Ok(eps) => eps, + Err(e) => { + println!("JSON parsing error: {}, resetting file", e); + std::fs::write(&db_path, "[]").map_err(|e| e.to_string())?; + return Ok(()); + } + }; + + // Remove duplicates based on episodeid + let mut unique_episodes = Vec::new(); + let mut seen_ids = HashSet::new(); + + for episode in episodes { + if seen_ids.insert(episode.episodeid) { + unique_episodes.push(episode); + } + } + + // Write back the deduplicated episodes + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&db_path) + .map_err(|e| e.to_string())?; + serde_json::to_writer(file, &unique_episodes).map_err(|e| e.to_string())?; + + Ok(()) +} + +#[command] +async fn get_local_episodes() -> Result, String> { + let proj_dirs = get_project_dirs().map_err(|e| e.to_string())?; + let db_path = proj_dirs.data_dir().join("local_episodes.json"); + + if !db_path.exists() { + return Ok(Vec::new()); + } + + let data = std::fs::read_to_string(&db_path).map_err(|e| e.to_string())?; + println!("Raw JSON data: {}", data); + + // If JSON is corrupted, reset it and return empty + let episodes = match serde_json::from_str::>(&data) { + Ok(eps) => eps, + Err(e) => { + println!("JSON parsing error: {}, resetting file", e); + // Reset the file to empty array + std::fs::write(&db_path, "[]").map_err(|e| e.to_string())?; + return Ok(Vec::new()); + } + }; + + // Convert EpisodeInfo to EpisodeDownload + let converted_episodes: Vec = episodes + .into_iter() + .map(|ep| EpisodeDownload { + episodetitle: ep.episodetitle, + podcastname: ep.podcastname, + episodepubdate: ep.episodepubdate, + episodedescription: ep.episodedescription, + episodeartwork: ep.episodeartwork, + episodeurl: ep.episodeurl, + episodeduration: ep.episodeduration, + listenduration: ep.listenduration, + episodeid: ep.episodeid, + downloadedlocation: ep.downloadedlocation, + podcastid: ep.podcastid, + podcastindexid: ep.podcastindexid, + completed: ep.completed, + queued: ep.is_queued, + saved: ep.is_saved, + downloaded: ep.is_downloaded, + is_youtube: ep.is_youtube, + }) + .collect(); + + Ok(converted_episodes) +} + +#[command] +fn delete_file(filename: String) -> Result<(), String> { + let proj_dirs = get_project_dirs()?; + let app_dir = proj_dirs.data_dir(); + let file_path = app_dir.join(filename); + if file_path.exists() { + fs::remove_file(file_path).map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("File does not exist".to_string()) + } +} + +#[command] +fn list_app_files() -> Result, String> { + let proj_dirs = get_project_dirs()?; + let app_dir = proj_dirs.data_dir(); + let mut entries = Vec::new(); + for entry in fs::read_dir(app_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + entries.push(FileEntry { + path: entry.path().display().to_string(), + }); + } + Ok(entries) +} + +#[derive(Deserialize, Debug, Clone, Serialize)] +pub struct PodcastDetails { + pub podcastid: i32, + pub podcastindexid: Option, + pub artworkurl: String, + pub author: String, + #[serde(deserialize_with = "deserialize_categories")] + pub categories: HashMap, + pub description: String, + pub episodecount: i32, + pub explicit: bool, + pub feedurl: String, + pub podcastname: String, + pub userid: i32, + pub websiteurl: String, +} + +#[command] +async fn update_podcast_db(podcast_details: PodcastDetails) -> Result<(), String> { + let proj_dirs = get_project_dirs().map_err(|e| e.to_string())?; + let db_path = proj_dirs.data_dir().join("local_podcasts.json"); + + let mut podcasts = if db_path.exists() { + let data = std::fs::read_to_string(&db_path).map_err(|e| e.to_string())?; + serde_json::from_str::>(&data).map_err(|e| e.to_string())? + } else { + Vec::new() + }; + + if !podcasts + .iter() + .any(|p| p.podcastid == podcast_details.podcastid) + { + podcasts.push(podcast_details); + } + + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&db_path) + .map_err(|e| e.to_string())?; + serde_json::to_writer(file, &podcasts).map_err(|e| e.to_string())?; + + Ok(()) +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[allow(non_snake_case)] +pub struct Podcast { + pub podcastid: i32, + pub podcastindexid: Option, + pub podcastname: String, + pub artworkurl: Option, + pub description: Option, + pub episodecount: i32, + pub websiteurl: Option, + pub feedurl: String, + pub author: Option, + #[serde(deserialize_with = "deserialize_categories")] + pub categories: HashMap, + pub explicit: bool, + // pub is_youtube: bool, +} + +#[command] +async fn get_local_podcasts() -> Result, String> { + let proj_dirs = get_project_dirs().map_err(|e| e.to_string())?; + let db_path = proj_dirs.data_dir().join("local_podcasts.json"); + + if !db_path.exists() { + return Ok(Vec::new()); + } + + let data = std::fs::read_to_string(&db_path).map_err(|e| e.to_string())?; + let podcasts = serde_json::from_str::>(&data).map_err(|e| e.to_string())?; + + Ok(podcasts) +} + +#[tauri::command] +async fn get_local_file(filepath: String) -> Result, String> { + use std::fs::File; + use std::io::Read; + use std::path::PathBuf; + + let path = PathBuf::from(filepath); + let mut file = File::open(&path).map_err(|e| e.to_string())?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer).map_err(|e| e.to_string())?; + Ok(buffer) +} + +#[tauri::command] +async fn start_file_server(filepath: String) -> Result { + // Log the file path to ensure it's correct + println!("Starting file server with path: {}", filepath); + + // Ensure the path exists and is accessible + if !std::path::Path::new(&filepath).exists() { + return Err(format!("File path does not exist: {}", filepath)); + } + + // Get the directory of the file + let file_dir = std::path::Path::new(&filepath) + .parent() + .unwrap() + .to_str() + .unwrap() + .to_string(); + + // Log the directory being served + println!("Serving files from directory: {}", file_dir); + + // Create the warp filter to serve the directory containing the file + let file_route = warp::fs::dir(file_dir); + + // Start the warp server + tokio::spawn(warp::serve(file_route).run(([127, 0, 0, 1], 3030))); + + Ok("http://127.0.0.1:3030".to_string()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + list_dir, + get_app_dir, + download_file, + delete_file, + update_local_db, + remove_from_local_db, + remove_multiple_from_local_db, + update_podcast_db, + get_local_podcasts, + get_local_episodes, + deduplicate_local_episodes, + list_app_files, + get_local_file, + start_file_server + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/PinePods-0.8.2/web/src-tauri/src/main.rs b/PinePods-0.8.2/web/src-tauri/src/main.rs new file mode 100644 index 0000000..cea0b30 --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::run(); +} \ No newline at end of file diff --git a/PinePods-0.8.2/web/src-tauri/tauri.conf.json b/PinePods-0.8.2/web/src-tauri/tauri.conf.json new file mode 100644 index 0000000..dfa3ebd --- /dev/null +++ b/PinePods-0.8.2/web/src-tauri/tauri.conf.json @@ -0,0 +1,70 @@ +{ + "build": { + "beforeBuildCommand": "", + "beforeDevCommand": "RUSTFLAGS='--cfg=web_sys_unstable_apis --cfg getrandom_backend=\"wasm_js\"' trunk serve", + "devUrl": "http://localhost:8080", + "frontendDist": "../dist" + }, + "identifier": "com.gooseberrydevelopment.pinepods", + "productName": "Pinepods", + "version": "1.2.4", + "app": { + "trayIcon": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true + }, + "withGlobalTauri": true, + "security": { + "csp": null + }, + "windows": [ + { + "fullscreen": false, + "height": 1000, + "resizable": true, + "title": "Pinepods", + "width": 1200 + } + ] + }, + "bundle": { + "active": true, + "category": "DeveloperTool", + "copyright": "", + "linux": { + "deb": { + "depends": [], + "files": { + "/usr/share/metainfo/com.gooseberrydevelopment.pinepods.metainfo.xml": "./com.gooseberrydevelopment.pinepods.metainfo.xml" + } + } + }, + "android": { + "versionCode": 100 + }, + "externalBin": [], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "longDescription": "", + "macOS": { + "entitlements": null, + "exceptionDomain": "", + "frameworks": [], + "providerShortName": null, + "signingIdentity": null + }, + "resources": [], + "shortDescription": "", + "targets": "all", + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + } +} diff --git a/PinePods-0.8.2/web/src/.cargo/config.toml b/PinePods-0.8.2/web/src/.cargo/config.toml new file mode 100644 index 0000000..9fbed5a --- /dev/null +++ b/PinePods-0.8.2/web/src/.cargo/config.toml @@ -0,0 +1,11 @@ +[build] +rustflags = [ + "--cfg", "web_sys_unstable_apis", + "--cfg", "getrandom_backend=\"wasm_js\"" +] + +[target.wasm32-unknown-unknown] +rustflags = [ + "--cfg", "web_sys_unstable_apis", + "--cfg", "getrandom_backend=\"wasm_js\"" +] \ No newline at end of file diff --git a/PinePods-0.8.2/web/src/components/app_drawer.rs b/PinePods-0.8.2/web/src/components/app_drawer.rs new file mode 100644 index 0000000..f4d921b --- /dev/null +++ b/PinePods-0.8.2/web/src/components/app_drawer.rs @@ -0,0 +1,463 @@ +use super::routes::Route; +use crate::components::context::{AppState, UserStatsStore}; +use crate::components::navigation::use_back_button; +use crate::requests::pod_req::{call_get_pinepods_version, connect_to_episode_websocket}; +use i18nrs::yew::use_translation; +use wasm_bindgen_futures::spawn_local; +use web_sys::window; +use yew::prelude::*; +use yew_router::prelude::Link; +use yewdux::use_store; + +#[function_component(BackButton)] +pub fn back_button() -> Html { + let on_back = use_back_button(); + + html! { + + } +} + +#[allow(non_camel_case_types)] +#[function_component(App_drawer)] +pub fn app_drawer() -> Html { + let (i18n, _) = use_translation(); + let (stats_state, stats_dispatch) = use_store::(); + // let selection = use_state(|| "".to_string()); + // let (state, _dispatch) = use_store::(); + + // Capture i18n strings before they get moved + let i18n_pinepods = i18n.t("app_drawer.pinepods").to_string(); + let i18n_home = i18n.t("navigation.home").to_string(); + let i18n_feed = i18n.t("app_drawer.feed").to_string(); + let i18n_search_podcasts = i18n.t("app_drawer.search_podcasts").to_string(); + let i18n_queue = i18n.t("navigation.queue").to_string(); + let i18n_saved = i18n.t("navigation.saved").to_string(); + let i18n_playlists = i18n.t("navigation.playlists").to_string(); + let i18n_history = i18n.t("navigation.history").to_string(); + let i18n_server_downloads = i18n.t("app_drawer.server_downloads").to_string(); + let i18n_local_downloads = i18n.t("app_drawer.local_downloads").to_string(); + let i18n_subscribed_people = i18n.t("app_drawer.subscribed_people").to_string(); + let i18n_podcasts = i18n.t("navigation.podcasts").to_string(); + let i18n_settings = i18n.t("app_drawer.settings").to_string(); + let i18n_sign_out = i18n.t("app_drawer.sign_out").to_string(); + let i18n_loading = i18n.t("common.loading").to_string(); + + let is_drawer_open = use_state(|| false); + let drawer_rotation = if *is_drawer_open { + "rotate-90 transform" + } else { + "" + }; + let (state, _dispatch) = use_store::(); + let (post_state, _post_dispatch) = use_store::(); + let api_key = post_state + .auth_details + .as_ref() + .map(|ud| ud.api_key.clone()); + let user_id = post_state.user_details.as_ref().map(|ud| ud.UserID.clone()); + let server_name = post_state + .auth_details + .as_ref() + .map(|ud| ud.server_name.clone()); + + // Fetch version on component mount if authenticated + { + let stats_dispatch = stats_dispatch.clone(); + let server_name_version = server_name.clone(); + let api_key_version = api_key.clone(); + + use_effect_with((api_key.clone(), server_name.clone()), move |_| { + if let (Some(api_key), Some(server_name)) = + (api_key_version.clone(), server_name_version.clone()) + { + let stats_dispatch = stats_dispatch.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(version) = + call_get_pinepods_version(server_name.clone(), &api_key).await + { + stats_dispatch.reduce_mut(move |state| { + state.pinepods_version = Some(version); + }); + } + }); + } + || () + }); + } + // let session_state = state.clone(); + let username = state + .user_details + .as_ref() + .map_or("Guest".to_string(), |ud| ud.Username.clone().unwrap()); + let toggle_drawer = { + let is_drawer_open = is_drawer_open.clone(); + move |_event: MouseEvent| { + is_drawer_open.set(!*is_drawer_open); + if let Some(window) = web_sys::window() { + let body = window.document().unwrap().body().unwrap(); + if !*is_drawer_open { + body.class_list().add_1("no-scroll").unwrap(); + } else { + body.class_list().remove_1("no-scroll").unwrap(); + } + } + } + }; + let on_refresh_click = { + let server_name = server_name.clone(); + let user_id = user_id.clone(); + let api_key = api_key.clone(); + let dispatch = _dispatch.clone(); + + // Use Callback instead of just MouseEvent + Callback::from(move |event: MouseEvent| { + event.prevent_default(); + event.stop_propagation(); + + let server_name_call = server_name.clone(); + let user_id_call = user_id.clone(); + let api_key_call = api_key.clone(); + let dispatch_clone = dispatch.clone(); + + // Set refreshing state before starting + dispatch_clone.reduce_mut(|state| { + state.is_refreshing = Some(true); + state.clone() + }); + + spawn_local(async move { + web_sys::console::log_1(&"Starting refresh...".into()); + + match connect_to_episode_websocket( + &server_name_call.unwrap(), + &user_id_call.unwrap(), + &api_key_call.unwrap().unwrap(), + false, + dispatch_clone.clone(), + ) + .await + { + Ok(_) => { + web_sys::console::log_1(&"Refresh completed successfully".into()); + } + Err(e) => { + web_sys::console::log_1(&format!("Refresh failed: {:?}", e).into()); + } + } + + // Reset the refreshing state after websocket completes + dispatch_clone.reduce_mut(|state| { + state.is_refreshing = Some(false); + state.clone() + }); + }); + }) + }; + + let current_path = window() + .unwrap() + .location() + .pathname() + .unwrap_or_else(|_| String::new()); + + let show_home_button = current_path != "/home"; + let show_refresh_button = current_path == "/home"; + let show_back_button = ![ + "/login", + "/home", + "/queue", + "/saved", + "/downloads", + "/people-subs", + "/podcasts", + "/user_stats", + "/settings", + "/search", + "/local_downloads", + "/people_subs", + "/feed", + "/playlists", + ] + .iter() + .any(|&path| current_path == path); + + #[cfg(not(feature = "server_build"))] + let local_download_link = html! { +
+
+ to={Route::LocalDownloads}> +
+ + {&i18n_local_downloads} +
+
> +
+
+ }; + #[cfg(feature = "server_build")] + let local_download_link = html! {}; + + html! { +
+ // Drawer +
+
+
+
+ Pinepods Logo +

{&i18n_pinepods}

+
+
+
+ // User Account with Gravatar +
+ to={Route::UserStats}> +
+ User Avatar + + {username} // Displaying the username + +
+
> +
+ + // Other Links +
+
+ to={Route::Home}> +
+ + {&i18n_home} +
+
> +
+
+
+
+ to={Route::Feed}> +
+ + {&i18n_feed} +
+
> +
+
+
+
+ to={Route::Search}> +
+ + {&i18n_search_podcasts} +
+
> +
+
+
+
+ to={Route::Queue}> +
+ + {&i18n_queue} +
+
> +
+
+
+
+ to={Route::Saved}> +
+ + {&i18n_saved} +
+
> +
+
+
+
+ to={Route::Playlists}> +
+ + {&i18n_playlists} +
+
> +
+
+
+
+ to={Route::PodHistory}> +
+ + {&i18n_history} +
+
> +
+
+
+
+ to={Route::Downloads}> +
+ + {&i18n_server_downloads} +
+
> +
+
+ { + { + local_download_link + } + } +
+
+ to={Route::SubscribedPeople}> +
+ + {&i18n_subscribed_people} +
+
> +
+
+
+
+ to={Route::Podcasts}> +
+ + {&i18n_podcasts} +
+
> +
+
+
+
+ to={Route::Settings}> +
+ + {&i18n_settings} +
+
> +
+
+
+
+
+
+
+ to={Route::LogOut}> +
+ + {&i18n_sign_out} +
+
> +
+
+ +
+
+ // Version display at bottom of drawer + { + if let Some(version) = &stats_state.pinepods_version { + html! { +
+
+ { format!("v{}", version) } +
+
+ } + } else { + html! {} + } + } +
+
+ +
+ +
+ + { if show_home_button { + html! { + to={Route::Home} classes="rounded-lg cursor-pointer"> +
+ +
+
> + } + } else { + html! {} + }} + { if show_back_button { + html! { + + } + } else { + html! {} + }} + { if show_refresh_button { + html! { + + } + } else { + html! {} + }} + + { + match state.is_loading { + Some(true) => html! { +
+ + {&i18n_loading} +
+ }, + _ => html! {}, // Covers both Some(false) and None + } + } +
+
+
+ } +} diff --git a/PinePods-0.8.2/web/src/components/audio.rs b/PinePods-0.8.2/web/src/components/audio.rs new file mode 100644 index 0000000..a1a033f --- /dev/null +++ b/PinePods-0.8.2/web/src/components/audio.rs @@ -0,0 +1,2508 @@ +use crate::components::context::{AppState, UIState}; +#[cfg(not(feature = "server_build"))] +use crate::components::downloads_tauri::start_local_file_server; +use crate::components::gen_components::{EpisodeModal, FallbackImage}; +use crate::components::gen_funcs::format_time_rm_hour; +#[cfg(not(feature = "server_build"))] +use crate::requests::pod_req::EpisodeDownload; +use crate::requests::pod_req::FetchPodcasting2DataRequest; +use crate::requests::pod_req::{ + call_add_history, call_check_episode_in_db, call_fetch_podcasting_2_data, + call_get_auto_skip_times, call_get_episode_id, call_get_play_episode_details, + call_get_podcast_id_from_ep, call_get_queued_episodes, call_increment_listen_time, + call_increment_played, call_mark_episode_completed, call_queue_episode, + call_record_listen_duration, call_remove_queued_episode, call_update_episode_duration, HistoryAddRequest, + MarkEpisodeCompletedRequest, QueuePodcastRequest, RecordListenDurationRequest, UpdateEpisodeDurationRequest +}; +use gloo_timers::callback::Interval; +use i18nrs::yew::use_translation; +use std::cell::Cell; +#[cfg(not(feature = "server_build"))] +use std::path::Path; +use std::rc::Rc; +use std::string::String; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::spawn_local; +use web_sys::{window, HtmlAudioElement, HtmlElement, HtmlInputElement, TouchEvent}; +use yew::prelude::*; +use yew::{function_component, html, Callback, Html}; +use yew_router::history::{BrowserHistory, History}; +use yewdux::prelude::*; + +#[derive(Properties, PartialEq, Debug, Clone)] +pub struct AudioPlayerProps { + pub src: String, + pub title: String, + pub description: String, + pub release_date: String, + pub artwork_url: String, + pub duration: String, + pub episode_id: i32, + pub duration_sec: f64, + pub start_pos_sec: f64, + pub end_pos_sec: f64, + pub offline: bool, + pub is_youtube: bool, +} + +#[derive(Properties, PartialEq)] +pub struct PlaybackControlProps { + pub speed: f64, + pub on_speed_change: Callback, +} + +#[function_component(PlaybackControl)] +pub fn playback_control(props: &PlaybackControlProps) -> Html { + let is_open = use_state(|| false); + let toggle_open = { + let is_open = is_open.clone(); + Callback::from(move |_: MouseEvent| { + is_open.set(!*is_open); + }) + }; + let on_speed_change = { + let on_speed_change = props.on_speed_change.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + if let Ok(speed) = input.value().parse::() { + on_speed_change.emit(speed); + } + }) + }; + + // Format the playback speed to show just one decimal place + let display_speed = format!("{:.1}x", props.speed); + + html! { +
+ +
+
+
+ {display_speed} +
+ +
+
+
+ } +} + +#[derive(Properties, PartialEq)] +pub struct VolumeControlProps { + pub volume: f64, + pub on_volume_change: Callback, +} + +#[function_component(VolumeControl)] +pub fn volume_control(props: &VolumeControlProps) -> Html { + let is_open = use_state(|| false); + + let toggle_open = { + let is_open = is_open.clone(); + Callback::from(move |_: MouseEvent| { + is_open.set(!*is_open); + }) + }; + + let on_volume_change = { + let on_volume_change = props.on_volume_change.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + if let Ok(volume) = input.value().parse::() { + on_volume_change.emit(volume); + } + }) + }; + + html! { +
+ + +
+
+ {format!("{}%", (props.volume as i32))} +
+ +
+
+ } +} + +#[function_component(AudioPlayer)] +pub fn audio_player(props: &AudioPlayerProps) -> Html { + let (i18n, _) = use_translation(); + let audio_ref = use_node_ref(); + let (state, _dispatch) = use_store::(); + let (audio_state, _audio_dispatch) = use_store::(); + let show_modal = use_state(|| false); + + // Capture i18n strings before they get moved + let i18n_chapters = i18n.t("audio.chapters").to_string(); + let i18n_close_modal = i18n.t("common.close_modal").to_string(); + let i18n_no_audio_playing = i18n.t("audio.no_audio_playing").to_string(); + let i18n_no_chapters_available = i18n.t("audio.no_chapters_available").to_string(); + let i18n_shownotes = i18n.t("audio.shownotes").to_string(); + let i18n_shownotes_unavailable = i18n.t("audio.shownotes_unavailable").to_string(); + let on_modal_close = { + let show_modal = show_modal.clone(); + Callback::from(move |_: MouseEvent| show_modal.set(false)) + }; + + // Add error handling state + let last_playback_position = use_state(|| 0.0); + + // Add periodic state saving + { + let props = props.clone(); + let audio_ref = audio_ref.clone(); + let last_position = last_playback_position.clone(); + + use_effect_with((), move |_| { + let props = props.clone(); + let audio_ref = audio_ref.clone(); + let last_position = last_position.clone(); + + let interval = Interval::new(5000, move || { + if let Some(audio) = audio_ref.cast::() { + last_position.set(audio.current_time()); + + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item( + &format!("audio_position_{}", props.episode_id), + &audio.current_time().to_string(), + ); + } + } + } + }); + + move || { + interval.cancel(); + } + }); + } + + // Restore previous state on mount + use_effect_with((), { + let audio_ref = audio_ref.clone(); + let props = props.clone(); + + move |_| { + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + if let Ok(Some(position)) = + storage.get_item(&format!("audio_position_{}", props.episode_id)) + { + if let Ok(position) = position.parse::() { + if let Some(audio) = audio_ref.cast::() { + audio.set_current_time(position); + } + } + } + } + } + || () + } + }); + + let user_id = state.user_details.as_ref().map(|ud| ud.UserID.clone()); + let api_key = state.auth_details.as_ref().map(|ud| ud.api_key.clone()); + let server_name = state.auth_details.as_ref().map(|ud| ud.server_name.clone()); + let episode_id = audio_state + .currently_playing + .as_ref() + .map(|props| props.episode_id); + let end_pos = audio_state + .currently_playing + .as_ref() + .map(|props| props.end_pos_sec); + let is_youtube_vid = audio_state + .currently_playing + .as_ref() + .map(|props| props.is_youtube) + .unwrap_or(false); + let history = BrowserHistory::new(); + let episode_in_db = audio_state.episode_in_db.unwrap_or_default(); + let progress: UseStateHandle = use_state(|| 0.0); + let offline_status = audio_state + .currently_playing + .as_ref() + .map(|props| props.offline); + let artwork_class = if audio_state.audio_playing.unwrap_or(false) { + classes!("artwork", "playing") + } else { + classes!("artwork") + }; + + let container_ref = use_node_ref(); + + let title_click = { + let audio_dispatch = _audio_dispatch.clone(); + let container_ref = container_ref.clone(); + Callback::from(move |_: MouseEvent| { + audio_dispatch.reduce_mut(UIState::toggle_expanded); + + // Scroll to the top of the container + if let Some(container) = container_ref.cast::() { + container.scroll_into_view(); + } + }) + }; + + // Touch drag functionality for mobile + let touch_start_y = use_state(|| None::); + let is_dragging = use_state(|| false); + let drag_offset = use_state(|| 0i32); + + let on_touch_start = { + let touch_start_y = touch_start_y.clone(); + let is_dragging = is_dragging.clone(); + let drag_offset = drag_offset.clone(); + + Callback::from(move |event: TouchEvent| { + if let Some(touch) = event.touches().get(0) { + touch_start_y.set(Some(touch.client_y())); + is_dragging.set(true); + drag_offset.set(0); + event.prevent_default(); // Prevent scrolling from the start + } + }) + }; + + let on_touch_move = { + let touch_start_y = touch_start_y.clone(); + let is_dragging = is_dragging.clone(); + let drag_offset = drag_offset.clone(); + + Callback::from(move |event: TouchEvent| { + if let (Some(start_y), true) = (*touch_start_y, *is_dragging) { + if let Some(touch) = event.touches().get(0) { + let current_y = touch.client_y(); + let delta_y = start_y - current_y; // Positive = drag up, Negative = drag down + + // Responsive drag - follow finger movement with no limits for better feel + // Visual feedback follows finger direction (up drag = negative translateY) + let visual_offset = delta_y / 2; // No min/max limits for smoother experience + drag_offset.set(visual_offset); + + event.prevent_default(); // Prevent scrolling while dragging + } + } + }) + }; + + let on_touch_end = { + let touch_start_y = touch_start_y.clone(); + let is_dragging = is_dragging.clone(); + let drag_offset = drag_offset.clone(); + let audio_dispatch = _audio_dispatch.clone(); + let container_ref = container_ref.clone(); + let audio_state = audio_state.clone(); + + Callback::from(move |event: TouchEvent| { + if let (Some(start_y), true) = (*touch_start_y, *is_dragging) { + if let Some(touch) = event.changed_touches().get(0) { + let end_y = touch.client_y(); + let delta_y = start_y - end_y; // Positive = swipe up, Negative = swipe down + let threshold = 20; // Minimum pixels to trigger action + + let is_expanded = audio_state.is_expanded; + + if delta_y.abs() > threshold { + if delta_y > 0 && !is_expanded { + // Swipe up and player is collapsed -> expand + audio_dispatch.reduce_mut(UIState::toggle_expanded); + if let Some(container) = container_ref.cast::() { + container.scroll_into_view(); + } + } else if delta_y < 0 && is_expanded { + // Swipe down and player is expanded -> collapse + audio_dispatch.reduce_mut(UIState::toggle_expanded); + } + } + } + } + + // Reset touch state + touch_start_y.set(None); + is_dragging.set(false); + drag_offset.set(0); + }) + }; + + let src_clone = props.src.clone(); + + // Update the audio source when `src` changes + use_effect_with(src_clone.clone(), { + let src = src_clone.clone(); + let audio_ref = audio_ref.clone(); + move |_| { + if let Some(audio_element) = audio_ref.cast::() { + audio_element.set_src(&src); + } else { + } + || () + } + }); + + let current_chapter_image = use_state(|| { + audio_state + .currently_playing + .as_ref() + .map(|props| props.artwork_url.clone()) + .unwrap_or_else(|| props.artwork_url.clone()) + }); + + { + let current_chapter_image = current_chapter_image.clone(); + let audio_state = audio_state.clone(); + let original_image_url = props.artwork_url.clone(); + + use_effect_with( + audio_state.current_time_seconds, + move |¤t_time_seconds| { + if let Some(chapters) = &audio_state.episode_chapters { + let mut image_updated = false; + for chapter in chapters.iter().rev() { + if let Some(start_time) = chapter.startTime { + if start_time as f64 <= current_time_seconds { + if let Some(img) = &chapter.img { + current_chapter_image.set(img.clone()); + image_updated = true; + } + break; + } + } + } + if !image_updated { + current_chapter_image.set(original_image_url.clone()); + } + } else { + current_chapter_image.set(original_image_url.clone()); + } + || () + }, + ); + } + + { + let current_chapter_image = current_chapter_image.clone(); + let audio_state = audio_state.clone(); + + use_effect_with( + audio_state.currently_playing.clone(), + move |currently_playing| { + if let Some(props) = currently_playing { + // Update the chapter image when a new episode starts playing + current_chapter_image.set(props.artwork_url.clone()); + } + || () + }, + ); + } + + // Get episode chapters if available + use_effect_with( + ( + episode_id.clone(), + user_id.clone(), + api_key.clone(), + server_name.clone(), + is_youtube_vid.clone(), + ), + { + let dispatch = _audio_dispatch.clone(); + move |(episode_id, user_id, api_key, server_name, is_youtube_vid)| { + if let (Some(episode_id), Some(user_id), Some(api_key), Some(server_name)) = + (episode_id, user_id, api_key, server_name) + { + let episode_id = *episode_id; // Dereference the option + let user_id = *user_id; // Dereference the option + let api_key = api_key.clone(); // Clone to make it owned + let server_name = server_name.clone(); // Clone to make it owned + + // Only proceed if the episode_id is not zero + if episode_id != 0 && !is_youtube_vid { + wasm_bindgen_futures::spawn_local(async move { + let chap_request = FetchPodcasting2DataRequest { + episode_id, + user_id, + }; + match call_fetch_podcasting_2_data( + &server_name, + &api_key, + &chap_request, + ) + .await + { + Ok(response) => { + let chapters = response.chapters.clone(); // Clone chapters to avoid move issue + let transcripts = response.transcripts.clone(); // Clone transcripts to avoid move issue + let people = response.people.clone(); // Clone people to avoid move issue + dispatch.reduce_mut(|state| { + state.episode_chapters = Some(chapters); + state.episode_transcript = Some(transcripts); + state.episode_people = Some(people); + }); + } + Err(e) => { + web_sys::console::log_1( + &format!("Error fetching chapters: {}", e).into(), + ); + } + } + }); + } + } + || () + } + }, + ); + + // Add keyboard controls + { + let audio_dispatch_effect = _audio_dispatch.clone(); + let audio_state_effect = audio_state.clone(); + + use_effect_with((), move |_| { + let keydown_handler = { + let audio_info = audio_dispatch_effect.clone(); + let state = audio_state_effect.clone(); + + Closure::wrap(Box::new(move |event: KeyboardEvent| { + // Check if the event target is not an input or textarea + let target = event + .target() + .unwrap() + .dyn_into::() + .unwrap(); + + if !(target.tag_name().eq_ignore_ascii_case("input") + || target.tag_name().eq_ignore_ascii_case("textarea")) + { + match event.key().as_str() { + " " => { + event.prevent_default(); + audio_info.reduce_mut(|state| state.toggle_playback()); + } + "ArrowRight" => { + event.prevent_default(); + if let Some(audio_element) = state.audio_element.as_ref() { + let new_time = audio_element.current_time() + 15.0; + audio_element.set_current_time(new_time); + audio_info + .reduce_mut(|state| state.update_current_time(new_time)); + } + } + "ArrowLeft" => { + event.prevent_default(); + if let Some(audio_element) = state.audio_element.as_ref() { + let new_time = (audio_element.current_time() - 15.0).max(0.0); + audio_element.set_current_time(new_time); + audio_info + .reduce_mut(|state| state.update_current_time(new_time)); + } + } + _ => {} + } + } + }) as Box) + }; + + window() + .unwrap() + .add_event_listener_with_callback( + "keydown", + keydown_handler.as_ref().unchecked_ref(), + ) + .unwrap(); + + move || { + keydown_handler.forget(); + } + }); + } + + // Effect for setting up an interval to update the current playback time + // Clone `audio_ref` for `use_effect_with` + let state_clone = audio_state.clone(); + use_effect_with((offline_status.clone(), episode_id.clone()), { + let audio_dispatch = _audio_dispatch.clone(); + let progress = progress.clone(); // Clone for the interval closure + let closure_api_key = api_key.clone(); + let closure_server_name = server_name.clone(); + let closure_user_id = user_id.clone(); + let closure_episode_id = episode_id.clone(); + let offline_status = offline_status.clone(); + move |_| { + //print the ep id + let interval_handle: Rc>> = Rc::new(Cell::new(None)); + let interval_handle_clone = interval_handle.clone(); + let interval = Interval::new(1000, move || { + if let Some(audio_element) = state_clone.audio_element.as_ref() { + let time_in_seconds = audio_element.current_time(); + let duration = audio_element.duration(); + + // Time updates happen regardless of duration + let hours = (time_in_seconds / 3600.0).floor() as i32; + let minutes = ((time_in_seconds % 3600.0) / 60.0).floor() as i32; + let seconds = (time_in_seconds % 60.0).floor() as i32; + let formatted_time = format!("{:02}:{:02}:{:02}", hours, minutes, seconds); + + let progress_percentage = if duration > 0.0 && !duration.is_nan() { + time_in_seconds / duration * 100.0 + } else { + 0.0 + }; + + audio_dispatch.reduce_mut(move |state_clone| { + // Update the global state with the current time + state_clone.current_time_seconds = time_in_seconds; + state_clone.current_time_formatted = formatted_time; + }); + + progress.set(progress_percentage); + + // Episode completion check only happens when we have valid duration + if !duration.is_nan() && duration > 0.0 { + let end_pos_sec = end_pos.clone(); + let complete_api_key = closure_api_key.clone(); + let complete_server_name = closure_server_name.clone(); + let complete_user_id = closure_user_id.clone(); + let complete_episode_id = closure_episode_id.clone(); + let offline_status_loop = offline_status.unwrap_or(false); + if time_in_seconds >= (duration - end_pos_sec.unwrap()) { + web_sys::console::log_1(&"Episode completed".into()); + audio_element.pause().unwrap_or(()); + // Manually trigger the `ended` event + let event = web_sys::Event::new("ended").unwrap(); + audio_element.dispatch_event(&event).unwrap(); + // Call the endpoint to mark episode as completed + if offline_status_loop { + // If offline, store the episode in the local database + } else { + // If online, call the endpoint + wasm_bindgen_futures::spawn_local(async move { + if let ( + Some(complete_api_key), + Some(complete_server_name), + Some(complete_user_id), + Some(complete_episode_id), + ) = ( + complete_api_key.as_ref(), + complete_server_name.as_ref(), + complete_user_id.as_ref(), + complete_episode_id.as_ref(), + ) { + let request = MarkEpisodeCompletedRequest { + episode_id: *complete_episode_id, // Dereference the option + user_id: *complete_user_id, // Dereference the option + is_youtube: is_youtube_vid, + }; + + match call_mark_episode_completed( + &complete_server_name, + &complete_api_key, + &request, + ) + .await + { + Ok(_) => {} + Err(e) => { + web_sys::console::log_1( + &format!("Error: {}", e).into(), + ); + } + } + } + }); + } + + // Stop the interval + if let Some(handle) = interval_handle.take() { + handle.cancel(); + interval_handle.set(None); + } + } + } + } + }); + + interval_handle_clone.set(Some(interval)); + let interval_handle = interval_handle_clone; + // Return a cleanup function that will be run when the component unmounts or the dependencies of the effect change + move || { + if let Some(handle) = interval_handle.take() { + handle.cancel(); + } + } + } + }); + + // Effect for recording the listen duration + let audio_state_clone = audio_state.clone(); + use_effect_with((offline_status.clone(), episode_id.clone()), { + let server_name = server_name.clone(); // Assuming this is defined elsewhere in your component + let api_key = api_key.clone(); // Assuming this is defined elsewhere in your component + let user_id = user_id.clone(); // Assuming this is defined elsewhere in your component + let offline_status = offline_status.clone(); + let episode_id = episode_id.clone(); + + move |_| { + // Create an interval task + let interval_handle = Rc::new(Cell::new(None)); + let interval_handle_clone = interval_handle.clone(); + + let interval = gloo_timers::callback::Interval::new(30_000, move || { + let state_clone = audio_state_clone.clone(); // Access the latest state + let offline_status_loop = offline_status.unwrap_or(false); + let episode_id_loop = episode_id.clone(); + let api_key = api_key.clone(); + let server_name = server_name.clone(); + + if offline_status_loop { + } else { + if state_clone.audio_playing.unwrap_or_default() { + if let Some(audio_element) = state_clone.audio_element.as_ref() { + let listen_duration = audio_element.current_time(); + let request_data = RecordListenDurationRequest { + episode_id: episode_id_loop.unwrap().clone(), + user_id: user_id.unwrap().clone(), + listen_duration, + is_youtube: Some(is_youtube_vid), + }; + + wasm_bindgen_futures::spawn_local(async move { + match call_record_listen_duration( + &server_name.clone().unwrap(), + &api_key.clone().unwrap().unwrap(), + request_data, + ) + .await + { + Ok(_response) => {} + Err(_e) => {} + } + }); + } + } + } + }); + + interval_handle_clone.set(Some(interval)); + + // Cleanup function to cancel the interval task when dependencies change + move || { + if let Some(interval) = interval_handle.take() { + interval.cancel(); + } + } + } + }); + + // Effect for incrementing user listen time + let state_increment_clone = audio_state.clone(); + use_effect_with((offline_status.clone(), episode_id.clone()), { + let server_name = server_name.clone(); // Make sure `server_name` is cloned from the parent scope + let api_key = api_key.clone(); // Make sure `api_key` is cloned from the parent scope + let user_id = user_id.clone(); // Make sure `user_id` is cloned from the parent scope + let offline_status = offline_status.clone(); + + move |_| { + let interval_handle: Rc>> = Rc::new(Cell::new(None)); + let interval_handle_clone = interval_handle.clone(); + + let interval = Interval::new(60000, move || { + let offline_status_loop = offline_status.unwrap_or(false); + // Check if audio is playing before making the API call + if offline_status_loop { + } else { + if state_increment_clone.audio_playing.unwrap_or_default() { + let server_name = server_name.clone(); + let api_key = api_key.clone(); + let user_id = user_id.clone(); + + // Spawn a new async task for the API call + wasm_bindgen_futures::spawn_local(async move { + match call_increment_listen_time( + &server_name.unwrap(), + &api_key.unwrap().unwrap(), + user_id.unwrap(), + ) + .await + { + Ok(_response) => {} + Err(_e) => {} + } + }); + } + } + }); + + interval_handle_clone.set(Some(interval)); + let interval_handle = interval_handle_clone; + // Return a cleanup function that will be run when the component unmounts or the dependencies of the effect change + move || { + if let Some(handle) = interval_handle.take() { + handle.cancel(); + } + } + } + }); + + // Effect for managing queued episodes + use_effect_with(audio_ref.clone(), { + let audio_dispatch = _audio_dispatch.clone(); + let server_name = server_name.clone(); + let api_key = api_key.clone(); + let user_id = user_id.clone(); + let current_episode_id = episode_id.clone(); // Assuming this is correctly obtained elsewhere + let audio_state = audio_state.clone(); + let audio_state_cloned = audio_state.clone(); + let offline_status = offline_status.clone(); + + move |_| { + if let Some(audio_element) = audio_state_cloned.audio_element.clone() { + // if let Some(audio_element) = audio_ref.cast::() { + // Clone all necessary data to be used inside the closure to avoid FnOnce limitation. + + // Flag to prevent processing the same ended event multiple times + let processing_ended = Rc::new(Cell::new(false)); + let processing_ended_clone = processing_ended.clone(); + + let ended_closure = Closure::wrap(Box::new(move || { + web_sys::console::log_1(&"Episode ended event fired".into()); + + // Check if we're already processing an ended event + if processing_ended_clone.get() { + web_sys::console::log_1(&"Already processing ended event, skipping duplicate".into()); + return; + } + + // Set flag to indicate we're processing + processing_ended_clone.set(true); + + let processing_flag_for_reset = processing_ended_clone.clone(); + let server_name = server_name.clone(); + let api_key = api_key.clone(); + let user_id = user_id.clone(); + let audio_dispatch = audio_dispatch.clone(); + let current_episode_id = current_episode_id.clone(); + let audio_state = audio_state.clone(); + let offline_status_loop = offline_status.unwrap_or(false); + // Closure::wrap(Box::new(move |_| { + if offline_status_loop { + // If offline, do not perform any action + web_sys::console::log_1(&"Offline mode - skipping queue advancement".into()); + processing_flag_for_reset.set(false); + } else { + wasm_bindgen_futures::spawn_local(async move { + web_sys::console::log_1(&"Fetching queued episodes...".into()); + let queued_episodes_result = call_get_queued_episodes( + &server_name.clone().unwrap(), + &api_key.clone().unwrap(), + &user_id.clone().unwrap(), + ) + .await; + match queued_episodes_result { + Ok(episodes) => { + web_sys::console::log_1(&format!("Found {} episodes in queue", episodes.len()).into()); + + // If queue is empty, just stop playback + if episodes.is_empty() { + web_sys::console::log_1(&"Queue is empty, stopping playback".into()); + audio_dispatch.reduce_mut(|state| { + state.audio_playing = Some(false); + }); + } else { + // Try to find current episode first to remove it properly + if let Some(current_episode) = episodes + .iter() + .find(|ep| ep.episodeid == current_episode_id.unwrap()) + { + web_sys::console::log_1(&format!("Found current episode in queue (ID: {}), removing it", current_episode.episodeid).into()); + // Remove the currently playing episode from the queue + let request = QueuePodcastRequest { + episode_id: current_episode_id.clone().unwrap(), + user_id: user_id.clone().unwrap(), + is_youtube: current_episode.is_youtube, + }; + let remove_result = call_remove_queued_episode( + &server_name.clone().unwrap(), + &api_key.clone().unwrap(), + &request, + ) + .await; + match remove_result { + Ok(_) => { + web_sys::console::log_1(&"Successfully removed current episode from queue".into()); + } + Err(e) => { + web_sys::console::log_1(&format!("Failed to remove episode from queue: {:?}", e).into()); + } + } + } else { + web_sys::console::log_1(&"Current episode not found in queue (likely already removed)".into()); + } + + // Now play the first episode in the queue (which is the next one to play) + // Sort by queue position to ensure we get the right one + let mut sorted_episodes = episodes.clone(); + sorted_episodes.sort_by_key(|ep| ep.queueposition.unwrap_or(999999)); + + if let Some(next_episode) = sorted_episodes.first() { + web_sys::console::log_1(&format!("Playing first episode in queue: {} (ID: {}, Position: {})", + next_episode.episodetitle, + next_episode.episodeid, + next_episode.queueposition.unwrap_or(0) + ).into()); + + // Check if we have the required data + if let (Some(Some(api_key_val)), Some(user_id_val), Some(server_name_val)) = + (api_key.clone(), user_id, server_name.clone()) { + web_sys::console::log_1(&"Calling on_play_click for next episode".into()); + on_play_click( + next_episode.episodeurl.clone(), + next_episode.episodetitle.clone(), + next_episode.episodedescription.clone(), + next_episode.episodepubdate.clone(), + next_episode.episodeartwork.clone(), + next_episode.episodeduration, + next_episode.episodeid, + next_episode.listenduration, + api_key_val, + user_id_val, + server_name_val, + audio_dispatch.clone(), + audio_state.clone(), + None, + Some(next_episode.is_youtube.clone()), + ) + .emit(MouseEvent::new("click").unwrap()); + web_sys::console::log_1(&"Successfully emitted play click for next episode".into()); + } else { + web_sys::console::log_1(&"ERROR: Missing required auth data (api_key, user_id, or server_name)".into()); + } + } else { + web_sys::console::log_1(&"No episodes found in queue after sorting".into()); + audio_dispatch.reduce_mut(|state| { + state.audio_playing = Some(false); + }); + } + } + } + Err(e) => { + web_sys::console::log_1(&format!("Failed to fetch queued episodes: {:?}", e).into()); + } + } + + // Reset the processing flag after all async work is complete + processing_flag_for_reset.set(false); + web_sys::console::log_1(&"Queue processing complete, flag reset".into()); + }); + } + // }) as Box); + }) as Box); + // Setting and forgetting the closure must be done within the same scope + audio_element.set_onended(Some(ended_closure.as_ref().unchecked_ref())); + ended_closure.forget(); // This will indeed cause a memory leak if the component mounts multiple times + } + + || () + } + }); + + { + let audio_state = audio_state.clone(); + use_effect_with(audio_state.clone(), move |_| { + if let Some(_window) = web_sys::window() { + + // Try to get media session + // Commented out due to MediaSession API compatibility issues + /* + if let Ok(media_session) = + js_sys::Reflect::get(&navigator, &JsValue::from_str("mediaSession")) + { + // Safely attempt to convert to MediaSession + if let Ok(media_session) = media_session.dyn_into::() { + // Update metadata if we have something playing + if let Some(audio_props) = &audio_state.currently_playing { + // Try to create new metadata + if let Ok(metadata) = web_sys::MediaMetadata::new() { + metadata.set_title(&audio_props.title); + + // Create artwork array + let artwork_array = Array::new(); + let artwork_object = Object::new(); + + // Set up artwork properties + let _ = js_sys::Reflect::set( + &artwork_object, + &"src".into(), + &audio_props.artwork_url.clone().into(), + ); + let _ = js_sys::Reflect::set( + &artwork_object, + &"sizes".into(), + &"512x512".into(), + ); + let _ = js_sys::Reflect::set( + &artwork_object, + &"type".into(), + &"image/jpeg".into(), + ); + + artwork_array.push(&artwork_object); + metadata.set_artwork(&artwork_array.into()); + media_session.set_metadata(Some(&metadata)); + + // Set playback state + if audio_state_clone.audio_playing.unwrap() { + media_session + .set_playback_state(MediaSessionPlaybackState::Playing); + } else { + media_session + .set_playback_state(MediaSessionPlaybackState::Paused); + } + + // Update position state + if let Some(audio_element) = &audio_state_clone.audio_element { + let duration = audio_props.duration_sec; + if !duration.is_nan() && duration > 0.0 { + let position_state = MediaPositionState::new(); + position_state.set_duration(duration); + position_state + .set_playback_rate(audio_state_clone.playback_speed); + position_state.set_position(audio_element.current_time()); + let _ = media_session + .set_position_state_with_state(&position_state); + } + } + } + // Inside your use_effect_with block, after setting up the initial position state: + // Inside your media session setup use_effect: + if let Some(audio_element) = &audio_state_clone.audio_element { + let media_session_clone = media_session.clone(); + let audio_state_for_callback = audio_state_clone.clone(); + let audio_element_clone = audio_element.clone(); + let timeupdate_callback = Closure::wrap(Box::new(move || { + let duration = audio_element_clone.duration(); + // Only update position state if we have a valid duration + if !duration.is_nan() && duration > 0.0 { + let position_state = MediaPositionState::new(); + position_state.set_duration(duration); + position_state.set_playback_rate( + audio_state_for_callback.playback_speed, + ); + position_state + .set_position(audio_element_clone.current_time()); + let _ = media_session_clone + .set_position_state_with_state(&position_state); + } + }) + as Box); + + audio_element.set_ontimeupdate(Some( + timeupdate_callback.as_ref().unchecked_ref(), + )); + timeupdate_callback.forget(); + } + } + + // Set up action handlers + let audio_dispatch_play = audio_dispatch.clone(); + let play_pause_callback = Closure::wrap(Box::new(move || { + audio_dispatch_play.reduce_mut(UIState::toggle_playback); + }) + as Box); + + // Set play/pause handlers + let _ = media_session.set_action_handler( + web_sys::MediaSessionAction::Play, + Some(play_pause_callback.as_ref().unchecked_ref()), + ); + let _ = media_session.set_action_handler( + web_sys::MediaSessionAction::Pause, + Some(play_pause_callback.as_ref().unchecked_ref()), + ); + play_pause_callback.forget(); + + // Set up seek backward handler + let audio_state_back = audio_state.clone(); + let audio_dispatch_back = audio_dispatch.clone(); + let seek_backward_callback = Closure::wrap(Box::new(move || { + if let Some(audio_element) = audio_state_back.audio_element.as_ref() { + let new_time = audio_element.current_time() - 15.0; + let _ = audio_element.set_current_time(new_time); + audio_dispatch_back + .reduce_mut(|state| state.update_current_time(new_time)); + } + }) + as Box); + + let _ = media_session.set_action_handler( + web_sys::MediaSessionAction::Seekbackward, + Some(seek_backward_callback.as_ref().unchecked_ref()), + ); + seek_backward_callback.forget(); + + // Set up seek forward handler + let audio_state_fwd = audio_state.clone(); + let audio_dispatch_fwd = audio_dispatch.clone(); + let seek_forward_callback = Closure::wrap(Box::new(move || { + if let Some(audio_element) = audio_state_fwd.audio_element.as_ref() { + let new_time = audio_element.current_time() + 15.0; + let _ = audio_element.set_current_time(new_time); + audio_dispatch_fwd + .reduce_mut(|state| state.update_current_time(new_time)); + } + }) + as Box); + + let _ = media_session.set_action_handler( + web_sys::MediaSessionAction::Seekforward, + Some(seek_forward_callback.as_ref().unchecked_ref()), + ); + seek_forward_callback.forget(); + } + } + */ + } + + || () + }); + } + + // Toggle playback + let toggle_playback = { + let dispatch = _audio_dispatch.clone(); + Callback::from(move |_| { + dispatch.reduce_mut(UIState::toggle_playback); + }) + }; + + let update_time = { + let audio_dispatch = _audio_dispatch.clone(); + Callback::from(move |e: InputEvent| { + // Get the value from the target of the InputEvent + if let Some(input) = e.target_dyn_into::() { + if let Ok(value) = input.value().parse::() { + // Update the state using dispatch + audio_dispatch.reduce_mut(move |state| { + if let Some(audio_element) = state.audio_element.as_ref() { + audio_element.set_current_time(value); + state.current_time_seconds = value; + + // Update formatted time + let hours = (value / 3600.0).floor() as i32; + let minutes = ((value % 3600.0) / 60.0).floor() as i32; + let seconds = (value % 60.0).floor() as i32; + state.current_time_formatted = + format!("{:02}:{:02}:{:02}", hours, minutes, seconds); + } + }); + } + } + }) + }; + let speed_dispatch = _audio_dispatch.clone(); + + // Adjust the playback speed based on a slider value + let update_playback_speed = { + Callback::from(move |speed: f64| { + speed_dispatch.reduce_mut(|speed_state| { + speed_state.playback_speed = speed; + if let Some(audio_element) = &speed_state.audio_element { + audio_element.set_playback_rate(speed); + } + }); + }) + }; + + let volume_dispatch = _audio_dispatch.clone(); + + // Adjust the volume based on a slider value + let update_playback_volume = { + let audio_dispatch = volume_dispatch.clone(); + Callback::from(move |volume: f64| { + audio_dispatch.reduce_mut(|audio_state| { + audio_state.audio_volume = volume; + if let Some(audio_element) = &audio_state.audio_element { + audio_element.set_volume(volume / 100.0); // Set volume as a percentage + } + }); + }) + }; + + // Skip forward + let skip_state = audio_state.clone(); + let skip_forward = { + // let dispatch = _dispatch.clone(); + let audio_dispatch = _audio_dispatch.clone(); + Callback::from(move |_| { + if let Some(audio_element) = skip_state.audio_element.as_ref() { + let new_time = audio_element.current_time() + 15.0; + audio_element.set_current_time(new_time); + audio_dispatch.reduce_mut(|state| state.update_current_time(new_time)); + } + }) + }; + + let backward_state = audio_state.clone(); + let skip_backward = { + // let dispatch = _dispatch.clone(); + let audio_dispatch = _audio_dispatch.clone(); + Callback::from(move |_| { + if let Some(audio_element) = backward_state.audio_element.as_ref() { + let new_time = audio_element.current_time() - 15.0; + audio_element.set_current_time(new_time); + audio_dispatch.reduce_mut(|state| state.update_current_time(new_time)); + } + }) + }; + + let skip_episode = { + let audio_dispatch = _audio_dispatch.clone(); + let server_name = server_name.clone(); + let api_key = api_key.clone(); + let user_id = user_id.clone(); + let current_episode_id = episode_id.clone(); // Assuming this is correctly obtained elsewhere + let audio_state = audio_state.clone(); + + Callback::from(move |_: MouseEvent| { + let server_name = server_name.clone(); + let api_key = api_key.clone(); + let audio_dispatch = audio_dispatch.clone(); + let audio_state = audio_state.clone(); + wasm_bindgen_futures::spawn_local(async move { + let episodes_result = call_get_queued_episodes( + &server_name.clone().unwrap(), + &api_key.clone().unwrap(), + &user_id.clone().unwrap(), + ) + .await; + if let Ok(episodes) = episodes_result { + if let Some(current_episode) = episodes + .iter() + .find(|ep| ep.episodeid == current_episode_id.unwrap()) + { + let current_queue_position = + current_episode.queueposition.unwrap_or_default(); + + if let Some(next_episode) = episodes + .iter() + .find(|ep| ep.queueposition == Some(current_queue_position + 1)) + { + on_play_click( + next_episode.episodeurl.clone(), + next_episode.episodetitle.clone(), + next_episode.episodedescription.clone(), + next_episode.episodepubdate.clone(), + next_episode.episodeartwork.clone(), + next_episode.episodeduration, + next_episode.episodeid, + next_episode.listenduration, + api_key.clone().unwrap().unwrap(), + user_id.unwrap(), + server_name.clone().unwrap(), + audio_dispatch.clone(), + audio_state.clone(), + None, + Some(next_episode.is_youtube.clone()), + ) + .emit(MouseEvent::new("click").unwrap()); + } else { + audio_dispatch.reduce_mut(|state| { + state.audio_playing = Some(false); + }); + } + } + } else { + // Handle the error, maybe log it or show a user-facing message + web_sys::console::log_1(&"Failed to fetch queued episodes".into()); + } + }); + }) + }; + + let on_chapter_click = { + let audio_dispatch = _audio_dispatch.clone(); + Callback::from(move |start_time: i32| { + let start_time = start_time as f64; + audio_dispatch.reduce_mut(|state| { + if let Some(audio_element) = state.audio_element.as_ref() { + audio_element.set_current_time(start_time); + state.current_time_seconds = start_time; + + // Update formatted time + let hours = (start_time / 3600.0).floor() as i32; + let minutes = ((start_time % 3600.0) / 60.0).floor() as i32; + let seconds = (start_time % 60.0).floor() as i32; + state.current_time_formatted = + format!("{:02}:{:02}:{:02}", hours, minutes, seconds); + } + }); + }) + }; + + #[derive(Clone, PartialEq)] + enum PageState { + Hidden, + Shown, + } + + let page_state = use_state(|| PageState::Hidden); + + let on_close_modal = { + let page_state = page_state.clone(); + Callback::from(move |_| { + page_state.set(PageState::Hidden); + }) + }; + + let on_chapter_select = { + let page_state = page_state.clone(); + Callback::from(move |_| { + page_state.set(PageState::Shown); + }) + }; + let stop_propagation = Callback::from(|e: MouseEvent| { + e.stop_propagation(); + }); + let audio_dispatch = _audio_dispatch.clone(); + let chapter_select_modal = html! { +