diff --git a/Cargo.lock b/Cargo.lock index ea4c1f0..c2f88db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[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.71" @@ -90,9 +99,9 @@ dependencies = [ [[package]] name = "base64" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" +checksum = "3f1e31e207a6b8fb791a38ea3105e6cb541f55e4d029902d3039a4ad07cc4105" [[package]] name = "bitflags" @@ -100,6 +109,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[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 = "bumpalo" version = "3.12.2" @@ -148,6 +166,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-integer", + "num-traits", + "time", + "wasm-bindgen", + "winapi", +] + [[package]] name = "combine" version = "3.8.1" @@ -161,6 +194,31 @@ dependencies = [ "unreachable", ] +[[package]] +name = "confique" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1803b359c0d8e0c280ffe88a2da6d2a22b2e187148e1df1df4d6bdc335a3690" +dependencies = [ + "confique-macro", + "json5", + "serde", + "serde_yaml", + "toml 0.5.11", +] + +[[package]] +name = "confique-macro" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7305b4979ffd6d8b02006da5520b21c66bfab961cd688b9b6db00780f61448ce" +dependencies = [ + "heck 0.3.3", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -186,6 +244,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "cpufeatures" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" +dependencies = [ + "libc", +] + [[package]] name = "crabidy" version = "0.1.0" @@ -231,6 +298,16 @@ dependencies = [ "winapi", ] +[[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 = "cynic" version = "3.0.0-beta.3" @@ -308,6 +385,16 @@ dependencies = [ "syn 1.0.109", ] +[[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 = "either" version = "1.8.1" @@ -369,7 +456,7 @@ dependencies = [ "futures-sink", "nanorand", "pin-project", - "spin", + "spin 0.9.8", ] [[package]] @@ -441,6 +528,16 @@ dependencies = [ "pin-utils", ] +[[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.9" @@ -450,7 +547,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -489,6 +586,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.4.1" @@ -593,6 +699,29 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[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 = "ident_case" version = "1.0.1" @@ -669,6 +798,17 @@ dependencies = [ "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 = "lazy_static" version = "1.4.0" @@ -683,9 +823,9 @@ checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" [[package]] name = "linux-raw-sys" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ece97ea872ece730aed82664c424eb4c8291e1ff2480247ccf7409044bc6479f" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "lock_api" @@ -732,7 +872,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.45.0", ] @@ -769,6 +909,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.15" @@ -890,6 +1040,50 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +[[package]] +name = "pest" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e68e84bfb01f0507134eac1e9b410a12ba379d064eab48c50ba4ce329a527b70" +dependencies = [ + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b79d4c71c865a25a4322296122e3924d30bc8ee0834c8bfc8b95f7f054afbfb" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c435bf1076437b851ebc8edc3a18442796b30f1728ffea6262d59bbe28b077e" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.16", +] + +[[package]] +name = "pest_meta" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "745a452f8eb71e39ffd8ee32b3c5f51d03845f99786fa9b68db6ff509c505411" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "petgraph" version = "0.6.3" @@ -1004,7 +1198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" dependencies = [ "bytes", - "heck", + "heck 0.4.1", "itertools", "lazy_static", "log", @@ -1150,6 +1344,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls", "serde", "serde_json", "serde_urlencoded", @@ -1163,6 +1358,21 @@ dependencies = [ "winreg", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted", + "web-sys", + "winapi", +] + [[package]] name = "rustix" version = "0.37.19" @@ -1177,6 +1387,28 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rustls" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c911ba11bc8433e811ce56fde130ccf32f5127cab0e0194e9c68c5a5b671791e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-webpki" +version = "0.100.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.12" @@ -1205,10 +1437,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] -name = "security-framework" -version = "2.9.0" +name = "sct" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2855b3715770894e67cbfa3df957790aa0c9edc3bf06efa1a84d77fa0839d1" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" dependencies = [ "bitflags", "core-foundation", @@ -1258,6 +1510,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93107647184f6027e3b7dcb2e11034cf95ffa1e3a682c67951963ac69c1c007d" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1270,6 +1531,30 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9d684e3ec7de3bf5466b32bd75303ac16f0736426e5a4e0d6e489559ce1249c" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook" version = "0.3.15" @@ -1325,6 +1610,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.9.8" @@ -1407,6 +1698,36 @@ dependencies = [ "syn 2.0.16", ] +[[package]] +name = "tidaldy" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "confique", + "crabidy-core", + "reqwest", + "secrecy", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "toml 0.7.4", +] + +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1497,6 +1818,49 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6135d499e69981f9ff0ef2167955a5333c35e36f6937d382974566b3d5b94ec" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a76a9312f5ba4c2dec6b9161fdf25d87ad8a09256ccea5a556fef03c706a10f" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d964908cec0d030b812013af25a0e57fddfadb1e066ecc6681d86253129d4f" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tonic" version = "0.9.2" @@ -1608,6 +1972,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "ucd-trie" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" + [[package]] name = "unicode-bidi" version = "0.3.13" @@ -1650,6 +2026,18 @@ dependencies = [ "void", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1865806a559042e51ab5414598446a5871b561d21b6764f2eabb0dd481d880a6" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "url" version = "2.3.1" @@ -1689,6 +2077,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1804,6 +2198,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.0", +] + [[package]] name = "windows-sys" version = "0.42.0" @@ -1951,6 +2354,15 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +[[package]] +name = "winnow" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" @@ -1959,3 +2371,9 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] + +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" diff --git a/Cargo.toml b/Cargo.toml index 5264d3e..1f91c52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["crabidy-core", "crabidy", "cbd-tui"] +members = ["crabidy-core", "crabidy", "cbd-tui", "tidaldy"] diff --git a/crabidy-core/build.rs b/crabidy-core/build.rs index c580b63..3cbeb79 100644 --- a/crabidy-core/build.rs +++ b/crabidy-core/build.rs @@ -1,4 +1,5 @@ fn main() -> Result<(), Box> { tonic_build::compile_protos("crabidy/v1/crabidy.proto")?; + tonic_build::compile_protos("crabidy/proto/crabidy.proto")?; Ok(()) } diff --git a/crabidy-core/proto/crabidy.proto b/crabidy-core/proto/crabidy.proto new file mode 100644 index 0000000..95cd696 --- /dev/null +++ b/crabidy-core/proto/crabidy.proto @@ -0,0 +1,128 @@ +syntax = "proto3"; +package crabidy; + + +service Library { + rpc GetLibraryNode (LibraryNodeRequest) returns (LibraryNodeResponse); +} + +// To signal whether it's loading data (for frontend only probably) +enum LibraryNodeState { + LIBRARY_NODE_STATE_UNSPECIFIED = 0; + LIBRARY_NODE_STATE_PENDING = 1; + LIBRARY_NODE_STATE_DONE = 2; +} + +message Track { + // Including provider + string uuid = 1; + string artist = 2; + string title = 3; +} + +message LibraryNodeRequest { + string uuid = 1; +} + +message LibraryNodeResponse { + // Including provider + string uuid = 1; + string name = 2; + repeated string children = 3; + optional string parent = 4; + LibraryNodeState state = 5; + repeated Track tracks = 6; + bool is_queable = 7; +} + +service Queue { + rpc QueueTrack (QueueTrackRequest) returns (EmptyResponse); + rpc QueueLibraryNode (QueueNodeRequest) returns (EmptyResponse); + rpc ReplaceWithTrack (QueueTrackRequest) returns (EmptyResponse); + rpc ReplaceWithNode (QueueNodeRequest) returns (EmptyResponse); + rpc AppendTrack (QueueTrackRequest) returns (EmptyResponse); + rpc AppendNode (QueueNodeRequest) returns (EmptyResponse); + rpc RemoveTracks (RemoveTracksRequest) returns (EmptyResponse); + rpc SetCurrentTrack (SetCurrentTrackRequest) returns (EmptyResponse); + rpc GetQueueUpdates (QueueUpdatesRequest) returns (stream QueueUpdateResponse); + rpc GetQueue(EmptyRequest) returns (CurrentQueue); + rpc SaveQueue (QueueSaveRequest) returns (EmptyResponse); +} + +message CurrentQueue { + uint64 timestamp =1; + uint32 current = 2; + repeated Track tracks = 3; +} + +message QueuePositionChange { + uint64 timestamp = 1; + uint32 new_position = 2; +} + +message QueueTrackRequest { + string uuid = 1; +} + +message QueueNodeRequest { + string uuid = 1; +} + +message RemoveTracksRequest { + repeated uint32 positions = 1; +} + +message SetCurrentTrackRequest { + uint32 position = 1; +} + + +message QueueUpdatesRequest { + uint64 timestamp =2; +} + +message QueueUpdateResponse { + oneof QueueUpdateResult{ + CurrentQueue full = 1; + QueuePositionChange position_change = 2; + } +} + +message QueueSaveRequest { + string name = 1; + // inside the configured path of crabidy + string path = 2; + +} + +service Playback { + rpc TogglePlay (EmptyRequest) returns (EmptyResponse); + rpc Stop (EmptyRequest) returns (EmptyResponse); + rpc GetActiveTrack (EmptyRequest) returns (ActiveTrack); + rpc GetTrackUpdates (ActiveTrackFilter) returns (stream ActiveTrack); +} + + +enum TrackPlayState { + TRACK_PLAY_STATE_STOPPED = 0; + TRACK_PLAY_STATE_LOADING = 1; + TRACK_PLAY_STATE_PLAYING = 2; + TRACK_PLAY_STATE_PAUSED = 3; +} + +message ActiveTrackFilter { + // defines how many of the update messages should be skipped + // before they are sent back via GetTrackUpdates + uint32 updates_skipped = 1; + repeated string type_whitelist = 2; + repeated string type_blacklist = 3; +} + +message ActiveTrack { + optional Track track = 1; + TrackPlayState play_state = 2; + uint32 completion = 3; +} + +message EmptyRequest { } +message EmptyResponse { } diff --git a/crabidy-core/src/lib.rs b/crabidy-core/src/lib.rs index c3a350c..89e5e4b 100644 --- a/crabidy-core/src/lib.rs +++ b/crabidy-core/src/lib.rs @@ -6,214 +6,37 @@ use async_trait::async_trait; #[async_trait] pub trait ProviderClient: std::fmt::Debug + Send + Sync { + async fn init(raw_toml_settings: &str) -> Result + where + Self: Sized; + fn settings(&self) -> String; async fn get_urls_for_track(&self, track_uuid: &str) -> Result, ProviderError>; - fn get_root_list(&self) -> ItemList; - async fn get_item_list(&self, list_uuid: &str, depth: usize) - -> Result; + fn get_library_root(&self) -> proto::crabidy::LibraryNodeResponse; + async fn get_library_node( + &self, + list_uuid: &str, + ) -> Result; } #[derive(Clone, Debug, Hash)] pub enum ProviderError { UnknownUser, + CouldNotLogin, FetchError, + MalformedUuid, + Other, } -#[derive(Clone, Debug)] -pub struct ItemList { - pub name: String, - pub uuid: String, - pub parent: String, - pub tracks: Option>, - pub children: Vec, - pub is_queable: bool, - pub ephemeral: bool, - pub is_partial: bool, -} - -impl ItemList { +impl proto::crabidy::LibraryNodeResponse { pub fn new() -> Self { Self { - name: "/".to_string(), uuid: "/".to_string(), - parent: "".to_string(), - tracks: None, + name: "/".to_string(), children: Vec::new(), + parent: None, + state: proto::crabidy::LibraryNodeState::Unspecified as i32, + tracks: Vec::new(), is_queable: false, - ephemeral: false, - is_partial: true, - } - } - pub fn replace_sublist(&mut self, sublist: &Self) { - if self.uuid == sublist.uuid { - self.name = sublist.name.clone(); - self.tracks = sublist.tracks.clone(); - self.children = sublist.children.clone(); - self.is_queable = sublist.is_queable; - self.ephemeral = sublist.ephemeral; - self.is_partial = sublist.is_partial; - return; - } - for child in self.children.iter_mut() { - child.replace_sublist(sublist); - } - } - pub fn flatten(&self) -> Vec { - let mut tracks = Vec::new(); - if let Some(own_tracks) = &self.tracks { - tracks.extend(own_tracks.clone()); - } - for child in self.children.iter() { - tracks.extend(child.flatten()); - } - tracks - } -} - -#[derive(Clone, Debug)] -struct ItemListFilter { - uuid_filter: Option, - name_filter: Option, - provider_filter: Option, -} - -#[derive(Clone, Debug)] -enum PlayState { - Buffering, - Playing, - Paused, - Stopped, -} - -#[derive(Clone, Debug)] -pub struct Track { - pub title: String, - pub uuid: String, - pub duration: Option, - pub album: Option, - pub artist: Option, - pub provider: String, -} - -#[derive(Clone, Debug)] -pub struct InputTrack { - pub uuid: String, - pub provider: String, -} - -#[derive(Clone, Debug)] -pub struct Album { - pub title: String, - pub release_date: Option, -} - -#[derive(Clone, Debug)] -pub struct Artist { - pub name: String, -} - -#[derive(Clone, Debug, Hash)] -pub enum QueueError { - ItemListNotQueuable, -} - -#[derive(Clone, Debug)] -pub struct Queue { - pub tracks: Vec, - current: i32, -} - -impl Queue { - pub fn new() -> Self { - Self { - tracks: vec![], - current: 0, - } - } - - pub fn clear(&mut self) { - self.current = 0; - self.tracks = vec![]; - } - - pub fn replace_with_track(&mut self, track: Track) { - self.current = 0; - self.tracks = vec![track]; - } - - pub fn replace_with_item_list(&mut self, item_list: &ItemList) -> Result<(), QueueError> { - if !item_list.is_queable { - return Err(QueueError::ItemListNotQueuable); - }; - self.current = 0; - self.tracks = item_list.flatten(); - Ok(()) - } - - pub fn set_current(&mut self, current: usize) -> bool { - if current < self.tracks.len() { - self.current = current as i32; - true - } else { - false - } - } - - pub fn current(&self) -> Option<&Track> { - self.tracks.get(self.current as usize) - } - - pub fn next(&mut self) -> Option<&Track> { - if (self.current as usize) < self.tracks.len() { - self.current += 1; - Some(&self.tracks[(self.current - 1) as usize]) - } else { - None - } - } - - pub fn previous(&mut self) -> Option<&Track> { - if self.current > 0 { - self.current -= 1; - Some(&self.tracks[self.current as usize]) - } else { - None - } - } - - pub fn append_track(&mut self, track: Track) { - self.tracks.push(track); - } - - pub fn append_playlist(&mut self, playlist: &[Track]) { - self.tracks.extend(playlist.to_vec()); - } - - pub fn queue_track(&mut self, track: Track) { - self.tracks.insert(self.current as usize, track); - } - - pub fn queue_playlist(&mut self, playlist: &[Track]) { - let tail: Vec = self - .tracks - .splice((self.current as usize).., playlist.to_vec()) - .collect(); - self.tracks.extend(tail); - } -} - -#[derive(Clone, Debug)] -pub struct ActiveTrack { - track: Option, - completion: i32, - play_state: PlayState, -} - -impl ActiveTrack { - pub fn new() -> Self { - Self { - track: None, - completion: 0, - play_state: PlayState::Stopped, } } } diff --git a/tidaldy/Cargo.toml b/tidaldy/Cargo.toml new file mode 100644 index 0000000..68a34d3 --- /dev/null +++ b/tidaldy/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tidaldy" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-trait = "0.1.68" +base64 = "0.21.0" +chrono = "0.4.24" +confique = "0.2.3" +crabidy-core = { path = "../crabidy-core" } +reqwest = { version = "0.11.17", features = ["json", "rustls"] } +secrecy = { version = "0.8.0", features = ["serde"] } +serde = { version = "1.0.162", features = ["derive"] } +serde_json = "1.0.96" +serde_urlencoded = "0.7.1" +thiserror = "1.0.40" +tokio = { version = "1.28.1", features = ["full", "time"] } +toml = "0.7.4" + +[dev-dependencies] +tokio = { version = "1.28.1", features = ["full"] } diff --git a/tidaldy/src/config.rs b/tidaldy/src/config.rs new file mode 100644 index 0000000..7834d9e --- /dev/null +++ b/tidaldy/src/config.rs @@ -0,0 +1,82 @@ +use serde::{Deserialize, Serialize}; +use std::iter::zip; +use thiserror::Error; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Settings { + pub base_url: String, + pub hifi_url: String, + pub audio_quality: AudioQuality, + pub login: LoginConfig, + pub oauth: OauthConfig, +} + +impl Default for Settings { + fn default() -> Self { + let client_id_key = b"abcdefghijklmnop"; + let hidden_id_from_fulltext_search = "\u{1b}7W<-01\u{3}\nX\u{1f}(=\u{1}[\u{4}".as_bytes(); + let mut client_id_bytes = Vec::new(); + for (k, c) in zip(client_id_key, hidden_id_from_fulltext_search) { + client_id_bytes.push(*c ^ *k); + } + let client_id = String::from_utf8(client_id_bytes).unwrap(); + + let client_secret_key = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQR"; + let hidden_secret_from_fulltext_search = "7((\u{c}! \u{16}\"9\u{1b}\u{1d}\u{1f}=8!2'D\u{6}\u{1f}-\"=\u{15}\u{e}\u{16}7 70\u{15}q0$\u{4}&9/z|<5eo".as_bytes(); + let mut client_secret_bytes = Vec::new(); + for (k, c) in zip(client_secret_key, hidden_secret_from_fulltext_search) { + client_secret_bytes.push(*c ^ *k); + } + let client_secret = String::from_utf8(client_secret_bytes).unwrap(); + + Self { + base_url: "https://api.tidal.com/v1".to_string(), + hifi_url: "https://api.tidalhifi.com/v1".to_string(), + audio_quality: AudioQuality::Lossless, + login: LoginConfig { + device_code: None, + user_id: None, + country_code: None, + access_token: None, + refresh_token: None, + expires_after: None, + }, + oauth: OauthConfig { + client_id, + client_secret, + base_url: "https://auth.tidal.com/v1/oauth2".to_string(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct LoginConfig { + pub device_code: Option, + pub user_id: Option, + pub country_code: Option, + pub access_token: Option, + pub refresh_token: Option, + pub expires_after: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct OauthConfig { + pub client_id: String, + pub client_secret: String, + pub base_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AudioQuality { + Low, + High, + Lossless, + HiRes, +} + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("failed to write config file")] + Write, +} diff --git a/tidaldy/src/lib.rs b/tidaldy/src/lib.rs new file mode 100644 index 0000000..c821e16 --- /dev/null +++ b/tidaldy/src/lib.rs @@ -0,0 +1,537 @@ +/// Lots of stuff and especially the auth handling is shamelessly copied from +/// https://github.com/MinisculeGirraffe/tdl +use reqwest::Client as HttpClient; +use serde::de::DeserializeOwned; +use tokio::time::{sleep, Duration, Instant}; +pub mod config; +pub mod models; +use async_trait::async_trait; +pub use models::*; + +#[derive(Debug)] +pub struct Client { + http_client: HttpClient, + settings: config::Settings, +} + +#[async_trait] +impl crabidy_core::ProviderClient for Client { + async fn init(raw_toml_settings: &str) -> Result { + let settings: config::Settings = if let Ok(settings) = toml::from_str(raw_toml_settings) { + settings + } else { + let settings = config::Settings::default(); + println!( + "could not parse toml settings: {:#?} using default settings instead: {:#?}", + raw_toml_settings, settings + ); + settings + }; + + let mut client = Self::new(settings)?; + if let Ok(_) = client.login_config().await { + return Ok(client); + } + if let Ok(_) = client.login_web().await { + return Ok(client); + } + Err(crabidy_core::ProviderError::CouldNotLogin) + } + fn settings(&self) -> String { + toml::to_string_pretty(&self.settings).unwrap() + } + async fn get_urls_for_track( + &self, + track_uuid: &str, + ) -> Result, crabidy_core::ProviderError> { + let Ok(playback) = self.get_track_playback(track_uuid).await else { + return Err(crabidy_core::ProviderError::FetchError) + }; + let Ok(manifest) = playback.get_manifest() else { + return Err(crabidy_core::ProviderError::FetchError) + }; + Ok(manifest.urls) + } + + fn get_library_root(&self) -> crabidy_core::proto::crabidy::LibraryNodeResponse { + let global_root = crabidy_core::proto::crabidy::LibraryNodeResponse::new(); + let children = vec!["userplaylists".to_string()]; + crabidy_core::proto::crabidy::LibraryNodeResponse { + uuid: "tidal".to_string(), + name: "tidal".to_string(), + parent: Some(format!("{}", global_root.uuid)), + state: crabidy_core::proto::crabidy::LibraryNodeState::Done as i32, + tracks: Vec::new(), + children, + is_queable: false, + } + } + + async fn get_library_node( + &self, + uuid: &str, + ) -> Result + { + let Some(user_id) = self.settings.login.user_id.clone() else { + return Err(crabidy_core::ProviderError::UnknownUser) + }; + let (module, uuid) = split_uuid(uuid); + let node = match module.as_str() { + "userplaylists" => { + let global_root = crabidy_core::proto::crabidy::LibraryNodeResponse::new(); + let mut node = crabidy_core::proto::crabidy::LibraryNodeResponse { + uuid: "userplaylists".to_string(), + name: "playlists".to_string(), + parent: Some(format!("{}", global_root.uuid)), + state: crabidy_core::proto::crabidy::LibraryNodeState::Unspecified as i32, + tracks: Vec::new(), + children: Vec::new(), + is_queable: false, + }; + for playlist in self + .get_users_playlists_and_favorite_playlists(&user_id) + .await? + { + node.children.push(playlist.playlist.uuid); + } + node + } + "playlist" => { + let mut node: crabidy_core::proto::crabidy::LibraryNodeResponse = + self.get_playlist(&uuid).await?.into(); + let tracks: Vec = self + .get_playlist_tracks(&uuid) + .await? + .iter() + .map(|t| t.into()) + .collect(); + node.tracks = tracks; + node.parent = Some("userplaylists".to_string()); + node + } + _ => return Err(crabidy_core::ProviderError::MalformedUuid), + }; + Ok(node) + } +} + +fn split_uuid(uuid: &str) -> (String, String) { + let mut split = uuid.splitn(2, ':'); + ( + split.next().unwrap().to_string(), + split.next().unwrap().to_string(), + ) +} + +impl Client { + pub fn new(settings: config::Settings) -> Result { + let http_client = HttpClient::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59") + .build()?; + + Ok(Self { + http_client, + settings, + }) + } + + pub fn get_user_id(&self) -> Option { + self.settings.login.user_id.clone() + } + + pub async fn make_request( + &self, + uri: &str, + query: Option<&[(&str, String)]>, + ) -> Result { + let Some(ref access_token) = self.settings.login.access_token.clone() else { + return Err(ClientError::AuthError( + "No access token found".to_string(), + )) + }; + let Some(country_code) = self.settings.login.country_code.clone() else { + return Err(ClientError::AuthError( + "No country code found".to_string(), + )) + }; + let country_param = ("countryCode", country_code); + let mut params: Vec<&(&str, String)> = vec![&country_param]; + if let Some(query) = query { + params.extend(query); + } + + let response: T = self + .http_client + .get(format!("{}/{}", self.settings.hifi_url, uri)) + .bearer_auth(access_token) + .query(¶ms) + .send() + .await? + .json() + .await?; + Ok(response) + } + + pub async fn make_paginated_request( + &self, + uri: &str, + query: Option<&[(&str, String)]>, + ) -> Result, ClientError> { + let Some(ref access_token) = self.settings.login.access_token.clone() else { + return Err(ClientError::AuthError( + "No access token found".to_string(), + )) + }; + let Some(country_code) = self.settings.login.country_code.clone() else { + return Err(ClientError::AuthError( + "No country code found".to_string(), + )) + }; + let country_param = ("countryCode", country_code); + let limit = 50; + let mut offset = 0; + let limit_param = ("limit", limit.to_string()); + let mut params: Vec<&(&str, String)> = vec![&country_param, &limit_param]; + if let Some(query) = query { + params.extend(query); + } + + let mut response: Page = self + .http_client + .get(format!("{}/{}", self.settings.hifi_url, uri)) + .bearer_auth(access_token) + .query(¶ms) + .send() + .await? + .json() + .await?; + let mut items = Vec::with_capacity(response.total_number_of_items); + items.extend(response.items); + while response.offset + limit < response.total_number_of_items { + offset += limit; + let offset_param = ("offset", offset.to_string()); + let mut params: Vec<&(&str, String)> = + vec![&country_param, &limit_param, &offset_param]; + if let Some(query) = query { + params.extend(query); + } + response = self + .http_client + .get(format!("{}/{}", self.settings.hifi_url, uri)) + .bearer_auth(access_token) + .query(¶ms) + .send() + .await? + .json() + .await?; + items.extend(response.items); + } + Ok(items) + } + + pub async fn make_explorer_request( + &self, + uri: &str, + query: Option<&[(&str, String)]>, + ) -> Result<(), ClientError> { + let Some(ref access_token) = self.settings.login.access_token.clone() else { + return Err(ClientError::AuthError( + "No access token found".to_string(), + )) + }; + let Some(country_code) = self.settings.login.country_code.clone() else { + return Err(ClientError::AuthError( + "No country code found".to_string(), + )) + }; + let country_param = ("countryCode", country_code); + let mut params: Vec<&(&str, String)> = vec![&country_param]; + if let Some(query) = query { + params.extend(query); + } + + let response = self + .http_client + .get(format!("{}/{}", self.settings.hifi_url, uri)) + .bearer_auth(access_token) + .query(¶ms) + .send() + .await? + .text() + .await?; + println!("{:?}", response); + Ok(()) + } + + pub async fn search(&self, query: &str) -> Result<(), ClientError> { + let query = vec![("query", query.to_string())]; + self.make_explorer_request(&format!("search/artists"), Some(&query)) + .await?; + Ok(()) + } + + pub async fn get_playlist_tracks( + &self, + playlist_uuid: &str, + ) -> Result, ClientError> { + Ok(self + .make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None) + .await?) + } + + pub async fn get_playlist(&self, playlist_uuid: &str) -> Result { + Ok(self + .make_request(&format!("playlists/{}", playlist_uuid), None) + .await?) + } + + pub async fn get_users_playlists(&self, user_id: u64) -> Result, ClientError> { + Ok(self + .make_paginated_request(&format!("users/{}/playlists", user_id), None) + .await?) + } + + pub async fn get_users_playlists_and_favorite_playlists( + &self, + user_id: &str, + ) -> Result, ClientError> { + Ok(self + .make_paginated_request( + &format!("users/{}/playlistsAndFavoritePlaylists", user_id), + None, + ) + .await?) + } + + pub async fn explore_get_users_playlists_and_favorite_playlists( + &self, + user_id: u64, + ) -> Result<(), ClientError> { + let limit = 50; + let offset = 0; + let limit_param = ("limit", limit.to_string()); + let offset_param = ("offset", offset.to_string()); + let params: Vec<(&str, String)> = vec![limit_param, offset_param]; + self.make_explorer_request( + &format!("users/{}/playlistsAndFavoritePlaylists", user_id), + Some(¶ms[..]), + ) + .await?; + Ok(()) + } + + pub async fn get_users_favorites(&self, user_id: u64) -> Result<(), ClientError> { + self.make_explorer_request( + &format!("users/{}/favorites", user_id), + None, + // Some(&query), + ) + .await?; + Ok(()) + } + + pub async fn get_user(&self, user_id: u64) -> Result<(), ClientError> { + self.make_explorer_request( + &format!("users/{}", user_id), + None, + // Some(&query), + ) + .await?; + Ok(()) + } + + pub async fn get_track_playback(&self, track_id: &str) -> Result { + let query = vec![ + ("audioquality", "LOSSLESS".to_string()), + ("playbackmode", "STREAM".to_string()), + ("assetpresentation", "FULL".to_string()), + ]; + self.make_request( + &format!("tracks/{}/playbackinfopostpaywall", track_id), + Some(&query), + ) + .await + } + + pub async fn get_track(&self, track_id: String) -> Result { + self.make_request(&format!("tracks/{}", track_id), None) + .await + } + + pub async fn login_web(&mut self) -> Result<(), ClientError> { + let code_response = self.get_device_code().await?; + let now = Instant::now(); + println!("{}", code_response.verification_uri_complete); + while now.elapsed().as_secs() <= code_response.expires_in { + let login = self.check_auth_status(&code_response.device_code).await; + if login.is_err() { + sleep(Duration::from_secs(code_response.interval)).await; + continue; + } + let timestamp = chrono::Utc::now().timestamp() as u64; + + let login_results = login?; + self.settings.login.device_code = Some(code_response.device_code); + self.settings.login.access_token = Some(login_results.access_token); + self.settings.login.refresh_token = login_results.refresh_token; + self.settings.login.expires_after = Some(login_results.expires_in + timestamp); + self.settings.login.user_id = Some(login_results.user.user_id); + self.settings.login.country_code = Some(login_results.user.country_code); + return Ok(()); + } + println!("login attempt expired"); + Err(ClientError::ConnectionError) + } + + pub async fn login_config(&mut self) -> Result<(), ClientError> { + let Some(access_token) = self.settings.login.access_token.clone() else { + return Err(ClientError::AuthError( + "No access token found".to_string(), + )) + }; + //return if our session is still valid + if self + .http_client + .get(format!("{}/sessions", self.settings.base_url)) + .bearer_auth(access_token) + .send() + .await? + .status() + .is_success() + { + return Ok(()); + } + + //otherwise refresh our token + let refresh = self.refresh_access_token().await?; + let now = chrono::Utc::now().timestamp() as u64; + + self.settings.login.expires_after = Some(refresh.expires_in + now); + self.settings.login.access_token = Some(refresh.access_token); + Ok(()) + } + + pub async fn refresh_access_token(&self) -> Result { + let Some(refresh_token) = self.settings.login.refresh_token.clone() else { + return Err(ClientError::AuthError( + "No refresh token found".to_string(), + )) + }; + let data = DeviceAuthRequest { + client_id: self.settings.oauth.client_id.clone(), + client_secret: Some(self.settings.oauth.client_secret.clone()), + refresh_token: Some(refresh_token.to_string()), + grant_type: Some("refresh_token".to_string()), + ..Default::default() + }; + let body = serde_urlencoded::to_string(&data)?; + + let req = self + .http_client + .post("https://auth.tidal.com/v1/oauth2/token") + .body(body) + .basic_auth( + self.settings.oauth.client_id.clone(), + Some(self.settings.oauth.client_secret.clone()), + ) + .header("Content-Type", "application/x-www-form-urlencoded") + .send() + .await?; + if req.status().is_success() { + let res = req.json::().await?; + Ok(res) + } else { + Err(ClientError::AuthError( + "Failed to refresh access token".to_string(), + )) + } + } + async fn get_device_code(&self) -> Result { + let req = DeviceAuthRequest { + client_id: self.settings.oauth.client_id.clone(), + scope: Some("r_usr+w_usr+w_sub".to_string()), + ..Default::default() + }; + let payload = serde_urlencoded::to_string(&req)?; + let res = self + .http_client + .post(format!( + "{}/device_authorization", + &self.settings.oauth.base_url + )) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(payload) + .send() + .await?; + + if !res.status().is_success() { + return Err(ClientError::AuthError(res.status().to_string())); + } + let code: DeviceAuthResponse = res.json().await?; + Ok(code) + } + + pub async fn check_auth_status( + &self, + device_code: &str, + ) -> Result { + let req = DeviceAuthRequest { + client_id: self.settings.oauth.client_id.clone(), + device_code: Some(device_code.to_string()), + scope: Some("r_usr+w_usr+w_sub".to_string()), + grant_type: Some("urn:ietf:params:oauth:grant-type:device_code".to_string()), + ..Default::default() + }; + let payload = serde_urlencoded::to_string(&req)?; + let res = self + .http_client + .post(format!("{}/token", self.settings.oauth.base_url)) + .basic_auth( + self.settings.oauth.client_id.clone(), + Some(self.settings.oauth.client_secret.clone()), + ) + .body(payload) + .header("Content-Type", "application/x-www-form-urlencoded") + .send() + .await?; + if !res.status().is_success() { + if res.status().is_client_error() { + return Err(ClientError::AuthError(format!( + "Failed to check auth status: {}", + res.status().canonical_reason().unwrap_or("") + ))); + } else { + return Err(ClientError::AuthError( + "Failed to check auth status".to_string(), + )); + } + } + let refresh = res.json::().await?; + Ok(refresh) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup() -> Client { + let settings = crate::config::Settings::default(); + Client::new(settings).unwrap() + } + + #[tokio::test] + async fn test_get_device_code() { + let client = setup(); + println!("{:#?}", client); + let response = client.get_device_code().await.unwrap(); + assert!(!response.device_code.is_empty()); + assert_eq!(response.device_code.len(), 36); + assert!(!response.user_code.is_empty()); + assert_eq!(response.user_code.len(), 5); + assert!(!response.verification_uri.is_empty()); + assert!(!response.verification_uri_complete.is_empty()); + assert!(response.expires_in == 300); + assert!(response.interval != 0); + } +} diff --git a/tidaldy/src/models.rs b/tidaldy/src/models.rs new file mode 100644 index 0000000..123809e --- /dev/null +++ b/tidaldy/src/models.rs @@ -0,0 +1,404 @@ +use std::{str::FromStr, string::FromUtf8Error}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +pub trait Paginated { + fn offset(&self) -> usize; + fn limit(&self) -> usize; + fn total(&self) -> usize; +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Page { + pub limit: Option, + pub offset: usize, + pub total_number_of_items: usize, + pub items: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistSearchPage { + pub limit: i64, + pub offset: i64, + pub total_number_of_items: i64, + pub items: Vec, +} + +impl Paginated for ArtistSearchPage { + fn offset(&self) -> usize { + self.offset as usize + } + fn limit(&self) -> usize { + self.limit as usize + } + fn total(&self) -> usize { + self.total_number_of_items as usize + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Item { + pub id: i64, + pub name: String, + pub artist_types: Vec, + pub url: String, + pub picture: Value, + pub popularity: i64, + pub artist_roles: Vec, + pub mixes: Mixes, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRole { + pub category_id: i64, + pub category: String, +} + +#[derive(Error, Debug)] +pub enum ClientError { + #[error("connecting to the tidal api servers failed")] + ConnectionError, + #[error("internal reqwest error")] + HttpClientError(#[from] reqwest::Error), + #[error("internal serde url error")] + SerdeUrlError(#[from] serde_urlencoded::ser::Error), + #[error("authentication failed")] + AuthError(String), + #[error("base64 decoding failed")] + Base64DecodeError(#[from] base64::DecodeError), + #[error("utf8 decoding failed")] + Utf8DecodeError(#[from] FromUtf8Error), + #[error("json decoding failed")] + JsonDecodeError(#[from] serde_json::Error), +} + +impl From for crabidy_core::ProviderError { + fn from(err: ClientError) -> Self { + match err { + ClientError::ConnectionError => Self::FetchError, + ClientError::HttpClientError(_) => Self::FetchError, + ClientError::SerdeUrlError(_) => Self::FetchError, + _ => Self::Other, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct DeviceAuthRequest { + pub client_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_code: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct DeviceAuthResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RefreshResponse { + pub user: UserResponse, + pub access_token: String, + pub refresh_token: Option, + pub token_type: String, + pub expires_in: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct UserResponse { + pub user_id: String, + pub country_code: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrackPlayback { + pub track_id: i64, + pub asset_presentation: String, + pub audio_mode: String, + pub audio_quality: String, + pub manifest_mime_type: String, + pub manifest_hash: String, + pub manifest: String, + pub album_replay_gain: f64, + pub album_peak_amplitude: f64, + pub track_replay_gain: f64, + pub track_peak_amplitude: f64, +} + +impl TrackPlayback { + pub fn get_manifest(&self) -> Result { + PlaybackManifest::from_str(&self.manifest) + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Track { + pub id: u64, + pub title: String, + pub duration: u64, + pub replay_gain: f64, + pub peak: f64, + pub allow_streaming: bool, + pub stream_ready: bool, + pub stream_start_date: Option, + pub premium_streaming_only: bool, + pub track_number: u64, + pub volume_number: u64, + pub version: Value, + pub popularity: u64, + pub copyright: Option, + pub url: Option, + pub isrc: Option, + pub editable: bool, + pub explicit: bool, + pub audio_quality: String, + pub audio_modes: Vec, + pub artist: Artist, + pub artists: Vec, + pub album: Album, + pub mixes: Mixes, +} + +impl From for crabidy_core::proto::crabidy::Track { + fn from(track: Track) -> Self { + Self { + uuid: track.id.to_string(), + title: track.title, + artist: track.artist.name, + } + } +} + +impl From<&Track> for crabidy_core::proto::crabidy::Track { + fn from(track: &Track) -> Self { + Self { + uuid: track.id.to_string(), + title: track.title.clone(), + artist: track.artist.name.clone(), + } + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Artist { + pub id: i64, + pub name: String, + #[serde(rename = "type")] + pub type_field: String, + pub picture: Value, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Artist2 { + pub id: i64, + pub name: String, + #[serde(rename = "type")] + pub type_field: String, + pub picture: Value, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Album { + pub id: i64, + pub title: String, + pub cover: String, + pub vibrant_color: String, + pub video_cover: Value, + pub release_date: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Mixes { + #[serde(rename = "TRACK_MIX")] + pub track_mix: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct PlaybackManifest { + pub mime_type: String, + pub codecs: String, + pub encryption_type: EncryptionType, + pub key_id: Option, + pub urls: Vec, +} + +impl FromStr for PlaybackManifest { + type Err = ClientError; + fn from_str(input: &str) -> Result { + let decode = base64::decode(input)?; + let json = String::from_utf8(decode)?; + let parsed: PlaybackManifest = serde_json::from_str(&json)?; + Ok(parsed) + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum EncryptionType { + #[serde(rename = "NONE")] + None, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct User { + pub id: i64, + pub username: String, + pub profile_name: String, + pub first_name: String, + pub last_name: String, + pub email: String, + pub email_verified: bool, + pub country_code: String, + pub created: String, + pub newsletter: bool, + #[serde(rename = "acceptedEULA")] + pub accepted_eula: bool, + pub gender: Value, + pub date_of_birth: String, + pub facebook_uid: i64, + pub apple_uid: Value, + pub partner: i64, + pub tidal_id: Value, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaylistAndFavorite { + #[serde(rename = "type")] + pub type_field: String, + pub created: String, + pub playlist: Playlist, +} + +impl From for crabidy_core::proto::crabidy::LibraryNodeResponse { + fn from(a: PlaylistAndFavorite) -> Self { + a.playlist.into() + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Playlist { + pub uuid: String, + pub title: String, + pub number_of_tracks: Option, + pub number_of_videos: Option, + pub creator: Option, + pub description: Option, + pub duration: Option, + pub last_updated: Option, + pub created: Option, + #[serde(rename = "type")] + pub type_field: Option, + pub public_playlist: bool, + pub url: Option, + pub image: Option, + pub popularity: Option, + pub square_image: Option, + pub promoted_artists: Option>, + pub last_item_added_at: Option, +} + +impl From for crabidy_core::proto::crabidy::LibraryNodeResponse { + fn from(a: Playlist) -> Self { + crabidy_core::proto::crabidy::LibraryNodeResponse { + name: a.title, + uuid: format!("playlist:{}", a.uuid), + tracks: Vec::new(), + parent: None, + state: crabidy_core::proto::crabidy::LibraryNodeState::Done as i32, + children: Vec::new(), + is_queable: true, + } + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Creator { + pub id: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaylistTracksPage { + pub limit: usize, + pub offset: usize, + pub total_number_of_items: usize, + pub items: Vec, +} + +impl Paginated for PlaylistTracksPage { + fn offset(&self) -> usize { + self.offset + } + + fn limit(&self) -> usize { + self.limit + } + + fn total(&self) -> usize { + self.total_number_of_items + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaylistTrack { + pub id: i64, + pub title: String, + pub duration: i64, + pub replay_gain: f64, + pub peak: f64, + pub allow_streaming: bool, + pub stream_ready: bool, + pub stream_start_date: Option, + pub premium_streaming_only: bool, + pub track_number: i64, + pub volume_number: i64, + pub version: Option, + pub popularity: i64, + pub copyright: String, + pub description: Value, + pub url: String, + pub isrc: String, + pub editable: bool, + pub explicit: bool, + pub audio_quality: String, + pub audio_modes: Vec, + pub artist: Artist, + pub artists: Vec, + pub album: Album, + pub mixes: Mixes, + pub date_added: String, + pub index: i64, + pub item_uuid: String, +}