diff --git a/Cargo.lock b/Cargo.lock index b88ea55f..39c960eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "bytes", "futures-core", "futures-sink", @@ -21,25 +21,25 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.9.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d48f96fc3003717aeb9856ca3d02a8c7de502667ad76eeacd830b48d2e91fac4" +checksum = "44dfe5c9e0004c623edc65391dfd51daa201e7e30ebd9c9bedf873048ec32bc2" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-utils", - "ahash 0.8.11", "base64 0.22.1", - "bitflags 2.8.0", + "bitflags 2.9.1", "brotli", "bytes", "bytestring", - "derive_more 0.99.19", + "derive_more 2.0.1", "encoding_rs", "flate2", + "foldhash", "futures-core", - "h2", + "h2 0.3.26", "http 0.2.12", "httparse", "httpdate", @@ -49,7 +49,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.8.5", + "rand 0.9.1", "sha1", "smallvec", "tokio", @@ -68,7 +68,7 @@ dependencies = [ "actix-utils", "actix-web", "chrono", - "derive_more 0.99.19", + "derive_more 0.99.20", "log", "redis 0.23.3", "time", @@ -81,7 +81,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -111,9 +111,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca2549781d8dd6d75c40cf6b6051260a2cc2f3c62343d761a969a0640646894" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" dependencies = [ "actix-rt", "actix-service", @@ -128,12 +128,11 @@ dependencies = [ [[package]] name = "actix-service" -version = "2.0.2" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" dependencies = [ "futures-core", - "paste", "pin-project-lite", ] @@ -148,7 +147,7 @@ dependencies = [ "actix-web", "anyhow", "async-trait", - "derive_more 0.99.19", + "derive_more 0.99.20", "serde", "serde_json", "tracing", @@ -183,9 +182,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.9.0" +version = "4.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9180d76e5cc7ccbc4d60a506f2c727730b154010262df5b910eb17dbe4b8cb38" +checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea" dependencies = [ "actix-codec", "actix-http", @@ -196,13 +195,13 @@ dependencies = [ "actix-service", "actix-utils", "actix-web-codegen", - "ahash 0.8.11", "bytes", "bytestring", "cfg-if", "cookie", - "derive_more 0.99.19", + "derive_more 2.0.1", "encoding_rs", + "foldhash", "futures-core", "futures-util", "impl-more", @@ -220,6 +219,7 @@ dependencies = [ "smallvec", "socket2", "time", + "tracing", "url", ] @@ -232,7 +232,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -291,23 +291,23 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "version_check", ] [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", - "getrandom 0.2.15", + "getrandom 0.3.3", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" dependencies = [ "anstyle", - "once_cell", + "once_cell_polyfill", "windows-sys 0.59.0", ] @@ -439,18 +439,18 @@ checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" [[package]] name = "argminmax" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52424b59d69d69d5056d508b260553afd91c57e21849579cd1f50ee8b8b88eaa" +checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65" dependencies = [ "num-traits", ] [[package]] name = "array-init-cursor" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7d0a018de4f6aa429b9d33d69edf69072b1c5b1cb8d3e4a5f7ef898fc3eb76" +checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3" [[package]] name = "arrayvec" @@ -460,9 +460,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "54.2.0" +version = "54.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755b6da235ac356a869393c23668c663720b8749dd6f15e52b6c214b4b964cc7" +checksum = "dc208515aa0151028e464cc94a692156e945ce5126abd3537bb7fd6ba2143ed1" dependencies = [ "arrow-arith", "arrow-array", @@ -478,9 +478,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "54.2.0" +version = "54.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64656a1e0b13ca766f8440752e9a93e11014eec7b67909986f83ed0ab1fe37b8" +checksum = "e07e726e2b3f7816a85c6a45b6ec118eeeabf0b2a8c208122ad949437181f49a" dependencies = [ "arrow-array", "arrow-buffer", @@ -492,25 +492,25 @@ dependencies = [ [[package]] name = "arrow-array" -version = "54.2.0" +version = "54.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a4a6d2896083cfbdf84a71a863b22460d0708f8206a8373c52e326cc72ea1a" +checksum = "a2262eba4f16c78496adfd559a29fe4b24df6088efc9985a873d58e92be022d5" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "arrow-buffer", "arrow-data", "arrow-schema", "chrono", "half", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "num", ] [[package]] name = "arrow-buffer" -version = "54.2.0" +version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef870583ce5e4f3b123c181706f2002fb134960f9a911900f64ba4830c7a43a" +checksum = "263f4801ff1839ef53ebd06f99a56cecd1dbaf314ec893d93168e2e860e0291c" dependencies = [ "bytes", "half", @@ -519,9 +519,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "54.2.0" +version = "54.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac7eba5a987f8b4a7d9629206ba48e19a1991762795bbe5d08497b7736017ee" +checksum = "4103d88c5b441525ed4ac23153be7458494c2b0c9a11115848fdb9b81f6f886a" dependencies = [ "arrow-array", "arrow-buffer", @@ -540,9 +540,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "54.2.0" +version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b095e8a4f3c309544935d53e04c3bfe4eea4e71c3de6fe0416d1f08bb4441a83" +checksum = "61cfdd7d99b4ff618f167e548b2411e5dd2c98c0ddebedd7df433d34c20a4429" dependencies = [ "arrow-buffer", "arrow-schema", @@ -552,9 +552,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "54.2.0" +version = "54.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c07223476f8219d1ace8cd8d85fa18c4ebd8d945013f25ef5c72e85085ca4ee" +checksum = "f841bfcc1997ef6ac48ee0305c4dfceb1f7c786fe31e67c1186edf775e1f1160" dependencies = [ "arrow-array", "arrow-buffer", @@ -565,9 +565,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "54.2.0" +version = "54.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91b194b38bfd89feabc23e798238989c6648b2506ad639be42ec8eb1658d82c4" +checksum = "1eeb55b0a0a83851aa01f2ca5ee5648f607e8506ba6802577afdda9d75cdedcd" dependencies = [ "arrow-array", "arrow-buffer", @@ -578,20 +578,20 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "54.2.0" +version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f40f6be8f78af1ab610db7d9b236e21d587b7168e368a36275d2e5670096735" +checksum = "39cfaf5e440be44db5413b75b72c2a87c1f8f0627117d110264048f2969b99e9" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", ] [[package]] name = "arrow-select" -version = "54.2.0" +version = "54.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac265273864a820c4a179fc67182ccc41ea9151b97024e1be956f0f2369c2539" +checksum = "7e2932aece2d0c869dd2125feb9bd1709ef5c445daa3838ac4112dcfa0fda52c" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "arrow-array", "arrow-buffer", "arrow-data", @@ -601,9 +601,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "54.2.0" +version = "54.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d44c8eed43be4ead49128370f7131f054839d3d6003e52aebf64322470b8fbd0" +checksum = "912e38bd6a7a7714c1d9b61df80315685553b7455e8a6045c27531d8ecd5b458" dependencies = [ "arrow-array", "arrow-buffer", @@ -644,7 +644,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", - "tokio-rustls 0.26.1", + "tokio-rustls 0.26.2", "tokio-util", "tokio-websockets", "tracing", @@ -671,18 +671,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] name = "async-trait" -version = "0.1.86" +version = "0.1.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -712,13 +712,13 @@ version = "0.28.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07a9b245ba0739fc90935094c29adbaee3f977218b5fb95e822e261cda7f56a3" dependencies = [ - "http 1.2.0", + "http 1.3.1", "log", - "rustls 0.23.23", + "rustls 0.23.27", "serde", "serde_json", "url", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] @@ -755,9 +755,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", "cfg-if", @@ -788,9 +788,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" [[package]] name = "bitflags" @@ -800,9 +800,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.8.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] @@ -830,9 +830,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.5.5" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5430e3be710b68d984d1391c854eb431a9d548640711faa54eecb1df93db91cc" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" dependencies = [ "borsh-derive", "cfg_aliases", @@ -840,22 +840,22 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.5.5" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b668d39970baad5356d7c83a86fee3a539e6f93bf6764c97368243e17a0487" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] name = "brotli" -version = "6.0.0" +version = "8.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -864,9 +864,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "4.0.2" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fa05ad7d803d413eb8380983b092cbbaf9a85f151b871360e7b00cd7060b37" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -902,22 +902,22 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.21.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" +checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.8.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fa76293b4f7bb636ab88fd78228235b5248b4d05cc589aed610f954af5d7c7a" +checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -928,9 +928,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" dependencies = [ "serde", ] @@ -961,15 +961,21 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.14" +version = "1.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" +checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" dependencies = [ "jobserver", "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.0" @@ -999,9 +1005,9 @@ dependencies = [ [[package]] name = "chrono-tz" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6ac4f2c0bf0f44e9161aec9675e1050aa4a530663c4a9e37e108fa948bca9f" +checksum = "efdce149c370f133a071ca8ef6ea340b7b88748ab0810097a9e2976eaa34b4f3" dependencies = [ "chrono", "chrono-tz-build", @@ -1010,9 +1016,9 @@ dependencies = [ [[package]] name = "chrono-tz-build" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +checksum = "8f10f8c9340e31fc120ff885fcdb54a0b48e474bbd77cab557f0c30a3e569402" dependencies = [ "parse-zoneinfo", "phf_codegen", @@ -1030,18 +1036,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.31" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.31" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" dependencies = [ "anstream", "anstyle", @@ -1110,6 +1116,27 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "connect" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "dirs", + "futures-util", + "http 1.3.1", + "jsonrpsee", + "owo-colors", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tungstenite", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1131,7 +1158,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "tiny-keccak", ] @@ -1170,6 +1197,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1187,9 +1224,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -1211,9 +1248,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] @@ -1258,7 +1295,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "crossterm_winapi", "parking_lot", "rustix 0.38.44", @@ -1323,20 +1360,20 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] name = "data-encoding" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "der" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "pem-rfc7468", @@ -1345,9 +1382,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", "serde", @@ -1355,15 +1392,15 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.19" +version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -1372,7 +1409,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" dependencies = [ - "derive_more-impl", + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl 2.0.1", ] [[package]] @@ -1383,7 +1429,19 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", + "unicode-xid", +] + +[[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 2.0.101", "unicode-xid", ] @@ -1399,6 +1457,27 @@ dependencies = [ "subtle", ] +[[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.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1407,7 +1486,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -1449,7 +1528,7 @@ dependencies = [ "lofty", "md5", "owo-colors", - "redis 0.29.0", + "redis 0.29.5", "reqwest", "serde", "serde_json", @@ -1463,9 +1542,9 @@ dependencies = [ [[package]] name = "duckdb" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2093a18d0c07e411104a9d27ef0097872172552ad5774feba304c2b47f382c" +checksum = "49ac283b6621e3becf8014d1efa655522794075834c72f744573debef9c9f6c8" dependencies = [ "arrow", "cast", @@ -1483,9 +1562,9 @@ dependencies = [ [[package]] name = "dyn-clone" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feeef44e73baff3a26d371801df019877a9866a8c493d315ab00177843314f35" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" [[package]] name = "ed25519" @@ -1511,9 +1590,9 @@ dependencies = [ [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] @@ -1536,7 +1615,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -1547,9 +1626,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" dependencies = [ "libc", "windows-sys 0.59.0", @@ -1568,9 +1647,9 @@ dependencies = [ [[package]] name = "ethnum" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90ca2580b73ab6a1f724b76ca11ab632df820fd6040c336200d2c1df7b3c82c" +checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" [[package]] name = "event-listener" @@ -1633,9 +1712,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" dependencies = [ "crc32fast", "miniz_oxide", @@ -1660,9 +1739,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "form_urlencoded" @@ -1746,7 +1825,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -1761,6 +1840,16 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +dependencies = [ + "gloo-timers", + "send_wrapper", +] + [[package]] name = "futures-util" version = "0.3.31" @@ -1791,9 +1880,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "js-sys", @@ -1804,14 +1893,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi 0.13.3+wasi-0.2.2", - "windows-targets 0.52.6", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1836,6 +1927,52 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[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", + "http 1.3.1", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[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 = "googledrive" version = "0.1.0" @@ -1854,7 +1991,7 @@ dependencies = [ "lofty", "md5", "owo-colors", - "redis 0.29.0", + "redis 0.29.5", "reqwest", "serde", "serde_json", @@ -1886,11 +2023,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" -version = "2.4.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" dependencies = [ "cfg-if", "crunchy", @@ -1912,7 +2068,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "allocator-api2", "rayon", "serde", @@ -1920,9 +2076,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" dependencies = [ "allocator-api2", "equivalent", @@ -1946,7 +2102,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.2", + "hashbrown 0.15.3", ] [[package]] @@ -2007,9 +2163,9 @@ dependencies = [ [[package]] name = "http" -version = "1.2.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -2034,27 +2190,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.2.0", + "http 1.3.1", ] [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", - "http 1.2.0", + "futures-core", + "http 1.3.1", "http-body 1.0.1", "pin-project-lite", ] [[package]] name = "httparse" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -2094,7 +2250,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.2.0", + "h2 0.4.10", + "http 1.3.1", "http-body 1.0.1", "httparse", "itoa", @@ -2120,34 +2277,35 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d" dependencies = [ - "futures-util", - "http 1.2.0", + "http 1.3.1", "hyper 1.6.0", "hyper-util", - "rustls 0.23.23", + "log", + "rustls 0.23.27", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.1", + "tokio-rustls 0.26.2", "tower-service", - "webpki-roots", + "webpki-roots 1.0.0", ] [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.2.0", + "http 1.3.1", "http-body 1.0.1", "hyper 1.6.0", + "libc", "pin-project-lite", "socket2", "tokio", @@ -2157,16 +2315,17 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", - "windows-core 0.52.0", + "windows-core 0.61.2", ] [[package]] @@ -2180,21 +2339,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -2203,31 +2363,11 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -2235,67 +2375,54 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.98", -] - [[package]] name = "idna" version = "1.0.3" @@ -2309,9 +2436,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -2325,20 +2452,20 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "indexmap" -version = "2.7.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "serde", ] [[package]] name = "inout" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "generic-array", ] @@ -2357,9 +2484,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jetstream" @@ -2383,12 +2510,35 @@ dependencies = [ "url", ] +[[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 = "jobserver" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" dependencies = [ + "getrandom 0.3.3", "libc", ] @@ -2403,45 +2553,172 @@ dependencies = [ ] [[package]] -name = "jsonwebtoken" -version = "9.3.1" +name = "jsonrpsee" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "1fba77a59c4c644fd48732367624d1bcf6f409f9c9a286fbc71d2f1fc0b2ea16" dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-http-client", + "jsonrpsee-types", + "jsonrpsee-wasm-client", + "jsonrpsee-ws-client", + "tokio", ] [[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - -[[package]] -name = "lazy_static" -version = "1.5.0" +name = "jsonrpsee-client-transport" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "a2a320a3f1464e4094f780c4d48413acd786ce5627aaaecfac9e9c7431d13ae1" dependencies = [ - "spin", + "base64 0.22.1", + "futures-channel", + "futures-util", + "gloo-net", + "http 1.3.1", + "jsonrpsee-core", + "pin-project", + "rustls 0.23.27", + "rustls-pki-types", + "rustls-platform-verifier", + "soketto", + "thiserror 2.0.12", + "tokio", + "tokio-rustls 0.26.2", + "tokio-util", + "tracing", + "url", ] [[package]] -name = "lexical-core" -version = "1.0.5" +name = "jsonrpsee-core" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b765c31809609075565a70b4b71402281283aeda7ecaf4818ac14a7b2ade8958" +checksum = "693c93cbb7db25f4108ed121304b671a36002c2db67dff2ee4391a688c738547" dependencies = [ - "lexical-parse-float", - "lexical-parse-integer", - "lexical-util", - "lexical-write-float", + "async-trait", + "bytes", + "futures-timer", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "jsonrpsee-types", + "pin-project", + "rustc-hash", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tower", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "jsonrpsee-http-client" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6962d2bd295f75e97dd328891e58fce166894b974c1f7ce2e7597f02eeceb791" +dependencies = [ + "base64 0.22.1", + "http-body 1.0.1", + "hyper 1.6.0", + "hyper-rustls 0.27.6", + "hyper-util", + "jsonrpsee-core", + "jsonrpsee-types", + "rustls 0.23.27", + "rustls-platform-verifier", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tower", + "url", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66df7256371c45621b3b7d2fb23aea923d577616b9c0e9c0b950a6ea5c2be0ca" +dependencies = [ + "http 1.3.1", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "jsonrpsee-wasm-client" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b67695cbcf4653f39f8f8738925547e0e23fd9fe315bccf951097b9f6a38781" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "tower", +] + +[[package]] +name = "jsonrpsee-ws-client" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da2694c9ff271a9d3ebfe520f6b36820e85133a51be77a3cb549fd615095261" +dependencies = [ + "http 1.3.1", + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "tower", + "url", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lexical-core" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b765c31809609075565a70b4b71402281283aeda7ecaf4818ac14a7b2ade8958" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", "lexical-write-integer", ] @@ -2498,15 +2775,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libduckdb-sys" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4020eaf07df4927b5205cd200ca2a5ed0798b49652dec22e09384ba8efa163" +checksum = "12cac9d03484c43fefac8b2066a253c9b0b3b0cd02cbe02a9ea2312f7e382618" dependencies = [ "autocfg", "flate2", @@ -2519,9 +2796,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.11" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" @@ -2529,7 +2806,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "libc", "redox_syscall", ] @@ -2552,15 +2829,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litemap" -version = "0.7.4" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "local-channel" @@ -2591,9 +2868,9 @@ dependencies = [ [[package]] name = "lofty" -version = "0.22.2" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "781de624f162b1a8cbfbd577103ee9b8e5f62854b053ff48f4e31e68a0a7df6f" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" dependencies = [ "byteorder", "data-encoding", @@ -2612,14 +2889,20 @@ checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] name = "log" -version = "0.4.25" +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 = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lz4" @@ -2648,7 +2931,7 @@ checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -2700,23 +2983,23 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.4" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2728,7 +3011,7 @@ dependencies = [ "data-encoding", "ed25519", "ed25519-dalek", - "getrandom 0.2.15", + "getrandom 0.2.16", "log", "rand 0.8.5", "signatory", @@ -2878,9 +3161,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.3" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +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 = "opaque-debug" @@ -2894,6 +3183,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2906,9 +3201,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.1.0" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" +checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec" [[package]] name = "parking" @@ -2956,9 +3251,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pem" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" dependencies = [ "base64 0.22.1", "serde", @@ -3019,22 +3314,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfe2e71e1471fe07709406bf725f710b02927c9c54b2b5b2ec0e8087d97c327d" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e859e6e5bd50440ab63c47e3ebabc90f26251f7c73c3d3e837b74a1cc3fa67" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -3072,9 +3367,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "planus" @@ -3116,7 +3411,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72571dde488ecccbe799798bf99ab7308ebdb7cf5d95bcc498dbd5a132f0da4d" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "polars-arrow", "polars-core", "polars-error", @@ -3136,7 +3431,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6611c758d52e799761cc25900666b71552e6c929d88052811bc9daad4b3321a8" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "atoi_simd", "bytemuck", "chrono", @@ -3144,8 +3439,8 @@ dependencies = [ "dyn-clone", "either", "ethnum", - "getrandom 0.2.15", - "hashbrown 0.15.2", + "getrandom 0.2.16", + "hashbrown 0.15.3", "itoa", "lz4", "num-traits", @@ -3199,15 +3494,15 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "796d06eae7e6e74ed28ea54a8fccc584ebac84e6cf0e1e9ba41ffc807b169a01" dependencies = [ - "ahash 0.8.11", - "bitflags 2.8.0", + "ahash 0.8.12", + "bitflags 2.9.1", "bytemuck", "chrono", "chrono-tz", "comfy-table", "either", "hashbrown 0.14.5", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "indexmap", "itoa", "num-traits", @@ -3223,7 +3518,7 @@ dependencies = [ "rayon", "regex", "strum_macros 0.26.4", - "thiserror 2.0.11", + "thiserror 2.0.12", "version_check", "xxhash-rust", ] @@ -3237,7 +3532,7 @@ dependencies = [ "polars-arrow-format", "regex", "simdutf8", - "thiserror 2.0.11", + "thiserror 2.0.12", ] [[package]] @@ -3246,9 +3541,9 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8e639991a8ad4fb12880ab44bcc3cf44a5703df003142334d9caf86d77d77e7" dependencies = [ - "ahash 0.8.11", - "bitflags 2.8.0", - "hashbrown 0.15.2", + "ahash 0.8.12", + "bitflags 2.9.1", + "hashbrown 0.15.3", "num-traits", "once_cell", "polars-arrow", @@ -3270,7 +3565,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719a77e94480f6be090512da196e378cbcbeb3584c6fe1134c600aee906e38ab" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "async-trait", "atoi_simd", "bytes", @@ -3278,7 +3573,7 @@ dependencies = [ "fast-float2", "futures", "glob", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "home", "itoa", "memchr", @@ -3307,8 +3602,8 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0a731a672dfc8ac38c1f73c9a4b2ae38d2fc8ac363bfb64c5f3a3e072ffc5ad" dependencies = [ - "ahash 0.8.11", - "bitflags 2.8.0", + "ahash 0.8.12", + "bitflags 2.9.1", "chrono", "memchr", "once_cell", @@ -3352,14 +3647,14 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb83218b0c216104f0076cd1a005128be078f958125f3d59b094ee73d78c18e" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "argminmax", "base64 0.22.1", "bytemuck", "chrono", "chrono-tz", "either", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "hex", "indexmap", "memchr", @@ -3386,13 +3681,13 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c60ee85535590a38db6c703a21be4cb25342e40f573f070d1e16f9d84a53ac7" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "async-stream", "base64 0.22.1", "bytemuck", "ethnum", "futures", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "num-traits", "polars-arrow", "polars-compute", @@ -3422,7 +3717,7 @@ dependencies = [ "crossbeam-channel", "crossbeam-queue", "enum_dispatch", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "num-traits", "once_cell", "polars-arrow", @@ -3445,14 +3740,14 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f03533a93aa66127fcb909a87153a3c7cfee6f0ae59f497e73d7736208da54c" dependencies = [ - "ahash 0.8.11", - "bitflags 2.8.0", + "ahash 0.8.12", + "bitflags 2.9.1", "bytemuck", "bytes", "chrono", "chrono-tz", "either", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "memmap2", "num-traits", "once_cell", @@ -3477,7 +3772,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bf47f7409f8e75328d7d034be390842924eb276716d0458607be0bddb8cc839" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "bytemuck", "polars-arrow", "polars-compute", @@ -3577,11 +3872,11 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f6c8166a4a7fbc15b87c81645ed9e1f0651ff2e8c96cafc40ac5bf43441a10" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "bytemuck", "bytes", "compact_str", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "indexmap", "libc", "memmap2", @@ -3614,6 +3909,15 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +[[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" @@ -3622,36 +3926,36 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.7.35", + "zerocopy", ] [[package]] name = "proc-macro-crate" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] name = "psm" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58e5423e24c18cc840e1c98370b3993c6649cd1678b4d24318bcf0a083cbe88" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" dependencies = [ "cc", ] @@ -3688,9 +3992,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.4" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", "serde", @@ -3698,37 +4002,40 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" dependencies = [ "bytes", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.23", + "rustls 0.23.27", "socket2", - "thiserror 2.0.11", + "thiserror 2.0.12", "tokio", "tracing", + "web-time", ] [[package]] name = "quinn-proto" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" dependencies = [ "bytes", - "getrandom 0.2.15", - "rand 0.8.5", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.1", "ring", "rustc-hash", - "rustls 0.23.23", + "rustls 0.23.27", "rustls-pki-types", "slab", - "thiserror 2.0.11", + "thiserror 2.0.12", "tinyvec", "tracing", "web-time", @@ -3736,9 +4043,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.9" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" +checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" dependencies = [ "cfg_aliases", "libc", @@ -3750,13 +4057,19 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.38" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "radium" version = "0.7.0" @@ -3776,13 +4089,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", - "zerocopy 0.8.24", ] [[package]] @@ -3811,7 +4123,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", ] [[package]] @@ -3820,7 +4132,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.1", + "getrandom 0.3.3", ] [[package]] @@ -3835,11 +4147,11 @@ dependencies = [ [[package]] name = "raw-cpuid" -version = "11.4.0" +version = "11.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529468c1335c1c03919960dfefdb1b3648858c20d7ec2d0663e728e4a717efbc" +checksum = "c6df7ab838ed27997ba19a4664507e6f82b41fe6e20be42929332156e5e85146" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", ] [[package]] @@ -3879,7 +4191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" dependencies = [ "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -3903,9 +4215,9 @@ dependencies = [ [[package]] name = "redis" -version = "0.29.0" +version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9568894e8bdefd16512bca9e286a9d2abc27773609aa4eb7f428497d64df4373" +checksum = "1bc42f3a12fd4408ce64d8efef67048a924e543bd35c6591c0447fda9054695f" dependencies = [ "arc-swap", "combine", @@ -3920,11 +4232,22 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", ] [[package]] @@ -3973,19 +4296,19 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.12" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" dependencies = [ "base64 0.22.1", "bytes", "futures-core", "futures-util", - "http 1.2.0", + "http 1.3.1", "http-body 1.0.1", "http-body-util", "hyper 1.6.0", - "hyper-rustls 0.27.5", + "hyper-rustls 0.27.6", "hyper-util", "ipnet", "js-sys", @@ -3996,7 +4319,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.23", + "rustls 0.23.27", "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", @@ -4004,7 +4327,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.1", + "tokio-rustls 0.26.2", "tokio-util", "tower", "tower-service", @@ -4013,19 +4336,19 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 0.26.11", "windows-registry", ] [[package]] name = "ring" -version = "0.17.9" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.16", "libc", "untrusted", "windows-sys 0.52.0", @@ -4062,9 +4385,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" dependencies = [ "const-oid", "digest", @@ -4130,9 +4453,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.36.0" +version = "1.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50" dependencies = [ "arrayvec", "borsh", @@ -4171,7 +4494,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4180,14 +4503,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.3" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "errno", "libc", - "linux-raw-sys 0.9.3", + "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] @@ -4205,14 +4528,15 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.23" +version = "0.23.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" +checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.102.8", + "rustls-webpki 0.103.3", "subtle", "zeroize", ] @@ -4226,7 +4550,7 @@ dependencies = [ "openssl-probe", "rustls-pemfile 1.0.4", "schannel", - "security-framework", + "security-framework 2.11.1", ] [[package]] @@ -4239,7 +4563,19 @@ dependencies = [ "rustls-pemfile 2.2.0", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.2.0", ] [[package]] @@ -4262,13 +4598,41 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ "web-time", + "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation 0.10.0", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.27", + "rustls-native-certs 0.8.1", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.3", + "security-framework 3.2.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -4284,6 +4648,16 @@ name = "rustls-webpki" version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ "ring", "rustls-pki-types", @@ -4292,15 +4666,24 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" [[package]] name = "ryu" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +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 = "schannel" @@ -4333,9 +4716,9 @@ dependencies = [ "jsonwebtoken", "md5", "owo-colors", - "quick-xml 0.37.4", - "rand 0.9.0", - "redis 0.29.0", + "quick-xml 0.37.5", + "rand 0.9.1", + "redis 0.29.5", "reqwest", "serde", "serde_json", @@ -4367,8 +4750,21 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.8.0", - "core-foundation", + "bitflags 2.9.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +dependencies = [ + "bitflags 2.9.1", + "core-foundation 0.10.0", "core-foundation-sys", "libc", "security-framework-sys", @@ -4386,9 +4782,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.25" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "send_wrapper" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" +checksum = "f638d531eccd6e23b980caf34876660d38e265409d8e99b397ab71eb3612fad0" [[package]] name = "serde" @@ -4407,7 +4809,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -4433,13 +4835,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -4473,9 +4875,9 @@ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -4503,9 +4905,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" dependencies = [ "libc", ] @@ -4546,7 +4948,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.11", + "thiserror 2.0.12", "time", ] @@ -4576,23 +4978,38 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "httparse", + "log", + "rand 0.8.5", + "sha1", +] + [[package]] name = "spin" version = "0.9.8" @@ -4625,7 +5042,7 @@ dependencies = [ "hex", "jsonwebtoken", "owo-colors", - "redis 0.29.0", + "redis 0.29.5", "reqwest", "serde", "serde_json", @@ -4645,9 +5062,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -4658,10 +5075,11 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ + "base64 0.22.1", "bytes", "chrono", "crc", @@ -4672,45 +5090,44 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "hashlink 0.10.0", "indexmap", "log", "memchr", "once_cell", "percent-encoding", - "rustls 0.23.23", - "rustls-pemfile 2.2.0", + "rustls 0.23.27", "serde", "serde_json", "sha2", "smallvec", - "thiserror 2.0.11", + "thiserror 2.0.12", "tokio", "tokio-stream", "tracing", "url", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] name = "sqlx-macros" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] name = "sqlx-macros-core" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -4726,21 +5143,20 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.98", - "tempfile", + "syn 2.0.101", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.8.0", + "bitflags 2.9.1", "byteorder", "bytes", "chrono", @@ -4770,20 +5186,20 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.11", + "thiserror 2.0.12", "tracing", "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.8.0", + "bitflags 2.9.1", "byteorder", "chrono", "crc", @@ -4808,16 +5224,16 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.11", + "thiserror 2.0.12", "tracing", "whoami", ] [[package]] name = "sqlx-sqlite" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", @@ -4833,6 +5249,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "thiserror 2.0.12", "tracing", "url", ] @@ -4845,9 +5262,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stacker" -version = "0.1.19" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9156ebd5870ef293bfb43f91c7a74528d363ec0d424afe24160ed5a4343d08a" +checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" dependencies = [ "cc", "cfg-if", @@ -4935,7 +5352,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -4948,7 +5365,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -5165,9 +5582,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.98" +version = "2.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", @@ -5185,13 +5602,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -5226,14 +5643,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", - "getrandom 0.3.1", + "getrandom 0.3.3", "once_cell", - "rustix 1.0.3", + "rustix 1.0.7", "windows-sys 0.59.0", ] @@ -5248,11 +5665,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl 2.0.11", + "thiserror-impl 2.0.12", ] [[package]] @@ -5263,25 +5680,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] name = "time" -version = "0.3.37" +version = "0.3.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", "itoa", @@ -5294,15 +5711,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" [[package]] name = "time-macros" -version = "0.2.19" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" dependencies = [ "num-conv", "time-core", @@ -5319,9 +5736,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -5329,9 +5746,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" dependencies = [ "tinyvec_macros", ] @@ -5344,9 +5761,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.43.0" +version = "1.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" dependencies = [ "backtrace", "bytes", @@ -5368,7 +5785,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -5383,11 +5800,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ - "rustls 0.23.23", + "rustls 0.23.27", "tokio", ] @@ -5411,22 +5828,23 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls 0.23.23", + "rustls 0.23.27", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.1", + "tokio-rustls 0.26.2", "tungstenite", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] name = "tokio-util" -version = "0.7.13" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -5442,28 +5860,28 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "http 1.2.0", + "http 1.3.1", "httparse", "rand 0.8.5", "ring", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.1", + "tokio-rustls 0.26.2", "tokio-util", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" [[package]] name = "toml_edit" -version = "0.22.24" +version = "0.22.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ "indexmap", "toml_datetime", @@ -5517,7 +5935,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -5560,14 +5978,14 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.2.0", + "http 1.3.1", "httparse", "log", - "rand 0.9.0", - "rustls 0.23.23", + "rand 0.9.1", + "rustls 0.23.27", "rustls-pki-types", "sha1", - "thiserror 2.0.11", + "thiserror 2.0.12", "utf-8", ] @@ -5591,9 +6009,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-normalization" @@ -5670,12 +6088,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5694,7 +6106,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ - "getrandom 0.3.1", + "getrandom 0.3.3", "js-sys", "wasm-bindgen", ] @@ -5711,6 +6123,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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" @@ -5728,9 +6150,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasi" -version = "0.13.3+wasi-0.2.2" +version = "0.14.2+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" dependencies = [ "wit-bindgen-rt", ] @@ -5763,7 +6185,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", "wasm-bindgen-shared", ] @@ -5798,7 +6220,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5846,10 +6268,28 @@ dependencies = [ ] [[package]] -name = "webpki-roots" +name = "webpki-root-certs" version = "0.26.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4" +dependencies = [ + "rustls-pki-types", +] + +[[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.0", +] + +[[package]] +name = "webpki-roots" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" dependencies = [ "rustls-pki-types", ] @@ -5870,8 +6310,8 @@ dependencies = [ "jsonwebtoken", "md5", "owo-colors", - "rand 0.9.0", - "redis 0.29.0", + "rand 0.9.1", + "redis 0.29.5", "reqwest", "serde", "serde_json", @@ -5882,9 +6322,9 @@ dependencies = [ [[package]] name = "whoami" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" dependencies = [ "redox_syscall", "wasite", @@ -5906,6 +6346,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -5924,23 +6373,27 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.52.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", "windows-targets 0.52.6", ] [[package]] name = "windows-core" -version = "0.57.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -5951,7 +6404,18 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", +] + +[[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.101", ] [[package]] @@ -5962,18 +6426,35 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", +] + +[[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.101", ] +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + [[package]] name = "windows-registry" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" dependencies = [ - "windows-result 0.2.0", - "windows-strings", - "windows-targets 0.52.6", + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.0", ] [[package]] @@ -5987,21 +6468,38 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.2.0" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", + "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.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", ] [[package]] @@ -6031,6 +6529,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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.48.5" @@ -6055,13 +6568,35 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6074,6 +6609,18 @@ 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.48.5" @@ -6086,6 +6633,18 @@ 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.48.5" @@ -6098,12 +6657,30 @@ 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.48.5" @@ -6116,6 +6693,18 @@ 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.48.5" @@ -6128,6 +6717,18 @@ 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.48.5" @@ -6140,6 +6741,18 @@ 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.48.5" @@ -6152,35 +6765,35 @@ 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.3" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen-rt" -version = "0.33.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "wyz" @@ -6193,13 +6806,12 @@ dependencies = [ [[package]] name = "xattr" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e105d177a3871454f754b33bb0ee637ecaaac997446375fd3e5d43a2ed00c909" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" dependencies = [ "libc", - "linux-raw-sys 0.4.15", - "rustix 0.38.44", + "rustix 1.0.7", ] [[package]] @@ -6210,9 +6822,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ "serde", "stable_deref_trait", @@ -6222,75 +6834,54 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", "synstructure", ] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" dependencies = [ - "byteorder", - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive 0.8.24", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.98", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.24" +version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] name = "zerofrom" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", "synstructure", ] @@ -6300,11 +6891,22 @@ 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.10.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" dependencies = [ "yoke", "zerofrom", @@ -6313,13 +6915,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.98", + "syn 2.0.101", ] [[package]] @@ -6333,18 +6935,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.3" +version = "7.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.14+zstd.1.5.7" +version = "2.0.15+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" dependencies = [ "cc", "pkg-config", diff --git a/crates/analytics/src/cmd/serve.rs b/crates/analytics/src/cmd/serve.rs index 58ac6268..c04a185d 100644 --- a/crates/analytics/src/cmd/serve.rs +++ b/crates/analytics/src/cmd/serve.rs @@ -1,8 +1,12 @@ use std::env; -use actix_web::{get, post, web::{self, Data}, App, HttpRequest, HttpResponse, HttpServer, Responder}; -use duckdb::Connection; +use actix_web::{ + get, post, + web::{self, Data}, + App, HttpRequest, HttpResponse, HttpServer, Responder, +}; use anyhow::Error; +use duckdb::Connection; use owo_colors::OwoColorize; use serde_json::json; use std::sync::{Arc, Mutex}; @@ -12,47 +16,48 @@ use crate::{handlers::handle, subscriber::subscribe}; // return json response #[get("/")] async fn index(_req: HttpRequest) -> HttpResponse { - HttpResponse::Ok().json(json!({ - "server": "Rocksky Analytics Server", - "version": "0.1.0", - })) + HttpResponse::Ok().json(json!({ + "server": "Rocksky Analytics Server", + "version": "0.1.0", + })) } #[post("/{method}")] async fn call_method( - data: web::Data>>, - mut payload: web::Payload, - req: HttpRequest) -> Result { - let method = req.match_info().get("method").unwrap_or("unknown"); - println!("Method: {}", method.bright_green()); - - let conn = data.get_ref().clone(); - handle(method, &mut payload, &req, conn).await - .map_err(actix_web::error::ErrorInternalServerError) + data: web::Data>>, + mut payload: web::Payload, + req: HttpRequest, +) -> Result { + let method = req.match_info().get("method").unwrap_or("unknown"); + println!("Method: {}", method.bright_green()); + + let conn = data.get_ref().clone(); + handle(method, &mut payload, &req, conn) + .await + .map_err(actix_web::error::ErrorInternalServerError) } - pub async fn serve(conn: Arc>) -> Result<(), Error> { - subscribe(conn.clone()).await?; - - let host = env::var("ANALYTICS_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port = env::var("ANALYTICS_PORT").unwrap_or_else(|_| "7879".to_string()); - let addr = format!("{}:{}", host, port); - - let url = format!("http://{}", addr); - println!("Listening on {}", url.bright_green()); - - let conn = conn.clone(); - HttpServer::new(move || { - App::new() - .app_data(Data::new(conn.clone())) - .service(index) - .service(call_method) - }) - .bind(&addr)? - .run() - .await - .map_err(Error::new)?; - - Ok(()) + subscribe(conn.clone()).await?; + + let host = env::var("ANALYTICS_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = env::var("ANALYTICS_PORT").unwrap_or_else(|_| "7879".to_string()); + let addr = format!("{}:{}", host, port); + + let url = format!("http://{}", addr); + println!("Listening on {}", url.bright_green()); + + let conn = conn.clone(); + HttpServer::new(move || { + App::new() + .app_data(Data::new(conn.clone())) + .service(index) + .service(call_method) + }) + .bind(&addr)? + .run() + .await + .map_err(Error::new)?; + + Ok(()) } diff --git a/crates/analytics/src/cmd/sync.rs b/crates/analytics/src/cmd/sync.rs index 05408737..cac56010 100644 --- a/crates/analytics/src/cmd/sync.rs +++ b/crates/analytics/src/cmd/sync.rs @@ -1,9 +1,9 @@ use std::sync::{Arc, Mutex}; +use crate::core::*; use anyhow::Error; use duckdb::Connection; use sqlx::{Pool, Postgres}; -use crate::core::*; pub async fn sync(conn: Arc>, pool: &Pool) -> Result<(), Error> { load_tracks(conn.clone(), pool).await?; diff --git a/crates/analytics/src/core.rs b/crates/analytics/src/core.rs index bb5d7062..4f67b565 100644 --- a/crates/analytics/src/core.rs +++ b/crates/analytics/src/core.rs @@ -1,16 +1,15 @@ use std::sync::{Arc, Mutex}; -use duckdb::{params, Connection}; use anyhow::Error; +use duckdb::{params, Connection}; use owo_colors::OwoColorize; use sqlx::{Pool, Postgres}; use crate::xata; - pub async fn create_tables(conn: &Connection) -> Result<(), Error> { - conn.execute_batch( - "BEGIN; + conn.execute_batch( + "BEGIN; CREATE TABLE IF NOT EXISTS artists ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, @@ -179,23 +178,30 @@ pub async fn create_tables(conn: &Connection) -> Result<(), Error> { ); COMMIT; ", - )?; + )?; - Ok(()) + Ok(()) } pub async fn load_tracks(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let tracks: Vec = sqlx::query_as(r#" + let conn = conn.lock().unwrap(); + let tracks: Vec = sqlx::query_as( + r#" SELECT * FROM tracks - "#) - .fetch_all(pool) - .await?; - - for (i, track) in tracks.clone().into_iter().enumerate() { - println!("track {} - {} - {}", i, track.title.bright_green(), track.artist); - match conn.execute( - "INSERT INTO tracks ( + "#, + ) + .fetch_all(pool) + .await?; + + for (i, track) in tracks.clone().into_iter().enumerate() { + println!( + "track {} - {} - {}", + i, + track.title.bright_green(), + track.artist + ); + match conn.execute( + "INSERT INTO tracks ( id, title, artist, @@ -221,54 +227,59 @@ pub async fn load_tracks(conn: Arc>, pool: &Pool) -> album_uri, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![ - track.xata_id, - track.title, - track.artist, - track.album_artist, - track.album_art, - track.album, - track.track_number, - track.duration, - track.mb_id, - track.youtube_link, - track.spotify_link, - track.tidal_link, - track.apple_music_link, - track.sha256, - track.lyrics, - track.composer, - track.genre, - track.disc_number, - track.copyright_message, - track.label, - track.uri, - track.artist_uri, - track.album_uri, - track.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("tracks: {:?}", tracks.len()); - Ok(()) + params![ + track.xata_id, + track.title, + track.artist, + track.album_artist, + track.album_art, + track.album, + track.track_number, + track.duration, + track.mb_id, + track.youtube_link, + track.spotify_link, + track.tidal_link, + track.apple_music_link, + track.sha256, + track.lyrics, + track.composer, + track.genre, + track.disc_number, + track.copyright_message, + track.label, + track.uri, + track.artist_uri, + track.album_uri, + track.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("tracks: {:?}", tracks.len()); + Ok(()) } -pub async fn load_artists(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let artists: Vec = sqlx::query_as(r#" +pub async fn load_artists( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); + let artists: Vec = sqlx::query_as( + r#" SELECT * FROM artists - "#) - .fetch_all(pool) - .await?; - - for (i, artist) in artists.clone().into_iter().enumerate() { - println!("artist {} - {}", i, artist.name.bright_green()); - match conn.execute( - "INSERT INTO artists ( + "#, + ) + .fetch_all(pool) + .await?; + + for (i, artist) in artists.clone().into_iter().enumerate() { + println!("artist {} - {}", i, artist.name.bright_green()); + match conn.execute( + "INSERT INTO artists ( id, name, biography, @@ -295,43 +306,45 @@ pub async fn load_artists(conn: Arc>, pool: &Pool) - ?, ?, ?)", - params![ - artist.xata_id, - artist.name, - artist.biography, - artist.born, - artist.born_in, - artist.died, - artist.picture, - artist.sha256, - artist.spotify_link, - artist.tidal_link, - artist.youtube_link, - artist.apple_music_link, - artist.uri, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("artists: {:?}", artists.len()); - Ok(()) + params![ + artist.xata_id, + artist.name, + artist.biography, + artist.born, + artist.born_in, + artist.died, + artist.picture, + artist.sha256, + artist.spotify_link, + artist.tidal_link, + artist.youtube_link, + artist.apple_music_link, + artist.uri, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("artists: {:?}", artists.len()); + Ok(()) } pub async fn load_albums(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let albums: Vec = sqlx::query_as(r#" + let conn = conn.lock().unwrap(); + let albums: Vec = sqlx::query_as( + r#" SELECT * FROM albums - "#) - .fetch_all(pool) - .await?; - - for (i, album) in albums.clone().into_iter().enumerate() { - println!("album {} - {}", i, album.title.bright_green()); - match conn.execute( - "INSERT INTO albums ( + "#, + ) + .fetch_all(pool) + .await?; + + for (i, album) in albums.clone().into_iter().enumerate() { + println!("album {} - {}", i, album.title.bright_green()); + match conn.execute( + "INSERT INTO albums ( id, title, artist, @@ -358,43 +371,45 @@ pub async fn load_albums(conn: Arc>, pool: &Pool) -> ?, ?, ?)", - params![ - album.xata_id, - album.title, - album.artist, - album.release_date, - album.album_art, - album.year, - album.spotify_link, - album.tidal_link, - album.youtube_link, - album.apple_music_link, - album.sha256, - album.uri, - album.artist_uri, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("albums: {:?}", albums.len()); - Ok(()) + params![ + album.xata_id, + album.title, + album.artist, + album.release_date, + album.album_art, + album.year, + album.spotify_link, + album.tidal_link, + album.youtube_link, + album.apple_music_link, + album.sha256, + album.uri, + album.artist_uri, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("albums: {:?}", albums.len()); + Ok(()) } pub async fn load_users(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let users: Vec = sqlx::query_as(r#" + let conn = conn.lock().unwrap(); + let users: Vec = sqlx::query_as( + r#" SELECT * FROM users - "#) - .fetch_all(pool) - .await?; - - for (i, user) in users.clone().into_iter().enumerate() { - println!("user {} - {}", i, user.display_name.bright_green()); - match conn.execute( - "INSERT INTO users ( + "#, + ) + .fetch_all(pool) + .await?; + + for (i, user) in users.clone().into_iter().enumerate() { + println!("user {} - {}", i, user.display_name.bright_green()); + match conn.execute( + "INSERT INTO users ( id, display_name, did, @@ -405,40 +420,48 @@ pub async fn load_users(conn: Arc>, pool: &Pool) -> ?, ?, ?)", - params![ - user.xata_id, - user.display_name, - user.did, - user.handle, - user.avatar, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("users: {:?}", users.len()); - Ok(()) + params![ + user.xata_id, + user.display_name, + user.did, + user.handle, + user.avatar, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("users: {:?}", users.len()); + Ok(()) } -pub async fn load_scrobbles(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let scrobbles: Vec = sqlx::query_as(r#" +pub async fn load_scrobbles( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); + let scrobbles: Vec = sqlx::query_as( + r#" SELECT * FROM scrobbles - "#) - .fetch_all(pool) - .await?; - - for (i, scrobble) in scrobbles.clone().into_iter().enumerate() { - println!("scrobble {} - {}", i, - match scrobble.uri.clone() { - Some(uri) => uri.to_string(), - None => "None".to_string(), - }.bright_green() - ); - match conn.execute( - "INSERT INTO scrobbles ( + "#, + ) + .fetch_all(pool) + .await?; + + for (i, scrobble) in scrobbles.clone().into_iter().enumerate() { + println!( + "scrobble {} - {}", + i, + match scrobble.uri.clone() { + Some(uri) => uri.to_string(), + None => "None".to_string(), + } + .bright_green() + ); + match conn.execute( + "INSERT INTO scrobbles ( id, user_id, track_id, @@ -455,69 +478,89 @@ pub async fn load_scrobbles(conn: Arc>, pool: &Pool) ?, ? )", - params![ - scrobble.xata_id, - scrobble.user_id, - scrobble.track_id, - scrobble.album_id, - scrobble.artist_id, - scrobble.uri, - scrobble.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("scrobbles: {:?}", scrobbles.len()); - Ok(()) + params![ + scrobble.xata_id, + scrobble.user_id, + scrobble.track_id, + scrobble.album_id, + scrobble.artist_id, + scrobble.uri, + scrobble.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("scrobbles: {:?}", scrobbles.len()); + Ok(()) } -pub async fn load_album_tracks(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let album_tracks: Vec = sqlx::query_as(r#" +pub async fn load_album_tracks( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); + let album_tracks: Vec = sqlx::query_as( + r#" SELECT * FROM album_tracks - "#) - .fetch_all(pool) - .await?; - - for (i, album_track) in album_tracks.clone().into_iter().enumerate() { - println!("album_track {} - {} - {}", i, album_track.album_id.bright_green(), album_track.track_id); - match conn.execute( - "INSERT INTO album_tracks ( + "#, + ) + .fetch_all(pool) + .await?; + + for (i, album_track) in album_tracks.clone().into_iter().enumerate() { + println!( + "album_track {} - {} - {}", + i, + album_track.album_id.bright_green(), + album_track.track_id + ); + match conn.execute( + "INSERT INTO album_tracks ( id, album_id, track_id ) VALUES (?, ?, ?)", - params![ - album_track.xata_id, - album_track.album_id, - album_track.track_id, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - println!("album_tracks: {:?}", album_tracks.len()); - Ok(()) + params![ + album_track.xata_id, + album_track.album_id, + album_track.track_id, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + println!("album_tracks: {:?}", album_tracks.len()); + Ok(()) } -pub async fn load_loved_tracks(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let loved_tracks: Vec = sqlx::query_as(r#" +pub async fn load_loved_tracks( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); + let loved_tracks: Vec = sqlx::query_as( + r#" SELECT * FROM loved_tracks - "#) - .fetch_all(pool) - .await?; - - for (i, loved_track) in loved_tracks.clone().into_iter().enumerate() { - println!("loved_track {} - {} - {}", i, loved_track.user_id.bright_green(), loved_track.track_id); - match conn.execute( - "INSERT INTO loved_tracks ( + "#, + ) + .fetch_all(pool) + .await?; + + for (i, loved_track) in loved_tracks.clone().into_iter().enumerate() { + println!( + "loved_track {} - {} - {}", + i, + loved_track.user_id.bright_green(), + loved_track.track_id + ); + match conn.execute( + "INSERT INTO loved_tracks ( id, user_id, track_id, @@ -526,69 +569,88 @@ pub async fn load_loved_tracks(conn: Arc>, pool: &Pool (), - Err(e) => println!("error: {}", e), - } - } - - println!("loved_tracks: {:?}", loved_tracks.len()); - Ok(()) + params![ + loved_track.xata_id, + loved_track.user_id, + loved_track.track_id, + loved_track.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("loved_tracks: {:?}", loved_tracks.len()); + Ok(()) } -pub async fn load_artist_tracks(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let artist_tracks: Vec = sqlx::query_as(r#" +pub async fn load_artist_tracks( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); + let artist_tracks: Vec = sqlx::query_as( + r#" SELECT * FROM artist_tracks - "#) - .fetch_all(pool) - .await?; - - for (i, artist_track) in artist_tracks.clone().into_iter().enumerate() { - println!("artist_track {} - {} - {}", i, artist_track.artist_id.bright_green(), artist_track.track_id); - match conn.execute( - "INSERT INTO artist_tracks (id, artist_id, track_id, created_at) VALUES (?, ?, ?, ?)", - params![ - artist_track.xata_id, - artist_track.artist_id, - artist_track.track_id, - artist_track.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("artist_tracks: {:?}", artist_tracks.len()); - Ok(()) -} + "#, + ) + .fetch_all(pool) + .await?; + for (i, artist_track) in artist_tracks.clone().into_iter().enumerate() { + println!( + "artist_track {} - {} - {}", + i, + artist_track.artist_id.bright_green(), + artist_track.track_id + ); + match conn.execute( + "INSERT INTO artist_tracks (id, artist_id, track_id, created_at) VALUES (?, ?, ?, ?)", + params![ + artist_track.xata_id, + artist_track.artist_id, + artist_track.track_id, + artist_track.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("artist_tracks: {:?}", artist_tracks.len()); + Ok(()) +} -pub async fn load_artist_albums(conn: Arc>, pool: &Pool) -> Result<(), Error> { +pub async fn load_artist_albums( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { let conn = conn.lock().unwrap(); - let artist_albums: Vec = sqlx::query_as(r#" + let artist_albums: Vec = sqlx::query_as( + r#" SELECT * FROM artist_albums - "#) + "#, + ) .fetch_all(pool) .await?; for (i, artist_album) in artist_albums.clone().into_iter().enumerate() { - println!("artist_albums {} - {} - {}", i, artist_album.artist_id.bright_green(), artist_album.album_id); + println!( + "artist_albums {} - {} - {}", + i, + artist_album.artist_id.bright_green(), + artist_album.album_id + ); match conn.execute( "INSERT INTO artist_albums (id, artist_id, album_id, created_at) VALUES (?, ?, ?, ?)", - params![ + params![ artist_album.xata_id, artist_album.artist_id, artist_album.album_id, artist_album.xata_createdat, - ], + ], ) { Ok(_) => (), Err(e) => println!("error: {}", e), @@ -599,86 +661,116 @@ pub async fn load_artist_albums(conn: Arc>, pool: &Pool>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let user_albums: Vec = sqlx::query_as(r#" +pub async fn load_user_albums( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); + let user_albums: Vec = sqlx::query_as( + r#" SELECT * FROM user_albums - "#) - .fetch_all(pool) - .await?; - - for (i, user_album) in user_albums.clone().into_iter().enumerate() { - println!("user_album {} - {} - {}", i, user_album.user_id.bright_green(), user_album.album_id); - match conn.execute( - "INSERT INTO user_albums (id, user_id, album_id, created_at) VALUES (?, ?, ?, ?)", - params![ - user_album.xata_id, - user_album.user_id, - user_album.album_id, - user_album.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("user_albums: {:?}", user_albums.len()); - Ok(()) + "#, + ) + .fetch_all(pool) + .await?; + + for (i, user_album) in user_albums.clone().into_iter().enumerate() { + println!( + "user_album {} - {} - {}", + i, + user_album.user_id.bright_green(), + user_album.album_id + ); + match conn.execute( + "INSERT INTO user_albums (id, user_id, album_id, created_at) VALUES (?, ?, ?, ?)", + params![ + user_album.xata_id, + user_album.user_id, + user_album.album_id, + user_album.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("user_albums: {:?}", user_albums.len()); + Ok(()) } -pub async fn load_user_artists(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let user_artists: Vec = sqlx::query_as(r#" +pub async fn load_user_artists( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); + let user_artists: Vec = sqlx::query_as( + r#" SELECT * FROM user_artists - "#) - .fetch_all(pool) - .await?; - - for (i, user_artist) in user_artists.clone().into_iter().enumerate() { - println!("user_artist {} - {} - {}", i, user_artist.user_id.bright_green(), user_artist.artist_id); - match conn.execute( - "INSERT INTO user_artists (id, user_id, artist_id, created_at) VALUES (?, ?, ?, ?)", - params![ - user_artist.xata_id, - user_artist.user_id, - user_artist.artist_id, - user_artist.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("user_artists: {:?}", user_artists.len()); - Ok(()) + "#, + ) + .fetch_all(pool) + .await?; + + for (i, user_artist) in user_artists.clone().into_iter().enumerate() { + println!( + "user_artist {} - {} - {}", + i, + user_artist.user_id.bright_green(), + user_artist.artist_id + ); + match conn.execute( + "INSERT INTO user_artists (id, user_id, artist_id, created_at) VALUES (?, ?, ?, ?)", + params![ + user_artist.xata_id, + user_artist.user_id, + user_artist.artist_id, + user_artist.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("user_artists: {:?}", user_artists.len()); + Ok(()) } -pub async fn load_user_tracks(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let user_tracks: Vec = sqlx::query_as(r#" +pub async fn load_user_tracks( + conn: Arc>, + pool: &Pool, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); + let user_tracks: Vec = sqlx::query_as( + r#" SELECT * FROM user_tracks - "#) - .fetch_all(pool) - .await?; - - for (i, user_track) in user_tracks.clone().into_iter().enumerate() { - println!("user_track {} - {} - {}", i, user_track.user_id.bright_green(), user_track.track_id); - match conn.execute( - "INSERT INTO user_tracks (id, user_id, track_id, created_at) VALUES (?, ?, ?, ?)", - params![ - user_track.xata_id, - user_track.user_id, - user_track.track_id, - user_track.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } - - println!("user_tracks: {:?}", user_tracks.len()); - Ok(()) -} \ No newline at end of file + "#, + ) + .fetch_all(pool) + .await?; + + for (i, user_track) in user_tracks.clone().into_iter().enumerate() { + println!( + "user_track {} - {} - {}", + i, + user_track.user_id.bright_green(), + user_track.track_id + ); + match conn.execute( + "INSERT INTO user_tracks (id, user_id, track_id, created_at) VALUES (?, ?, ?, ?)", + params![ + user_track.xata_id, + user_track.user_id, + user_track.track_id, + user_track.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } + + println!("user_tracks: {:?}", user_tracks.len()); + Ok(()) +} diff --git a/crates/analytics/src/handlers/albums.rs b/crates/analytics/src/handlers/albums.rs index ff4812e7..7d554783 100644 --- a/crates/analytics/src/handlers/albums.rs +++ b/crates/analytics/src/handlers/albums.rs @@ -1,25 +1,32 @@ use std::sync::{Arc, Mutex}; use actix_web::{web, HttpRequest, HttpResponse}; -use analytics::types::{album::{Album, GetAlbumTracksParams, GetAlbumsParams, GetTopAlbumsParams}, track::Track}; -use duckdb::Connection; +use analytics::types::{ + album::{Album, GetAlbumTracksParams, GetAlbumsParams, GetTopAlbumsParams}, + track::Track, +}; use anyhow::Error; +use duckdb::Connection; use tokio_stream::StreamExt; use crate::read_payload; -pub async fn get_albums(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let pagination = params.pagination.unwrap_or_default(); - let offset = pagination.skip.unwrap_or(0); - let limit = pagination.take.unwrap_or(20); - let did = params.user_did; +pub async fn get_albums( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let pagination = params.pagination.unwrap_or_default(); + let offset = pagination.skip.unwrap_or(0); + let limit = pagination.take.unwrap_or(20); + let did = params.user_did; - let conn = conn.lock().unwrap(); - let mut stmt = match did { - Some(_) => { - conn.prepare(r#" + let conn = conn.lock().unwrap(); + let mut stmt = match did { + Some(_) => conn.prepare( + r#" SELECT a.*, COUNT(*) AS play_count, COUNT(DISTINCT s.user_id) AS unique_listeners @@ -30,85 +37,92 @@ pub async fn get_albums(payload: &mut web::Payload, _req: &HttpRequest, conn: Ar WHERE u.did = ? OR u.handle = ? GROUP BY a.* ORDER BY play_count DESC OFFSET ? LIMIT ?; - "#)? - }, - None => { - conn.prepare("SELECT a.*, + "#, + )?, + None => conn.prepare( + "SELECT a.*, COUNT(*) AS play_count, COUNT(DISTINCT s.user_id) AS unique_listeners FROM albums a LEFT JOIN scrobbles s ON s.album_id = a.id GROUP BY a.* - ORDER BY play_count DESC OFFSET ? LIMIT ?")? - } - }; + ORDER BY play_count DESC OFFSET ? LIMIT ?", + )?, + }; - match did { - Some(did) => { - let albums_iter = stmt.query_map([&did, &did, &limit.to_string(), &offset.to_string()], |row| { - Ok(Album { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - release_date: row.get(3)?, - album_art: row.get(4)?, - year: row.get(5)?, - spotify_link: row.get(6)?, - tidal_link: row.get(7)?, - youtube_link: row.get(8)?, - apple_music_link: row.get(9)?, - sha256: row.get(10)?, - uri: row.get(11)?, - artist_uri: row.get(12)?, - play_count: Some(row.get(13)?), - unique_listeners: Some(row.get(14)?), - ..Default::default() - }) - })?; + match did { + Some(did) => { + let albums_iter = stmt.query_map( + [&did, &did, &limit.to_string(), &offset.to_string()], + |row| { + Ok(Album { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + release_date: row.get(3)?, + album_art: row.get(4)?, + year: row.get(5)?, + spotify_link: row.get(6)?, + tidal_link: row.get(7)?, + youtube_link: row.get(8)?, + apple_music_link: row.get(9)?, + sha256: row.get(10)?, + uri: row.get(11)?, + artist_uri: row.get(12)?, + play_count: Some(row.get(13)?), + unique_listeners: Some(row.get(14)?), + ..Default::default() + }) + }, + )?; - let albums: Result, _> = albums_iter.collect(); - Ok(HttpResponse::Ok().json(web::Json(albums?))) - }, - None => { - let albums_iter = stmt.query_map([limit, offset], |row| { - Ok(Album { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - release_date: row.get(3)?, - album_art: row.get(4)?, - year: row.get(5)?, - spotify_link: row.get(6)?, - tidal_link: row.get(7)?, - youtube_link: row.get(8)?, - apple_music_link: row.get(9)?, - sha256: row.get(10)?, - uri: row.get(11)?, - artist_uri: row.get(12)?, - play_count: Some(row.get(13)?), - unique_listeners: Some(row.get(14)?), - ..Default::default() - }) - })?; + let albums: Result, _> = albums_iter.collect(); + Ok(HttpResponse::Ok().json(web::Json(albums?))) + } + None => { + let albums_iter = stmt.query_map([limit, offset], |row| { + Ok(Album { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + release_date: row.get(3)?, + album_art: row.get(4)?, + year: row.get(5)?, + spotify_link: row.get(6)?, + tidal_link: row.get(7)?, + youtube_link: row.get(8)?, + apple_music_link: row.get(9)?, + sha256: row.get(10)?, + uri: row.get(11)?, + artist_uri: row.get(12)?, + play_count: Some(row.get(13)?), + unique_listeners: Some(row.get(14)?), + ..Default::default() + }) + })?; - let albums: Result, _> = albums_iter.collect(); - Ok(HttpResponse::Ok().json(web::Json(albums?))) + let albums: Result, _> = albums_iter.collect(); + Ok(HttpResponse::Ok().json(web::Json(albums?))) + } } - } } +pub async fn get_top_albums( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let pagination = params.pagination.unwrap_or_default(); + let offset = pagination.skip.unwrap_or(0); + let limit = pagination.take.unwrap_or(20); + let did = params.user_did; -pub async fn get_top_albums(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let pagination = params.pagination.unwrap_or_default(); - let offset = pagination.skip.unwrap_or(0); - let limit = pagination.take.unwrap_or(20); - let did = params.user_did; - - let conn = conn.lock().unwrap(); - let mut stmt = match did { - Some(_) => conn.prepare(r#" + let conn = conn.lock().unwrap(); + let mut stmt = match did { + Some(_) => conn.prepare( + r#" SELECT s.album_id AS id, a.title AS title, @@ -136,8 +150,10 @@ pub async fn get_top_albums(payload: &mut web::Payload, _req: &HttpRequest, conn play_count DESC OFFSET ? LIMIT ?; - "#)?, - None => conn.prepare(r#" + "#, + )?, + None => conn.prepare( + r#" SELECT s.album_id AS id, a.title AS title, @@ -162,58 +178,66 @@ pub async fn get_top_albums(payload: &mut web::Payload, _req: &HttpRequest, conn play_count DESC OFFSET ? LIMIT ?; - "#)? - }; + "#, + )?, + }; - match did { - Some(did) => { - let albums = stmt.query_map([&did, &did, &limit.to_string(), &offset.to_string()], |row| { - Ok(Album { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - artist_uri: row.get(3)?, - album_art: row.get(4)?, - release_date: row.get(5)?, - year: row.get(6)?, - uri: row.get(7)?, - sha256: row.get(8)?, - play_count: Some(row.get(9)?), - unique_listeners: Some(row.get(10)?), - ..Default::default() - }) - })?; - let albums: Result, _> = albums.collect(); - Ok(HttpResponse::Ok().json(web::Json(albums?))) - }, - None => { - let albums = stmt.query_map([limit, offset], |row| { - Ok(Album { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - artist_uri: row.get(3)?, - album_art: row.get(4)?, - release_date: row.get(5)?, - year: row.get(6)?, - uri: row.get(7)?, - sha256: row.get(8)?, - play_count: Some(row.get(9)?), - unique_listeners: Some(row.get(10)?), - ..Default::default() - }) - })?; - let albums: Result, _> = albums.collect(); - Ok(HttpResponse::Ok().json(web::Json(albums?))) + match did { + Some(did) => { + let albums = stmt.query_map( + [&did, &did, &limit.to_string(), &offset.to_string()], + |row| { + Ok(Album { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + artist_uri: row.get(3)?, + album_art: row.get(4)?, + release_date: row.get(5)?, + year: row.get(6)?, + uri: row.get(7)?, + sha256: row.get(8)?, + play_count: Some(row.get(9)?), + unique_listeners: Some(row.get(10)?), + ..Default::default() + }) + }, + )?; + let albums: Result, _> = albums.collect(); + Ok(HttpResponse::Ok().json(web::Json(albums?))) + } + None => { + let albums = stmt.query_map([limit, offset], |row| { + Ok(Album { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + artist_uri: row.get(3)?, + album_art: row.get(4)?, + release_date: row.get(5)?, + year: row.get(6)?, + uri: row.get(7)?, + sha256: row.get(8)?, + play_count: Some(row.get(9)?), + unique_listeners: Some(row.get(10)?), + ..Default::default() + }) + })?; + let albums: Result, _> = albums.collect(); + Ok(HttpResponse::Ok().json(web::Json(albums?))) + } } - } } -pub async fn get_album_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare(r#" +pub async fn get_album_tracks( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare(r#" SELECT t.id, t.title, @@ -243,30 +267,30 @@ pub async fn get_album_tracks(payload: &mut web::Payload, _req: &HttpRequest, co ORDER BY t.track_number ASC; "#)?; - let tracks = stmt.query_map([¶ms.album_id, ¶ms.album_id], |row| { - Ok(Track { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - album_artist: row.get(3)?, - album: row.get(4)?, - uri: row.get(5)?, - album_art: row.get(6)?, - duration: row.get(7)?, - disc_number: row.get(8)?, - track_number: row.get(9)?, - artist_uri: row.get(10)?, - album_uri: row.get(11)?, - sha256: row.get(12)?, - copyright_message: row.get(13)?, - label: row.get(14)?, - created_at: row.get(15)?, - play_count: Some(row.get(16)?), - unique_listeners: Some(row.get(17)?), - ..Default::default() - }) - })?; + let tracks = stmt.query_map([¶ms.album_id, ¶ms.album_id], |row| { + Ok(Track { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + album_artist: row.get(3)?, + album: row.get(4)?, + uri: row.get(5)?, + album_art: row.get(6)?, + duration: row.get(7)?, + disc_number: row.get(8)?, + track_number: row.get(9)?, + artist_uri: row.get(10)?, + album_uri: row.get(11)?, + sha256: row.get(12)?, + copyright_message: row.get(13)?, + label: row.get(14)?, + created_at: row.get(15)?, + play_count: Some(row.get(16)?), + unique_listeners: Some(row.get(17)?), + ..Default::default() + }) + })?; - let tracks: Result, _> = tracks.collect(); - Ok(HttpResponse::Ok().json(web::Json(tracks?))) + let tracks: Result, _> = tracks.collect(); + Ok(HttpResponse::Ok().json(web::Json(tracks?))) } diff --git a/crates/analytics/src/handlers/artists.rs b/crates/analytics/src/handlers/artists.rs index 4bd99e95..42a79653 100644 --- a/crates/analytics/src/handlers/artists.rs +++ b/crates/analytics/src/handlers/artists.rs @@ -1,14 +1,24 @@ use std::sync::{Arc, Mutex}; use actix_web::{web, HttpRequest, HttpResponse}; -use analytics::types::{album::Album, artist::{Artist, GetArtistAlbumsParams, GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams}, track::Track}; -use duckdb::Connection; +use analytics::types::{ + album::Album, + artist::{ + Artist, GetArtistAlbumsParams, GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams, + }, + track::Track, +}; use anyhow::Error; +use duckdb::Connection; use tokio_stream::StreamExt; use crate::read_payload; -pub async fn get_artists(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_artists( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; let pagination = params.pagination.unwrap_or_default(); @@ -18,8 +28,8 @@ pub async fn get_artists(payload: &mut web::Payload, _req: &HttpRequest, conn: A let conn = conn.lock().unwrap(); let mut stmt = match did { - Some(_) => { - conn.prepare(r#" + Some(_) => conn.prepare( + r#" SELECT a.*, COUNT(*) AS play_count, COUNT(DISTINCT s.user_id) AS unique_listeners @@ -30,44 +40,47 @@ pub async fn get_artists(payload: &mut web::Payload, _req: &HttpRequest, conn: A WHERE u.did = ? OR u.handle = ? GROUP BY a.* ORDER BY play_count DESC OFFSET ? LIMIT ?; - "#)? - }, - None => { - conn.prepare("SELECT a.*, + "#, + )?, + None => conn.prepare( + "SELECT a.*, COUNT(*) AS play_count, COUNT(DISTINCT s.user_id) AS unique_listeners FROM artists a LEFT JOIN scrobbles s ON s.artist_id = a.id GROUP BY a.* - ORDER BY play_count DESC OFFSET ? LIMIT ?")? - } + ORDER BY play_count DESC OFFSET ? LIMIT ?", + )?, }; match did { Some(did) => { - let artists = stmt.query_map([&did, &did, &limit.to_string(), &offset.to_string()], |row| { - Ok(Artist { - id: row.get(0)?, - name: row.get(1)?, - biography: row.get(2)?, - born: row.get(3)?, - born_in: row.get(4)?, - died: row.get(5)?, - picture: row.get(6)?, - sha256: row.get(7)?, - spotify_link: row.get(8)?, - tidal_link: row.get(9)?, - youtube_link: row.get(10)?, - apple_music_link: row.get(11)?, - uri: row.get(12)?, - play_count: row.get(13)?, - unique_listeners: row.get(14)?, - }) - })?; + let artists = stmt.query_map( + [&did, &did, &limit.to_string(), &offset.to_string()], + |row| { + Ok(Artist { + id: row.get(0)?, + name: row.get(1)?, + biography: row.get(2)?, + born: row.get(3)?, + born_in: row.get(4)?, + died: row.get(5)?, + picture: row.get(6)?, + sha256: row.get(7)?, + spotify_link: row.get(8)?, + tidal_link: row.get(9)?, + youtube_link: row.get(10)?, + apple_music_link: row.get(11)?, + uri: row.get(12)?, + play_count: row.get(13)?, + unique_listeners: row.get(14)?, + }) + }, + )?; let artists: Result, _> = artists.collect(); Ok(HttpResponse::Ok().json(artists?)) - }, + } None => { let artists = stmt.query_map([limit, offset], |row| { Ok(Artist { @@ -95,7 +108,11 @@ pub async fn get_artists(payload: &mut web::Payload, _req: &HttpRequest, conn: A } } -pub async fn get_top_artists(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_top_artists( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; let pagination = params.pagination.unwrap_or_default(); @@ -105,8 +122,8 @@ pub async fn get_top_artists(payload: &mut web::Payload, _req: &HttpRequest, con let conn = conn.lock().unwrap(); let mut stmt = match did { - Some(_) => { - conn.prepare(r#" + Some(_) => conn.prepare( + r#" SELECT s.artist_id AS id, ar.name AS artist_name, @@ -129,10 +146,10 @@ pub async fn get_top_artists(payload: &mut web::Payload, _req: &HttpRequest, con play_count DESC OFFSET ? LIMIT ?; - "#)? - }, - None => { - conn.prepare(r#" + "#, + )?, + None => conn.prepare( + r#" SELECT s.artist_id AS id, ar.name AS artist_name, @@ -153,35 +170,38 @@ pub async fn get_top_artists(payload: &mut web::Payload, _req: &HttpRequest, con play_count DESC OFFSET ? LIMIT ?; - "#)? - } + "#, + )?, }; match did { Some(did) => { - let artists = stmt.query_map([&did, &did, &limit.to_string(), &offset.to_string()], |row| { - Ok(Artist { - id: row.get(0)?, - name: row.get(1)?, - biography: None, - born: None, - born_in: None, - died: None, - picture: row.get(2)?, - sha256: row.get(3)?, - spotify_link: None, - tidal_link: None, - youtube_link: None, - apple_music_link: None, - uri: row.get(4)?, - play_count: Some(row.get(5)?), - unique_listeners: Some(row.get(6)?), - }) - })?; + let artists = stmt.query_map( + [&did, &did, &limit.to_string(), &offset.to_string()], + |row| { + Ok(Artist { + id: row.get(0)?, + name: row.get(1)?, + biography: None, + born: None, + born_in: None, + died: None, + picture: row.get(2)?, + sha256: row.get(3)?, + spotify_link: None, + tidal_link: None, + youtube_link: None, + apple_music_link: None, + uri: row.get(4)?, + play_count: Some(row.get(5)?), + unique_listeners: Some(row.get(6)?), + }) + }, + )?; let artists: Result, _> = artists.collect(); Ok(HttpResponse::Ok().json(artists?)) - }, + } None => { let artists = stmt.query_map([limit, offset], |row| { Ok(Artist { @@ -209,7 +229,11 @@ pub async fn get_top_artists(payload: &mut web::Payload, _req: &HttpRequest, con } } -pub async fn get_artist_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_artist_tracks( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; let pagination = params.pagination.unwrap_or_default(); @@ -249,35 +273,47 @@ pub async fn get_artist_tracks(payload: &mut web::Payload, _req: &HttpRequest, c LIMIT ?; "#)?; - let tracks = stmt.query_map([¶ms.artist_id, ¶ms.artist_id, &limit.to_string(), &offset.to_string()], |row| { - Ok(Track { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - album_artist: row.get(3)?, - album: row.get(4)?, - uri: row.get(5)?, - album_art: row.get(6)?, - duration: row.get(7)?, - disc_number: row.get(8)?, - track_number: row.get(9)?, - artist_uri: row.get(10)?, - album_uri: row.get(11)?, - sha256: row.get(12)?, - copyright_message: row.get(13)?, - label: row.get(14)?, - created_at: row.get(15)?, - play_count: Some(row.get(16)?), - unique_listeners: Some(row.get(17)?), - ..Default::default() - }) - })?; + let tracks = stmt.query_map( + [ + ¶ms.artist_id, + ¶ms.artist_id, + &limit.to_string(), + &offset.to_string(), + ], + |row| { + Ok(Track { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + album_artist: row.get(3)?, + album: row.get(4)?, + uri: row.get(5)?, + album_art: row.get(6)?, + duration: row.get(7)?, + disc_number: row.get(8)?, + track_number: row.get(9)?, + artist_uri: row.get(10)?, + album_uri: row.get(11)?, + sha256: row.get(12)?, + copyright_message: row.get(13)?, + label: row.get(14)?, + created_at: row.get(15)?, + play_count: Some(row.get(16)?), + unique_listeners: Some(row.get(17)?), + ..Default::default() + }) + }, + )?; let tracks: Result, _> = tracks.collect(); Ok(HttpResponse::Ok().json(tracks?)) } -pub async fn get_artist_albums(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_artist_albums( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; let conn = conn.lock().unwrap(); diff --git a/crates/analytics/src/handlers/mod.rs b/crates/analytics/src/handlers/mod.rs index 30963b53..9900bdd6 100644 --- a/crates/analytics/src/handlers/mod.rs +++ b/crates/analytics/src/handlers/mod.rs @@ -2,55 +2,62 @@ use std::sync::{Arc, Mutex}; use actix_web::{web, HttpRequest, HttpResponse}; use albums::{get_album_tracks, get_albums, get_top_albums}; +use anyhow::Error; use artists::{get_artist_albums, get_artist_tracks, get_artists, get_top_artists}; use duckdb::Connection; use scrobbles::{get_distinct_scrobbles, get_scrobbles}; -use stats::{get_album_scrobbles, get_artist_scrobbles, get_scrobbles_per_day, get_scrobbles_per_month, get_scrobbles_per_year, get_stats, get_track_scrobbles}; +use stats::{ + get_album_scrobbles, get_artist_scrobbles, get_scrobbles_per_day, get_scrobbles_per_month, + get_scrobbles_per_year, get_stats, get_track_scrobbles, +}; use tracks::{get_loved_tracks, get_top_tracks, get_tracks}; -use anyhow::Error; pub mod albums; pub mod artists; pub mod scrobbles; -pub mod tracks; pub mod stats; - +pub mod tracks; #[macro_export] macro_rules! read_payload { - ($payload:expr) => {{ - let mut body = Vec::new(); - while let Some(chunk) = $payload.next().await { - match chunk { - Ok(bytes) => body.extend_from_slice(&bytes), - Err(err) => return Err(err.into()), - } - } - body - }}; + ($payload:expr) => {{ + let mut body = Vec::new(); + while let Some(chunk) = $payload.next().await { + match chunk { + Ok(bytes) => body.extend_from_slice(&bytes), + Err(err) => return Err(err.into()), + } + } + body + }}; } -pub async fn handle(method: &str, payload: &mut web::Payload, req: &HttpRequest, conn: Arc>) -> Result { - match method { - "library.getAlbums" => get_albums(payload, req, conn.clone()).await, - "library.getArtists" => get_artists(payload, req, conn.clone()).await, - "library.getTracks" => get_tracks(payload, req, conn.clone()).await, - "library.getScrobbles" => get_scrobbles(payload, req, conn.clone()).await, - "library.getDistinctScrobbles" => get_distinct_scrobbles(payload, req, conn.clone()).await, - "library.getLovedTracks" => get_loved_tracks(payload, req, conn.clone()).await, - "library.getStats" => get_stats(payload, req, conn.clone()).await, - "library.getTopAlbums" => get_top_albums(payload, req, conn.clone()).await, - "library.getTopArtists" => get_top_artists(payload, req, conn.clone()).await, - "library.getTopTracks" => get_top_tracks(payload, req, conn.clone()).await, - "library.getScrobblesPerDay" => get_scrobbles_per_day(payload, req, conn.clone()).await, - "library.getScrobblesPerMonth" => get_scrobbles_per_month(payload, req, conn.clone()).await, - "library.getScrobblesPerYear" => get_scrobbles_per_year(payload, req, conn.clone()).await, - "library.getAlbumScrobbles" => get_album_scrobbles(payload, req, conn.clone()).await, - "library.getArtistScrobbles" => get_artist_scrobbles(payload, req, conn.clone()).await, - "library.getTrackScrobbles" => get_track_scrobbles(payload, req, conn.clone()).await, - "library.getAlbumTracks" => get_album_tracks(payload, req, conn.clone()).await, - "library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await, - "library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await, - _ => return Err(anyhow::anyhow!("Method not found")), - } +pub async fn handle( + method: &str, + payload: &mut web::Payload, + req: &HttpRequest, + conn: Arc>, +) -> Result { + match method { + "library.getAlbums" => get_albums(payload, req, conn.clone()).await, + "library.getArtists" => get_artists(payload, req, conn.clone()).await, + "library.getTracks" => get_tracks(payload, req, conn.clone()).await, + "library.getScrobbles" => get_scrobbles(payload, req, conn.clone()).await, + "library.getDistinctScrobbles" => get_distinct_scrobbles(payload, req, conn.clone()).await, + "library.getLovedTracks" => get_loved_tracks(payload, req, conn.clone()).await, + "library.getStats" => get_stats(payload, req, conn.clone()).await, + "library.getTopAlbums" => get_top_albums(payload, req, conn.clone()).await, + "library.getTopArtists" => get_top_artists(payload, req, conn.clone()).await, + "library.getTopTracks" => get_top_tracks(payload, req, conn.clone()).await, + "library.getScrobblesPerDay" => get_scrobbles_per_day(payload, req, conn.clone()).await, + "library.getScrobblesPerMonth" => get_scrobbles_per_month(payload, req, conn.clone()).await, + "library.getScrobblesPerYear" => get_scrobbles_per_year(payload, req, conn.clone()).await, + "library.getAlbumScrobbles" => get_album_scrobbles(payload, req, conn.clone()).await, + "library.getArtistScrobbles" => get_artist_scrobbles(payload, req, conn.clone()).await, + "library.getTrackScrobbles" => get_track_scrobbles(payload, req, conn.clone()).await, + "library.getAlbumTracks" => get_album_tracks(payload, req, conn.clone()).await, + "library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await, + "library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await, + _ => return Err(anyhow::anyhow!("Method not found")), + } } diff --git a/crates/analytics/src/handlers/scrobbles.rs b/crates/analytics/src/handlers/scrobbles.rs index 326b78c3..17d41d81 100644 --- a/crates/analytics/src/handlers/scrobbles.rs +++ b/crates/analytics/src/handlers/scrobbles.rs @@ -2,13 +2,17 @@ use std::sync::{Arc, Mutex}; use actix_web::{web, HttpRequest, HttpResponse}; use analytics::types::scrobble::{GetScrobblesParams, ScrobbleTrack}; -use duckdb::Connection; use anyhow::Error; +use duckdb::Connection; use tokio_stream::StreamExt; use crate::read_payload; -pub async fn get_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_scrobbles( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; let pagination = params.pagination.unwrap_or_default(); @@ -74,28 +78,31 @@ pub async fn get_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, conn: }; match did { Some(did) => { - let scrobbles = stmt.query_map([&did, &did, &limit.to_string(), &offset.to_string()], |row| { - Ok(ScrobbleTrack { - id: row.get(0)?, - track_id: row.get(1)?, - title: row.get(2)?, - artist: row.get(3)?, - album_artist: row.get(4)?, - album: row.get(5)?, - album_art: row.get(6)?, - handle: row.get(7)?, - did: row.get(8)?, - avatar: None, - uri: row.get(9)?, - track_uri: row.get(10)?, - artist_uri: row.get(11)?, - album_uri: row.get(12)?, - created_at: row.get(13)?, - }) - })?; + let scrobbles = stmt.query_map( + [&did, &did, &limit.to_string(), &offset.to_string()], + |row| { + Ok(ScrobbleTrack { + id: row.get(0)?, + track_id: row.get(1)?, + title: row.get(2)?, + artist: row.get(3)?, + album_artist: row.get(4)?, + album: row.get(5)?, + album_art: row.get(6)?, + handle: row.get(7)?, + did: row.get(8)?, + avatar: None, + uri: row.get(9)?, + track_uri: row.get(10)?, + artist_uri: row.get(11)?, + album_uri: row.get(12)?, + created_at: row.get(13)?, + }) + }, + )?; let scrobbles: Result, _> = scrobbles.collect(); Ok(HttpResponse::Ok().json(scrobbles?)) - }, + } None => { let scrobbles = stmt.query_map([limit, offset], |row| { Ok(ScrobbleTrack { @@ -122,7 +129,11 @@ pub async fn get_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, conn: } } -pub async fn get_distinct_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_distinct_scrobbles( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; let pagination = params.pagination.unwrap_or_default(); @@ -130,7 +141,8 @@ pub async fn get_distinct_scrobbles(payload: &mut web::Payload, _req: &HttpReque let limit = pagination.take.unwrap_or(10); let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" WITH ranked_scrobbles AS ( SELECT s.id, @@ -177,7 +189,8 @@ pub async fn get_distinct_scrobbles(payload: &mut web::Payload, _req: &HttpReque ORDER BY created_at DESC OFFSET ? LIMIT ?; - "#)?; + "#, + )?; let scrobbles = stmt.query_map([limit, offset], |row| { Ok(ScrobbleTrack { @@ -201,4 +214,3 @@ pub async fn get_distinct_scrobbles(payload: &mut web::Payload, _req: &HttpReque let scrobbles: Result, _> = scrobbles.collect(); Ok(HttpResponse::Ok().json(scrobbles?)) } - diff --git a/crates/analytics/src/handlers/stats.rs b/crates/analytics/src/handlers/stats.rs index 8a07e631..95455a52 100644 --- a/crates/analytics/src/handlers/stats.rs +++ b/crates/analytics/src/handlers/stats.rs @@ -1,14 +1,25 @@ use std::sync::{Arc, Mutex}; +use crate::read_payload; use actix_web::{web, HttpRequest, HttpResponse}; -use analytics::types::{scrobble::{ScrobblesPerDay, ScrobblesPerMonth, ScrobblesPerYear}, stats::{GetAlbumScrobblesParams, GetArtistScrobblesParams, GetScrobblesPerDayParams, GetScrobblesPerMonthParams, GetScrobblesPerYearParams, GetStatsParams, GetTrackScrobblesParams}}; -use duckdb::Connection; +use analytics::types::{ + scrobble::{ScrobblesPerDay, ScrobblesPerMonth, ScrobblesPerYear}, + stats::{ + GetAlbumScrobblesParams, GetArtistScrobblesParams, GetScrobblesPerDayParams, + GetScrobblesPerMonthParams, GetScrobblesPerYearParams, GetStatsParams, + GetTrackScrobblesParams, + }, +}; use anyhow::Error; +use duckdb::Connection; use serde_json::json; use tokio_stream::StreamExt; -use crate::read_payload; -pub async fn get_stats(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_stats( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; @@ -17,7 +28,8 @@ pub async fn get_stats(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc let mut stmt = conn.prepare("SELECT COUNT(*) FROM scrobbles s LEFT JOIN users u ON s.user_id = u.id WHERE u.did = ? OR u.handle = ?")?; let scrobbles: i64 = stmt.query_row([¶ms.user_did, ¶ms.user_did], |row| row.get(0))?; - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT COUNT(*) FROM ( SELECT s.artist_id AS id, @@ -38,11 +50,13 @@ pub async fn get_stats(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc GROUP BY s.artist_id, ar.name, ar.uri, ar.picture, ar.sha256 ) - "#)?; + "#, + )?; let artists: i64 = stmt.query_row([¶ms.user_did, ¶ms.user_did], |row| row.get(0))?; let mut stmt = conn.prepare("SELECT COUNT(*) FROM loved_tracks LEFT JOIN users u ON loved_tracks.user_id = u.id WHERE u.did = ? OR u.handle = ?")?; - let loved_tracks: i64 = stmt.query_row([¶ms.user_did, ¶ms.user_did], |row| row.get(0))?; + let loved_tracks: i64 = + stmt.query_row([¶ms.user_did, ¶ms.user_did], |row| row.get(0))?; let mut stmt = conn.prepare(r#"SELECT COUNT(*) FROM ( SELECT @@ -83,17 +97,26 @@ pub async fn get_stats(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc }))) } -pub async fn get_scrobbles_per_day(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_scrobbles_per_day( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let start = params.start.unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); - let end = params.end.unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); + let start = params + .start + .unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); + let end = params + .end + .unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); let did = params.user_did; let conn = conn.lock().unwrap(); match did { Some(did) => { - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT date_trunc('day', created_at) AS date, COUNT(track_id) AS count @@ -107,7 +130,8 @@ pub async fn get_scrobbles_per_day(payload: &mut web::Payload, _req: &HttpReques date_trunc('day', created_at) ORDER BY date; - "#)?; + "#, + )?; let scrobbles = stmt.query_map([&did, &did, &start, &end], |row| { Ok(ScrobblesPerDay { date: row.get(0)?, @@ -116,9 +140,10 @@ pub async fn get_scrobbles_per_day(payload: &mut web::Payload, _req: &HttpReques })?; let scrobbles: Result, _> = scrobbles.collect(); Ok(HttpResponse::Ok().json(scrobbles?)) - }, + } None => { - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT date_trunc('day', created_at) AS date, COUNT(track_id) AS count @@ -130,7 +155,8 @@ pub async fn get_scrobbles_per_day(payload: &mut web::Payload, _req: &HttpReques date_trunc('day', created_at) ORDER BY date; - "#)?; + "#, + )?; let scrobbles = stmt.query_map([start, end], |row| { Ok(ScrobblesPerDay { date: row.get(0)?, @@ -143,17 +169,26 @@ pub async fn get_scrobbles_per_day(payload: &mut web::Payload, _req: &HttpReques } } -pub async fn get_scrobbles_per_month(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_scrobbles_per_month( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let start = params.start.unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); - let end = params.end.unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); + let start = params + .start + .unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); + let end = params + .end + .unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); let did = params.user_did; let conn = conn.lock().unwrap(); match did { Some(did) => { - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT EXTRACT(YEAR FROM created_at) || '-' || LPAD(EXTRACT(MONTH FROM created_at)::VARCHAR, 2, '0') AS year_month, @@ -169,7 +204,8 @@ pub async fn get_scrobbles_per_month(payload: &mut web::Payload, _req: &HttpRequ EXTRACT(MONTH FROM created_at) ORDER BY year_month; - "#)?; + "#, + )?; let scrobbles = stmt.query_map([&did, &did, &start, &end], |row| { Ok(ScrobblesPerMonth { year_month: row.get(0)?, @@ -178,9 +214,10 @@ pub async fn get_scrobbles_per_month(payload: &mut web::Payload, _req: &HttpRequ })?; let scrobbles: Result, _> = scrobbles.collect(); Ok(HttpResponse::Ok().json(scrobbles?)) - }, + } None => { - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT EXTRACT(YEAR FROM created_at) || '-' || LPAD(EXTRACT(MONTH FROM created_at)::VARCHAR, 2, '0') AS year_month, @@ -194,7 +231,8 @@ pub async fn get_scrobbles_per_month(payload: &mut web::Payload, _req: &HttpRequ EXTRACT(MONTH FROM created_at) ORDER BY year_month; - "#)?; + "#, + )?; let scrobbles = stmt.query_map([start, end], |row| { Ok(ScrobblesPerMonth { year_month: row.get(0)?, @@ -207,17 +245,26 @@ pub async fn get_scrobbles_per_month(payload: &mut web::Payload, _req: &HttpRequ } } -pub async fn get_scrobbles_per_year(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_scrobbles_per_year( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let start = params.start.unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); - let end = params.end.unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); + let start = params + .start + .unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); + let end = params + .end + .unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); let did = params.user_did; let conn = conn.lock().unwrap(); match did { Some(did) => { - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT EXTRACT(YEAR FROM created_at) AS year, COUNT(*) AS count @@ -231,7 +278,8 @@ pub async fn get_scrobbles_per_year(payload: &mut web::Payload, _req: &HttpReque EXTRACT(YEAR FROM created_at) ORDER BY year; - "#)?; + "#, + )?; let scrobbles = stmt.query_map([&did, &did, &start, &end], |row| { Ok(ScrobblesPerYear { year: row.get(0)?, @@ -240,9 +288,10 @@ pub async fn get_scrobbles_per_year(payload: &mut web::Payload, _req: &HttpReque })?; let scrobbles: Result, _> = scrobbles.collect(); Ok(HttpResponse::Ok().json(scrobbles?)) - }, + } None => { - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT EXTRACT(YEAR FROM created_at) AS year, COUNT(*) AS count @@ -254,7 +303,8 @@ pub async fn get_scrobbles_per_year(payload: &mut web::Payload, _req: &HttpReque EXTRACT(YEAR FROM created_at) ORDER BY year; - "#)?; + "#, + )?; let scrobbles = stmt.query_map([start, end], |row| { Ok(ScrobblesPerYear { year: row.get(0)?, @@ -267,13 +317,22 @@ pub async fn get_scrobbles_per_year(payload: &mut web::Payload, _req: &HttpReque } } -pub async fn get_album_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_album_scrobbles( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let start = params.start.unwrap_or(GetAlbumScrobblesParams::default().start.unwrap()); - let end = params.end.unwrap_or(GetAlbumScrobblesParams::default().end.unwrap()); + let start = params + .start + .unwrap_or(GetAlbumScrobblesParams::default().start.unwrap()); + let end = params + .end + .unwrap_or(GetAlbumScrobblesParams::default().end.unwrap()); let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT date_trunc('day', s.created_at) AS date, COUNT(s.album_id) AS count @@ -287,13 +346,9 @@ pub async fn get_album_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, date_trunc('day', s.created_at) ORDER BY date; - "#)?; - let scrobbles = stmt.query_map([ - ¶ms.album_id, - ¶ms.album_id, - &start, - &end - ], |row| { + "#, + )?; + let scrobbles = stmt.query_map([¶ms.album_id, ¶ms.album_id, &start, &end], |row| { Ok(ScrobblesPerDay { date: row.get(0)?, count: row.get(1)?, @@ -303,14 +358,23 @@ pub async fn get_album_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, Ok(HttpResponse::Ok().json(scrobbles?)) } -pub async fn get_artist_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_artist_scrobbles( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let start = params.start.unwrap_or(GetArtistScrobblesParams::default().start.unwrap()); - let end = params.end.unwrap_or(GetArtistScrobblesParams::default().end.unwrap()); + let start = params + .start + .unwrap_or(GetArtistScrobblesParams::default().start.unwrap()); + let end = params + .end + .unwrap_or(GetArtistScrobblesParams::default().end.unwrap()); let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT date_trunc('day', s.created_at) AS date, COUNT(s.artist_id) AS count @@ -324,32 +388,40 @@ pub async fn get_artist_scrobbles(payload: &mut web::Payload, _req: &HttpRequest date_trunc('day', s.created_at) ORDER BY date; - "#)?; + "#, + )?; - let scrobbles = stmt.query_map([ - ¶ms.artist_id, - ¶ms.artist_id, - &start, - &end - ], |row| { - Ok(ScrobblesPerDay { - date: row.get(0)?, - count: row.get(1)?, - }) - })?; + let scrobbles = stmt.query_map( + [¶ms.artist_id, ¶ms.artist_id, &start, &end], + |row| { + Ok(ScrobblesPerDay { + date: row.get(0)?, + count: row.get(1)?, + }) + }, + )?; let scrobbles: Result, _> = scrobbles.collect(); Ok(HttpResponse::Ok().json(scrobbles?)) } -pub async fn get_track_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_track_scrobbles( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let start = params.start.unwrap_or(GetTrackScrobblesParams::default().start.unwrap()); - let end = params.end.unwrap_or(GetTrackScrobblesParams::default().end.unwrap()); + let start = params + .start + .unwrap_or(GetTrackScrobblesParams::default().start.unwrap()); + let end = params + .end + .unwrap_or(GetTrackScrobblesParams::default().end.unwrap()); let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT date_trunc('day', s.created_at) AS date, COUNT(s.track_id) AS count @@ -363,14 +435,10 @@ pub async fn get_track_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, date_trunc('day', s.created_at) ORDER BY date; - "#)?; + "#, + )?; - let scrobbles = stmt.query_map([ - ¶ms.track_id, - ¶ms.track_id, - &start, - &end - ], |row| { + let scrobbles = stmt.query_map([¶ms.track_id, ¶ms.track_id, &start, &end], |row| { Ok(ScrobblesPerDay { date: row.get(0)?, count: row.get(1)?, diff --git a/crates/analytics/src/handlers/tracks.rs b/crates/analytics/src/handlers/tracks.rs index 44dae0d7..4524925f 100644 --- a/crates/analytics/src/handlers/tracks.rs +++ b/crates/analytics/src/handlers/tracks.rs @@ -2,13 +2,16 @@ use std::sync::{Arc, Mutex}; use actix_web::{web, HttpRequest, HttpResponse}; use analytics::types::track::{GetLovedTracksParams, GetTopTracksParams, GetTracksParams, Track}; -use duckdb::Connection; use anyhow::Error; +use duckdb::Connection; use tokio_stream::StreamExt; use crate::read_payload; -pub async fn get_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc> +pub async fn get_tracks( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, ) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; @@ -57,39 +60,42 @@ pub async fn get_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Ar OFFSET ? LIMIT ?; "#)?; - let tracks = stmt.query_map([&did, &did, &limit.to_string(), &offset.to_string()], |row| { - Ok(Track { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - album_artist: row.get(3)?, - album_art: row.get(4)?, - album: row.get(5)?, - track_number: row.get(6)?, - duration: row.get(7)?, - mb_id: row.get(8)?, - youtube_link: row.get(9)?, - spotify_link: row.get(10)?, - tidal_link: row.get(11)?, - apple_music_link: row.get(12)?, - sha256: row.get(13)?, - composer: row.get(14)?, - genre: row.get(15)?, - disc_number: row.get(16)?, - label: row.get(17)?, - uri: row.get(18)?, - copyright_message: row.get(19)?, - artist_uri: row.get(20)?, - album_uri: row.get(21)?, - created_at: row.get(22)?, - play_count: row.get(23)?, - unique_listeners: row.get(24)?, - ..Default::default() - }) - })?; + let tracks = stmt.query_map( + [&did, &did, &limit.to_string(), &offset.to_string()], + |row| { + Ok(Track { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + album_artist: row.get(3)?, + album_art: row.get(4)?, + album: row.get(5)?, + track_number: row.get(6)?, + duration: row.get(7)?, + mb_id: row.get(8)?, + youtube_link: row.get(9)?, + spotify_link: row.get(10)?, + tidal_link: row.get(11)?, + apple_music_link: row.get(12)?, + sha256: row.get(13)?, + composer: row.get(14)?, + genre: row.get(15)?, + disc_number: row.get(16)?, + label: row.get(17)?, + uri: row.get(18)?, + copyright_message: row.get(19)?, + artist_uri: row.get(20)?, + album_uri: row.get(21)?, + created_at: row.get(22)?, + play_count: row.get(23)?, + unique_listeners: row.get(24)?, + ..Default::default() + }) + }, + )?; let tracks: Result, _> = tracks.collect(); Ok(HttpResponse::Ok().json(tracks?)) - }, + } None => { let mut stmt = conn.prepare(r#" SELECT @@ -161,7 +167,11 @@ pub async fn get_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Ar } } -pub async fn get_loved_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_loved_tracks( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; let pagination = params.pagination.unwrap_or_default(); @@ -170,7 +180,8 @@ pub async fn get_loved_tracks(payload: &mut web::Payload, _req: &HttpRequest, co let did = params.user_did; let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT t.id, t.title, @@ -202,40 +213,48 @@ pub async fn get_loved_tracks(payload: &mut web::Payload, _req: &HttpRequest, co ORDER BY l.created_at DESC OFFSET ? LIMIT ?; - "#)?; - let loved_tracks = stmt.query_map([&did, &did, &limit.to_string(), &offset.to_string()], |row| { - Ok(Track { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - album: row.get(3)?, - album_artist: row.get(4)?, - album_art: row.get(5)?, - album_uri: row.get(6)?, - artist_uri: row.get(7)?, - composer: row.get(8)?, - copyright_message: row.get(9)?, - disc_number: row.get(10)?, - duration: row.get(11)?, - track_number: row.get(12)?, - label: row.get(13)?, - spotify_link: row.get(14)?, - tidal_link: row.get(15)?, - youtube_link: row.get(16)?, - apple_music_link: row.get(17)?, - sha256: row.get(18)?, - uri: row.get(19)?, - handle: row.get(20)?, - did: row.get(21)?, - created_at: row.get(22)?, - ..Default::default() - }) - })?; + "#, + )?; + let loved_tracks = stmt.query_map( + [&did, &did, &limit.to_string(), &offset.to_string()], + |row| { + Ok(Track { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + album: row.get(3)?, + album_artist: row.get(4)?, + album_art: row.get(5)?, + album_uri: row.get(6)?, + artist_uri: row.get(7)?, + composer: row.get(8)?, + copyright_message: row.get(9)?, + disc_number: row.get(10)?, + duration: row.get(11)?, + track_number: row.get(12)?, + label: row.get(13)?, + spotify_link: row.get(14)?, + tidal_link: row.get(15)?, + youtube_link: row.get(16)?, + apple_music_link: row.get(17)?, + sha256: row.get(18)?, + uri: row.get(19)?, + handle: row.get(20)?, + did: row.get(21)?, + created_at: row.get(22)?, + ..Default::default() + }) + }, + )?; let loved_tracks: Result, _> = loved_tracks.collect(); Ok(HttpResponse::Ok().json(loved_tracks?)) } -pub async fn get_top_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc>) -> Result { +pub async fn get_top_tracks( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; let pagination = params.pagination.unwrap_or_default(); @@ -275,30 +294,33 @@ pub async fn get_top_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn OFFSET ? LIMIT ?; "#)?; - let top_tracks = stmt.query_map([&did, &did, &limit.to_string(), &offset.to_string()], |row| { - Ok(Track { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - album_artist: row.get(3)?, - album: row.get(4)?, - uri: row.get(5)?, - album_art: row.get(6)?, - duration: row.get(7)?, - disc_number: row.get(8)?, - track_number: row.get(9)?, - artist_uri: row.get(10)?, - album_uri: row.get(11)?, - sha256: row.get(12)?, - created_at: row.get(13)?, - play_count: row.get(14)?, - unique_listeners: row.get(15)?, - ..Default::default() - }) - })?; + let top_tracks = stmt.query_map( + [&did, &did, &limit.to_string(), &offset.to_string()], + |row| { + Ok(Track { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + album_artist: row.get(3)?, + album: row.get(4)?, + uri: row.get(5)?, + album_art: row.get(6)?, + duration: row.get(7)?, + disc_number: row.get(8)?, + track_number: row.get(9)?, + artist_uri: row.get(10)?, + album_uri: row.get(11)?, + sha256: row.get(12)?, + created_at: row.get(13)?, + play_count: row.get(14)?, + unique_listeners: row.get(15)?, + ..Default::default() + }) + }, + )?; let top_tracks: Result, _> = top_tracks.collect(); Ok(HttpResponse::Ok().json(top_tracks?)) - }, + } None => { let mut stmt = conn.prepare(r#" SELECT @@ -353,4 +375,4 @@ pub async fn get_top_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn Ok(HttpResponse::Ok().json(top_tracks?)) } } -} \ No newline at end of file +} diff --git a/crates/analytics/src/main.rs b/crates/analytics/src/main.rs index b8744652..61af8c51 100644 --- a/crates/analytics/src/main.rs +++ b/crates/analytics/src/main.rs @@ -1,39 +1,38 @@ use core::create_tables; -use std::{env, sync::{Arc, Mutex}}; +use std::{ + env, + sync::{Arc, Mutex}, +}; use clap::Command; use cmd::{serve::serve, sync::sync}; +use dotenv::dotenv; use duckdb::Connection; use sqlx::postgres::PgPoolOptions; -use dotenv::dotenv; -pub mod types; -pub mod xata; pub mod cmd; pub mod core; pub mod handlers; pub mod subscriber; +pub mod types; +pub mod xata; fn cli() -> Command { Command::new("analytics") .version(env!("CARGO_PKG_VERSION")) .about("Rocksky Analytics CLI built with Rust and DuckDB") - .subcommand( - Command::new("sync") - .about("Sync data from Xata to DuckDB") - ) - .subcommand( - Command::new("serve") - .about("Serve the Rocksky Analytics API") - ) + .subcommand(Command::new("sync").about("Sync data from Xata to DuckDB")) + .subcommand(Command::new("serve").about("Serve the Rocksky Analytics API")) } #[tokio::main] async fn main() -> Result<(), Box> { dotenv().ok(); - - let pool= PgPoolOptions::new().max_connections(5).connect(&env::var("XATA_POSTGRES_URL")?).await?; + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&env::var("XATA_POSTGRES_URL")?) + .await?; let conn = Connection::open("./rocksky-analytics.ddb")?; create_tables(&conn).await?; diff --git a/crates/analytics/src/subscriber/mod.rs b/crates/analytics/src/subscriber/mod.rs index cf0b3cc3..5f803335 100644 --- a/crates/analytics/src/subscriber/mod.rs +++ b/crates/analytics/src/subscriber/mod.rs @@ -1,8 +1,12 @@ -use std::{env, sync::{Arc, Mutex}, thread}; use anyhow::Error; use async_nats::{connect, Client}; use duckdb::{params, Connection}; use owo_colors::OwoColorize; +use std::{ + env, + sync::{Arc, Mutex}, + thread, +}; use tokio_stream::StreamExt; use types::{LikePayload, NewTrackPayload, ScrobblePayload, UnlikePayload, UserPayload}; @@ -25,175 +29,183 @@ pub async fn subscribe(conn: Arc>) -> Result<(), Error> { } pub fn on_scrobble(nc: Arc>, conn: Arc>) { - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let conn = conn.clone(); - let nc = nc.clone(); - rt.block_on(async { - let nc = nc.lock().unwrap(); - let mut sub = nc.subscribe("rocksky.scrobble".to_string()).await?; - drop(nc); - - while let Some(msg) = sub.next().await { - let data = String::from_utf8(msg.payload.to_vec()).unwrap(); - match serde_json::from_str::(&data) { - Ok(payload) => { - match save_scrobble(conn.clone(), payload.clone()).await { - Ok(_) => println!("Scrobble saved successfully for {}", payload.scrobble.uri.cyan()), - Err(e) => eprintln!("Error saving scrobble: {}", e), + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let conn = conn.clone(); + let nc = nc.clone(); + rt.block_on(async { + let nc = nc.lock().unwrap(); + let mut sub = nc.subscribe("rocksky.scrobble".to_string()).await?; + drop(nc); + + while let Some(msg) = sub.next().await { + let data = String::from_utf8(msg.payload.to_vec()).unwrap(); + match serde_json::from_str::(&data) { + Ok(payload) => match save_scrobble(conn.clone(), payload.clone()).await { + Ok(_) => println!( + "Scrobble saved successfully for {}", + payload.scrobble.uri.cyan() + ), + Err(e) => eprintln!("Error saving scrobble: {}", e), + }, + Err(e) => { + eprintln!("Error parsing payload: {}", e); + println!("{}", data); + } + } } - }, - Err(e) => { - eprintln!("Error parsing payload: {}", e); - println!("{}", data); - } - } - } - Ok::<(), Error>(()) - })?; + Ok::<(), Error>(()) + })?; - Ok::<(), Error>(()) - }); + Ok::<(), Error>(()) + }); } pub fn on_new_track(nc: Arc>, conn: Arc>) { - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let conn = conn.clone(); - let nc = nc.clone(); - rt.block_on(async { - let nc = nc.lock().unwrap(); - let mut sub = nc.subscribe("rocksky.track".to_string()).await?; - drop(nc); - - while let Some(msg) = sub.next().await { - let data = String::from_utf8(msg.payload.to_vec()).unwrap(); - match serde_json::from_str::(&data) { - Ok(payload) => { - match save_track(conn.clone(), payload.clone()).await { - Ok(_) => println!("Song saved successfully for {}", payload.track.title.cyan()), - Err(e) => eprintln!("Error saving song: {}", e), + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let conn = conn.clone(); + let nc = nc.clone(); + rt.block_on(async { + let nc = nc.lock().unwrap(); + let mut sub = nc.subscribe("rocksky.track".to_string()).await?; + drop(nc); + + while let Some(msg) = sub.next().await { + let data = String::from_utf8(msg.payload.to_vec()).unwrap(); + match serde_json::from_str::(&data) { + Ok(payload) => match save_track(conn.clone(), payload.clone()).await { + Ok(_) => { + println!("Song saved successfully for {}", payload.track.title.cyan()) + } + Err(e) => eprintln!("Error saving song: {}", e), + }, + Err(e) => { + eprintln!("Error parsing payload: {}", e); + println!("{}", data); + } + } } - }, - Err(e) => { - eprintln!("Error parsing payload: {}", e); - println!("{}", data); - } - } - } - Ok::<(), Error>(()) - })?; + Ok::<(), Error>(()) + })?; - Ok::<(), Error>(()) - }); + Ok::<(), Error>(()) + }); } pub fn on_like(nc: Arc>, conn: Arc>) { - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let conn = conn.clone(); - let nc = nc.clone(); - rt.block_on(async { - let nc = nc.lock().unwrap(); - let mut sub = nc.subscribe("rocksky.like".to_string()).await?; - drop(nc); - - while let Some(msg) = sub.next().await { - let data = String::from_utf8(msg.payload.to_vec()).unwrap(); - match serde_json::from_str::(&data) { - Ok(payload) => { - match like(conn.clone(), payload.clone()).await { - Ok(_) => println!("Like saved successfully for {}", payload.track_id.xata_id.cyan()), - Err(e) => eprintln!("Error saving like: {}", e), + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let conn = conn.clone(); + let nc = nc.clone(); + rt.block_on(async { + let nc = nc.lock().unwrap(); + let mut sub = nc.subscribe("rocksky.like".to_string()).await?; + drop(nc); + + while let Some(msg) = sub.next().await { + let data = String::from_utf8(msg.payload.to_vec()).unwrap(); + match serde_json::from_str::(&data) { + Ok(payload) => match like(conn.clone(), payload.clone()).await { + Ok(_) => println!( + "Like saved successfully for {}", + payload.track_id.xata_id.cyan() + ), + Err(e) => eprintln!("Error saving like: {}", e), + }, + Err(e) => { + eprintln!("Error parsing payload: {}", e); + println!("{}", data); + } + } } - }, - Err(e) => { - eprintln!("Error parsing payload: {}", e); - println!("{}", data); - } - } - } - Ok::<(), Error>(()) - })?; + Ok::<(), Error>(()) + })?; - Ok::<(), Error>(()) - }); + Ok::<(), Error>(()) + }); } pub fn on_unlike(nc: Arc>, conn: Arc>) { - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let conn = conn.clone(); - let nc = nc.clone(); - rt.block_on(async { - let nc = nc.lock().unwrap(); - let mut sub = nc.subscribe("rocksky.unlike".to_string()).await?; - drop(nc); - - while let Some(msg) = sub.next().await { - let data = String::from_utf8(msg.payload.to_vec()).unwrap(); - match serde_json::from_str::(&data) { - Ok(payload) => { - match unlike(conn.clone(), payload.clone()).await { - Ok(_) => println!("Unlike saved successfully for {}", payload.track_id.xata_id.cyan()), - Err(e) => eprintln!("Error saving unlike: {}", e), + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let conn = conn.clone(); + let nc = nc.clone(); + rt.block_on(async { + let nc = nc.lock().unwrap(); + let mut sub = nc.subscribe("rocksky.unlike".to_string()).await?; + drop(nc); + + while let Some(msg) = sub.next().await { + let data = String::from_utf8(msg.payload.to_vec()).unwrap(); + match serde_json::from_str::(&data) { + Ok(payload) => match unlike(conn.clone(), payload.clone()).await { + Ok(_) => println!( + "Unlike saved successfully for {}", + payload.track_id.xata_id.cyan() + ), + Err(e) => eprintln!("Error saving unlike: {}", e), + }, + Err(e) => { + eprintln!("Error parsing payload: {}", e); + println!("{}", data); + } + } } - }, - Err(e) => { - eprintln!("Error parsing payload: {}", e); - println!("{}", data); - } - } - } - Ok::<(), Error>(()) - })?; + Ok::<(), Error>(()) + })?; - Ok::<(), Error>(()) - }); + Ok::<(), Error>(()) + }); } pub fn on_new_user(nc: Arc>, conn: Arc>) { - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let conn = conn.clone(); - let nc = nc.clone(); - rt.block_on(async { - let nc = nc.lock().unwrap(); - let mut sub = nc.subscribe("rocksky.user".to_string()).await?; - drop(nc); - - while let Some(msg) = sub.next().await { - let data = String::from_utf8(msg.payload.to_vec()).unwrap(); - match serde_json::from_str::(&data) { - Ok(payload) => { - match save_user(conn.clone(), payload.clone()).await { - Ok(_) => println!("User saved successfully for {}{}", "@".cyan(), payload.handle.cyan()), - Err(e) => eprintln!("Error saving user: {}", e), + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let conn = conn.clone(); + let nc = nc.clone(); + rt.block_on(async { + let nc = nc.lock().unwrap(); + let mut sub = nc.subscribe("rocksky.user".to_string()).await?; + drop(nc); + + while let Some(msg) = sub.next().await { + let data = String::from_utf8(msg.payload.to_vec()).unwrap(); + match serde_json::from_str::(&data) { + Ok(payload) => match save_user(conn.clone(), payload.clone()).await { + Ok(_) => println!( + "User saved successfully for {}{}", + "@".cyan(), + payload.handle.cyan() + ), + Err(e) => eprintln!("Error saving user: {}", e), + }, + Err(e) => { + eprintln!("Error parsing payload: {}", e); + println!("{}", data); + } + } } - }, - Err(e) => { - eprintln!("Error parsing payload: {}", e); - println!("{}", data); - } - } - } - Ok::<(), Error>(()) - })?; + Ok::<(), Error>(()) + })?; - Ok::<(), Error>(()) - }); + Ok::<(), Error>(()) + }); } -pub async fn save_scrobble(conn: Arc>, payload: ScrobblePayload) -> Result<(), Error> { +pub async fn save_scrobble( + conn: Arc>, + payload: ScrobblePayload, +) -> Result<(), Error> { let conn = conn.lock().unwrap(); match conn.execute( - "INSERT INTO artists ( + "INSERT INTO artists ( id, name, biography, @@ -222,33 +234,33 @@ pub async fn save_scrobble(conn: Arc>, payload: ScrobblePayloa ?, ? )", - params![ - payload.scrobble.artist_id.xata_id, - payload.scrobble.artist_id.name, - payload.scrobble.artist_id.biography, - payload.scrobble.artist_id.born, - payload.scrobble.artist_id.born_in, - payload.scrobble.artist_id.died, - payload.scrobble.artist_id.picture, - payload.scrobble.artist_id.sha256, - payload.scrobble.artist_id.spotify_link, - payload.scrobble.artist_id.tidal_link, - payload.scrobble.artist_id.youtube_link, - payload.scrobble.artist_id.apple_music_link, - payload.scrobble.artist_id.uri, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[artists] error: {}", e); - return Err(e.into()); - } - } - } + params![ + payload.scrobble.artist_id.xata_id, + payload.scrobble.artist_id.name, + payload.scrobble.artist_id.biography, + payload.scrobble.artist_id.born, + payload.scrobble.artist_id.born_in, + payload.scrobble.artist_id.died, + payload.scrobble.artist_id.picture, + payload.scrobble.artist_id.sha256, + payload.scrobble.artist_id.spotify_link, + payload.scrobble.artist_id.tidal_link, + payload.scrobble.artist_id.youtube_link, + payload.scrobble.artist_id.apple_music_link, + payload.scrobble.artist_id.uri, + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[artists] error: {}", e); + return Err(e.into()); + } + } + } match conn.execute( - "INSERT INTO albums ( + "INSERT INTO albums ( id, title, artist, @@ -277,33 +289,33 @@ pub async fn save_scrobble(conn: Arc>, payload: ScrobblePayloa ?, ? )", - params![ - payload.scrobble.album_id.xata_id, - payload.scrobble.album_id.title, - payload.scrobble.album_id.artist, - payload.scrobble.album_id.release_date, - payload.scrobble.album_id.album_art, - payload.scrobble.album_id.year, - payload.scrobble.album_id.spotify_link, - payload.scrobble.album_id.tidal_link, - payload.scrobble.album_id.youtube_link, - payload.scrobble.album_id.apple_music_link, - payload.scrobble.album_id.sha256, - payload.scrobble.album_id.uri, - payload.scrobble.album_id.artist_uri, - ], + params![ + payload.scrobble.album_id.xata_id, + payload.scrobble.album_id.title, + payload.scrobble.album_id.artist, + payload.scrobble.album_id.release_date, + payload.scrobble.album_id.album_art, + payload.scrobble.album_id.year, + payload.scrobble.album_id.spotify_link, + payload.scrobble.album_id.tidal_link, + payload.scrobble.album_id.youtube_link, + payload.scrobble.album_id.apple_music_link, + payload.scrobble.album_id.sha256, + payload.scrobble.album_id.uri, + payload.scrobble.album_id.artist_uri, + ], ) { Ok(_) => (), Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[albums] error: {}", e); - return Err(e.into()); - } - }, + if !e.to_string().contains("violates primary key constraint") { + println!("[albums] error: {}", e); + return Err(e.into()); + } + } } match conn.execute( - "INSERT INTO tracks ( + "INSERT INTO tracks ( id, title, artist, @@ -329,157 +341,157 @@ pub async fn save_scrobble(conn: Arc>, payload: ScrobblePayloa album_uri, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![ - payload.scrobble.track_id.xata_id, - payload.scrobble.track_id.title, - payload.scrobble.track_id.artist, - payload.scrobble.track_id.album_artist, - payload.scrobble.track_id.album_art, - payload.scrobble.track_id.album, - payload.scrobble.track_id.track_number, - payload.scrobble.track_id.duration, - payload.scrobble.track_id.mb_id, - payload.scrobble.track_id.youtube_link, - payload.scrobble.track_id.spotify_link, - payload.scrobble.track_id.tidal_link, - payload.scrobble.track_id.apple_music_link, - payload.scrobble.track_id.sha256, - payload.scrobble.track_id.lyrics, - payload.scrobble.track_id.composer, - payload.scrobble.track_id.genre, - payload.scrobble.track_id.disc_number, - payload.scrobble.track_id.copyright_message, - payload.scrobble.track_id.label, - payload.scrobble.track_id.uri, - payload.scrobble.track_id.artist_uri, - payload.scrobble.track_id.album_uri, - payload.scrobble.track_id.xata_createdat, - ], + params![ + payload.scrobble.track_id.xata_id, + payload.scrobble.track_id.title, + payload.scrobble.track_id.artist, + payload.scrobble.track_id.album_artist, + payload.scrobble.track_id.album_art, + payload.scrobble.track_id.album, + payload.scrobble.track_id.track_number, + payload.scrobble.track_id.duration, + payload.scrobble.track_id.mb_id, + payload.scrobble.track_id.youtube_link, + payload.scrobble.track_id.spotify_link, + payload.scrobble.track_id.tidal_link, + payload.scrobble.track_id.apple_music_link, + payload.scrobble.track_id.sha256, + payload.scrobble.track_id.lyrics, + payload.scrobble.track_id.composer, + payload.scrobble.track_id.genre, + payload.scrobble.track_id.disc_number, + payload.scrobble.track_id.copyright_message, + payload.scrobble.track_id.label, + payload.scrobble.track_id.uri, + payload.scrobble.track_id.artist_uri, + payload.scrobble.track_id.album_uri, + payload.scrobble.track_id.xata_createdat, + ], ) { Ok(_) => (), Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[tracks] error: {}", e); - return Err(e.into()); - } + if !e.to_string().contains("violates primary key constraint") { + println!("[tracks] error: {}", e); + return Err(e.into()); + } } } match conn.execute( - "INSERT INTO album_tracks ( + "INSERT INTO album_tracks ( id, album_id, track_id ) VALUES (?, ?, ?)", - params![ - payload.album_track.xata_id, - payload.album_track.album_id.xata_id, - payload.album_track.track_id.xata_id, - ], + params![ + payload.album_track.xata_id, + payload.album_track.album_id.xata_id, + payload.album_track.track_id.xata_id, + ], ) { Ok(_) => (), Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[album_tracks] error: {}", e); - return Err(e.into()); - } + if !e.to_string().contains("violates primary key constraint") { + println!("[album_tracks] error: {}", e); + return Err(e.into()); + } } } match conn.execute( - "INSERT INTO artist_tracks (id, artist_id, track_id, created_at) VALUES (?, ?, ?, ?)", - params![ - payload.artist_track.xata_id, - payload.artist_track.artist_id.xata_id, - payload.artist_track.track_id.xata_id, - payload.artist_track.xata_createdat, - ], + "INSERT INTO artist_tracks (id, artist_id, track_id, created_at) VALUES (?, ?, ?, ?)", + params![ + payload.artist_track.xata_id, + payload.artist_track.artist_id.xata_id, + payload.artist_track.track_id.xata_id, + payload.artist_track.xata_createdat, + ], ) { Ok(_) => (), Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[artist_tracks] error: {}", e); - return Err(e.into()); - } + if !e.to_string().contains("violates primary key constraint") { + println!("[artist_tracks] error: {}", e); + return Err(e.into()); + } } } match conn.execute( - "INSERT INTO artist_albums (id, artist_id, album_id, created_at) VALUES (?, ?, ?, ?)", - params![ + "INSERT INTO artist_albums (id, artist_id, album_id, created_at) VALUES (?, ?, ?, ?)", + params![ payload.artist_album.xata_id, payload.artist_album.artist_id.xata_id, payload.artist_album.album_id.xata_id, payload.artist_album.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[artist_albums] error: {}", e); - return Err(e.into()); + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[artist_albums] error: {}", e); + return Err(e.into()); + } } - } - } + } match conn.execute( - "INSERT INTO user_albums (id, user_id, album_id, created_at) VALUES (?, ?, ?, ?)", - params![ - payload.user_album.xata_id, - payload.user_album.user_id.xata_id, - payload.user_album.album_id.xata_id, - payload.user_album.xata_createdat, - ], + "INSERT INTO user_albums (id, user_id, album_id, created_at) VALUES (?, ?, ?, ?)", + params![ + payload.user_album.xata_id, + payload.user_album.user_id.xata_id, + payload.user_album.album_id.xata_id, + payload.user_album.xata_createdat, + ], ) { Ok(_) => (), Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[user_albums] error: {}", e); - return Err(e.into()); - } + if !e.to_string().contains("violates primary key constraint") { + println!("[user_albums] error: {}", e); + return Err(e.into()); + } } } match conn.execute( - "INSERT INTO user_artists (id, user_id, artist_id, created_at) VALUES (?, ?, ?, ?)", - params![ - payload.user_artist.xata_id, - payload.user_artist.user_id.xata_id, - payload.user_artist.artist_id.xata_id, - payload.user_artist.xata_createdat, - ], + "INSERT INTO user_artists (id, user_id, artist_id, created_at) VALUES (?, ?, ?, ?)", + params![ + payload.user_artist.xata_id, + payload.user_artist.user_id.xata_id, + payload.user_artist.artist_id.xata_id, + payload.user_artist.xata_createdat, + ], ) { Ok(_) => (), Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[user_artists] error: {}", e); - return Err(e.into()); - } + if !e.to_string().contains("violates primary key constraint") { + println!("[user_artists] error: {}", e); + return Err(e.into()); + } } } match conn.execute( - "INSERT INTO user_tracks (id, user_id, track_id, created_at) VALUES (?, ?, ?, ?)", - params![ - payload.user_track.xata_id, - payload.user_track.user_id.xata_id, - payload.user_track.track_id.xata_id, - payload.user_track.xata_createdat, - ], + "INSERT INTO user_tracks (id, user_id, track_id, created_at) VALUES (?, ?, ?, ?)", + params![ + payload.user_track.xata_id, + payload.user_track.user_id.xata_id, + payload.user_track.track_id.xata_id, + payload.user_track.xata_createdat, + ], ) { Ok(_) => (), Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[user_tracks] error: {}", e); - return Err(e.into()); - } + if !e.to_string().contains("violates primary key constraint") { + println!("[user_tracks] error: {}", e); + return Err(e.into()); + } } } match conn.execute( - "INSERT INTO scrobbles ( + "INSERT INTO scrobbles ( id, user_id, track_id, @@ -496,33 +508,36 @@ pub async fn save_scrobble(conn: Arc>, payload: ScrobblePayloa ?, ? )", - params![ - payload.scrobble.xata_id, - payload.scrobble.user_id.xata_id, - payload.scrobble.track_id.xata_id, - payload.scrobble.album_id.xata_id, - payload.scrobble.artist_id.xata_id, - payload.scrobble.uri, - payload.scrobble.timestamp, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[scrobbles] error: {}", e); - return Err(e.into()); - } - } - } + params![ + payload.scrobble.xata_id, + payload.scrobble.user_id.xata_id, + payload.scrobble.track_id.xata_id, + payload.scrobble.album_id.xata_id, + payload.scrobble.artist_id.xata_id, + payload.scrobble.uri, + payload.scrobble.timestamp, + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[scrobbles] error: {}", e); + return Err(e.into()); + } + } + } Ok(()) } -pub async fn save_track(conn: Arc>, payload: NewTrackPayload) -> Result<(), Error> { - let conn = conn.lock().unwrap(); +pub async fn save_track( + conn: Arc>, + payload: NewTrackPayload, +) -> Result<(), Error> { + let conn = conn.lock().unwrap(); - match conn.execute( - "INSERT INTO tracks ( + match conn.execute( + "INSERT INTO tracks ( id, title, artist, @@ -548,107 +563,107 @@ pub async fn save_track(conn: Arc>, payload: NewTrackPayload) album_uri, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![ - payload.track.xata_id, - payload.track.title, - payload.track.artist, - payload.track.album_artist, - payload.track.album_art, - payload.track.album, - payload.track.track_number, - payload.track.duration, - payload.track.mb_id, - payload.track.youtube_link, - payload.track.spotify_link, - payload.track.tidal_link, - payload.track.apple_music_link, - payload.track.sha256, - payload.track.lyrics, - payload.track.composer, - payload.track.genre, - payload.track.disc_number, - payload.track.copyright_message, - payload.track.label, - payload.track.uri, - payload.track.artist_uri, - payload.track.album_uri, - payload.track.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[tracks] error: {}", e); - return Err(e.into()); + params![ + payload.track.xata_id, + payload.track.title, + payload.track.artist, + payload.track.album_artist, + payload.track.album_art, + payload.track.album, + payload.track.track_number, + payload.track.duration, + payload.track.mb_id, + payload.track.youtube_link, + payload.track.spotify_link, + payload.track.tidal_link, + payload.track.apple_music_link, + payload.track.sha256, + payload.track.lyrics, + payload.track.composer, + payload.track.genre, + payload.track.disc_number, + payload.track.copyright_message, + payload.track.label, + payload.track.uri, + payload.track.artist_uri, + payload.track.album_uri, + payload.track.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[tracks] error: {}", e); + return Err(e.into()); + } } - } - } + } - match conn.execute( - "INSERT INTO album_tracks ( + match conn.execute( + "INSERT INTO album_tracks ( id, album_id, track_id ) VALUES (?, ?, ?)", - params![ - payload.album_track.xata_id, - payload.album_track.album_id.xata_id, - payload.album_track.track_id.xata_id, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[album_tracks] error: {}", e); - return Err(e.into()); + params![ + payload.album_track.xata_id, + payload.album_track.album_id.xata_id, + payload.album_track.track_id.xata_id, + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[album_tracks] error: {}", e); + return Err(e.into()); + } } - } - } + } - match conn.execute( - "INSERT INTO artist_tracks (id, artist_id, track_id, created_at) VALUES (?, ?, ?, ?)", - params![ - payload.artist_track.xata_id, - payload.artist_track.artist_id.xata_id, - payload.artist_track.track_id.xata_id, - payload.artist_track.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[artist_tracks] error: {}", e); - return Err(e.into()); + match conn.execute( + "INSERT INTO artist_tracks (id, artist_id, track_id, created_at) VALUES (?, ?, ?, ?)", + params![ + payload.artist_track.xata_id, + payload.artist_track.artist_id.xata_id, + payload.artist_track.track_id.xata_id, + payload.artist_track.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[artist_tracks] error: {}", e); + return Err(e.into()); + } } - } - } + } - match conn.execute( - "INSERT INTO artist_albums (id, artist_id, album_id, created_at) VALUES (?, ?, ?, ?)", + match conn.execute( + "INSERT INTO artist_albums (id, artist_id, album_id, created_at) VALUES (?, ?, ?, ?)", params![ - payload.artist_album.xata_id, - payload.artist_album.artist_id.xata_id, - payload.artist_album.album_id.xata_id, - payload.artist_album.xata_createdat, + payload.artist_album.xata_id, + payload.artist_album.artist_id.xata_id, + payload.artist_album.album_id.xata_id, + payload.artist_album.xata_createdat, ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[artist_albums] error: {}", e); - return Err(e.into()); + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[artist_albums] error: {}", e); + return Err(e.into()); + } } - } - } - Ok(()) + } + Ok(()) } pub async fn like(conn: Arc>, payload: LikePayload) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - match conn.execute( - "INSERT INTO loved_tracks ( + let conn = conn.lock().unwrap(); + match conn.execute( + "INSERT INTO loved_tracks ( id, user_id, track_id, @@ -659,47 +674,44 @@ pub async fn like(conn: Arc>, payload: LikePayload) -> Result< ?, ? )", - params![ - payload.xata_id, - payload.user_id.xata_id, - payload.track_id.xata_id, - payload.xata_createdat, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[likes] error: {}", e); - return Err(e.into()); - } - } - } - Ok(()) + params![ + payload.xata_id, + payload.user_id.xata_id, + payload.track_id.xata_id, + payload.xata_createdat, + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[likes] error: {}", e); + return Err(e.into()); + } + } + } + Ok(()) } pub async fn unlike(conn: Arc>, payload: UnlikePayload) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - match conn.execute( - "DELETE FROM loved_tracks WHERE user_id = ? AND track_id = ?", - params![ - payload.user_id.xata_id, - payload.track_id.xata_id, - ], - ) { - Ok(_) => (), - Err(e) => { - println!("[unlikes] error: {}", e); - return Err(e.into()); - } - } - Ok(()) + let conn = conn.lock().unwrap(); + match conn.execute( + "DELETE FROM loved_tracks WHERE user_id = ? AND track_id = ?", + params![payload.user_id.xata_id, payload.track_id.xata_id,], + ) { + Ok(_) => (), + Err(e) => { + println!("[unlikes] error: {}", e); + return Err(e.into()); + } + } + Ok(()) } pub async fn save_user(conn: Arc>, payload: UserPayload) -> Result<(), Error> { - let conn = conn.lock().unwrap(); + let conn = conn.lock().unwrap(); - match conn.execute( - "INSERT INTO users ( + match conn.execute( + "INSERT INTO users ( id, avatar, did, @@ -717,23 +729,23 @@ pub async fn save_user(conn: Arc>, payload: UserPayload) -> Re did = EXCLUDED.did, display_name = EXCLUDED.display_name, handle = EXCLUDED.handle", - params![ - payload.xata_id, - payload.avatar, - payload.did, - payload.display_name, - payload.handle, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[users] error: {}", e); - return Err(e.into()); - } - } - } - Ok(()) + params![ + payload.xata_id, + payload.avatar, + payload.did, + payload.display_name, + payload.handle, + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[users] error: {}", e); + return Err(e.into()); + } + } + } + Ok(()) } #[cfg(test)] @@ -742,8 +754,8 @@ mod tests { use super::types; #[test] - fn test_parse_scrobble() { - let data = r#" + fn test_parse_scrobble() { + let data = r#" { "scrobble": { "album_id": { @@ -907,14 +919,13 @@ mod tests { } "#; - match serde_json::from_str::(data) { - Err(e) => { - eprintln!("Error parsing payload: {}", e); - println!("{}", data); - }, - Ok(_) => { - } + match serde_json::from_str::(data) { + Err(e) => { + eprintln!("Error parsing payload: {}", e); + println!("{}", data); + } + Ok(_) => {} + } + assert!(true); } - assert!(true); - } -} \ No newline at end of file +} diff --git a/crates/connect/Cargo.toml b/crates/connect/Cargo.toml new file mode 100644 index 00000000..cbc08b52 --- /dev/null +++ b/crates/connect/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "connect" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +tungstenite = { version = "0.26.2", features = ["rustls"] } +tokio-tungstenite = { version = "0.26.2", features = [ + "tokio-rustls", + "rustls-tls-webpki-roots", +] } +futures-util = "0.3.31" +tokio-stream = "0.1.17" +tokio = { version = "1.45.1", features = ["full"] } +dirs = "6.0.0" +serde = { version = "1.0.217", features = ["derive"] } +serde_json = "1.0.139" +owo-colors = "4.2.1" +anyhow = "1.0.98" +async-trait = "0.1.88" +reqwest = { version = "0.12.15", features = [ + "rustls-tls", + "json", +], default-features = false } +jsonrpsee = { version = "0.25.1", features = ["client", "tokio"] } +http = "1.3.1" +base64 = "0.22.1" diff --git a/crates/connect/LICENSE b/crates/connect/LICENSE new file mode 100644 index 00000000..bb09768d --- /dev/null +++ b/crates/connect/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Tsiry Sandratraina + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/crates/connect/README.md b/crates/connect/README.md new file mode 100644 index 00000000..5e1d5e66 --- /dev/null +++ b/crates/connect/README.md @@ -0,0 +1,7 @@ +# Rocksky Connect 🔌 + +Remote playback control for any local player — inspired by "Spotify Connect". + +Rocksky Connect lets you link your local music player to the Rocksky Web UI, enabling seamless remote playback control from anywhere. It's like Spotify Connect, but designed to work with any player that integrates with Rocksky. + +🛠️ **Work in progress:** Rocksky Connect is still under active development — features and compatibility may change frequently. \ No newline at end of file diff --git a/crates/connect/src/main.rs b/crates/connect/src/main.rs new file mode 100644 index 00000000..6d9c580d --- /dev/null +++ b/crates/connect/src/main.rs @@ -0,0 +1,55 @@ +use std::thread; + +use owo_colors::OwoColorize; +use websocket::connect_to_rocksky_websocket; + +pub mod players; +pub mod websocket; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let home = dirs::home_dir().unwrap(); + let token_file = home.join(".rocksky").join("token.json"); + + if !token_file.exists() { + println!( + "Please run {} to authenticate with Rocksky before connecting to the WebSocket", + "`rocksky login`".magenta() + ); + return Ok(()); + } + + let token = std::fs::read_to_string(token_file)?; + let token: serde_json::Value = serde_json::from_str(&token)?; + let token = token + .get("token") + .and_then(|t| t.as_str()) + .ok_or("Token not found")? + .to_string(); + + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async move { + let delay = 3; + + loop { + match connect_to_rocksky_websocket(token.clone()).await { + Ok(_) => { + println!("WebSocket session ended cleanly"); + } + Err(e) => { + eprintln!("WebSocket session error: {}", e); + } + } + + println!("Reconnecting in {} seconds...", delay); + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; + } + }) + }); + + // Keep the main thread alive to allow the WebSocket to run + loop { + std::thread::park(); + } +} diff --git a/crates/connect/src/players/jellyfin.rs b/crates/connect/src/players/jellyfin.rs new file mode 100644 index 00000000..0a9865b7 --- /dev/null +++ b/crates/connect/src/players/jellyfin.rs @@ -0,0 +1,41 @@ +use super::Player; +use async_trait::async_trait; + +use anyhow::Error; +use tokio::sync::mpsc::Sender; +pub struct JellyfinPlayer {} + +pub fn new() -> JellyfinPlayer { + JellyfinPlayer {} +} + +#[async_trait] +impl Player for JellyfinPlayer { + async fn play(&self) -> Result<(), Error> { + Ok(()) + } + + async fn pause(&self) -> Result<(), Error> { + Ok(()) + } + + async fn next(&self) -> Result<(), Error> { + Ok(()) + } + + async fn previous(&self) -> Result<(), Error> { + Ok(()) + } + + async fn seek(&self, _position: u64) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_now_playing(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_status(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } +} diff --git a/crates/connect/src/players/kodi.rs b/crates/connect/src/players/kodi.rs new file mode 100644 index 00000000..8541f96b --- /dev/null +++ b/crates/connect/src/players/kodi.rs @@ -0,0 +1,246 @@ +use std::{env, time::Duration}; + +use super::Player; +use anyhow::Error; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use jsonrpsee::{ + core::{ + client::ClientT, + params::{ArrayParams, ObjectParams}, + }, + http_client::{HttpClient, HttpClientBuilder}, + rpc_params, +}; +use reqwest::header::HeaderMap; +use serde_json::{json, Value}; +use tokio::sync::mpsc::Sender; + +#[derive(Clone)] +pub struct KodiPlayer { + client: HttpClient, + player_id: usize, +} + +pub fn new() -> Result { + let user = env::var("KODI_USER")?; + let password = env::var("KODI_PASSWORD")?; + let mut headers = HeaderMap::new(); + headers.insert( + http::header::AUTHORIZATION, + format!( + "Basic {}", + STANDARD.encode(format!("{}:{}", user, password).as_bytes()) + ) + .parse() + .unwrap(), + ); + + let kodi_url = + env::var("KODI_URL").unwrap_or_else(|_| "http://localhost:8080/jsonrpc".to_string()); + + let client = HttpClientBuilder::default() + .set_headers(headers) + .build(kodi_url)?; + + Ok(KodiPlayer { + client, + player_id: 0, + }) +} + +impl KodiPlayer { + pub async fn get_properties(&self, properties: Vec<&str>) -> Result { + let mut params = ObjectParams::new(); + params.insert("properties", properties)?; + + let response = self + .client + .request::("Application.GetProperties", params) + .await?; + Ok(response) + } + + pub async fn get_active_players(&self) -> Result { + let response = self + .client + .request::("Application.GetActivePlayers", rpc_params![]) + .await?; + Ok(response) + } + + pub async fn set_player_id(&mut self, player_id: usize) -> Result { + self.player_id = player_id; + Ok(self.clone()) + } +} + +#[async_trait] +impl Player for KodiPlayer { + async fn play(&self) -> Result<(), Error> { + let mut params = ObjectParams::new(); + params.insert("playerid", self.player_id)?; + let _response = self + .client + .request::("Player.PlayPause", params) + .await?; + + Ok(()) + } + + async fn pause(&self) -> Result<(), Error> { + let mut params = ObjectParams::new(); + params.insert("playerid", self.player_id)?; + let _response = self + .client + .request::("Player.PlayPause", params) + .await?; + Ok(()) + } + + async fn next(&self) -> Result<(), Error> { + let mut params = ObjectParams::new(); + params.insert("playerid", self.player_id)?; + params.insert("to", "next")?; + let _response = self + .client + .request::("Player.GoTo", params) + .await?; + Ok(()) + } + + async fn previous(&self) -> Result<(), Error> { + let mut params = ObjectParams::new(); + params.insert("playerid", self.player_id)?; + params.insert("to", "previous")?; + let _response = self + .client + .request::("Player.GoTo", params) + .await?; + Ok(()) + } + + async fn seek(&self, position: u64) -> Result<(), Error> { + let mut params = ObjectParams::new(); + params.insert("playerid", self.player_id)?; + params.insert("value", position)?; + let _response = self + .client + .request::("Player.Seek", params) + .await?; + Ok(()) + } + + async fn broadcast_now_playing(&self, tx: Sender) -> Result<(), Error> { + loop { + let mut params = ObjectParams::new(); + params.insert("playerid", self.player_id)?; + params.insert( + "properties", + vec!["title", "artist", "album", "duration", "file"], + )?; + + let current_track = self + .client + .request::("Player.GetItem", params) + .await?; + + let mut params = ObjectParams::new(); + params.insert("playerid", self.player_id)?; + params.insert( + "properties", + vec!["time", "totaltime", "percentage", "speed"], + )?; + + let progress = self + .client + .request::("Player.GetProperties", params) + .await?; + + println!("{:#?}", progress); + + let hours = progress + .get("time") + .and_then(|time| time.get("hours")) + .and_then(Value::as_u64) + .unwrap_or(0); + let minutes = progress + .get("time") + .and_then(|time| time.get("minutes")) + .and_then(Value::as_u64) + .unwrap_or(0); + let seconds = progress + .get("time") + .and_then(|time| time.get("seconds")) + .and_then(Value::as_u64) + .unwrap_or(0); + let milliseconds = progress + .get("time") + .and_then(|time| time.get("milliseconds")) + .and_then(Value::as_u64) + .unwrap_or(0); + + tx.send( + json!({ + "type": "track", + "title": current_track + .get("item") + .and_then(|item| item.get("title")) + .and_then(Value::as_str) + .unwrap_or("Unknown Title"), + "artist": current_track + .get("item") + .and_then(|item| item.get("artist")) + .and_then(Value::as_array) + .map(|arr| arr.iter().map(Value::as_str).map(|x| x.unwrap()).collect::>().join(", ")) + .unwrap_or("Unknown Artist".into()), + // "album_artist": "", + "album": current_track + .get("item") + .and_then(|item| item.get("album")) + .and_then(Value::as_str) + .unwrap_or("Unknown Album"), + "length": current_track + .get("item") + .and_then(|item| item.get("duration")) + .and_then(Value::as_u64) + .unwrap_or(0) * 1000, // Convert to milliseconds + "elapsed": ((hours * 3600) + (minutes * 60) + seconds) * 1000 + milliseconds, + }) + .to_string(), + ).await?; + + tokio::time::sleep(Duration::from_secs(3)).await; + } + } + + async fn broadcast_status(&self, tx: Sender) -> Result<(), Error> { + loop { + let mut params = ObjectParams::new(); + params.insert("playerid", self.player_id).unwrap(); + params.insert("properties", vec!["speed"]).unwrap(); + + let response = self + .client + .request::("Player.GetProperties", params) + .await?; + + tx.send( + json!({ + "type": "status", + "status": match response.get("speed") { + Some(Value::Number(speed)) => match speed.as_i64() { + Some(0) => 2, + Some(_) => 1, + None => 2, + }, + _ => 2, + }, + }) + .to_string(), + ) + .await?; + tokio::time::sleep(Duration::from_secs(3)).await; + } + } +} diff --git a/crates/connect/src/players/mod.rs b/crates/connect/src/players/mod.rs new file mode 100644 index 00000000..004dd0eb --- /dev/null +++ b/crates/connect/src/players/mod.rs @@ -0,0 +1,49 @@ +use anyhow::Error; +use async_trait::async_trait; +use owo_colors::OwoColorize; +use tokio::sync::mpsc::Sender; + +pub mod jellyfin; +pub mod kodi; +pub mod mopidy; +pub mod mpd; +pub mod mpris; +pub mod vlc; + +pub const SUPPORTED_PLAYERS: [&str; 6] = ["jellyfin", "kodi", "mopidy", "mpd", "mpris", "vlc"]; + +#[async_trait] +pub trait Player { + async fn play(&self) -> Result<(), Error>; + async fn pause(&self) -> Result<(), Error>; + async fn next(&self) -> Result<(), Error>; + async fn previous(&self) -> Result<(), Error>; + async fn seek(&self, position: u64) -> Result<(), Error>; + async fn broadcast_now_playing(&self, tx: Sender) -> Result<(), Error>; + async fn broadcast_status(&self, tx: Sender) -> Result<(), Error>; +} + +pub fn get_current_player() -> Result, Error> { + let player_type = std::env::var("ROCKSKY_PLAYER"); + if player_type.is_err() { + return Err(Error::msg(format!( + "{} environment variable not set", + "ROCKSKY_PLAYER".green() + ))); + } + + let player_type = player_type.unwrap(); + + match player_type.as_str() { + "jellyfin" => Ok(Box::new(jellyfin::new())), + "kodi" => Ok(Box::new(kodi::new()?)), + "mopidy" => Ok(Box::new(mopidy::new())), + "mpd" => Ok(Box::new(mpd::new())), + "mpris" => Ok(Box::new(mpris::new())), + "vlc" => Ok(Box::new(vlc::new())), + _ => Err(Error::msg(format!( + "Unsupported player type: {}", + player_type.magenta() + ))), + } +} diff --git a/crates/connect/src/players/mopidy.rs b/crates/connect/src/players/mopidy.rs new file mode 100644 index 00000000..e485618d --- /dev/null +++ b/crates/connect/src/players/mopidy.rs @@ -0,0 +1,41 @@ +use super::Player; +use anyhow::Error; +use async_trait::async_trait; +use tokio::sync::mpsc::Sender; + +pub struct MopidyPlayer {} + +pub fn new() -> MopidyPlayer { + MopidyPlayer {} +} + +#[async_trait] +impl Player for MopidyPlayer { + async fn play(&self) -> Result<(), Error> { + Ok(()) + } + + async fn pause(&self) -> Result<(), Error> { + Ok(()) + } + + async fn next(&self) -> Result<(), Error> { + Ok(()) + } + + async fn previous(&self) -> Result<(), Error> { + Ok(()) + } + + async fn seek(&self, _position: u64) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_now_playing(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_status(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } +} diff --git a/crates/connect/src/players/mpd.rs b/crates/connect/src/players/mpd.rs new file mode 100644 index 00000000..b91967b2 --- /dev/null +++ b/crates/connect/src/players/mpd.rs @@ -0,0 +1,41 @@ +use super::Player; +use anyhow::Error; +use async_trait::async_trait; +use tokio::sync::mpsc::Sender; + +pub struct MpdPlayer {} + +pub fn new() -> MpdPlayer { + MpdPlayer {} +} + +#[async_trait] +impl Player for MpdPlayer { + async fn play(&self) -> Result<(), Error> { + Ok(()) + } + + async fn pause(&self) -> Result<(), Error> { + Ok(()) + } + + async fn next(&self) -> Result<(), Error> { + Ok(()) + } + + async fn previous(&self) -> Result<(), Error> { + Ok(()) + } + + async fn seek(&self, _position: u64) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_now_playing(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_status(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } +} diff --git a/crates/connect/src/players/mpris.rs b/crates/connect/src/players/mpris.rs new file mode 100644 index 00000000..95b1316e --- /dev/null +++ b/crates/connect/src/players/mpris.rs @@ -0,0 +1,41 @@ +use super::Player; +use anyhow::Error; +use async_trait::async_trait; +use tokio::sync::mpsc::Sender; + +pub struct MprisPlayer {} + +pub fn new() -> MprisPlayer { + MprisPlayer {} +} + +#[async_trait] +impl Player for MprisPlayer { + async fn play(&self) -> Result<(), Error> { + Ok(()) + } + + async fn pause(&self) -> Result<(), Error> { + Ok(()) + } + + async fn next(&self) -> Result<(), Error> { + Ok(()) + } + + async fn previous(&self) -> Result<(), Error> { + Ok(()) + } + + async fn seek(&self, _position: u64) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_now_playing(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_status(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } +} diff --git a/crates/connect/src/players/vlc.rs b/crates/connect/src/players/vlc.rs new file mode 100644 index 00000000..be43f3fe --- /dev/null +++ b/crates/connect/src/players/vlc.rs @@ -0,0 +1,41 @@ +use super::Player; +use anyhow::Error; +use async_trait::async_trait; +use tokio::sync::mpsc::Sender; + +pub struct VlcPlayer {} + +pub fn new() -> VlcPlayer { + VlcPlayer {} +} + +#[async_trait] +impl Player for VlcPlayer { + async fn play(&self) -> Result<(), Error> { + Ok(()) + } + + async fn pause(&self) -> Result<(), Error> { + Ok(()) + } + + async fn next(&self) -> Result<(), Error> { + Ok(()) + } + + async fn previous(&self) -> Result<(), Error> { + Ok(()) + } + + async fn seek(&self, _position: u64) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_now_playing(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } + + async fn broadcast_status(&self, _tx: Sender) -> Result<(), Error> { + Ok(()) + } +} diff --git a/crates/connect/src/websocket.rs b/crates/connect/src/websocket.rs new file mode 100644 index 00000000..5b5186d4 --- /dev/null +++ b/crates/connect/src/websocket.rs @@ -0,0 +1,130 @@ +use std::{env, sync::Arc}; + +use anyhow::Error; +use futures_util::{SinkExt, StreamExt}; +use owo_colors::OwoColorize; +use serde_json::{json, Value}; +use tokio::sync::Mutex; +use tokio_tungstenite::connect_async; + +use crate::players::{get_current_player, Player}; + +pub async fn connect_to_rocksky_websocket(token: String) -> Result<(), Error> { + let rocksky_ws = + env::var("ROCKSKY_WS").unwrap_or_else(|_| "wss://api.rocksky.app/ws".to_string()); + let (ws_stream, _) = connect_async(&rocksky_ws).await?; + println!("Connected to {}", rocksky_ws); + + let (mut write, mut read) = ws_stream.split(); + let device_id = Arc::new(Mutex::new(String::new())); + + write + .send( + json!({ + "type": "register", + "clientName": "Rockbox", + "token": token + }) + .to_string() + .into(), + ) + .await?; + + let (tx, mut rx) = tokio::sync::mpsc::channel::(32); + let tx_clone = tx.clone(); + + tokio::spawn(async move { + let player: Box = get_current_player().map_err(|err| { + println!("Error getting current player: {}", err); + err + })?; + player + .broadcast_now_playing(tx_clone) + .await + .unwrap_or_else(|err| eprintln!("Error broadcasting now playing: {}", err)); + Ok::<(), Error>(()) + }); + + tokio::spawn(async move { + let player: Box = get_current_player().map_err(|err| { + println!("Error getting current player: {}", err); + err + })?; + player + .broadcast_status(tx) + .await + .unwrap_or_else(|err| eprintln!("Error broadcasting status: {}", err)); + Ok::<(), Error>(()) + }); + + { + let device_id = Arc::clone(&device_id); + let token = token.clone(); + tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + println!("Sending message: {}", msg); + let id = device_id.lock().await.clone(); + if let Err(err) = write + .send( + json!({ + "type": "message", + "data": serde_json::from_str::(&msg).unwrap(), + "device_id": id, + "token": token + }) + .to_string() + .into(), + ) + .await + { + eprintln!("Send error: {}", err); + break; + } + } + }); + } + + while let Some(msg) = read.next().await { + let msg = match msg { + Ok(m) => m.to_string(), + Err(e) => { + eprintln!("Read error: {}", e); + break; + } + }; + + let msg: Value = serde_json::from_str(&msg)?; + if let Some(id) = msg["deviceId"].as_str() { + println!("Device ID: {}", id); + *device_id.lock().await = id.to_string(); + } + + if let Some("command") = msg["type"].as_str() { + if let Some(cmd) = msg["action"].as_str() { + println!("Received command: {}", cmd); + + let player: Box = get_current_player()?; + + if let Some("command") = msg["type"].as_str() { + if let Some(cmd) = msg["action"].as_str() { + match cmd { + "play" => player.play().await?, + "pause" => player.pause().await?, + "next" => player.next().await?, + "previous" => player.previous().await?, + "seek" => player.seek(msg["position"].as_u64().unwrap_or(0)).await?, + _ => { + eprintln!("Unknown command: {}", cmd.magenta()); + continue; + } + } + } else { + println!("No action specified in command message, ignoring."); + } + } + } + } + } + + Ok(()) +} diff --git a/crates/dropbox/src/client.rs b/crates/dropbox/src/client.rs index 41454d42..c381bf6a 100644 --- a/crates/dropbox/src/client.rs +++ b/crates/dropbox/src/client.rs @@ -5,109 +5,118 @@ use anyhow::Error; use reqwest::Client; use serde_json::json; -use crate::types::{file::{Entry, EntryList, TemporaryLink}, token::AccessToken}; +use crate::types::{ + file::{Entry, EntryList, TemporaryLink}, + token::AccessToken, +}; pub const BASE_URL: &str = "https://api.dropboxapi.com/2"; pub const CONTENT_URL: &str = "https://content.dropboxapi.com/2"; pub async fn get_access_token(refresh_token: &str) -> Result { - let client = Client::new(); - let res = client.post("https://api.dropboxapi.com/oauth2/token") - .header("Content-Type", "application/x-www-form-urlencoded") - .query(&[ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token), - ("client_id", &env::var("DROPBOX_CLIENT_ID")?), - ("client_secret", &env::var("DROPBOX_CLIENT_SECRET")?), - ]) - .send() - .await?; - - Ok(res.json::().await?) + let client = Client::new(); + let res = client + .post("https://api.dropboxapi.com/oauth2/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .query(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", &env::var("DROPBOX_CLIENT_ID")?), + ("client_secret", &env::var("DROPBOX_CLIENT_SECRET")?), + ]) + .send() + .await?; + + Ok(res.json::().await?) } pub struct DropboxClient { - pub access_token: String, + pub access_token: String, } impl DropboxClient { - pub async fn new(refresh_token: &str) -> Result { - let res = get_access_token(refresh_token).await?; - Ok(DropboxClient { - access_token: res.access_token, - }) - } - - pub async fn create_music_folder(&self) -> Result<(), Error> { - let client = Client::new(); - client.post(&format!("{}/files/create_folder_v2", BASE_URL)) - .bearer_auth(&self.access_token) - .json(&json!({ "path": "/Music" })) - .send() - .await?; - - Ok(()) - } - - pub async fn get_metadata(&self, path: &str) -> Result { - let client = Client::new(); - let res = client.post(&format!("{}/files/get_metadata", BASE_URL)) - .bearer_auth(&self.access_token) - .json(&json!({ "path": path })) - .send() - .await?; - - Ok(res.json::().await?) - } - - pub async fn get_files(&self, path: &str) -> Result { - let client = Client::new(); - let res = client.post(&format!("{}/files/list_folder", BASE_URL)) - .bearer_auth(&self.access_token) - .json(&json!({ - "path": path, - "recursive": false, - "include_media_info": true, - "include_deleted": false, - "include_has_explicit_shared_members": false, - "include_mounted_folders": true, - "include_non_downloadable_files": true, - })) - .send() - .await?; - - Ok(res.json::().await?) - } - - pub async fn download_file(&self, path: &str) -> Result { - let client = Client::new(); - let res = client.post(&format!("{}/files/download", CONTENT_URL)) - .bearer_auth(&self.access_token) - .header("Dropbox-API-Arg", &json!({ "path": path }).to_string()) - .send() - .await?; - - let mut actix_response = HttpResponse::Ok(); - - // Forward headers - for (key, value) in res.headers().iter() { - actix_response.append_header((key.as_str(), value.to_str().unwrap_or(""))); - } - - // Forward body - let body = res.bytes_stream(); - - Ok(actix_response.streaming(body)) - } - - pub async fn get_temporary_link(&self, path: &str) -> Result { - let client = Client::new(); - let res = client.post(&format!("{}/files/get_temporary_link", BASE_URL)) - .bearer_auth(&self.access_token) - .json(&json!({ "path": path })) - .send() - .await?; - - Ok(res.json::().await?) - } + pub async fn new(refresh_token: &str) -> Result { + let res = get_access_token(refresh_token).await?; + Ok(DropboxClient { + access_token: res.access_token, + }) + } + + pub async fn create_music_folder(&self) -> Result<(), Error> { + let client = Client::new(); + client + .post(&format!("{}/files/create_folder_v2", BASE_URL)) + .bearer_auth(&self.access_token) + .json(&json!({ "path": "/Music" })) + .send() + .await?; + + Ok(()) + } + + pub async fn get_metadata(&self, path: &str) -> Result { + let client = Client::new(); + let res = client + .post(&format!("{}/files/get_metadata", BASE_URL)) + .bearer_auth(&self.access_token) + .json(&json!({ "path": path })) + .send() + .await?; + + Ok(res.json::().await?) + } + + pub async fn get_files(&self, path: &str) -> Result { + let client = Client::new(); + let res = client + .post(&format!("{}/files/list_folder", BASE_URL)) + .bearer_auth(&self.access_token) + .json(&json!({ + "path": path, + "recursive": false, + "include_media_info": true, + "include_deleted": false, + "include_has_explicit_shared_members": false, + "include_mounted_folders": true, + "include_non_downloadable_files": true, + })) + .send() + .await?; + + Ok(res.json::().await?) + } + + pub async fn download_file(&self, path: &str) -> Result { + let client = Client::new(); + let res = client + .post(&format!("{}/files/download", CONTENT_URL)) + .bearer_auth(&self.access_token) + .header("Dropbox-API-Arg", &json!({ "path": path }).to_string()) + .send() + .await?; + + let mut actix_response = HttpResponse::Ok(); + + // Forward headers + for (key, value) in res.headers().iter() { + actix_response.append_header((key.as_str(), value.to_str().unwrap_or(""))); + } + + // Forward body + let body = res.bytes_stream(); + + Ok(actix_response.streaming(body)) + } + + pub async fn get_temporary_link(&self, path: &str) -> Result { + let client = Client::new(); + let res = client + .post(&format!("{}/files/get_temporary_link", BASE_URL)) + .bearer_auth(&self.access_token) + .json(&json!({ "path": path })) + .send() + .await?; + + Ok(res.json::().await?) + } } diff --git a/crates/dropbox/src/cmd/scan.rs b/crates/dropbox/src/cmd/scan.rs index 9c4a7488..3b754849 100644 --- a/crates/dropbox/src/cmd/scan.rs +++ b/crates/dropbox/src/cmd/scan.rs @@ -6,10 +6,13 @@ use sqlx::postgres::PgPoolOptions; use crate::scan::scan_dropbox; pub async fn scan() -> Result<(), Error> { - let pool = PgPoolOptions::new().max_connections(5).connect(&env::var("XATA_POSTGRES_URL")?).await?; - let conn = Arc::new(pool); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&env::var("XATA_POSTGRES_URL")?) + .await?; + let conn = Arc::new(pool); - scan_dropbox(conn).await?; + scan_dropbox(conn).await?; - Ok(()) + Ok(()) } diff --git a/crates/dropbox/src/cmd/serve.rs b/crates/dropbox/src/cmd/serve.rs index 35a52ba0..9053f43d 100644 --- a/crates/dropbox/src/cmd/serve.rs +++ b/crates/dropbox/src/cmd/serve.rs @@ -63,4 +63,3 @@ pub async fn serve() -> Result<(), Error> { Ok(()) } - diff --git a/crates/dropbox/src/handlers/files.rs b/crates/dropbox/src/handlers/files.rs index 814cb6cc..9878de70 100644 --- a/crates/dropbox/src/handlers/files.rs +++ b/crates/dropbox/src/handlers/files.rs @@ -6,144 +6,175 @@ use sqlx::{Pool, Postgres}; use tokio_stream::StreamExt; use crate::{ - client::DropboxClient, crypto::decrypt_aes_256_ctr, read_payload, repo::dropbox_token::find_dropbox_refresh_token, scan, types::file::{DownloadFileParams, GetFilesAtParams, GetFilesParams, ScanFolderParams} + client::DropboxClient, + crypto::decrypt_aes_256_ctr, + read_payload, + repo::dropbox_token::find_dropbox_refresh_token, + scan, + types::file::{DownloadFileParams, GetFilesAtParams, GetFilesParams, ScanFolderParams}, }; pub const MUSIC_DIR: &str = "/Music"; -pub async fn get_files(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; +pub async fn get_files( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let client = DropboxClient::new(&refresh_token).await?; - let entries = client.get_files(MUSIC_DIR).await?; + let client = DropboxClient::new(&refresh_token).await?; + let entries = client.get_files(MUSIC_DIR).await?; - Ok(HttpResponse::Ok().json(web::Json(entries))) + Ok(HttpResponse::Ok().json(web::Json(entries))) } +pub async fn create_music_folder( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; -pub async fn create_music_folder(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let client = DropboxClient::new(&refresh_token).await?; + client.create_music_folder().await?; - let client = DropboxClient::new(&refresh_token).await?; - client.create_music_folder().await?; - - Ok(HttpResponse::Ok().finish()) + Ok(HttpResponse::Ok().finish()) } -pub async fn get_files_at(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; +pub async fn get_files_at( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let client = DropboxClient::new(&refresh_token).await?; - let entries = client.get_files(¶ms.path).await?; + let client = DropboxClient::new(&refresh_token).await?; + let entries = client.get_files(¶ms.path).await?; - Ok(HttpResponse::Ok().json(web::Json(entries))) + Ok(HttpResponse::Ok().json(web::Json(entries))) } -pub async fn download_file(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; - - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } - - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - - let client = DropboxClient::new(&refresh_token).await?; - client.download_file(¶ms.path).await +pub async fn download_file( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; + + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } + + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; + + let client = DropboxClient::new(&refresh_token).await?; + client.download_file(¶ms.path).await } -pub async fn get_temporary_link(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; +pub async fn get_temporary_link( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let client = DropboxClient::new(&refresh_token).await?; - let temporary_link = client.get_temporary_link(¶ms.path).await?; + let client = DropboxClient::new(&refresh_token).await?; + let temporary_link = client.get_temporary_link(¶ms.path).await?; - Ok(HttpResponse::Ok().json(web::Json(temporary_link))) + Ok(HttpResponse::Ok().json(web::Json(temporary_link))) } +pub async fn get_metadata( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; -pub async fn get_metadata(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let refresh_token = find_dropbox_refresh_token(&pool.clone(), ¶ms.did).await?; + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let client = DropboxClient::new(&refresh_token).await?; + let metadata = client.get_metadata(¶ms.path).await?; - let client = DropboxClient::new(&refresh_token).await?; - let metadata = client.get_metadata(¶ms.path).await?; - - Ok(HttpResponse::Ok().json(web::Json(metadata))) + Ok(HttpResponse::Ok().json(web::Json(metadata))) } -pub async fn scan_folder(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - - let pool = pool.clone(); - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(scan::scan_folder(pool, ¶ms.did, ¶ms.path))?; - Ok::<(), Error>(()) - }); - - // sleep for 2 second to allow the thread to start - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - - Ok(HttpResponse::Ok().finish()) +pub async fn scan_folder( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + + let pool = pool.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(scan::scan_folder(pool, ¶ms.did, ¶ms.path))?; + Ok::<(), Error>(()) + }); + + // sleep for 2 second to allow the thread to start + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + Ok(HttpResponse::Ok().finish()) } diff --git a/crates/dropbox/src/handlers/mod.rs b/crates/dropbox/src/handlers/mod.rs index 878106af..f693e1da 100644 --- a/crates/dropbox/src/handlers/mod.rs +++ b/crates/dropbox/src/handlers/mod.rs @@ -2,34 +2,42 @@ use std::sync::Arc; use actix_web::{web, HttpRequest, HttpResponse}; use anyhow::Error; -use files::{create_music_folder, download_file, get_files, get_files_at, get_metadata, get_temporary_link, scan_folder}; +use files::{ + create_music_folder, download_file, get_files, get_files_at, get_metadata, get_temporary_link, + scan_folder, +}; use sqlx::{Pool, Postgres}; pub mod files; #[macro_export] macro_rules! read_payload { - ($payload:expr) => {{ - let mut body = Vec::new(); - while let Some(chunk) = $payload.next().await { - match chunk { - Ok(bytes) => body.extend_from_slice(&bytes), - Err(err) => return Err(err.into()), - } - } - body - }}; + ($payload:expr) => {{ + let mut body = Vec::new(); + while let Some(chunk) = $payload.next().await { + match chunk { + Ok(bytes) => body.extend_from_slice(&bytes), + Err(err) => return Err(err.into()), + } + } + body + }}; } -pub async fn handle(method: &str, payload: &mut web::Payload, req: &HttpRequest, conn: Arc>) -> Result { - match method { - "dropbox.getFiles" => get_files(payload, req, conn.clone()).await, - "dropbox.createMusicFolder" => create_music_folder(payload, req, conn.clone()).await, - "dropbox.getFilesAt" => get_files_at(payload, req, conn.clone()).await, - "dropbox.downloadFile" => download_file(payload, req, conn.clone()).await, - "dropbox.getTemporaryLink" => get_temporary_link(payload, req, conn.clone()).await, - "dropbox.getMetadata" => get_metadata(payload, req, conn.clone()).await, - "dropbox.scanFolder" => scan_folder(payload, req, conn.clone()).await, - _ => return Err(anyhow::anyhow!("Method not found")), - } +pub async fn handle( + method: &str, + payload: &mut web::Payload, + req: &HttpRequest, + conn: Arc>, +) -> Result { + match method { + "dropbox.getFiles" => get_files(payload, req, conn.clone()).await, + "dropbox.createMusicFolder" => create_music_folder(payload, req, conn.clone()).await, + "dropbox.getFilesAt" => get_files_at(payload, req, conn.clone()).await, + "dropbox.downloadFile" => download_file(payload, req, conn.clone()).await, + "dropbox.getTemporaryLink" => get_temporary_link(payload, req, conn.clone()).await, + "dropbox.getMetadata" => get_metadata(payload, req, conn.clone()).await, + "dropbox.scanFolder" => scan_folder(payload, req, conn.clone()).await, + _ => return Err(anyhow::anyhow!("Method not found")), + } } diff --git a/crates/dropbox/src/main.rs b/crates/dropbox/src/main.rs index 4fb78ab9..7f9d0fd6 100644 --- a/crates/dropbox/src/main.rs +++ b/crates/dropbox/src/main.rs @@ -1,30 +1,24 @@ use clap::Command; -use cmd::{serve::serve, scan::scan}; +use cmd::{scan::scan, serve::serve}; use dotenv::dotenv; -pub mod types; -pub mod xata; +pub mod client; pub mod cmd; +pub mod consts; +pub mod crypto; pub mod handlers; pub mod repo; -pub mod client; -pub mod crypto; -pub mod token; -pub mod consts; pub mod scan; +pub mod token; +pub mod types; +pub mod xata; fn cli() -> Command { Command::new("dropbox") .version(env!("CARGO_PKG_VERSION")) .about("Rocksky Dropbox Service") - .subcommand( - Command::new("scan") - .about("Scan Dropbox Music Folder") - ) - .subcommand( - Command::new("serve") - .about("Serve Rocksky Dropbox API") - ) + .subcommand(Command::new("scan").about("Scan Dropbox Music Folder")) + .subcommand(Command::new("serve").about("Serve Rocksky Dropbox API")) } #[tokio::main] diff --git a/crates/dropbox/src/repo/dropbox_path.rs b/crates/dropbox/src/repo/dropbox_path.rs index 527d4e3b..6135ba41 100644 --- a/crates/dropbox/src/repo/dropbox_path.rs +++ b/crates/dropbox/src/repo/dropbox_path.rs @@ -1,19 +1,21 @@ use sqlx::{Pool, Postgres}; -use crate::xata::track::Track; use crate::types::file::Entry; +use crate::xata::track::Track; pub async fn create_dropbox_path( - pool: &Pool, - file: &Entry, - track: &Track, - dropbox_id: &str + pool: &Pool, + file: &Entry, + track: &Track, + dropbox_id: &str, ) -> Result<(), sqlx::Error> { - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO dropbox_paths (dropbox_id, path, file_id, track_id, name) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING - "#) + "#, + ) .bind(dropbox_id) .bind(&file.path_display) .bind(&file.id) @@ -22,5 +24,5 @@ pub async fn create_dropbox_path( .execute(pool) .await?; - Ok(()) -} \ No newline at end of file + Ok(()) +} diff --git a/crates/dropbox/src/repo/dropbox_token.rs b/crates/dropbox/src/repo/dropbox_token.rs index 91625f2b..51ce045a 100644 --- a/crates/dropbox/src/repo/dropbox_token.rs +++ b/crates/dropbox/src/repo/dropbox_token.rs @@ -3,8 +3,12 @@ use sqlx::{Pool, Postgres}; use crate::xata::dropbox_token::DropboxTokenWithDid; -pub async fn find_dropbox_refresh_token(pool: &Pool, did: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn find_dropbox_refresh_token( + pool: &Pool, + did: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT d.xata_id, d.xata_version, @@ -16,20 +20,27 @@ pub async fn find_dropbox_refresh_token(pool: &Pool, did: &str) -> Res LEFT JOIN users u ON d.user_id = u.xata_id LEFT JOIN dropbox_tokens dt ON d.dropbox_token_id = dt.xata_id WHERE u.did = $1 - "#) + "#, + ) .bind(did) .fetch_all(pool) .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some((results[0].refresh_token.clone(), results[0].xata_id.clone()))) + Ok(Some(( + results[0].refresh_token.clone(), + results[0].xata_id.clone(), + ))) } -pub async fn find_dropbox_refresh_tokens(pool: &Pool) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn find_dropbox_refresh_tokens( + pool: &Pool, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT d.xata_id, d.xata_version, @@ -40,9 +51,10 @@ pub async fn find_dropbox_refresh_tokens(pool: &Pool) -> Result, sha256: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_track_by_hash( + pool: &Pool, + sha256: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM tracks WHERE sha256 = $1 - "#) + "#, + ) .bind(sha256) .fetch_all(pool) .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) -} \ No newline at end of file + Ok(Some(results[0].clone())) +} diff --git a/crates/dropbox/src/scan.rs b/crates/dropbox/src/scan.rs index d6d7dd7e..8dddcaca 100644 --- a/crates/dropbox/src/scan.rs +++ b/crates/dropbox/src/scan.rs @@ -2,61 +2,73 @@ use std::{env, fs::File, io::Write, path::Path, sync::Arc}; use anyhow::Error; use futures::future::BoxFuture; -use lofty::{file::TaggedFileExt, picture::{MimeType, Picture}, probe::Probe, tag::Accessor}; +use lofty::{ + file::TaggedFileExt, + picture::{MimeType, Picture}, + probe::Probe, + tag::Accessor, +}; use owo_colors::OwoColorize; use reqwest::{multipart, Client}; use serde_json::json; use sqlx::{Pool, Postgres}; -use symphonia::core::{formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint}; +use symphonia::core::{ + formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint, +}; use tempfile::TempDir; use crate::{ - client::{get_access_token, BASE_URL, CONTENT_URL}, - consts::AUDIO_EXTENSIONS, crypto::decrypt_aes_256_ctr, - repo::{dropbox_path::create_dropbox_path, dropbox_token::{find_dropbox_refresh_token, find_dropbox_refresh_tokens}, track::get_track_by_hash}, - token::generate_token, - types::file::{Entry, EntryList} + client::{get_access_token, BASE_URL, CONTENT_URL}, + consts::AUDIO_EXTENSIONS, + crypto::decrypt_aes_256_ctr, + repo::{ + dropbox_path::create_dropbox_path, + dropbox_token::{find_dropbox_refresh_token, find_dropbox_refresh_tokens}, + track::get_track_by_hash, + }, + token::generate_token, + types::file::{Entry, EntryList}, }; -pub async fn scan_dropbox(pool: Arc>) -> Result<(), Error>{ - let refresh_tokens = find_dropbox_refresh_tokens(&pool).await?; - for token in refresh_tokens { - let refresh_token = decrypt_aes_256_ctr( - &token.refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - scan_audio_files( - pool.clone(), - "/Music".to_string(), - refresh_token, - token.did, - token.xata_id - ).await?; - } - Ok(()) +pub async fn scan_dropbox(pool: Arc>) -> Result<(), Error> { + let refresh_tokens = find_dropbox_refresh_tokens(&pool).await?; + for token in refresh_tokens { + let refresh_token = decrypt_aes_256_ctr( + &token.refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; + scan_audio_files( + pool.clone(), + "/Music".to_string(), + refresh_token, + token.did, + token.xata_id, + ) + .await?; + } + Ok(()) } +pub async fn scan_folder(pool: Arc>, did: &str, path: &str) -> Result<(), Error> { + let refresh_tokens = find_dropbox_refresh_token(&pool, did).await?; + if let Some((refresh_token, dropbox_id)) = refresh_tokens { + let refresh_token = decrypt_aes_256_ctr( + &refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; -pub async fn scan_folder(pool: Arc>, did: &str, path: &str) -> Result<(), Error>{ - let refresh_tokens = find_dropbox_refresh_token(&pool, did).await?; - if let Some((refresh_token, dropbox_id)) = refresh_tokens { - let refresh_token = decrypt_aes_256_ctr( - &refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - - scan_audio_files( - pool.clone(), - path.to_string(), - refresh_token, - did.to_string(), - dropbox_id, - ).await?; - } - Ok(()) + scan_audio_files( + pool.clone(), + path.to_string(), + refresh_token, + did.to_string(), + dropbox_id, + ) + .await?; + } + Ok(()) } - pub fn scan_audio_files( pool: Arc>, path: String, @@ -64,167 +76,221 @@ pub fn scan_audio_files( did: String, dropbox_id: String, ) -> BoxFuture<'static, Result<(), Error>> { - Box::pin(async move { - let res = get_access_token(&refresh_token).await?; - let access_token = res.access_token; + Box::pin(async move { + let res = get_access_token(&refresh_token).await?; + let access_token = res.access_token; - let client = Client::new(); - - let res = client.post(&format!("{}/files/get_metadata", BASE_URL)) - .bearer_auth(&access_token) - .json(&json!({ "path": path })) - .send() - .await?; - - if res.status().as_u16() == 400 || res.status().as_u16() == 409 { - println!("Path not found: {}", path.bright_red()); - return Ok(()); - } - - let entry = res.json::().await?; - - if entry.tag.clone().unwrap().as_str() == "folder" { - println!("Scanning folder: {}", path.bright_green()); - - let mut entries: Vec = Vec::new(); - - let res = client.post(&format!("{}/files/list_folder", BASE_URL)) - .bearer_auth(&access_token) - .json(&json!({ "path": path })) - .send() - .await?; - - let mut entry_list = res.json::().await?; - entries.extend(entry_list.entries); - - // Handle pagination using list_folder/continue - while entry_list.has_more { - let res = client.post(&format!("{}/files/list_folder/continue", BASE_URL)) - .bearer_auth(&access_token) - .json(&json!({ "cursor": entry_list.cursor })) - .send() - .await?; - - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - entry_list = res.json::().await?; - entries.extend(entry_list.entries); - } - - for entry in entries { - scan_audio_files( - pool.clone(), - entry.path_display, - refresh_token.clone(), - did.clone(), - dropbox_id.clone() - ).await?; - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - - return Ok(()); - } - - if !AUDIO_EXTENSIONS - .into_iter() - .any(|ext| path.ends_with(&format!(".{}", ext))) - { - return Ok(()); - } - - let client = Client::new(); - - println!("Downloading file: {}", path.bright_green()); - - let res = client.post(&format!("{}/files/download", CONTENT_URL)) - .bearer_auth(&access_token) - .header("Dropbox-API-Arg", &json!({ "path": path }).to_string()) - .send() - .await?; + let client = Client::new(); - let bytes = res.bytes().await?; + let res = client + .post(&format!("{}/files/get_metadata", BASE_URL)) + .bearer_auth(&access_token) + .json(&json!({ "path": path })) + .send() + .await?; - let temp_dir = TempDir::new()?; - let tmppath = temp_dir.path().join(&format!("{}", entry.name)); - let mut tmpfile = File::create(&tmppath)?; - tmpfile.write_all(&bytes)?; + if res.status().as_u16() == 400 || res.status().as_u16() == 409 { + println!("Path not found: {}", path.bright_red()); + return Ok(()); + } - println!("Reading file: {}", &tmppath.clone().display().to_string().bright_green()); + let entry = res.json::().await?; + + if entry.tag.clone().unwrap().as_str() == "folder" { + println!("Scanning folder: {}", path.bright_green()); + + let mut entries: Vec = Vec::new(); + + let res = client + .post(&format!("{}/files/list_folder", BASE_URL)) + .bearer_auth(&access_token) + .json(&json!({ "path": path })) + .send() + .await?; + + let mut entry_list = res.json::().await?; + entries.extend(entry_list.entries); + + // Handle pagination using list_folder/continue + while entry_list.has_more { + let res = client + .post(&format!("{}/files/list_folder/continue", BASE_URL)) + .bearer_auth(&access_token) + .json(&json!({ "cursor": entry_list.cursor })) + .send() + .await?; + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + entry_list = res.json::().await?; + entries.extend(entry_list.entries); + } + + for entry in entries { + scan_audio_files( + pool.clone(), + entry.path_display, + refresh_token.clone(), + did.clone(), + dropbox_id.clone(), + ) + .await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } - let tagged_file = match Probe::open(&tmppath)?.read() - { - Ok(tagged_file) => tagged_file, - Err(e) => { - println!("Error opening file: {}", e); return Ok(()); } - }; - let primary_tag = tagged_file.primary_tag(); - let tag = match primary_tag { - Some(tag) => tag, - None => { - println!("No tag found in file"); + if !AUDIO_EXTENSIONS + .into_iter() + .any(|ext| path.ends_with(&format!(".{}", ext))) + { return Ok(()); } - }; - let pictures = tag.pictures(); - - println!("Title: {}", tag.get_string(&lofty::tag::ItemKey::TrackTitle).unwrap_or_default().bright_green()); - println!("Artist: {}", tag.get_string(&lofty::tag::ItemKey::TrackArtist).unwrap_or_default().bright_green()); - println!("Album Artist: {}", tag.get_string(&lofty::tag::ItemKey::AlbumArtist).unwrap_or_default().bright_green()); - println!("Album: {}", tag.get_string(&lofty::tag::ItemKey::AlbumTitle).unwrap_or_default().bright_green()); - println!("Lyrics: {}", tag.get_string(&lofty::tag::ItemKey::Lyrics).unwrap_or_default().bright_green()); - println!("Year: {}", tag.year().unwrap_or_default().bright_green()); - println!("Track Number: {}", tag.track().unwrap_or_default().bright_green()); - println!("Track Total: {}", tag.track_total().unwrap_or_default().bright_green()); - println!("Release Date: {:?}", tag.get_string(&lofty::tag::ItemKey::OriginalReleaseDate).unwrap_or_default().bright_green()); - println!("Recording Date: {:?}", tag.get_string(&lofty::tag::ItemKey::RecordingDate).unwrap_or_default().bright_green()); - println!("Copyright Message: {}", tag.get_string(&lofty::tag::ItemKey::CopyrightMessage).unwrap_or_default().bright_green()); - println!("Pictures: {:?}", pictures); - - let title = tag.get_string(&lofty::tag::ItemKey::TrackTitle).unwrap_or_default(); - let artist = tag.get_string(&lofty::tag::ItemKey::TrackArtist).unwrap_or_default(); - let album = tag.get_string(&lofty::tag::ItemKey::AlbumTitle).unwrap_or_default(); - let album_artist = tag.get_string(&lofty::tag::ItemKey::AlbumArtist).unwrap_or_default(); - - let access_token = generate_token(&did)?; - - // check if track exists - // - // if not, create track - // upload album art - // - // link path to track - - let hash = sha256::digest( - format!("{} - {} - {}", title, artist, album).to_lowercase(), - ); - - let track = get_track_by_hash(&pool, &hash).await?; - let duration = get_track_duration(&tmppath).await?; - let albumart_id = md5::compute(&format!("{} - {}", album_artist, album).to_lowercase()); - let albumart_id = format!("{:x}", albumart_id); - - match track { - Some(track) => { - println!("Track exists: {}", title.bright_green()); - let status = create_dropbox_path( - &pool, - &entry, - &track, - &dropbox_id, - ) - .await; - println!("status: {:?}", status); - }, - None => { - println!("Creating track: {}", title.bright_green()); - let album_art = upload_album_cover(albumart_id.into(), pictures, &access_token).await?; let client = Client::new(); - const URL: &str = "https://api.rocksky.app/tracks"; - let response = client + + println!("Downloading file: {}", path.bright_green()); + + let res = client + .post(&format!("{}/files/download", CONTENT_URL)) + .bearer_auth(&access_token) + .header("Dropbox-API-Arg", &json!({ "path": path }).to_string()) + .send() + .await?; + + let bytes = res.bytes().await?; + + let temp_dir = TempDir::new()?; + let tmppath = temp_dir.path().join(&format!("{}", entry.name)); + let mut tmpfile = File::create(&tmppath)?; + tmpfile.write_all(&bytes)?; + + println!( + "Reading file: {}", + &tmppath.clone().display().to_string().bright_green() + ); + + let tagged_file = match Probe::open(&tmppath)?.read() { + Ok(tagged_file) => tagged_file, + Err(e) => { + println!("Error opening file: {}", e); + return Ok(()); + } + }; + + let primary_tag = tagged_file.primary_tag(); + let tag = match primary_tag { + Some(tag) => tag, + None => { + println!("No tag found in file"); + return Ok(()); + } + }; + + let pictures = tag.pictures(); + + println!( + "Title: {}", + tag.get_string(&lofty::tag::ItemKey::TrackTitle) + .unwrap_or_default() + .bright_green() + ); + println!( + "Artist: {}", + tag.get_string(&lofty::tag::ItemKey::TrackArtist) + .unwrap_or_default() + .bright_green() + ); + println!( + "Album Artist: {}", + tag.get_string(&lofty::tag::ItemKey::AlbumArtist) + .unwrap_or_default() + .bright_green() + ); + println!( + "Album: {}", + tag.get_string(&lofty::tag::ItemKey::AlbumTitle) + .unwrap_or_default() + .bright_green() + ); + println!( + "Lyrics: {}", + tag.get_string(&lofty::tag::ItemKey::Lyrics) + .unwrap_or_default() + .bright_green() + ); + println!("Year: {}", tag.year().unwrap_or_default().bright_green()); + println!( + "Track Number: {}", + tag.track().unwrap_or_default().bright_green() + ); + println!( + "Track Total: {}", + tag.track_total().unwrap_or_default().bright_green() + ); + println!( + "Release Date: {:?}", + tag.get_string(&lofty::tag::ItemKey::OriginalReleaseDate) + .unwrap_or_default() + .bright_green() + ); + println!( + "Recording Date: {:?}", + tag.get_string(&lofty::tag::ItemKey::RecordingDate) + .unwrap_or_default() + .bright_green() + ); + println!( + "Copyright Message: {}", + tag.get_string(&lofty::tag::ItemKey::CopyrightMessage) + .unwrap_or_default() + .bright_green() + ); + println!("Pictures: {:?}", pictures); + + let title = tag + .get_string(&lofty::tag::ItemKey::TrackTitle) + .unwrap_or_default(); + let artist = tag + .get_string(&lofty::tag::ItemKey::TrackArtist) + .unwrap_or_default(); + let album = tag + .get_string(&lofty::tag::ItemKey::AlbumTitle) + .unwrap_or_default(); + let album_artist = tag + .get_string(&lofty::tag::ItemKey::AlbumArtist) + .unwrap_or_default(); + + let access_token = generate_token(&did)?; + + // check if track exists + // + // if not, create track + // upload album art + // + // link path to track + + let hash = sha256::digest(format!("{} - {} - {}", title, artist, album).to_lowercase()); + + let track = get_track_by_hash(&pool, &hash).await?; + let duration = get_track_duration(&tmppath).await?; + let albumart_id = md5::compute(&format!("{} - {}", album_artist, album).to_lowercase()); + let albumart_id = format!("{:x}", albumart_id); + + match track { + Some(track) => { + println!("Track exists: {}", title.bright_green()); + let status = create_dropbox_path(&pool, &entry, &track, &dropbox_id).await; + println!("status: {:?}", status); + } + None => { + println!("Creating track: {}", title.bright_green()); + let album_art = + upload_album_cover(albumart_id.into(), pictures, &access_token).await?; + let client = Client::new(); + const URL: &str = "https://api.rocksky.app/tracks"; + let response = client .post(URL) .header("Authorization", format!("Bearer {}", access_token)) .json(&serde_json::json!({ @@ -256,114 +322,111 @@ pub fn scan_audio_files( })) .send() .await?; - println!("Track Saved: {} {}", title, response.status()); - tokio::time::sleep(std::time::Duration::from_secs(3)).await; + println!("Track Saved: {} {}", title, response.status()); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let track = get_track_by_hash(&pool, &hash).await?; + if let Some(track) = track { + create_dropbox_path(&pool, &entry, &track, &dropbox_id).await?; + return Ok(()); + } - let track = get_track_by_hash(&pool, &hash).await?; - if let Some(track) = track { - create_dropbox_path( - &pool, - &entry, - &track, - &dropbox_id, - ) - .await?; - return Ok(()); + println!("Failed to create track: {}", title.bright_green()); + } } - println!("Failed to create track: {}", title.bright_green()); - } - } - - Ok(()) - }) + Ok(()) + }) } -pub async fn upload_album_cover(name: String, pictures: &[Picture], token: &str) -> Result, Error> { - if pictures.is_empty() { - return Ok(None); - } - - let picture = &pictures[0]; - - let buffer = match picture.mime_type() { - Some(MimeType::Jpeg) => Some(picture.data().to_vec()), - Some(MimeType::Png) => Some(picture.data().to_vec()), - Some(MimeType::Gif) => Some(picture.data().to_vec()), - Some(MimeType::Bmp) => Some(picture.data().to_vec()), - Some(MimeType::Tiff) => Some(picture.data().to_vec()), - _ => None - }; - - if buffer.is_none() { - return Ok(None); - } - - let buffer = buffer.unwrap(); - - let ext = match picture.mime_type() { - Some(MimeType::Jpeg) => "jpg", - Some(MimeType::Png) => "png", - Some(MimeType::Gif) => "gif", - Some(MimeType::Bmp) => "bmp", - Some(MimeType::Tiff) => "tiff", - _ => { - return Ok(None); +pub async fn upload_album_cover( + name: String, + pictures: &[Picture], + token: &str, +) -> Result, Error> { + if pictures.is_empty() { + return Ok(None); } - }; - - let name = format!("{}.{}", name, ext); - let part = multipart::Part::bytes(buffer).file_name(name.clone()); - let form = multipart::Form::new().part("file", part); - let client = Client::new(); + let picture = &pictures[0]; - const URL: &str = "https://uploads.rocksky.app"; + let buffer = match picture.mime_type() { + Some(MimeType::Jpeg) => Some(picture.data().to_vec()), + Some(MimeType::Png) => Some(picture.data().to_vec()), + Some(MimeType::Gif) => Some(picture.data().to_vec()), + Some(MimeType::Bmp) => Some(picture.data().to_vec()), + Some(MimeType::Tiff) => Some(picture.data().to_vec()), + _ => None, + }; - let response = client - .post(URL) - .header("Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await?; + if buffer.is_none() { + return Ok(None); + } - println!("Cover uploaded: {}", response.status()); + let buffer = buffer.unwrap(); - Ok(Some(name)) -} + let ext = match picture.mime_type() { + Some(MimeType::Jpeg) => "jpg", + Some(MimeType::Png) => "png", + Some(MimeType::Gif) => "gif", + Some(MimeType::Bmp) => "bmp", + Some(MimeType::Tiff) => "tiff", + _ => { + return Ok(None); + } + }; + let name = format!("{}.{}", name, ext); + let part = multipart::Part::bytes(buffer).file_name(name.clone()); + let form = multipart::Form::new().part("file", part); + let client = Client::new(); -pub async fn get_track_duration(path: &Path) -> Result { - let duration = 0; - let media_source = MediaSourceStream::new(Box::new(std::fs::File::open(path)?), Default::default()); - let mut hint = Hint::new(); + const URL: &str = "https://uploads.rocksky.app"; - if let Some(extension) = path.extension() { - if let Some(extension) = extension.to_str() { - hint.with_extension(extension); - } - } + let response = client + .post(URL) + .header("Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await?; + println!("Cover uploaded: {}", response.status()); - let meta_opts = MetadataOptions::default(); - let format_opts = FormatOptions::default(); + Ok(Some(name)) +} - let probed = match symphonia::default::get_probe().format(&hint, media_source, &format_opts, &meta_opts) { - Ok(probed) => probed, - Err(_) => { - println!("Error probing file"); - return Ok(duration); - }, - }; +pub async fn get_track_duration(path: &Path) -> Result { + let duration = 0; + let media_source = + MediaSourceStream::new(Box::new(std::fs::File::open(path)?), Default::default()); + let mut hint = Hint::new(); + + if let Some(extension) = path.extension() { + if let Some(extension) = extension.to_str() { + hint.with_extension(extension); + } + } - if let Some(track) = probed.format.tracks().first() { - if let Some(duration) = track.codec_params.n_frames { - if let Some(sample_rate) = track.codec_params.sample_rate { - return Ok((duration as f64 / sample_rate as f64) as u64 * 1000); + let meta_opts = MetadataOptions::default(); + let format_opts = FormatOptions::default(); + + let probed = + match symphonia::default::get_probe().format(&hint, media_source, &format_opts, &meta_opts) + { + Ok(probed) => probed, + Err(_) => { + println!("Error probing file"); + return Ok(duration); + } + }; + + if let Some(track) = probed.format.tracks().first() { + if let Some(duration) = track.codec_params.n_frames { + if let Some(sample_rate) = track.codec_params.sample_rate { + return Ok((duration as f64 / sample_rate as f64) as u64 * 1000); + } } } + Ok(duration) } - Ok(duration) -} \ No newline at end of file diff --git a/crates/googledrive/src/client.rs b/crates/googledrive/src/client.rs index 077aaabd..b83c238c 100644 --- a/crates/googledrive/src/client.rs +++ b/crates/googledrive/src/client.rs @@ -5,78 +5,91 @@ use anyhow::Error; use reqwest::Client; use serde_json::json; -use crate::types::{file::{File, FileList}, token::AccessToken}; +use crate::types::{ + file::{File, FileList}, + token::AccessToken, +}; pub const BASE_URL: &str = "https://www.googleapis.com/drive/v3"; pub async fn get_access_token(refresh_token: &str) -> Result { - let client = Client::new(); - - let params = [ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token), - ("client_id", &env::var("GOOGLE_CLIENT_ID")?), - ("client_secret", &env::var("GOOGLE_CLIENT_SECRET")?), - ]; - - let body = serde_urlencoded::to_string(¶ms)?; - let res = client.post("https://oauth2.googleapis.com/token") - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Content-Length", body.len()) - .body(body) - .send() - .await?; - - Ok(res.json::().await?) + let client = Client::new(); + + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", &env::var("GOOGLE_CLIENT_ID")?), + ("client_secret", &env::var("GOOGLE_CLIENT_SECRET")?), + ]; + + let body = serde_urlencoded::to_string(¶ms)?; + let res = client + .post("https://oauth2.googleapis.com/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Content-Length", body.len()) + .body(body) + .send() + .await?; + + Ok(res.json::().await?) } pub struct GoogleDriveClient { - pub access_token: String, + pub access_token: String, } impl GoogleDriveClient { - pub async fn new(refresh_token: &str) -> Result { - let res = get_access_token(refresh_token).await?; - Ok(Self { - access_token: res.access_token, - }) - } - - pub async fn get_files(&self, name: &str) -> Result { - let client = Client::new(); - let url = format!("{}/files", BASE_URL); - let res = client.get(&url) - .bearer_auth(&self.access_token) - .query(&[ - ("q", format!("name='{}' and mimeType='application/vnd.google-apps.folder'", name).as_str()), - ("fields", "files(id, name, mimeType, parents)"), - ("orderBy", "name"), - ]) - .send() - .await?; - - Ok(res.json::().await?) - } + pub async fn new(refresh_token: &str) -> Result { + let res = get_access_token(refresh_token).await?; + Ok(Self { + access_token: res.access_token, + }) + } - pub async fn create_music_directory(&self) -> Result { - let client = Client::new(); - let url = format!("{}/files", BASE_URL); - let res = client.post(&url) - .bearer_auth(&self.access_token) - .json(&json!({ - "name": "Music", - "mimeType": "application/vnd.google-apps.folder", - })) - .send() - .await?; + pub async fn get_files(&self, name: &str) -> Result { + let client = Client::new(); + let url = format!("{}/files", BASE_URL); + let res = client + .get(&url) + .bearer_auth(&self.access_token) + .query(&[ + ( + "q", + format!( + "name='{}' and mimeType='application/vnd.google-apps.folder'", + name + ) + .as_str(), + ), + ("fields", "files(id, name, mimeType, parents)"), + ("orderBy", "name"), + ]) + .send() + .await?; + + Ok(res.json::().await?) + } - Ok(res.json::().await?) - } + pub async fn create_music_directory(&self) -> Result { + let client = Client::new(); + let url = format!("{}/files", BASE_URL); + let res = client + .post(&url) + .bearer_auth(&self.access_token) + .json(&json!({ + "name": "Music", + "mimeType": "application/vnd.google-apps.folder", + })) + .send() + .await?; + + Ok(res.json::().await?) + } - pub async fn get_music_directory(&self) -> Result { - let client = Client::new(); - let url = format!("{}/files", BASE_URL); - let res = client.get(&url) + pub async fn get_music_directory(&self) -> Result { + let client = Client::new(); + let url = format!("{}/files", BASE_URL); + let res = client.get(&url) .bearer_auth(&self.access_token) .query(&[ ("q", "name='Music' and mimeType='application/vnd.google-apps.folder' and 'root' in parents"), @@ -86,70 +99,68 @@ impl GoogleDriveClient { .send() .await?; - let files = res.json::().await?; - - if files.files.len() == 0 { - let music_dir = self.create_music_directory().await?; - return Ok(FileList { - files: vec![music_dir], - next_page_token: None, - }); - } + let files = res.json::().await?; - Ok(files) - } - - pub async fn get_files_in_parents(&self, parent_id: &str) -> Result { - let client = Client::new(); - let url = format!("{}/files", BASE_URL); - let res = client.get(&url) - .bearer_auth(&self.access_token) - .query(&[ - ("q", format!("'{}' in parents", parent_id).as_str()), - ("fields", "files(id, name, mimeType, parents)"), - ("orderBy", "name"), - ]) - .send() - .await?; - Ok(res.json::().await?) - } + if files.files.len() == 0 { + let music_dir = self.create_music_directory().await?; + return Ok(FileList { + files: vec![music_dir], + next_page_token: None, + }); + } - pub async fn get_file(&self, file_id: &str) -> Result { - let client = Client::new(); - let url = format!("{}/files/{}", BASE_URL, file_id); - let res = client.get(&url) - .bearer_auth(&self.access_token) - .query(&[ - ("fields", "id, name, mimeType, parents"), - ]) - .send() - .await?; + Ok(files) + } - Ok(res.json::().await?) - } + pub async fn get_files_in_parents(&self, parent_id: &str) -> Result { + let client = Client::new(); + let url = format!("{}/files", BASE_URL); + let res = client + .get(&url) + .bearer_auth(&self.access_token) + .query(&[ + ("q", format!("'{}' in parents", parent_id).as_str()), + ("fields", "files(id, name, mimeType, parents)"), + ("orderBy", "name"), + ]) + .send() + .await?; + Ok(res.json::().await?) + } - pub async fn download_file(&self, file_id: &str) -> Result { - let client = Client::new(); - let url = format!("{}/files/{}", BASE_URL, file_id); - let res = client.get(&url) - .bearer_auth(&self.access_token) - .query(&[ - ("alt", "media"), - ]) - .send() - .await?; + pub async fn get_file(&self, file_id: &str) -> Result { + let client = Client::new(); + let url = format!("{}/files/{}", BASE_URL, file_id); + let res = client + .get(&url) + .bearer_auth(&self.access_token) + .query(&[("fields", "id, name, mimeType, parents")]) + .send() + .await?; + + Ok(res.json::().await?) + } + pub async fn download_file(&self, file_id: &str) -> Result { + let client = Client::new(); + let url = format!("{}/files/{}", BASE_URL, file_id); + let res = client + .get(&url) + .bearer_auth(&self.access_token) + .query(&[("alt", "media")]) + .send() + .await?; - let mut actix_response = HttpResponse::Ok(); + let mut actix_response = HttpResponse::Ok(); - // Forward headers - for (key, value) in res.headers().iter() { - actix_response.append_header((key.as_str(), value.to_str().unwrap_or(""))); - } + // Forward headers + for (key, value) in res.headers().iter() { + actix_response.append_header((key.as_str(), value.to_str().unwrap_or(""))); + } - // Forward body - let body = res.bytes_stream(); + // Forward body + let body = res.bytes_stream(); - Ok(actix_response.streaming(body)) - } -} \ No newline at end of file + Ok(actix_response.streaming(body)) + } +} diff --git a/crates/googledrive/src/cmd/scan.rs b/crates/googledrive/src/cmd/scan.rs index 7e005e4a..26a86a1a 100644 --- a/crates/googledrive/src/cmd/scan.rs +++ b/crates/googledrive/src/cmd/scan.rs @@ -6,10 +6,13 @@ use sqlx::postgres::PgPoolOptions; use crate::scan::scan_googledrive; pub async fn scan() -> Result<(), Error> { - let pool = PgPoolOptions::new().max_connections(5).connect(&env::var("XATA_POSTGRES_URL")?).await?; - let conn = Arc::new(pool); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&env::var("XATA_POSTGRES_URL")?) + .await?; + let conn = Arc::new(pool); - scan_googledrive(conn).await?; + scan_googledrive(conn).await?; - Ok(()) + Ok(()) } diff --git a/crates/googledrive/src/cmd/serve.rs b/crates/googledrive/src/cmd/serve.rs index a172faf5..8ead9b56 100644 --- a/crates/googledrive/src/cmd/serve.rs +++ b/crates/googledrive/src/cmd/serve.rs @@ -63,4 +63,3 @@ pub async fn serve() -> Result<(), Error> { Ok(()) } - diff --git a/crates/googledrive/src/handlers/files.rs b/crates/googledrive/src/handlers/files.rs index f6f5d7e5..71692c21 100644 --- a/crates/googledrive/src/handlers/files.rs +++ b/crates/googledrive/src/handlers/files.rs @@ -8,125 +8,153 @@ use tokio_stream::StreamExt; pub const MUSIC_DIR: &str = "Music"; use crate::{ - client::GoogleDriveClient, crypto::decrypt_aes_256_ctr, read_payload, repo::google_drive_token::find_google_drive_refresh_token, scan, types::file::{DownloadFileParams, GetFilesInParentsParams, GetFilesParams, ScanFolderParams} + client::GoogleDriveClient, + crypto::decrypt_aes_256_ctr, + read_payload, + repo::google_drive_token::find_google_drive_refresh_token, + scan, + types::file::{DownloadFileParams, GetFilesInParentsParams, GetFilesParams, ScanFolderParams}, }; -pub async fn create_music_directory(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; +pub async fn create_music_directory( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; - let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; + let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let client = GoogleDriveClient::new(&refresh_token).await?; - let file = client.create_music_directory().await?; + let client = GoogleDriveClient::new(&refresh_token).await?; + let file = client.create_music_directory().await?; - Ok(HttpResponse::Ok().json(web::Json(file))) + Ok(HttpResponse::Ok().json(web::Json(file))) } -pub async fn get_music_directory(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; +pub async fn get_music_directory( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; - let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; + let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let client = GoogleDriveClient::new(&refresh_token).await?; - let files = client.get_music_directory().await?; + let client = GoogleDriveClient::new(&refresh_token).await?; + let files = client.get_music_directory().await?; - Ok(HttpResponse::Ok().json(web::Json(files))) + Ok(HttpResponse::Ok().json(web::Json(files))) } -pub async fn get_files_in_parents(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; +pub async fn get_files_in_parents( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; - let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; + let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let client = GoogleDriveClient::new(&refresh_token).await?; - let files = client.get_files_in_parents(¶ms.parent_id).await?; + let client = GoogleDriveClient::new(&refresh_token).await?; + let files = client.get_files_in_parents(¶ms.parent_id).await?; - Ok(HttpResponse::Ok().json(web::Json(files))) + Ok(HttpResponse::Ok().json(web::Json(files))) } -pub async fn get_file(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; +pub async fn get_file( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let client = GoogleDriveClient::new(&refresh_token).await?; - let file = client.get_file(¶ms.file_id).await?; + let client = GoogleDriveClient::new(&refresh_token).await?; + let file = client.get_file(¶ms.file_id).await?; - Ok(HttpResponse::Ok().json(web::Json(file))) + Ok(HttpResponse::Ok().json(web::Json(file))) } +pub async fn download_file( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; -pub async fn download_file(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; + let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; - let refresh_token = find_google_drive_refresh_token(&pool.clone(), ¶ms.did).await?; + if refresh_token.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } - if refresh_token.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } + let refresh_token = decrypt_aes_256_ctr( + &refresh_token.unwrap().0, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; - let refresh_token = decrypt_aes_256_ctr( - &refresh_token.unwrap().0, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - - let client = GoogleDriveClient::new(&refresh_token).await?; - client.download_file(¶ms.file_id).await + let client = GoogleDriveClient::new(&refresh_token).await?; + client.download_file(¶ms.file_id).await } -pub async fn scan_folder(payload: &mut web::Payload, _req: &HttpRequest, pool: Arc>) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - - let pool = pool.clone(); - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(scan::scan_folder(pool, ¶ms.did, ¶ms.folder_id))?; - Ok::<(), Error>(()) - }); - - // sleep for 2 second to allow the thread to start - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - - Ok(HttpResponse::Ok().finish()) +pub async fn scan_folder( + payload: &mut web::Payload, + _req: &HttpRequest, + pool: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + + let pool = pool.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(scan::scan_folder(pool, ¶ms.did, ¶ms.folder_id))?; + Ok::<(), Error>(()) + }); + + // sleep for 2 second to allow the thread to start + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + Ok(HttpResponse::Ok().finish()) } diff --git a/crates/googledrive/src/handlers/mod.rs b/crates/googledrive/src/handlers/mod.rs index 91431256..b6469ead 100644 --- a/crates/googledrive/src/handlers/mod.rs +++ b/crates/googledrive/src/handlers/mod.rs @@ -2,34 +2,44 @@ use std::sync::Arc; use actix_web::{web, HttpRequest, HttpResponse}; use anyhow::Error; -use files::{create_music_directory, download_file, get_file, get_files_in_parents, get_music_directory, scan_folder}; +use files::{ + create_music_directory, download_file, get_file, get_files_in_parents, get_music_directory, + scan_folder, +}; use sqlx::{Pool, Postgres}; pub mod files; #[macro_export] macro_rules! read_payload { - ($payload:expr) => {{ - let mut body = Vec::new(); - while let Some(chunk) = $payload.next().await { - // skip if None - match chunk { - Ok(bytes) => body.extend_from_slice(&bytes), - Err(err) => return Err(err.into()), - } - } - body - }}; + ($payload:expr) => {{ + let mut body = Vec::new(); + while let Some(chunk) = $payload.next().await { + // skip if None + match chunk { + Ok(bytes) => body.extend_from_slice(&bytes), + Err(err) => return Err(err.into()), + } + } + body + }}; } -pub async fn handle(method: &str, payload: &mut web::Payload, req: &HttpRequest, conn: Arc>) -> Result { - match method { - "googledrive.getFilesInParents" => get_files_in_parents(payload, req, conn.clone()).await, - "googledrive.createMusicDirectory" => create_music_directory(payload, req, conn.clone()).await, - "googledrive.getMusicDirectory" => get_music_directory(payload, req, conn.clone()).await, - "googledrive.getFile" => get_file(payload, req, conn.clone()).await, - "googledrive.downloadFile" => download_file(payload, req, conn.clone()).await, - "googledrive.scanFolder" => scan_folder(payload, req, conn.clone()).await, - _ => return Err(anyhow::anyhow!("Method not found")), - } +pub async fn handle( + method: &str, + payload: &mut web::Payload, + req: &HttpRequest, + conn: Arc>, +) -> Result { + match method { + "googledrive.getFilesInParents" => get_files_in_parents(payload, req, conn.clone()).await, + "googledrive.createMusicDirectory" => { + create_music_directory(payload, req, conn.clone()).await + } + "googledrive.getMusicDirectory" => get_music_directory(payload, req, conn.clone()).await, + "googledrive.getFile" => get_file(payload, req, conn.clone()).await, + "googledrive.downloadFile" => download_file(payload, req, conn.clone()).await, + "googledrive.scanFolder" => scan_folder(payload, req, conn.clone()).await, + _ => return Err(anyhow::anyhow!("Method not found")), + } } diff --git a/crates/googledrive/src/main.rs b/crates/googledrive/src/main.rs index c54e774b..bd6ceb21 100644 --- a/crates/googledrive/src/main.rs +++ b/crates/googledrive/src/main.rs @@ -1,30 +1,24 @@ use clap::Command; -use cmd::{serve::serve, scan::scan}; +use cmd::{scan::scan, serve::serve}; use dotenv::dotenv; -pub mod types; -pub mod xata; +pub mod client; pub mod cmd; +pub mod consts; +pub mod crypto; pub mod handlers; pub mod repo; -pub mod client; -pub mod crypto; -pub mod token; -pub mod consts; pub mod scan; +pub mod token; +pub mod types; +pub mod xata; fn cli() -> Command { Command::new("googledrive") .version(env!("CARGO_PKG_VERSION")) .about("Rocksky Google Drive Service") - .subcommand( - Command::new("scan") - .about("Scan Google Drive Music Folder") - ) - .subcommand( - Command::new("serve") - .about("Serve Rocksky Google Drive API") - ) + .subcommand(Command::new("scan").about("Scan Google Drive Music Folder")) + .subcommand(Command::new("serve").about("Serve Rocksky Google Drive API")) } #[tokio::main] diff --git a/crates/googledrive/src/repo/google_drive_path.rs b/crates/googledrive/src/repo/google_drive_path.rs index a4397855..5431241d 100644 --- a/crates/googledrive/src/repo/google_drive_path.rs +++ b/crates/googledrive/src/repo/google_drive_path.rs @@ -3,16 +3,18 @@ use sqlx::{Pool, Postgres}; use crate::{types::file::File, xata::track::Track}; pub async fn create_google_drive_path( - pool: &Pool, - file: &File, - track: &Track, - google_drive_id: &str + pool: &Pool, + file: &File, + track: &Track, + google_drive_id: &str, ) -> Result<(), sqlx::Error> { - let result = sqlx::query(r#" + let result = sqlx::query( + r#" INSERT INTO google_drive_paths (google_drive_id, file_id, track_id, name) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING - "#) + "#, + ) .bind(google_drive_id) .bind(&file.id) .bind(&track.xata_id) @@ -20,7 +22,7 @@ pub async fn create_google_drive_path( .execute(pool) .await?; - println!("{:?}", result); + println!("{:?}", result); - Ok(()) -} \ No newline at end of file + Ok(()) +} diff --git a/crates/googledrive/src/repo/google_drive_token.rs b/crates/googledrive/src/repo/google_drive_token.rs index 92408ccd..facc35b1 100644 --- a/crates/googledrive/src/repo/google_drive_token.rs +++ b/crates/googledrive/src/repo/google_drive_token.rs @@ -1,10 +1,14 @@ -use sqlx::{Pool, Postgres}; use anyhow::Error; +use sqlx::{Pool, Postgres}; use crate::xata::google_drive_token::GoogleDriveTokenWithDid; -pub async fn find_google_drive_refresh_token(pool: &Pool, did: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn find_google_drive_refresh_token( + pool: &Pool, + did: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT gd.xata_id, gd.xata_version, @@ -16,20 +20,27 @@ pub async fn find_google_drive_refresh_token(pool: &Pool, did: &str) - LEFT JOIN users u ON gd.user_id = u.xata_id LEFT JOIN google_drive_tokens gt ON gd.google_drive_token_id = gt.xata_id WHERE u.did = $1 - "#) + "#, + ) .bind(did) .fetch_all(pool) .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some((results[0].refresh_token.clone(), results[0].xata_id.clone()))) + Ok(Some(( + results[0].refresh_token.clone(), + results[0].xata_id.clone(), + ))) } -pub async fn find_google_drive_refresh_tokens(pool: &Pool) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn find_google_drive_refresh_tokens( + pool: &Pool, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT gd.xata_id, gd.xata_version, @@ -40,9 +51,10 @@ pub async fn find_google_drive_refresh_tokens(pool: &Pool) -> Result, sha256: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_track_by_hash( + pool: &Pool, + sha256: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM tracks WHERE sha256 = $1 - "#) + "#, + ) .bind(sha256) .fetch_all(pool) .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) -} \ No newline at end of file + Ok(Some(results[0].clone())) +} diff --git a/crates/googledrive/src/scan.rs b/crates/googledrive/src/scan.rs index 53776930..d7e84cd6 100644 --- a/crates/googledrive/src/scan.rs +++ b/crates/googledrive/src/scan.rs @@ -2,57 +2,80 @@ use std::{env, io::Write, path::Path, sync::Arc}; use anyhow::Error; use futures::future::BoxFuture; -use lofty::{file::TaggedFileExt, picture::{MimeType, Picture}, probe::Probe, tag::Accessor}; +use lofty::{ + file::TaggedFileExt, + picture::{MimeType, Picture}, + probe::Probe, + tag::Accessor, +}; use owo_colors::OwoColorize; use reqwest::{multipart, Client}; use sqlx::{Pool, Postgres}; -use symphonia::core::{formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint}; +use symphonia::core::{ + formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint, +}; use tempfile::TempDir; -use crate::{client::{GoogleDriveClient, BASE_URL}, consts::AUDIO_EXTENSIONS, crypto::decrypt_aes_256_ctr, repo::{google_drive_path::create_google_drive_path, google_drive_token::{find_google_drive_refresh_token, find_google_drive_refresh_tokens}, track::get_track_by_hash}, token::generate_token, types::file::{File, FileList}}; +use crate::{ + client::{GoogleDriveClient, BASE_URL}, + consts::AUDIO_EXTENSIONS, + crypto::decrypt_aes_256_ctr, + repo::{ + google_drive_path::create_google_drive_path, + google_drive_token::{find_google_drive_refresh_token, find_google_drive_refresh_tokens}, + track::get_track_by_hash, + }, + token::generate_token, + types::file::{File, FileList}, +}; pub async fn scan_googledrive(pool: Arc>) -> Result<(), Error> { - let refresh_tokens = find_google_drive_refresh_tokens(&pool).await?; - for token in refresh_tokens { - let refresh_token = decrypt_aes_256_ctr( - &token.refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - - let client = GoogleDriveClient::new(&refresh_token).await?; - let filelist = client.get_music_directory().await?; - let music_dir = filelist.files.first().unwrap(); - scan_audio_files( - pool.clone(), - music_dir.id.clone(), - refresh_token.clone(), - token.did.clone(), - token.xata_id.clone() - ).await?; - } - Ok(()) + let refresh_tokens = find_google_drive_refresh_tokens(&pool).await?; + for token in refresh_tokens { + let refresh_token = decrypt_aes_256_ctr( + &token.refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; + + let client = GoogleDriveClient::new(&refresh_token).await?; + let filelist = client.get_music_directory().await?; + let music_dir = filelist.files.first().unwrap(); + scan_audio_files( + pool.clone(), + music_dir.id.clone(), + refresh_token.clone(), + token.did.clone(), + token.xata_id.clone(), + ) + .await?; + } + Ok(()) } -pub async fn scan_folder(pool: Arc>, did: &str, folder_id: &str) -> Result<(), Error> { - let refresh_token = find_google_drive_refresh_token(&pool, did).await?; - if let Some((refresh_token, google_drive_id)) = refresh_token { - let refresh_token = decrypt_aes_256_ctr( - &refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - - scan_audio_files( - pool.clone(), - folder_id.to_string(), - refresh_token, - did.to_string(), - google_drive_id.clone() - ).await?; - } - - Ok(()) -} +pub async fn scan_folder( + pool: Arc>, + did: &str, + folder_id: &str, +) -> Result<(), Error> { + let refresh_token = find_google_drive_refresh_token(&pool, did).await?; + if let Some((refresh_token, google_drive_id)) = refresh_token { + let refresh_token = decrypt_aes_256_ctr( + &refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; + scan_audio_files( + pool.clone(), + folder_id.to_string(), + refresh_token, + did.to_string(), + google_drive_id.clone(), + ) + .await?; + } + + Ok(()) +} pub fn scan_audio_files( pool: Arc>, @@ -61,174 +84,226 @@ pub fn scan_audio_files( did: String, google_drive_id: String, ) -> BoxFuture<'static, Result<(), Error>> { - Box::pin(async move { - let client = GoogleDriveClient::new(&refresh_token).await?; - let access_token = client.access_token.clone(); - - let client = Client::new(); - let url = format!("{}/files/{}", BASE_URL, file_id); - let res = client.get(&url) - .bearer_auth(&access_token) - .query(&[ - ("fields", "id, name, mimeType, parents"), - ]) - .send() - .await?; - - let file = res.json::().await?; - - if file.mime_type == "application/vnd.google-apps.folder" { - println!("Scanning folder: {}", file.name.bright_green()); - - let mut page_token: Option = None; - let mut files: Vec = Vec::new(); - - loop { - let mut req = client.get(&format!("{}/files", BASE_URL)) - .bearer_auth(&access_token) - .query(&[ - ("q", format!("'{}' in parents", file.id).as_str()), - ("fields", "nextPageToken, files(id, name, mimeType, parents)"), - ("orderBy", "name"), - ("pageSize", "1000"), // max is 1000 - ]); - - if let Some(token) = &page_token { - req = req.query(&[("pageToken", token)]); - } - - let res = req.send().await?; - let filelist = res.json::().await?; - - files.extend(filelist.files); - - if let Some(token) = filelist.next_page_token { - page_token = Some(token); - } else { - break; - } - } - - - for file in files { - scan_audio_files( - pool.clone(), - file.id, - refresh_token.clone(), - did.clone(), - google_drive_id.clone() - ).await?; - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - } - - return Ok(()); - } - - if !AUDIO_EXTENSIONS - .into_iter() - .any(|ext| file.name.ends_with(&format!(".{}", ext))) - { - return Ok(()); - } - - println!("Downloading file: {}", file.name.bright_green()); + Box::pin(async move { + let client = GoogleDriveClient::new(&refresh_token).await?; + let access_token = client.access_token.clone(); - let client = Client::new(); + let client = Client::new(); + let url = format!("{}/files/{}", BASE_URL, file_id); + let res = client + .get(&url) + .bearer_auth(&access_token) + .query(&[("fields", "id, name, mimeType, parents")]) + .send() + .await?; + + let file = res.json::().await?; + + if file.mime_type == "application/vnd.google-apps.folder" { + println!("Scanning folder: {}", file.name.bright_green()); + + let mut page_token: Option = None; + let mut files: Vec = Vec::new(); + + loop { + let mut req = client + .get(&format!("{}/files", BASE_URL)) + .bearer_auth(&access_token) + .query(&[ + ("q", format!("'{}' in parents", file.id).as_str()), + ( + "fields", + "nextPageToken, files(id, name, mimeType, parents)", + ), + ("orderBy", "name"), + ("pageSize", "1000"), // max is 1000 + ]); + + if let Some(token) = &page_token { + req = req.query(&[("pageToken", token)]); + } + + let res = req.send().await?; + let filelist = res.json::().await?; + + files.extend(filelist.files); + + if let Some(token) = filelist.next_page_token { + page_token = Some(token); + } else { + break; + } + } + + for file in files { + scan_audio_files( + pool.clone(), + file.id, + refresh_token.clone(), + did.clone(), + google_drive_id.clone(), + ) + .await?; + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } - let url = format!("{}/files/{}", BASE_URL, file_id); - let res = client.get(&url) - .bearer_auth(&access_token) - .query(&[ - ("alt", "media"), - ]) - .send() - .await?; - - let bytes = res.bytes().await?; - - let temp_dir = TempDir::new()?; - let tmppath = temp_dir.path().join(&format!("{}", file.name)); - let mut tmpfile = std::fs::File::create(&tmppath)?; - tmpfile.write_all(&bytes)?; - - println!("Reading file: {}", &tmppath.clone().display().to_string().bright_green()); - - let tagged_file = match Probe::open(&tmppath)?.read() - { - Ok(tagged_file) => tagged_file, - Err(e) => { - println!("Error opening file: {}", e); return Ok(()); } - }; - let primary_tag = tagged_file.primary_tag(); - let tag = match primary_tag { - Some(tag) => tag, - None => { - println!("No tag found in file"); + if !AUDIO_EXTENSIONS + .into_iter() + .any(|ext| file.name.ends_with(&format!(".{}", ext))) + { return Ok(()); } - }; - let pictures = tag.pictures(); - - println!("Title: {}", tag.get_string(&lofty::tag::ItemKey::TrackTitle).unwrap_or_default().bright_green()); - println!("Artist: {}", tag.get_string(&lofty::tag::ItemKey::TrackArtist).unwrap_or_default().bright_green()); - println!("Album Artist: {}", tag.get_string(&lofty::tag::ItemKey::AlbumArtist).unwrap_or_default().bright_green()); - println!("Album: {}", tag.get_string(&lofty::tag::ItemKey::AlbumTitle).unwrap_or_default().bright_green()); - println!("Lyrics: {}", tag.get_string(&lofty::tag::ItemKey::Lyrics).unwrap_or_default().bright_green()); - println!("Year: {}", tag.year().unwrap_or_default().bright_green()); - println!("Track Number: {}", tag.track().unwrap_or_default().bright_green()); - println!("Track Total: {}", tag.track_total().unwrap_or_default().bright_green()); - println!("Release Date: {:?}", tag.get_string(&lofty::tag::ItemKey::OriginalReleaseDate).unwrap_or_default().bright_green()); - println!("Recording Date: {:?}", tag.get_string(&lofty::tag::ItemKey::RecordingDate).unwrap_or_default().bright_green()); - println!("Copyright Message: {}", tag.get_string(&lofty::tag::ItemKey::CopyrightMessage).unwrap_or_default().bright_green()); - println!("Pictures: {:?}", pictures); - - let title = tag.get_string(&lofty::tag::ItemKey::TrackTitle).unwrap_or_default(); - let artist = tag.get_string(&lofty::tag::ItemKey::TrackArtist).unwrap_or_default(); - let album_artist = tag.get_string(&lofty::tag::ItemKey::AlbumArtist).unwrap_or_default(); - let album = tag.get_string(&lofty::tag::ItemKey::AlbumTitle).unwrap_or_default(); - let access_token = generate_token(&did)?; - - // check if track exists - // - // if not, create track - // upload album artist - // - // link path to track - - let hash = sha256::digest( - format!("{} - {} - {}", title, artist, album).to_lowercase(), - ); - - let track = get_track_by_hash(&pool, &hash).await?; - let duration = get_track_duration(&tmppath).await?; - let albumart_id = md5::compute(&format!("{} - {}", album_artist, album).to_lowercase()); - let albumart_id = format!("{:x}", albumart_id); - - match track { - Some(track) => { - println!("Track exists: {}", title.bright_green()); - let status = create_google_drive_path( - &pool, - &file, - &track, - &google_drive_id, - ) - .await?; + println!("Downloading file: {}", file.name.bright_green()); - println!("status: {:?}", status); - }, - None => { - println!("Creating track: {}", title.bright_green()); + let client = Client::new(); - let albumart = upload_album_cover(albumart_id.into(), pictures, &access_token).await?; + let url = format!("{}/files/{}", BASE_URL, file_id); + let res = client + .get(&url) + .bearer_auth(&access_token) + .query(&[("alt", "media")]) + .send() + .await?; + + let bytes = res.bytes().await?; + + let temp_dir = TempDir::new()?; + let tmppath = temp_dir.path().join(&format!("{}", file.name)); + let mut tmpfile = std::fs::File::create(&tmppath)?; + tmpfile.write_all(&bytes)?; + + println!( + "Reading file: {}", + &tmppath.clone().display().to_string().bright_green() + ); + + let tagged_file = match Probe::open(&tmppath)?.read() { + Ok(tagged_file) => tagged_file, + Err(e) => { + println!("Error opening file: {}", e); + return Ok(()); + } + }; + + let primary_tag = tagged_file.primary_tag(); + let tag = match primary_tag { + Some(tag) => tag, + None => { + println!("No tag found in file"); + return Ok(()); + } + }; + + let pictures = tag.pictures(); + + println!( + "Title: {}", + tag.get_string(&lofty::tag::ItemKey::TrackTitle) + .unwrap_or_default() + .bright_green() + ); + println!( + "Artist: {}", + tag.get_string(&lofty::tag::ItemKey::TrackArtist) + .unwrap_or_default() + .bright_green() + ); + println!( + "Album Artist: {}", + tag.get_string(&lofty::tag::ItemKey::AlbumArtist) + .unwrap_or_default() + .bright_green() + ); + println!( + "Album: {}", + tag.get_string(&lofty::tag::ItemKey::AlbumTitle) + .unwrap_or_default() + .bright_green() + ); + println!( + "Lyrics: {}", + tag.get_string(&lofty::tag::ItemKey::Lyrics) + .unwrap_or_default() + .bright_green() + ); + println!("Year: {}", tag.year().unwrap_or_default().bright_green()); + println!( + "Track Number: {}", + tag.track().unwrap_or_default().bright_green() + ); + println!( + "Track Total: {}", + tag.track_total().unwrap_or_default().bright_green() + ); + println!( + "Release Date: {:?}", + tag.get_string(&lofty::tag::ItemKey::OriginalReleaseDate) + .unwrap_or_default() + .bright_green() + ); + println!( + "Recording Date: {:?}", + tag.get_string(&lofty::tag::ItemKey::RecordingDate) + .unwrap_or_default() + .bright_green() + ); + println!( + "Copyright Message: {}", + tag.get_string(&lofty::tag::ItemKey::CopyrightMessage) + .unwrap_or_default() + .bright_green() + ); + println!("Pictures: {:?}", pictures); + + let title = tag + .get_string(&lofty::tag::ItemKey::TrackTitle) + .unwrap_or_default(); + let artist = tag + .get_string(&lofty::tag::ItemKey::TrackArtist) + .unwrap_or_default(); + let album_artist = tag + .get_string(&lofty::tag::ItemKey::AlbumArtist) + .unwrap_or_default(); + let album = tag + .get_string(&lofty::tag::ItemKey::AlbumTitle) + .unwrap_or_default(); + let access_token = generate_token(&did)?; + + // check if track exists + // + // if not, create track + // upload album artist + // + // link path to track + + let hash = sha256::digest(format!("{} - {} - {}", title, artist, album).to_lowercase()); - let client = Client::new(); - const URL: &str = "https://api.rocksky.app/tracks"; - let response = client + let track = get_track_by_hash(&pool, &hash).await?; + let duration = get_track_duration(&tmppath).await?; + let albumart_id = md5::compute(&format!("{} - {}", album_artist, album).to_lowercase()); + let albumart_id = format!("{:x}", albumart_id); + + match track { + Some(track) => { + println!("Track exists: {}", title.bright_green()); + let status = + create_google_drive_path(&pool, &file, &track, &google_drive_id).await?; + + println!("status: {:?}", status); + } + None => { + println!("Creating track: {}", title.bright_green()); + + let albumart = + upload_album_cover(albumart_id.into(), pictures, &access_token).await?; + + let client = Client::new(); + const URL: &str = "https://api.rocksky.app/tracks"; + let response = client .post(URL) .header("Authorization", format!("Bearer {}", access_token)) .json(&serde_json::json!({ @@ -260,118 +335,117 @@ pub fn scan_audio_files( })) .send() .await?; - println!("Track Saved: {} {}", title, response.status()); - tokio::time::sleep(std::time::Duration::from_secs(3)).await; + println!("Track Saved: {} {}", title, response.status()); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let track = get_track_by_hash(&pool, &hash).await?; + if let Some(track) = track { + let status = + create_google_drive_path(&pool, &file, &track, &google_drive_id).await; - let track = get_track_by_hash(&pool, &hash).await?; - if let Some(track) = track { - let status = create_google_drive_path( - &pool, - &file, - &track, - &google_drive_id, - ) - .await; + println!("status: {:?}", status); - println!("status: {:?}", status); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } - return Ok(()); + println!("Failed to create track: {}", title.bright_green()); + } } - println!("Failed to create track: {}", title.bright_green()); - } - } - - Ok(()) - }) + Ok(()) + }) } -pub async fn upload_album_cover(name: String, pictures: &[Picture], token: &str) -> Result, Error> { - if pictures.is_empty() { - return Ok(None); - } - - let picture = &pictures[0]; - - let buffer = match picture.mime_type() { - Some(MimeType::Jpeg) => Some(picture.data().to_vec()), - Some(MimeType::Png) => Some(picture.data().to_vec()), - Some(MimeType::Gif) => Some(picture.data().to_vec()), - Some(MimeType::Bmp) => Some(picture.data().to_vec()), - Some(MimeType::Tiff) => Some(picture.data().to_vec()), - _ => None - }; - - if buffer.is_none() { - return Ok(None); - } - - let buffer = buffer.unwrap(); - - - let ext = match picture.mime_type() { - Some(MimeType::Jpeg) => "jpg", - Some(MimeType::Png) => "png", - Some(MimeType::Gif) => "gif", - Some(MimeType::Bmp) => "bmp", - Some(MimeType::Tiff) => "tiff", - _ => { - return Ok(None); +pub async fn upload_album_cover( + name: String, + pictures: &[Picture], + token: &str, +) -> Result, Error> { + if pictures.is_empty() { + return Ok(None); } - }; - let name = format!("{}.{}", name, ext); + let picture = &pictures[0]; + + let buffer = match picture.mime_type() { + Some(MimeType::Jpeg) => Some(picture.data().to_vec()), + Some(MimeType::Png) => Some(picture.data().to_vec()), + Some(MimeType::Gif) => Some(picture.data().to_vec()), + Some(MimeType::Bmp) => Some(picture.data().to_vec()), + Some(MimeType::Tiff) => Some(picture.data().to_vec()), + _ => None, + }; - let part = multipart::Part::bytes(buffer).file_name(name.clone()); - let form = multipart::Form::new().part("file", part); - let client = Client::new(); + if buffer.is_none() { + return Ok(None); + } - const URL: &str = "https://uploads.rocksky.app"; + let buffer = buffer.unwrap(); - let response = client - .post(URL) - .header("Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await?; + let ext = match picture.mime_type() { + Some(MimeType::Jpeg) => "jpg", + Some(MimeType::Png) => "png", + Some(MimeType::Gif) => "gif", + Some(MimeType::Bmp) => "bmp", + Some(MimeType::Tiff) => "tiff", + _ => { + return Ok(None); + } + }; - println!("Cover uploaded: {}", response.status()); + let name = format!("{}.{}", name, ext); - Ok(Some(name)) -} + let part = multipart::Part::bytes(buffer).file_name(name.clone()); + let form = multipart::Form::new().part("file", part); + let client = Client::new(); -pub async fn get_track_duration(path: &Path) -> Result { - let duration = 0; - let media_source = MediaSourceStream::new(Box::new(std::fs::File::open(path)?), Default::default()); - let mut hint = Hint::new(); + const URL: &str = "https://uploads.rocksky.app"; - if let Some(extension) = path.extension() { - if let Some(extension) = extension.to_str() { - hint.with_extension(extension); - } - } + let response = client + .post(URL) + .header("Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await?; + println!("Cover uploaded: {}", response.status()); - let meta_opts = MetadataOptions::default(); - let format_opts = FormatOptions::default(); + Ok(Some(name)) +} - let probed = match symphonia::default::get_probe().format(&hint, media_source, &format_opts, &meta_opts) { - Ok(probed) => probed, - Err(_) => { - println!("Error probing file"); - return Ok(duration); - }, - }; +pub async fn get_track_duration(path: &Path) -> Result { + let duration = 0; + let media_source = + MediaSourceStream::new(Box::new(std::fs::File::open(path)?), Default::default()); + let mut hint = Hint::new(); + + if let Some(extension) = path.extension() { + if let Some(extension) = extension.to_str() { + hint.with_extension(extension); + } + } - if let Some(track) = probed.format.tracks().first() { - if let Some(duration) = track.codec_params.n_frames { - if let Some(sample_rate) = track.codec_params.sample_rate { - return Ok((duration as f64 / sample_rate as f64) as u64 * 1000); + let meta_opts = MetadataOptions::default(); + let format_opts = FormatOptions::default(); + + let probed = + match symphonia::default::get_probe().format(&hint, media_source, &format_opts, &meta_opts) + { + Ok(probed) => probed, + Err(_) => { + println!("Error probing file"); + return Ok(duration); + } + }; + + if let Some(track) = probed.format.tracks().first() { + if let Some(duration) = track.codec_params.n_frames { + if let Some(sample_rate) = track.codec_params.sample_rate { + return Ok((duration as f64 / sample_rate as f64) as u64 * 1000); + } } } + Ok(duration) } - Ok(duration) -} \ No newline at end of file diff --git a/crates/jetstream/src/main.rs b/crates/jetstream/src/main.rs index 66e089f0..595d5c1b 100644 --- a/crates/jetstream/src/main.rs +++ b/crates/jetstream/src/main.rs @@ -1,19 +1,23 @@ use std::env; -use subscriber::ScrobbleSubscriber; use dotenv::dotenv; +use subscriber::ScrobbleSubscriber; +pub mod profile; +pub mod repo; pub mod subscriber; pub mod types; pub mod xata; -pub mod repo; -pub mod profile; #[tokio::main] async fn main() -> Result<(), anyhow::Error> { dotenv()?; - let jetstream_server = env::var("JETSTREAM_SERVER").unwrap_or_else(|_| "wss://jetstream2.us-east.bsky.network".to_string()); - let url = format!("{}/subscribe?wantedCollections=app.rocksky.*", jetstream_server); + let jetstream_server = env::var("JETSTREAM_SERVER") + .unwrap_or_else(|_| "wss://jetstream2.us-east.bsky.network".to_string()); + let url = format!( + "{}/subscribe?wantedCollections=app.rocksky.*", + jetstream_server + ); let subscriber = ScrobbleSubscriber::new(&url); subscriber.run().await?; diff --git a/crates/jetstream/src/profile.rs b/crates/jetstream/src/profile.rs index 2f1c7317..5d7b5275 100644 --- a/crates/jetstream/src/profile.rs +++ b/crates/jetstream/src/profile.rs @@ -3,67 +3,80 @@ use anyhow::Error; use crate::types::{Profile, ProfileResponse}; pub async fn did_to_profile(did: &str) -> Result { - let client = reqwest::Client::new(); - let response = client.get(format!("https://plc.directory/{}", did)) - .header("Accept", "application/json") - .send() - .await? - .json::() - .await?; + let client = reqwest::Client::new(); + let response = client + .get(format!("https://plc.directory/{}", did)) + .header("Accept", "application/json") + .send() + .await? + .json::() + .await?; - let handle = response["alsoKnownAs"][0].as_str() - .unwrap_or("") - .split("at://") - .last() - .unwrap_or(""); + let handle = response["alsoKnownAs"][0] + .as_str() + .unwrap_or("") + .split("at://") + .last() + .unwrap_or(""); - let service_endpoint = response["service"][0]["serviceEndpoint"].as_str().unwrap_or(""); + let service_endpoint = response["service"][0]["serviceEndpoint"] + .as_str() + .unwrap_or(""); - if service_endpoint.is_empty() { - return Err(Error::msg("Invalid did")); - } + if service_endpoint.is_empty() { + return Err(Error::msg("Invalid did")); + } - let client = reqwest::Client::new(); - let mut response = client.get(format!("{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.bsky.actor.profile&rkey=self", service_endpoint, did)) + let client = reqwest::Client::new(); + let mut response = client.get(format!("{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.bsky.actor.profile&rkey=self", service_endpoint, did)) .header("Accept", "application/json") .send() .await? .json::() .await?; - response.value.handle = Some(handle.to_string()); - Ok(response.value) + response.value.handle = Some(handle.to_string()); + Ok(response.value) } #[cfg(test)] mod tests { - use super::*; - use anyhow::Result; + use super::*; + use anyhow::Result; - #[tokio::test] - async fn test_did_to_profile() -> Result<()> { - let did = "did:plc:7vdlgi2bflelz7mmuxoqjfcr"; - let profile = did_to_profile(did).await?; + #[tokio::test] + async fn test_did_to_profile() -> Result<()> { + let did = "did:plc:7vdlgi2bflelz7mmuxoqjfcr"; + let profile = did_to_profile(did).await?; - assert_eq!(profile.r#type, "app.bsky.actor.profile"); - assert!(profile.display_name.map(|s| s.starts_with("Tsiry Sandratraina")).unwrap_or(false)); - assert!(profile.handle.map(|s| s == "tsiry-sandratraina.com").unwrap_or(false)); + assert_eq!(profile.r#type, "app.bsky.actor.profile"); + assert!(profile + .display_name + .map(|s| s.starts_with("Tsiry Sandratraina")) + .unwrap_or(false)); + assert!(profile + .handle + .map(|s| s == "tsiry-sandratraina.com") + .unwrap_or(false)); - let did = "did:plc:fgvx5xqinqoqgpfhito5er3s"; - let profile = did_to_profile(did).await?; + let did = "did:plc:fgvx5xqinqoqgpfhito5er3s"; + let profile = did_to_profile(did).await?; - assert_eq!(profile.r#type, "app.bsky.actor.profile"); - assert!(profile.display_name.map(|s| s.starts_with("Lixtrix")).unwrap_or(false)); - assert!(profile.handle.map(|s| s == "lixtrix.art").unwrap_or(false)); + assert_eq!(profile.r#type, "app.bsky.actor.profile"); + assert!(profile + .display_name + .map(|s| s.starts_with("Lixtrix")) + .unwrap_or(false)); + assert!(profile.handle.map(|s| s == "lixtrix.art").unwrap_or(false)); - let did = "did:plc:d5jvs7uo4z6lw63zzreukgt4"; - let profile = did_to_profile(did).await?; - assert_eq!(profile.r#type, "app.bsky.actor.profile"); + let did = "did:plc:d5jvs7uo4z6lw63zzreukgt4"; + let profile = did_to_profile(did).await?; + assert_eq!(profile.r#type, "app.bsky.actor.profile"); - let did = "did:plc:gwxwdfmun3aqaiu5mx7nnyof"; - let profile = did_to_profile(did).await?; - assert_eq!(profile.r#type, "app.bsky.actor.profile"); + let did = "did:plc:gwxwdfmun3aqaiu5mx7nnyof"; + let profile = did_to_profile(did).await?; + assert_eq!(profile.r#type, "app.bsky.actor.profile"); - Ok(()) - } -} \ No newline at end of file + Ok(()) + } +} diff --git a/crates/jetstream/src/repo.rs b/crates/jetstream/src/repo.rs index 86233996..7410225a 100644 --- a/crates/jetstream/src/repo.rs +++ b/crates/jetstream/src/repo.rs @@ -6,42 +6,61 @@ use owo_colors::OwoColorize; use sqlx::{Pool, Postgres}; use tokio::sync::Mutex; -use crate::{profile::did_to_profile, subscriber::{ALBUM_NSID, ARTIST_NSID, SCROBBLE_NSID, SONG_NSID}, types::{AlbumRecord, ArtistRecord, Commit, ScrobbleRecord, SongRecord}, xata::{album::Album, album_track::AlbumTrack, artist::Artist, artist_album::ArtistAlbum, artist_track::ArtistTrack, track::Track, user::User, user_album::UserAlbum, user_artist::UserArtist, user_track::UserTrack}}; - -pub async fn save_scrobble(pool: Arc>>, did: &str, commit: Commit) -> Result<(), Error> { - // skip unknown collection - if !vec![ - SCROBBLE_NSID, - ARTIST_NSID, - ALBUM_NSID, - SONG_NSID, - ].contains(&commit.collection.as_str()) { - return Ok(()); - } +use crate::{ + profile::did_to_profile, + subscriber::{ALBUM_NSID, ARTIST_NSID, SCROBBLE_NSID, SONG_NSID}, + types::{AlbumRecord, ArtistRecord, Commit, ScrobbleRecord, SongRecord}, + xata::{ + album::Album, album_track::AlbumTrack, artist::Artist, artist_album::ArtistAlbum, + artist_track::ArtistTrack, track::Track, user::User, user_album::UserAlbum, + user_artist::UserArtist, user_track::UserTrack, + }, +}; + +pub async fn save_scrobble( + pool: Arc>>, + did: &str, + commit: Commit, +) -> Result<(), Error> { + // skip unknown collection + if !vec![SCROBBLE_NSID, ARTIST_NSID, ALBUM_NSID, SONG_NSID] + .contains(&commit.collection.as_str()) + { + return Ok(()); + } - let pool = pool.lock().await; + let pool = pool.lock().await; - match commit.operation.as_str() { - "create" => { - if commit.collection == SCROBBLE_NSID { - let mut tx = pool.begin().await?; - let scrobble_record: ScrobbleRecord = serde_json::from_value(commit.record.clone())?; + match commit.operation.as_str() { + "create" => { + if commit.collection == SCROBBLE_NSID { + let mut tx = pool.begin().await?; + let scrobble_record: ScrobbleRecord = + serde_json::from_value(commit.record.clone())?; - let album_id = save_album(&mut tx, scrobble_record.clone(), did).await?; - let artist_id = save_artist(&mut tx, scrobble_record.clone()).await?; - let track_id = save_track(&mut tx, scrobble_record.clone(), did).await?; + let album_id = save_album(&mut tx, scrobble_record.clone(), did).await?; + let artist_id = save_artist(&mut tx, scrobble_record.clone()).await?; + let track_id = save_track(&mut tx, scrobble_record.clone(), did).await?; - save_album_track(&mut tx, &album_id, &track_id).await?; - save_artist_track(&mut tx, &artist_id, &track_id).await?; - save_artist_album(&mut tx, &artist_id, &album_id).await?; + save_album_track(&mut tx, &album_id, &track_id).await?; + save_artist_track(&mut tx, &artist_id, &track_id).await?; + save_artist_album(&mut tx, &artist_id, &album_id).await?; - let uri = format!("at://{}/app.rocksky.scrobble/{}", did, commit.rkey); + let uri = format!("at://{}/app.rocksky.scrobble/{}", did, commit.rkey); - let user_id = save_user(&mut tx, did).await?; + let user_id = save_user(&mut tx, did).await?; - println!("Saving scrobble: {} ", format!("{} - {} - {}", scrobble_record.title, scrobble_record.artist, scrobble_record.album).magenta()); + println!( + "Saving scrobble: {} ", + format!( + "{} - {} - {}", + scrobble_record.title, scrobble_record.artist, scrobble_record.album + ) + .magenta() + ); - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO scrobbles ( album_id, artist_id, @@ -50,114 +69,135 @@ pub async fn save_scrobble(pool: Arc>>, did: &str, commit: user_id, timestamp ) VALUES ($1, $2, $3, $4, $5, $6) - "#) - .bind(album_id) - .bind(artist_id) - .bind(track_id) - .bind(uri) - .bind(user_id) - .bind(DateTime::parse_from_rfc3339(&scrobble_record.created_at).unwrap().with_timezone(&chrono::Utc)) - .execute(&mut *tx).await?; - - tx.commit().await?; - } - - if commit.collection == ARTIST_NSID { - let mut tx = pool.begin().await?; - - let user_id = save_user(&mut tx, did).await?; - let uri = format!("at://{}/app.rocksky.artist/{}", did, commit.rkey); - - let artist_record: ArtistRecord = serde_json::from_value(commit.record.clone())?; - save_user_artist(&mut tx, &user_id, artist_record.clone(), &uri).await?; - update_artist_uri(&mut tx, &user_id, artist_record, &uri).await?; - - tx.commit().await?; - } - - if commit.collection == ALBUM_NSID { - let mut tx = pool.begin().await?; - let user_id = save_user(&mut tx, did).await?; - let uri = format!("at://{}/app.rocksky.album/{}", did, commit.rkey); - - let album_record: AlbumRecord = serde_json::from_value(commit.record.clone())?; - save_user_album(&mut tx, &user_id, album_record.clone(), &uri).await?; - update_album_uri(&mut tx, &user_id, album_record, &uri).await?; - - tx.commit().await?; - } - - if commit.collection == SONG_NSID { - let mut tx = pool.begin().await?; - - let user_id = save_user(&mut tx, did).await?; - let uri = format!("at://{}/app.rocksky.song/{}", did, commit.rkey); - - let song_record: SongRecord = serde_json::from_value(commit.record.clone())?; - save_user_track(&mut tx, &user_id, song_record.clone(), &uri).await?; - update_track_uri(&mut tx, &user_id, song_record, &uri).await?; - - tx.commit().await?; - } - - }, - _ => { - println!("Unsupported operation: {}", commit.operation); + "#, + ) + .bind(album_id) + .bind(artist_id) + .bind(track_id) + .bind(uri) + .bind(user_id) + .bind( + DateTime::parse_from_rfc3339(&scrobble_record.created_at) + .unwrap() + .with_timezone(&chrono::Utc), + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + } + + if commit.collection == ARTIST_NSID { + let mut tx = pool.begin().await?; + + let user_id = save_user(&mut tx, did).await?; + let uri = format!("at://{}/app.rocksky.artist/{}", did, commit.rkey); + + let artist_record: ArtistRecord = serde_json::from_value(commit.record.clone())?; + save_user_artist(&mut tx, &user_id, artist_record.clone(), &uri).await?; + update_artist_uri(&mut tx, &user_id, artist_record, &uri).await?; + + tx.commit().await?; + } + + if commit.collection == ALBUM_NSID { + let mut tx = pool.begin().await?; + let user_id = save_user(&mut tx, did).await?; + let uri = format!("at://{}/app.rocksky.album/{}", did, commit.rkey); + + let album_record: AlbumRecord = serde_json::from_value(commit.record.clone())?; + save_user_album(&mut tx, &user_id, album_record.clone(), &uri).await?; + update_album_uri(&mut tx, &user_id, album_record, &uri).await?; + + tx.commit().await?; + } + + if commit.collection == SONG_NSID { + let mut tx = pool.begin().await?; + + let user_id = save_user(&mut tx, did).await?; + let uri = format!("at://{}/app.rocksky.song/{}", did, commit.rkey); + + let song_record: SongRecord = serde_json::from_value(commit.record.clone())?; + save_user_track(&mut tx, &user_id, song_record.clone(), &uri).await?; + update_track_uri(&mut tx, &user_id, song_record, &uri).await?; + + tx.commit().await?; + } + } + _ => { + println!("Unsupported operation: {}", commit.operation); + } } - } - Ok(()) + Ok(()) } +pub async fn save_user( + tx: &mut sqlx::Transaction<'_, Postgres>, + did: &str, +) -> Result { + let profile = did_to_profile(did).await?; -pub async fn save_user(tx: &mut sqlx::Transaction<'_, Postgres>, did: &str) -> Result { - let profile = did_to_profile(did).await?; + // Check if the user exists in the database + let mut users: Vec = sqlx::query_as("SELECT * FROM users WHERE did = $1") + .bind(did) + .fetch_all(&mut **tx) + .await?; - // Check if the user exists in the database - let mut users: Vec = sqlx::query_as("SELECT * FROM users WHERE did = $1") - .bind(did) - .fetch_all(&mut **tx) - .await?; + // If the user does not exist, create a new user + if users.is_empty() { + let avatar = profile.avatar.map(|blob| { + format!( + "https://cdn.bsky.app/img/avatar/plain/{}/{}@{}", + did, + blob.r#ref.link, + blob.mime_type.split('/').last().unwrap_or("jpeg") + ) + }); + sqlx::query( + "INSERT INTO users (display_name, did, handle, avatar) VALUES ($1, $2, $3, $4)", + ) + .bind(profile.display_name) + .bind(did) + .bind(profile.handle) + .bind(avatar) + .execute(&mut **tx) + .await?; - // If the user does not exist, create a new user - if users.is_empty() { - let avatar = profile.avatar.map(|blob| format!("https://cdn.bsky.app/img/avatar/plain/{}/{}@{}", did, blob.r#ref.link, blob.mime_type.split('/').last().unwrap_or("jpeg"))); - sqlx::query("INSERT INTO users (display_name, did, handle, avatar) VALUES ($1, $2, $3, $4)") - .bind(profile.display_name) - .bind(did) - .bind(profile.handle) - .bind(avatar) - .execute(&mut **tx).await?; - - users = sqlx::query_as("SELECT * FROM users WHERE did = $1") - .bind(did) - .fetch_all(&mut **tx) - .await?; - } - - Ok(users[0].xata_id.clone()) + users = sqlx::query_as("SELECT * FROM users WHERE did = $1") + .bind(did) + .fetch_all(&mut **tx) + .await?; + } + + Ok(users[0].xata_id.clone()) } -pub async fn save_track(tx: &mut sqlx::Transaction<'_, Postgres>, scrobble_record: ScrobbleRecord, did: &str) -> Result { - let uri: Option = None; - let hash = sha256::digest( - format!( - "{} - {} - {}", - scrobble_record.title, - scrobble_record.artist, - scrobble_record.album) - .to_lowercase() - ); - - let tracks: Vec = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; +pub async fn save_track( + tx: &mut sqlx::Transaction<'_, Postgres>, + scrobble_record: ScrobbleRecord, + did: &str, +) -> Result { + let uri: Option = None; + let hash = sha256::digest( + format!( + "{} - {} - {}", + scrobble_record.title, scrobble_record.artist, scrobble_record.album + ) + .to_lowercase(), + ); + + let tracks: Vec = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; - if !tracks.is_empty() { - return Ok(tracks[0].xata_id.clone()); - } + if !tracks.is_empty() { + return Ok(tracks[0].xata_id.clone()); + } - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO tracks ( title, artist, @@ -181,60 +221,74 @@ pub async fn save_track(tx: &mut sqlx::Transaction<'_, Postgres>, scrobble_recor ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 ) - "#) - .bind(scrobble_record.title) - .bind(scrobble_record.artist) - .bind(scrobble_record.album) - .bind(scrobble_record.album_art.map(|x| format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@{}", did, x.r#ref.link, x.mime_type.split('/').last().unwrap_or("jpeg")))) - .bind(scrobble_record.album_artist) - .bind(scrobble_record.track_number) - .bind(scrobble_record.duration) - .bind(scrobble_record.mbid) - .bind(scrobble_record.composer) - .bind(scrobble_record.lyrics) - .bind(scrobble_record.disc_number) - .bind(&hash) - .bind(scrobble_record.copyright_message) - .bind(uri) - .bind(scrobble_record.spotify_link) - .bind(scrobble_record.apple_music_link) - .bind(scrobble_record.tidal_link) - .bind(scrobble_record.youtube_link) - .bind(scrobble_record.label) - .execute(&mut **tx).await?; - - let tracks: Vec = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") + "#, + ) + .bind(scrobble_record.title) + .bind(scrobble_record.artist) + .bind(scrobble_record.album) + .bind(scrobble_record.album_art.map(|x| { + format!( + "https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@{}", + did, + x.r#ref.link, + x.mime_type.split('/').last().unwrap_or("jpeg") + ) + })) + .bind(scrobble_record.album_artist) + .bind(scrobble_record.track_number) + .bind(scrobble_record.duration) + .bind(scrobble_record.mbid) + .bind(scrobble_record.composer) + .bind(scrobble_record.lyrics) + .bind(scrobble_record.disc_number) .bind(&hash) - .fetch_all(&mut **tx) + .bind(scrobble_record.copyright_message) + .bind(uri) + .bind(scrobble_record.spotify_link) + .bind(scrobble_record.apple_music_link) + .bind(scrobble_record.tidal_link) + .bind(scrobble_record.youtube_link) + .bind(scrobble_record.label) + .execute(&mut **tx) .await?; - Ok(tracks[0].xata_id.clone()) + let tracks: Vec = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; + + Ok(tracks[0].xata_id.clone()) } -pub async fn save_album(tx: &mut sqlx::Transaction<'_, Postgres>, scrobble_record: ScrobbleRecord, did: &str) -> Result { - let hash = sha256::digest(format!( - "{} - {}", - scrobble_record.album, - scrobble_record.album_artist - ) - .to_lowercase() - ); +pub async fn save_album( + tx: &mut sqlx::Transaction<'_, Postgres>, + scrobble_record: ScrobbleRecord, + did: &str, +) -> Result { + let hash = sha256::digest( + format!( + "{} - {}", + scrobble_record.album, scrobble_record.album_artist + ) + .to_lowercase(), + ); - let albums: Vec = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; + let albums: Vec = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; - if !albums.is_empty() { - println!("Album already exists: {}", albums[0].title.magenta()); - return Ok(albums[0].xata_id.clone()); - } + if !albums.is_empty() { + println!("Album already exists: {}", albums[0].title.magenta()); + return Ok(albums[0].xata_id.clone()); + } - println!("Saving album: {}", scrobble_record.album.magenta()); + println!("Saving album: {}", scrobble_record.album.magenta()); - let uri: Option = None; - let artist_uri: Option = None; - sqlx::query(r#" + let uri: Option = None; + let artist_uri: Option = None; + sqlx::query( + r#" INSERT INTO albums ( title, artist, @@ -247,42 +301,55 @@ pub async fn save_album(tx: &mut sqlx::Transaction<'_, Postgres>, scrobble_recor ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8 ) - "#) - .bind(scrobble_record.album) - .bind(scrobble_record.album_artist) - .bind(scrobble_record.album_art.map(|x| format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@{}", did, x.r#ref.link, x.mime_type.split('/').last().unwrap_or("jpeg")))) - .bind(scrobble_record.year) - .bind(scrobble_record.release_date) - .bind(&hash) - .bind(uri) - .bind(artist_uri) - .execute(&mut **tx).await?; - - let albums: Vec = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") + "#, + ) + .bind(scrobble_record.album) + .bind(scrobble_record.album_artist) + .bind(scrobble_record.album_art.map(|x| { + format!( + "https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@{}", + did, + x.r#ref.link, + x.mime_type.split('/').last().unwrap_or("jpeg") + ) + })) + .bind(scrobble_record.year) + .bind(scrobble_record.release_date) .bind(&hash) - .fetch_all(&mut **tx) + .bind(uri) + .bind(artist_uri) + .execute(&mut **tx) .await?; - Ok(albums[0].xata_id.clone()) + let albums: Vec = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; + + Ok(albums[0].xata_id.clone()) } -pub async fn save_artist(tx: &mut sqlx::Transaction<'_, Postgres>, scrobble_record: ScrobbleRecord) -> Result { - let hash = sha256::digest(scrobble_record.album_artist.to_lowercase()); - let artists: Vec = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; +pub async fn save_artist( + tx: &mut sqlx::Transaction<'_, Postgres>, + scrobble_record: ScrobbleRecord, +) -> Result { + let hash = sha256::digest(scrobble_record.album_artist.to_lowercase()); + let artists: Vec = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; - if !artists.is_empty() { - println!("Artist already exists: {}", artists[0].name.magenta()); - return Ok(artists[0].xata_id.clone()); - } + if !artists.is_empty() { + println!("Artist already exists: {}", artists[0].name.magenta()); + return Ok(artists[0].xata_id.clone()); + } - println!("Saving artist: {}", scrobble_record.album_artist.magenta()); + println!("Saving artist: {}", scrobble_record.album_artist.magenta()); - let uri: Option = None; - let picture = ""; - sqlx::query(r#" + let uri: Option = None; + let picture = ""; + sqlx::query( + r#" INSERT INTO artists ( name, sha256, @@ -291,126 +358,175 @@ pub async fn save_artist(tx: &mut sqlx::Transaction<'_, Postgres>, scrobble_reco ) VALUES ( $1, $2, $3, $4 ) - "#) - .bind(scrobble_record.artist) - .bind(&hash) - .bind(uri) - .bind(picture) - .execute(&mut **tx).await?; - - let artists: Vec = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") + "#, + ) + .bind(scrobble_record.artist) .bind(&hash) - .fetch_all(&mut **tx) + .bind(uri) + .bind(picture) + .execute(&mut **tx) .await?; - Ok(artists[0].xata_id.clone()) -} + let artists: Vec = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; -pub async fn save_album_track(tx: &mut sqlx::Transaction<'_, Postgres>, album_id: &str, track_id: &str) -> Result<(), Error> { - let album_tracks : Vec = sqlx::query_as("SELECT * FROM album_tracks WHERE album_id = $1 AND track_id = $2") - .bind(album_id) - .bind(track_id) - .fetch_all(&mut **tx) - .await?; + Ok(artists[0].xata_id.clone()) +} - if !album_tracks.is_empty() { - println!("Album track already exists: {}", format!("{} - {}", album_id, track_id).magenta()); - return Ok(()); - } +pub async fn save_album_track( + tx: &mut sqlx::Transaction<'_, Postgres>, + album_id: &str, + track_id: &str, +) -> Result<(), Error> { + let album_tracks: Vec = + sqlx::query_as("SELECT * FROM album_tracks WHERE album_id = $1 AND track_id = $2") + .bind(album_id) + .bind(track_id) + .fetch_all(&mut **tx) + .await?; + + if !album_tracks.is_empty() { + println!( + "Album track already exists: {}", + format!("{} - {}", album_id, track_id).magenta() + ); + return Ok(()); + } - println!("Saving album track: {}", format!("{} - {}", album_id, track_id).magenta()); + println!( + "Saving album track: {}", + format!("{} - {}", album_id, track_id).magenta() + ); - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO album_tracks ( album_id, track_id ) VALUES ( $1, $2 ) - "#) - .bind(album_id) - .bind(track_id) - .execute(&mut **tx).await?; - Ok(()) -} - -pub async fn save_artist_track(tx: &mut sqlx::Transaction<'_, Postgres>, artist_id: &str, track_id: &str) -> Result<(), Error> { - let artist_tracks : Vec = sqlx::query_as("SELECT * FROM artist_tracks WHERE artist_id = $1 AND track_id = $2") - .bind(artist_id) + "#, + ) + .bind(album_id) .bind(track_id) - .fetch_all(&mut **tx) + .execute(&mut **tx) .await?; + Ok(()) +} - if !artist_tracks.is_empty() { - println!("Artist track already exists: {}", format!("{} - {}", artist_id, track_id).magenta()); - return Ok(()); - } +pub async fn save_artist_track( + tx: &mut sqlx::Transaction<'_, Postgres>, + artist_id: &str, + track_id: &str, +) -> Result<(), Error> { + let artist_tracks: Vec = + sqlx::query_as("SELECT * FROM artist_tracks WHERE artist_id = $1 AND track_id = $2") + .bind(artist_id) + .bind(track_id) + .fetch_all(&mut **tx) + .await?; + + if !artist_tracks.is_empty() { + println!( + "Artist track already exists: {}", + format!("{} - {}", artist_id, track_id).magenta() + ); + return Ok(()); + } - println!("Saving artist track: {}", format!("{} - {}", artist_id, track_id).magenta()); + println!( + "Saving artist track: {}", + format!("{} - {}", artist_id, track_id).magenta() + ); - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO artist_tracks ( artist_id, track_id ) VALUES ( $1, $2 ) - "#) - .bind(artist_id) - .bind(track_id) - .execute(&mut **tx).await?; - Ok(()) -} - -pub async fn save_artist_album(tx: &mut sqlx::Transaction<'_, Postgres>, artist_id: &str, album_id: &str) -> Result<(), Error> { - let artist_albums : Vec = sqlx::query_as("SELECT * FROM artist_albums WHERE artist_id = $1 AND album_id = $2") + "#, + ) .bind(artist_id) - .bind(album_id) - .fetch_all(&mut **tx) + .bind(track_id) + .execute(&mut **tx) .await?; + Ok(()) +} - if !artist_albums.is_empty() { - println!("Artist album already exists: {}", format!("{} - {}", artist_id, album_id).magenta()); - return Ok(()); - } +pub async fn save_artist_album( + tx: &mut sqlx::Transaction<'_, Postgres>, + artist_id: &str, + album_id: &str, +) -> Result<(), Error> { + let artist_albums: Vec = + sqlx::query_as("SELECT * FROM artist_albums WHERE artist_id = $1 AND album_id = $2") + .bind(artist_id) + .bind(album_id) + .fetch_all(&mut **tx) + .await?; + + if !artist_albums.is_empty() { + println!( + "Artist album already exists: {}", + format!("{} - {}", artist_id, album_id).magenta() + ); + return Ok(()); + } - println!("Saving artist album: {}", format!("{} - {}", artist_id, album_id).magenta()); + println!( + "Saving artist album: {}", + format!("{} - {}", artist_id, album_id).magenta() + ); - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO artist_albums ( artist_id, album_id ) VALUES ( $1, $2 ) - "#) - .bind(artist_id) - .bind(album_id) - .execute(&mut **tx).await?; - Ok(()) + "#, + ) + .bind(artist_id) + .bind(album_id) + .execute(&mut **tx) + .await?; + Ok(()) } +pub async fn save_user_artist( + tx: &mut sqlx::Transaction<'_, Postgres>, + user_id: &str, + record: ArtistRecord, + uri: &str, +) -> Result<(), Error> { + let hash = sha256::digest(record.name.to_lowercase()); -pub async fn save_user_artist(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: &str, record: ArtistRecord, uri: &str) -> Result<(), Error> { - let hash = sha256::digest(record.name.to_lowercase()); - - let mut artists: Vec = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; + let mut artists: Vec = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; - let users: Vec = sqlx::query_as("SELECT * FROM users WHERE xata_id = $1") - .bind(user_id) - .fetch_all(&mut **tx) - .await?; + let users: Vec = sqlx::query_as("SELECT * FROM users WHERE xata_id = $1") + .bind(user_id) + .fetch_all(&mut **tx) + .await?; - let artist_id: &str; + let artist_id: &str; - match artists.is_empty() { - true => { - println!("Saving artist: {}", record.name.magenta()); - let did = users[0].did.clone(); - sqlx::query(r#" + match artists.is_empty() { + true => { + println!("Saving artist: {}", record.name.magenta()); + let did = users[0].did.clone(); + sqlx::query( + r#" INSERT INTO artists ( name, sha256, @@ -419,48 +535,68 @@ pub async fn save_user_artist(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: ) VALUES ( $1, $2, $3, $4 ) - "#) - .bind(record.name) - .bind(&hash) - .bind(uri) - .bind(record.picture.map(|x| format!("https://cdn.bsky.app/img/avatar/plain/{}/{}@{}", did, x.r#ref.link, x.mime_type.split('/').last().unwrap_or("jpeg")))) - .execute(&mut **tx).await?; - - artists = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; - artist_id = &artists[0].xata_id; - }, - false => { - artist_id = &artists[0].xata_id; - } - }; - - let user_artists: Vec = sqlx::query_as("SELECT * FROM user_artists WHERE user_id = $1 AND artist_id = $2") - .bind(user_id) - .bind(artist_id) - .fetch_all(&mut **tx) - .await?; - - if !user_artists.is_empty() { - println!("User artist already exists: {}", format!("{} - {}", user_id, artist_id).magenta()); - sqlx::query(r#" + "#, + ) + .bind(record.name) + .bind(&hash) + .bind(uri) + .bind(record.picture.map(|x| { + format!( + "https://cdn.bsky.app/img/avatar/plain/{}/{}@{}", + did, + x.r#ref.link, + x.mime_type.split('/').last().unwrap_or("jpeg") + ) + })) + .execute(&mut **tx) + .await?; + + artists = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; + artist_id = &artists[0].xata_id; + } + false => { + artist_id = &artists[0].xata_id; + } + }; + + let user_artists: Vec = + sqlx::query_as("SELECT * FROM user_artists WHERE user_id = $1 AND artist_id = $2") + .bind(user_id) + .bind(artist_id) + .fetch_all(&mut **tx) + .await?; + + if !user_artists.is_empty() { + println!( + "User artist already exists: {}", + format!("{} - {}", user_id, artist_id).magenta() + ); + sqlx::query( + r#" UPDATE user_artists SET scrobbles = scrobbles + 1, uri = $3 WHERE user_id = $1 AND artist_id = $2 - "#) - .bind(user_id) - .bind(artist_id) - .bind(uri) - .execute(&mut **tx).await?; - return Ok(()); - } + "#, + ) + .bind(user_id) + .bind(artist_id) + .bind(uri) + .execute(&mut **tx) + .await?; + return Ok(()); + } - println!("Saving user artist: {}", format!("{} - {}", user_id, artist_id).magenta()); + println!( + "Saving user artist: {}", + format!("{} - {}", user_id, artist_id).magenta() + ); - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO user_artists ( user_id, artist_id, @@ -469,40 +605,42 @@ pub async fn save_user_artist(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: ) VALUES ( $1, $2, $3, $4 ) - "#) - .bind(user_id) - .bind(artist_id) - .bind(uri) - .bind(1) - .execute(&mut **tx).await?; - Ok(()) -} - -pub async fn save_user_album(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: &str, record: AlbumRecord, uri: &str) -> Result<(), Error> { - let users: Vec = sqlx::query_as("SELECT * FROM users WHERE xata_id = $1") + "#, + ) .bind(user_id) - .fetch_all(&mut **tx) + .bind(artist_id) + .bind(uri) + .bind(1) + .execute(&mut **tx) .await?; + Ok(()) +} - let hash = sha256::digest(format!( - "{} - {}", - record.title, - record.artist - ) - .to_lowercase() - ); - let mut albums: Vec = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; +pub async fn save_user_album( + tx: &mut sqlx::Transaction<'_, Postgres>, + user_id: &str, + record: AlbumRecord, + uri: &str, +) -> Result<(), Error> { + let users: Vec = sqlx::query_as("SELECT * FROM users WHERE xata_id = $1") + .bind(user_id) + .fetch_all(&mut **tx) + .await?; - let album_id: &str; + let hash = sha256::digest(format!("{} - {}", record.title, record.artist).to_lowercase()); + let mut albums: Vec = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; + + let album_id: &str; - match albums.is_empty() { - true => { - println!("Saving album: {}", record.title.magenta()); - let did = users[0].did.clone(); - sqlx::query(r#" + match albums.is_empty() { + true => { + println!("Saving album: {}", record.title.magenta()); + let did = users[0].did.clone(); + sqlx::query( + r#" INSERT INTO albums ( title, artist, @@ -514,51 +652,71 @@ pub async fn save_user_album(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: ) VALUES ( $1, $2, $3, $4, $5, $6, $7 ) - "#) - .bind(record.title) - .bind(record.artist) - .bind(record.album_art.map(|x| format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@{}", did, x.r#ref.link, x.mime_type.split('/').last().unwrap_or("jpeg")))) - .bind(record.year) - .bind(record.release_date) - .bind(&hash) - .bind(uri) - .execute(&mut **tx).await?; - - albums = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; - album_id = &albums[0].xata_id; - }, - false => { - album_id = &albums[0].xata_id; - } - }; - - let user_albums: Vec = sqlx::query_as("SELECT * FROM user_albums WHERE user_id = $1 AND album_id = $2") - .bind(user_id) - .bind(album_id) - .fetch_all(&mut **tx) - .await?; - - if !user_albums.is_empty() { - println!("User album already exists: {}", format!("{} - {}", user_id, album_id).magenta()); - sqlx::query(r#" + "#, + ) + .bind(record.title) + .bind(record.artist) + .bind(record.album_art.map(|x| { + format!( + "https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@{}", + did, + x.r#ref.link, + x.mime_type.split('/').last().unwrap_or("jpeg") + ) + })) + .bind(record.year) + .bind(record.release_date) + .bind(&hash) + .bind(uri) + .execute(&mut **tx) + .await?; + + albums = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; + album_id = &albums[0].xata_id; + } + false => { + album_id = &albums[0].xata_id; + } + }; + + let user_albums: Vec = + sqlx::query_as("SELECT * FROM user_albums WHERE user_id = $1 AND album_id = $2") + .bind(user_id) + .bind(album_id) + .fetch_all(&mut **tx) + .await?; + + if !user_albums.is_empty() { + println!( + "User album already exists: {}", + format!("{} - {}", user_id, album_id).magenta() + ); + sqlx::query( + r#" UPDATE user_albums SET scrobbles = scrobbles + 1, uri = $3 WHERE user_id = $1 AND album_id = $2 - "#) - .bind(user_id) - .bind(album_id) - .bind(uri) - .execute(&mut **tx).await?; - return Ok(()); - } + "#, + ) + .bind(user_id) + .bind(album_id) + .bind(uri) + .execute(&mut **tx) + .await?; + return Ok(()); + } - println!("Saving user album: {}", format!("{} - {}", user_id, album_id).magenta()); + println!( + "Saving user album: {}", + format!("{} - {}", user_id, album_id).magenta() + ); - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO user_albums ( user_id, album_id, @@ -567,42 +725,45 @@ pub async fn save_user_album(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: ) VALUES ( $1, $2, $3, $4 ) - "#) - .bind(user_id) - .bind(album_id) - .bind(uri) - .bind(1) - .execute(&mut **tx).await?; - Ok(()) -} - -pub async fn save_user_track(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: &str, record: SongRecord, uri: &str) -> Result<(), Error> { - let hash = sha256::digest(format!( - "{} - {} - {}", - record.title, - record.artist, - record.album + "#, ) - .to_lowercase() - ); - - let mut tracks: Vec = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; - - let users: Vec = sqlx::query_as("SELECT * FROM users WHERE xata_id = $1") .bind(user_id) - .fetch_all(&mut **tx) + .bind(album_id) + .bind(uri) + .bind(1) + .execute(&mut **tx) .await?; + Ok(()) +} - let track_id: &str; +pub async fn save_user_track( + tx: &mut sqlx::Transaction<'_, Postgres>, + user_id: &str, + record: SongRecord, + uri: &str, +) -> Result<(), Error> { + let hash = sha256::digest( + format!("{} - {} - {}", record.title, record.artist, record.album).to_lowercase(), + ); + + let mut tracks: Vec = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; - match tracks.is_empty() { - true => { - println!("Saving track: {}", record.title.magenta()); - let did = users[0].did.clone(); - sqlx::query(r#" + let users: Vec = sqlx::query_as("SELECT * FROM users WHERE xata_id = $1") + .bind(user_id) + .fetch_all(&mut **tx) + .await?; + + let track_id: &str; + + match tracks.is_empty() { + true => { + println!("Saving track: {}", record.title.magenta()); + let did = users[0].did.clone(); + sqlx::query( + r#" INSERT INTO tracks ( title, artist, @@ -623,61 +784,81 @@ pub async fn save_user_track(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 ) - "#) - .bind(record.title) - .bind(record.artist) - .bind(record.album) - .bind(record.album_art.map(|x| format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@{}", did, x.r#ref.link, x.mime_type.split('/').last().unwrap_or("jpeg")))) - .bind(record.album_artist) - .bind(record.track_number) - .bind(record.duration) - .bind(record.mbid) - .bind(record.composer) - .bind(record.lyrics) - .bind(record.disc_number) - .bind(&hash) - .bind(record.copyright_message) - .bind(uri) - .bind(record.spotify_link) - .bind(record.label) - .execute(&mut **tx).await?; - - tracks = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; - - track_id = &tracks[0].xata_id; - }, - false => { - track_id = &tracks[0].xata_id; + "#, + ) + .bind(record.title) + .bind(record.artist) + .bind(record.album) + .bind(record.album_art.map(|x| { + format!( + "https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@{}", + did, + x.r#ref.link, + x.mime_type.split('/').last().unwrap_or("jpeg") + ) + })) + .bind(record.album_artist) + .bind(record.track_number) + .bind(record.duration) + .bind(record.mbid) + .bind(record.composer) + .bind(record.lyrics) + .bind(record.disc_number) + .bind(&hash) + .bind(record.copyright_message) + .bind(uri) + .bind(record.spotify_link) + .bind(record.label) + .execute(&mut **tx) + .await?; + + tracks = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; + + track_id = &tracks[0].xata_id; + } + false => { + track_id = &tracks[0].xata_id; + } } - } - - let user_tracks: Vec = sqlx::query_as("SELECT * FROM user_tracks WHERE user_id = $1 AND track_id = $2") - .bind(user_id) - .bind(track_id) - .fetch_all(&mut **tx) - .await?; - if !user_tracks.is_empty() { - println!("User track already exists: {}", format!("{} - {}", user_id, track_id).magenta()); - sqlx::query(r#" + let user_tracks: Vec = + sqlx::query_as("SELECT * FROM user_tracks WHERE user_id = $1 AND track_id = $2") + .bind(user_id) + .bind(track_id) + .fetch_all(&mut **tx) + .await?; + + if !user_tracks.is_empty() { + println!( + "User track already exists: {}", + format!("{} - {}", user_id, track_id).magenta() + ); + sqlx::query( + r#" UPDATE user_tracks SET scrobbles = scrobbles + 1, uri = $3 WHERE user_id = $1 AND track_id = $2 - "#) - .bind(user_id) - .bind(track_id) - .bind(uri) - .execute(&mut **tx).await?; - return Ok(()); - } + "#, + ) + .bind(user_id) + .bind(track_id) + .bind(uri) + .execute(&mut **tx) + .await?; + return Ok(()); + } - println!("Saving user track: {}", format!("{} - {}", user_id, track_id).magenta()); + println!( + "Saving user track: {}", + format!("{} - {}", user_id, track_id).magenta() + ); - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO user_tracks ( user_id, track_id, @@ -686,156 +867,188 @@ pub async fn save_user_track(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: ) VALUES ( $1, $2, $3, $4 ) - "#) - .bind(user_id) - .bind(track_id) - .bind(uri) - .bind(1) - .execute(&mut **tx).await?; - - Ok(()) + "#, + ) + .bind(user_id) + .bind(track_id) + .bind(uri) + .bind(1) + .execute(&mut **tx) + .await?; + + Ok(()) } -pub async fn update_artist_uri(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: &str, record: ArtistRecord, uri: &str) -> Result<(), Error> { - let hash = sha256::digest(record.name.to_lowercase()); - let artists: Vec = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) - .await?; +pub async fn update_artist_uri( + tx: &mut sqlx::Transaction<'_, Postgres>, + user_id: &str, + record: ArtistRecord, + uri: &str, +) -> Result<(), Error> { + let hash = sha256::digest(record.name.to_lowercase()); + let artists: Vec = sqlx::query_as("SELECT * FROM artists WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; - if artists.is_empty() { - println!("Artist not found: {}", record.name.magenta()); - return Ok(()); - } + if artists.is_empty() { + println!("Artist not found: {}", record.name.magenta()); + return Ok(()); + } - let artist_id = &artists[0].xata_id; + let artist_id = &artists[0].xata_id; - sqlx::query(r#" + sqlx::query( + r#" UPDATE user_artists SET uri = $3 WHERE user_id = $1 AND artist_id = $2 - "#) - .bind(user_id) - .bind(artist_id) - .bind(uri) - .execute(&mut **tx).await?; + "#, + ) + .bind(user_id) + .bind(artist_id) + .bind(uri) + .execute(&mut **tx) + .await?; - sqlx::query(r#" + sqlx::query( + r#" UPDATE tracks SET artist_uri = $2 WHERE artist_uri IS NULL AND album_artist = $1 - "#) - .bind(&record.name) - .bind(uri) - .execute(&mut **tx).await?; + "#, + ) + .bind(&record.name) + .bind(uri) + .execute(&mut **tx) + .await?; - sqlx::query(r#" + sqlx::query( + r#" UPDATE artists SET uri = $2 WHERE sha256 = $1 AND uri IS NULL - "#) - .bind(&hash) - .bind(uri) - .execute(&mut **tx).await?; + "#, + ) + .bind(&hash) + .bind(uri) + .execute(&mut **tx) + .await?; - sqlx::query(r#" + sqlx::query( + r#" UPDATE albums SET artist_uri = $2 WHERE artist_uri IS NULL AND artist = $1 - "#) - .bind(&record.name) - .bind(uri) - .execute(&mut **tx).await?; - Ok(()) -} - -pub async fn update_album_uri(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: &str, record: AlbumRecord, uri: &str) -> Result<(), Error> { - let hash = sha256::digest(format!( - "{} - {}", - record.title, - record.artist + "#, ) - .to_lowercase() - ); - let albums: Vec = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") - .bind(&hash) - .fetch_all(&mut **tx) + .bind(&record.name) + .bind(uri) + .execute(&mut **tx) .await?; - if albums.is_empty() { - println!("Album not found: {}", record.title.magenta()); - return Ok(()); - } - let album_id = &albums[0].xata_id; - sqlx::query(r#" + Ok(()) +} + +pub async fn update_album_uri( + tx: &mut sqlx::Transaction<'_, Postgres>, + user_id: &str, + record: AlbumRecord, + uri: &str, +) -> Result<(), Error> { + let hash = sha256::digest(format!("{} - {}", record.title, record.artist).to_lowercase()); + let albums: Vec = sqlx::query_as("SELECT * FROM albums WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; + if albums.is_empty() { + println!("Album not found: {}", record.title.magenta()); + return Ok(()); + } + let album_id = &albums[0].xata_id; + sqlx::query( + r#" UPDATE user_albums SET uri = $3 WHERE user_id = $1 AND album_id = $2 - "#) - .bind(user_id) - .bind(album_id) - .bind(uri) - .execute(&mut **tx).await?; + "#, + ) + .bind(user_id) + .bind(album_id) + .bind(uri) + .execute(&mut **tx) + .await?; - sqlx::query(r#" + sqlx::query( + r#" UPDATE tracks SET album_uri = $2 WHERE album_uri IS NULL AND album = $1 - "#) - .bind(record.title) - .bind(uri) - .execute(&mut **tx).await?; + "#, + ) + .bind(record.title) + .bind(uri) + .execute(&mut **tx) + .await?; - sqlx::query(r#" + sqlx::query( + r#" UPDATE albums SET uri = $2 WHERE sha256 = $1 AND uri IS NULL - "#) - .bind(&hash) - .bind(uri) - .execute(&mut **tx).await?; - - Ok(()) -} - -pub async fn update_track_uri(tx: &mut sqlx::Transaction<'_, Postgres>, user_id: &str, record: SongRecord, uri: &str) -> Result<(), Error> { - let hash = sha256::digest(format!( - "{} - {} - {}", - record.title, - record.artist, - record.album + "#, ) - .to_lowercase() - ); - let tracks: Vec = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") .bind(&hash) - .fetch_all(&mut **tx) + .bind(uri) + .execute(&mut **tx) .await?; - if tracks.is_empty() { - println!("Track not found: {}", record.title.magenta()); - return Ok(()); - } + Ok(()) +} + +pub async fn update_track_uri( + tx: &mut sqlx::Transaction<'_, Postgres>, + user_id: &str, + record: SongRecord, + uri: &str, +) -> Result<(), Error> { + let hash = sha256::digest( + format!("{} - {} - {}", record.title, record.artist, record.album).to_lowercase(), + ); + let tracks: Vec = sqlx::query_as("SELECT * FROM tracks WHERE sha256 = $1") + .bind(&hash) + .fetch_all(&mut **tx) + .await?; + + if tracks.is_empty() { + println!("Track not found: {}", record.title.magenta()); + return Ok(()); + } - let track_id = &tracks[0].xata_id; - sqlx::query(r#" + let track_id = &tracks[0].xata_id; + sqlx::query( + r#" UPDATE user_tracks SET uri = $3 WHERE user_id = $1 AND track_id = $2 - "#) - .bind(user_id) - .bind(track_id) - .bind(uri) - .execute(&mut **tx).await?; + "#, + ) + .bind(user_id) + .bind(track_id) + .bind(uri) + .execute(&mut **tx) + .await?; - sqlx::query(r#" + sqlx::query( + r#" UPDATE tracks SET uri = $2 WHERE sha256 = $1 AND uri IS NULL - "#) - .bind(&hash) - .bind(uri) - .execute(&mut **tx).await?; + "#, + ) + .bind(&hash) + .bind(uri) + .execute(&mut **tx) + .await?; - Ok(()) + Ok(()) } - diff --git a/crates/jetstream/src/subscriber.rs b/crates/jetstream/src/subscriber.rs index 953bad71..9698b359 100644 --- a/crates/jetstream/src/subscriber.rs +++ b/crates/jetstream/src/subscriber.rs @@ -1,13 +1,12 @@ use std::{env, sync::Arc}; -use anyhow::{Error, Context}; +use anyhow::{Context, Error}; use futures_util::StreamExt; use owo_colors::OwoColorize; use sqlx::postgres::PgPoolOptions; use tokio::sync::Mutex; use tokio_tungstenite::{connect_async, tungstenite::Message}; - use crate::{repo::save_scrobble, types::Root}; pub const SCROBBLE_NSID: &str = "app.rocksky.scrobble"; @@ -18,7 +17,6 @@ pub const PLAYLIST_NSID: &str = "app.rocksky.playlist"; pub const LIKE_NSID: &str = "app.rocksky.like"; pub const SHOUT_NSID: &str = "app.rocksky.shout"; - pub struct ScrobbleSubscriber { pub service_url: String, } @@ -35,12 +33,17 @@ impl ScrobbleSubscriber { let db_url = env::var("XATA_POSTGRES_URL") .context("Failed to get XATA_POSTGRES_URL environment variable")?; - let pool = PgPoolOptions::new().max_connections(5) - .connect(&db_url).await?; + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&db_url) + .await?; let pool = Arc::new(Mutex::new(pool)); let (mut ws_stream, _) = connect_async(&self.service_url).await?; - println!("Connected to jetstream at {}", self.service_url.bright_green()); + println!( + "Connected to jetstream at {}", + self.service_url.bright_green() + ); while let Some(msg) = ws_stream.next().await { match msg { @@ -56,15 +59,11 @@ impl ScrobbleSubscriber { } } - Ok(()) } } -async fn handle_message( - pool: Arc>, - msg: Message, -) -> Result<(), Error> { +async fn handle_message(pool: Arc>, msg: Message) -> Result<(), Error> { tokio::spawn(async move { if let Message::Text(text) = msg { let message: Root = serde_json::from_str(&text)?; diff --git a/crates/playlists/src/core.rs b/crates/playlists/src/core.rs index fb08f1d0..6e47b996 100644 --- a/crates/playlists/src/core.rs +++ b/crates/playlists/src/core.rs @@ -1,4 +1,7 @@ -use std::{env, sync::{Arc, Mutex}}; +use std::{ + env, + sync::{Arc, Mutex}, +}; use anyhow::Error; use duckdb::{params, Connection}; @@ -8,7 +11,11 @@ use serde_json::json; use sha2::Digest; use sqlx::{Pool, Postgres}; -use crate::{crypto::{decrypt_aes_256_ctr, generate_token}, types::{self, spotify_token::SpotifyTokenWithEmail}, xata::{self, track::Track}}; +use crate::{ + crypto::{decrypt_aes_256_ctr, generate_token}, + types::{self, spotify_token::SpotifyTokenWithEmail}, + xata::{self, track::Track}, +}; const ROCKSKY_API: &str = "https://api.rocksky.app"; @@ -84,19 +91,20 @@ pub fn create_tables(conn: Arc>) -> Result<(), Error> { Ok(()) } - pub async fn load_users(conn: Arc>, pool: &Pool) -> Result<(), Error> { - let conn = conn.lock().unwrap(); - let users: Vec = sqlx::query_as(r#" + let conn = conn.lock().unwrap(); + let users: Vec = sqlx::query_as( + r#" SELECT * FROM users - "#) - .fetch_all(pool) - .await?; + "#, + ) + .fetch_all(pool) + .await?; - for (i, user) in users.clone().into_iter().enumerate() { - println!("user {} - {}", i, user.display_name.bright_green()); - match conn.execute( - "INSERT INTO users ( + for (i, user) in users.clone().into_iter().enumerate() { + println!("user {} - {}", i, user.display_name.bright_green()); + match conn.execute( + "INSERT INTO users ( id, display_name, did, @@ -107,58 +115,77 @@ pub async fn load_users(conn: Arc>, pool: &Pool) -> ?, ?, ?) ON CONFLICT DO NOTHING", - params![ - user.xata_id, - user.display_name, - user.did, - user.handle, - user.avatar, - ], - ) { - Ok(_) => (), - Err(e) => println!("error: {}", e), - } - } + params![ + user.xata_id, + user.display_name, + user.did, + user.handle, + user.avatar, + ], + ) { + Ok(_) => (), + Err(e) => println!("error: {}", e), + } + } - println!("users: {:?}", users.len()); - Ok(()) + println!("users: {:?}", users.len()); + Ok(()) } pub async fn find_spotify_users( - pool: &Pool, - offset: usize, - limit: usize + pool: &Pool, + offset: usize, + limit: usize, ) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" + let results: Vec = sqlx::query_as( + r#" SELECT * FROM spotify_tokens LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id LEFT JOIN users ON spotify_accounts.user_id = users.xata_id LIMIT $1 OFFSET $2 - "#) + "#, + ) .bind(limit as i64) .bind(offset as i64) .fetch_all(pool) .await?; - let mut user_tokens = vec![]; - - for result in &results { - let token = decrypt_aes_256_ctr( - &result.refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - user_tokens.push((result.email.clone(), token, result.did.clone(), result.user_id.clone())); - } + let mut user_tokens = vec![]; + + for result in &results { + let token = decrypt_aes_256_ctr( + &result.refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; + user_tokens.push(( + result.email.clone(), + token, + result.did.clone(), + result.user_id.clone(), + )); + } - Ok(user_tokens) + Ok(user_tokens) } -pub async fn save_playlists(pool: &Pool, conn: Arc>, nc: Arc>, playlists: Vec, user_id: &str, did: &str) -> Result<(), Error> { - let token = generate_token(did)?; - for playlist in playlists { - println!("Saving playlist: {} - {} tracks", playlist.name.bright_green(), playlist.tracks.total); - - sqlx::query(r#" +pub async fn save_playlists( + pool: &Pool, + conn: Arc>, + nc: Arc>, + playlists: Vec, + user_id: &str, + did: &str, +) -> Result<(), Error> { + let token = generate_token(did)?; + for playlist in playlists { + println!( + "Saving playlist: {} - {} tracks", + playlist.name.bright_green(), + playlist.tracks.total + ); + + sqlx::query( + r#" INSERT INTO playlists (name, description, picture, spotify_link, created_by) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (spotify_link) DO UPDATE set @@ -167,88 +194,105 @@ pub async fn save_playlists(pool: &Pool, conn: Arc>, picture = EXCLUDED.picture, spotify_link = EXCLUDED.spotify_link, created_by = EXCLUDED.created_by - "#) - .bind(playlist.name) - .bind(playlist.description) - .bind(playlist.images.first().map(|i| i.url.clone())) - .bind(&playlist.external_urls.spotify) - .bind(user_id) - .execute(pool) - .await?; - - let new_playlist: Vec = sqlx::query_as(r#"SELECT * FROM playlists WHERE spotify_link = $1"#) - .bind(&playlist.external_urls.spotify) - .fetch_all(pool) - .await?; - - let new_playlist = new_playlist.first().unwrap(); - - let nc = nc.lock().unwrap(); - nc.publish("rocksky.playlist", - serde_json::to_string(&json!({ - "id": new_playlist.xata_id.clone(), - "did": did, - }) - ).unwrap().into() - ).await?; - drop(nc); + "#, + ) + .bind(playlist.name) + .bind(playlist.description) + .bind(playlist.images.first().map(|i| i.url.clone())) + .bind(&playlist.external_urls.spotify) + .bind(user_id) + .execute(pool) + .await?; - let mut tracks_to_save: Vec<(String, String)> = vec![]; - let mut i = 1; - for track in playlist.tracks.items.unwrap_or_default() { - println!("Saving track: {} - {}/{}", track.track.name.bright_green(), i, playlist.tracks.total); - i += 1; - match save_track(track.track, &token).await? { - Some(track) => { - println!("Saved track: {}", track.xata_id.bright_green()); - tracks_to_save.push((new_playlist.xata_id.clone(), track.xata_id.clone())); - }, - None => { - println!("Failed to save track"); + let new_playlist: Vec = + sqlx::query_as(r#"SELECT * FROM playlists WHERE spotify_link = $1"#) + .bind(&playlist.external_urls.spotify) + .fetch_all(pool) + .await?; + + let new_playlist = new_playlist.first().unwrap(); + + let nc = nc.lock().unwrap(); + nc.publish( + "rocksky.playlist", + serde_json::to_string(&json!({ + "id": new_playlist.xata_id.clone(), + "did": did, + })) + .unwrap() + .into(), + ) + .await?; + drop(nc); + + let mut tracks_to_save: Vec<(String, String)> = vec![]; + let mut i = 1; + for track in playlist.tracks.items.unwrap_or_default() { + println!( + "Saving track: {} - {}/{}", + track.track.name.bright_green(), + i, + playlist.tracks.total + ); + i += 1; + match save_track(track.track, &token).await? { + Some(track) => { + println!("Saved track: {}", track.xata_id.bright_green()); + tracks_to_save.push((new_playlist.xata_id.clone(), track.xata_id.clone())); + } + None => { + println!("Failed to save track"); + } + }; } - }; - } - // delete all tracks from playlist - sqlx::query(r#" + // delete all tracks from playlist + sqlx::query( + r#" DELETE FROM playlist_tracks WHERE playlist_id = $1 - "#) - .bind(&new_playlist.xata_id) - .execute(pool) - .await?; + "#, + ) + .bind(&new_playlist.xata_id) + .execute(pool) + .await?; - // save tracks to playlist - for (playlist_id, track_id) in tracks_to_save { - sqlx::query(r#" + // save tracks to playlist + for (playlist_id, track_id) in tracks_to_save { + sqlx::query( + r#" INSERT INTO playlist_tracks (playlist_id, track_id) VALUES ($1, $2) ON CONFLICT DO NOTHING - "#) - .bind(&playlist_id) - .bind(&track_id) - .execute(pool) - .await?; - } + "#, + ) + .bind(&playlist_id) + .bind(&track_id) + .execute(pool) + .await?; + } - sqlx::query(r#" + sqlx::query( + r#" INSERT INTO user_playlists (user_id, playlist_id) VALUES ($1, $2) ON CONFLICT (user_id, playlist_id) DO NOTHING - "#) - .bind(user_id) - .bind(&new_playlist.xata_id) - .execute(pool) - .await?; + "#, + ) + .bind(user_id) + .bind(&new_playlist.xata_id) + .execute(pool) + .await?; - let user_playlist: Vec = sqlx::query_as("SELECT * FROM user_playlists WHERE user_id = $1 AND playlist_id = $2") - .bind(user_id) - .bind(&new_playlist.xata_id) - .fetch_all(pool) - .await?; - let user_playlist = user_playlist.first().unwrap(); + let user_playlist: Vec = + sqlx::query_as("SELECT * FROM user_playlists WHERE user_id = $1 AND playlist_id = $2") + .bind(user_id) + .bind(&new_playlist.xata_id) + .fetch_all(pool) + .await?; + let user_playlist = user_playlist.first().unwrap(); - let conn = conn.lock().unwrap(); - conn.execute("INSERT INTO playlists (id, name, description, picture, spotify_link, uri, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING", + let conn = conn.lock().unwrap(); + conn.execute("INSERT INTO playlists (id, name, description, picture, spotify_link, uri, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING", params![ &new_playlist.xata_id, &new_playlist.name, @@ -260,7 +304,7 @@ pub async fn save_playlists(pool: &Pool, conn: Arc>, ] )?; - conn.execute( + conn.execute( "INSERT INTO user_playlists (id, user_id, playlist_id, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", params![ &user_playlist.xata_id, @@ -269,14 +313,15 @@ pub async fn save_playlists(pool: &Pool, conn: Arc>, chrono::Utc::now() ] )?; - - } - Ok(()) + } + Ok(()) } - -pub async fn save_track(track: types::playlist::Track, token: &str) -> Result, Error> { - let client = Client::new(); +pub async fn save_track( + track: types::playlist::Track, + token: &str, +) -> Result, Error> { + let client = Client::new(); let response = client .post(&format!("{}/tracks", ROCKSKY_API)) .bearer_auth(token) @@ -304,18 +349,35 @@ pub async fn save_track(track: types::playlist::Track, token: &str) -> Result>().join(", "), track.album.name).to_lowercase().as_bytes())); + let sha256 = format!( + "{:x}", + sha2::Sha256::digest( + format!( + "{} - {} - {}", + track.name, + track + .artists + .iter() + .map(|artist| artist.name.clone()) + .collect::>() + .join(", "), + track.album.name + ) + .to_lowercase() + .as_bytes() + ) + ); // get by sha256 let response = client - .get(&format!("{}/tracks/{}", ROCKSKY_API, sha256)) - .bearer_auth(token) - .send() - .await?; + .get(&format!("{}/tracks/{}", ROCKSKY_API, sha256)) + .bearer_auth(token) + .send() + .await?; // wait 6 seconds to avoid rate limiting tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; @@ -323,10 +385,10 @@ pub async fn save_track(track: types::playlist::Track, token: &str) -> Result Result<(), Error> { @@ -24,11 +27,12 @@ async fn main() -> Result<(), Error> { let conn = Arc::new(Mutex::new(conn)); create_tables(conn.clone())?; - subscribe( - conn.clone() - ).await?; + subscribe(conn.clone()).await?; - let pool = PgPoolOptions::new().max_connections(5).connect(&env::var("XATA_POSTGRES_URL")?).await?; + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&env::var("XATA_POSTGRES_URL")?) + .await?; let users = find_spotify_users(&pool, 0, 100).await?; load_users(conn.clone(), &pool).await?; @@ -46,17 +50,16 @@ async fn main() -> Result<(), Error> { println!("Connected to NATS server at {}", addr.bright_green()); for user in users { - let token = user.1.clone(); - let did = user.2.clone(); - let user_id = user.3.clone(); - let playlists = get_user_playlists(token).await?; - save_playlists(&pool, conn.clone(), nc.clone(),playlists, &user_id, &did).await?; + let token = user.1.clone(); + let did = user.2.clone(); + let user_id = user.3.clone(); + let playlists = get_user_playlists(token).await?; + save_playlists(&pool, conn.clone(), nc.clone(), playlists, &user_id, &did).await?; } println!("Done!"); loop { - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; } } - diff --git a/crates/playlists/src/spotify.rs b/crates/playlists/src/spotify.rs index 0bda1fa4..eb9928ed 100644 --- a/crates/playlists/src/spotify.rs +++ b/crates/playlists/src/spotify.rs @@ -6,62 +6,57 @@ use reqwest::Client; use crate::types::{self, token::AccessToken}; pub async fn refresh_token(token: &str) -> Result { - if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { - panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); - } - - let client_id = env::var("SPOTIFY_CLIENT_ID")?; - let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; - - let client = Client::new(); - - let response = client.post("https://accounts.spotify.com/api/token") - .basic_auth(&client_id, Some(client_secret)) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", token), - ("client_id", &client_id) - ]) - .send() - .await?; - let token = response.json::().await?; - Ok(token) + if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { + panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); + } + + let client_id = env::var("SPOTIFY_CLIENT_ID")?; + let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; + + let client = Client::new(); + + let response = client + .post("https://accounts.spotify.com/api/token") + .basic_auth(&client_id, Some(client_secret)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", token), + ("client_id", &client_id), + ]) + .send() + .await?; + let token = response.json::().await?; + Ok(token) } - - -pub async fn get_user_playlists( - token: String, -) -> Result, Error>{ - let token = refresh_token(&token).await?; - let client = Client::new(); - let response = client - .get("https://api.spotify.com/v1/me/playlists") - .header("Authorization", format!("Bearer {}", token.access_token)) - .send() - .await?; - let playlists = response.json::().await?; - let mut all_playlists = vec![]; - - for playlist in playlists.items { - all_playlists.push( - get_playlist(&playlist.id, &token.access_token).await? - ); - // wait for 1 second to avoid rate limiting - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - } - - Ok(all_playlists) +pub async fn get_user_playlists(token: String) -> Result, Error> { + let token = refresh_token(&token).await?; + let client = Client::new(); + let response = client + .get("https://api.spotify.com/v1/me/playlists") + .header("Authorization", format!("Bearer {}", token.access_token)) + .send() + .await?; + let playlists = response.json::().await?; + let mut all_playlists = vec![]; + + for playlist in playlists.items { + all_playlists.push(get_playlist(&playlist.id, &token.access_token).await?); + // wait for 1 second to avoid rate limiting + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + + Ok(all_playlists) } pub async fn get_playlist(id: &str, token: &str) -> Result { - let client = Client::new(); - let response = client - .get(format!("https://api.spotify.com/v1/playlists/{}", id)) - .header("Authorization", format!("Bearer {}", token)) - .send() - .await?; - - let playlist = response.json::().await?; - Ok(playlist) -} \ No newline at end of file + let client = Client::new(); + let response = client + .get(format!("https://api.spotify.com/v1/playlists/{}", id)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + let playlist = response.json::().await?; + Ok(playlist) +} diff --git a/crates/playlists/src/subscriber/mod.rs b/crates/playlists/src/subscriber/mod.rs index 6509f147..9ff44c72 100644 --- a/crates/playlists/src/subscriber/mod.rs +++ b/crates/playlists/src/subscriber/mod.rs @@ -1,8 +1,12 @@ -use std::{env, sync::{Arc, Mutex}, thread}; use anyhow::Error; use async_nats::{connect, Client}; use duckdb::{params, Connection}; use owo_colors::OwoColorize; +use std::{ + env, + sync::{Arc, Mutex}, + thread, +}; use tokio_stream::StreamExt; use types::UserPayload; @@ -20,46 +24,46 @@ pub async fn subscribe(conn: Arc>) -> Result<(), Error> { Ok(()) } - pub fn on_new_user(nc: Arc>, conn: Arc>) { - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let conn = conn.clone(); - let nc = nc.clone(); - rt.block_on(async { - let nc = nc.lock().unwrap(); - let mut sub = nc.subscribe("rocksky.user".to_string()).await?; - drop(nc); + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let conn = conn.clone(); + let nc = nc.clone(); + rt.block_on(async { + let nc = nc.lock().unwrap(); + let mut sub = nc.subscribe("rocksky.user".to_string()).await?; + drop(nc); - while let Some(msg) = sub.next().await { - let data = String::from_utf8(msg.payload.to_vec()).unwrap(); - match serde_json::from_str::(&data) { - Ok(payload) => { - match save_user(conn.clone(), payload.clone()).await { - Ok(_) => println!("User saved successfully for {}{}", "@".cyan(), payload.handle.cyan()), - Err(e) => eprintln!("Error saving user: {}", e), + while let Some(msg) = sub.next().await { + let data = String::from_utf8(msg.payload.to_vec()).unwrap(); + match serde_json::from_str::(&data) { + Ok(payload) => match save_user(conn.clone(), payload.clone()).await { + Ok(_) => println!( + "User saved successfully for {}{}", + "@".cyan(), + payload.handle.cyan() + ), + Err(e) => eprintln!("Error saving user: {}", e), + }, + Err(e) => { + eprintln!("Error parsing payload: {}", e); + println!("{}", data); + } + } } - }, - Err(e) => { - eprintln!("Error parsing payload: {}", e); - println!("{}", data); - } - } - } - Ok::<(), Error>(()) - })?; + Ok::<(), Error>(()) + })?; - Ok::<(), Error>(()) - }); + Ok::<(), Error>(()) + }); } - pub async fn save_user(conn: Arc>, payload: UserPayload) -> Result<(), Error> { - let conn = conn.lock().unwrap(); + let conn = conn.lock().unwrap(); - match conn.execute( - "INSERT INTO users ( + match conn.execute( + "INSERT INTO users ( id, avatar, did, @@ -77,21 +81,21 @@ pub async fn save_user(conn: Arc>, payload: UserPayload) -> Re did = EXCLUDED.did, display_name = EXCLUDED.display_name, handle = EXCLUDED.handle", - params![ - payload.xata_id, - payload.avatar, - payload.did, - payload.display_name, - payload.handle, - ], - ) { - Ok(_) => (), - Err(e) => { - if !e.to_string().contains("violates primary key constraint") { - println!("[users] error: {}", e); - return Err(e.into()); - } - } - } - Ok(()) + params![ + payload.xata_id, + payload.avatar, + payload.did, + payload.display_name, + payload.handle, + ], + ) { + Ok(_) => (), + Err(e) => { + if !e.to_string().contains("violates primary key constraint") { + println!("[users] error: {}", e); + return Err(e.into()); + } + } + } + Ok(()) } diff --git a/crates/scrobbler/src/auth.rs b/crates/scrobbler/src/auth.rs index 39f92319..a7171db4 100644 --- a/crates/scrobbler/src/auth.rs +++ b/crates/scrobbler/src/auth.rs @@ -1,12 +1,12 @@ -use std::collections::BTreeMap; use anyhow::Error; -use sqlx::{Pool, Postgres}; -use std::env; use jsonwebtoken::DecodingKey; use jsonwebtoken::EncodingKey; use jsonwebtoken::Header; use jsonwebtoken::Validation; use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Postgres}; +use std::collections::BTreeMap; +use std::env; use crate::cache::Cache; use crate::repo; @@ -28,7 +28,8 @@ pub async fn authenticate_v1( ) -> Result<(), Error> { match repo::user::get_user_by_apikey(pool, api_key).await? { Some(user) => { - let shared_secret = user.shared_secret + let shared_secret = user + .shared_secret .ok_or_else(|| Error::msg("User does not have a shared secret"))?; let hashed_password = md5::compute(format!("{}", shared_secret)); let hashed_password = format!("{:x}", hashed_password); @@ -40,19 +41,17 @@ pub async fn authenticate_v1( return Err(Error::msg("Invalid password")); } Ok(()) - }, - None => { - Err(Error::msg("Invalid API key")) } + None => Err(Error::msg("Invalid API key")), } } pub async fn authenticate( - pool: &Pool, - api_key: &str, - api_sig: &str, - session_key: &str, - form: &BTreeMap, + pool: &Pool, + api_key: &str, + api_sig: &str, + session_key: &str, + form: &BTreeMap, ) -> Result<(), Error> { let claims = decode_token(session_key)?; @@ -73,10 +72,17 @@ pub async fn authenticate( Ok(()) } -pub async fn extract_did(pool: &Pool, form: &BTreeMap) -> Result { - let apikey = form.get("api_key").ok_or_else(|| Error::msg("Missing api_key"))?; +pub async fn extract_did( + pool: &Pool, + form: &BTreeMap, +) -> Result { + let apikey = form + .get("api_key") + .ok_or_else(|| Error::msg("Missing api_key"))?; let user = repo::user::get_user_by_apikey(pool, apikey).await?; - let did = user.ok_or_else(|| Error::msg("Corresponding user not found"))?.did; + let did = user + .ok_or_else(|| Error::msg("Corresponding user not found"))? + .did; Ok(did) } @@ -118,28 +124,23 @@ pub async fn generate_session_id( cache: &Cache, api_key: &str, ) -> Result { - match repo::user::get_user_by_apikey(pool, &api_key).await? { + match repo::user::get_user_by_apikey(pool, &api_key).await? { Some(user) => { - let mut bytes = [0u8; 16]; - rand::fill(&mut bytes[..]); - - let session_id = hex::encode(bytes); - - let user = serde_json::to_string(&user) - .map_err(|_| Error::msg("Failed to serialize user"))?; - cache.set(&format!("lastfm:{}", session_id), &user)?; - Ok(session_id) - }, - None => { - Err(Error::msg("Invalid API key")) + let mut bytes = [0u8; 16]; + rand::fill(&mut bytes[..]); + + let session_id = hex::encode(bytes); + + let user = + serde_json::to_string(&user).map_err(|_| Error::msg("Failed to serialize user"))?; + cache.set(&format!("lastfm:{}", session_id), &user)?; + Ok(session_id) } + None => Err(Error::msg("Invalid API key")), } } -pub fn verify_session_id( - cache: &Cache, - session_id: &str, -) -> Result { +pub fn verify_session_id(cache: &Cache, session_id: &str) -> Result { let user = cache.get(&format!("lastfm:{}", session_id))?; if user.is_none() { return Err(Error::msg("Session ID not found")); @@ -164,4 +165,4 @@ mod tests { assert_eq!(claims.did, "did:plc:7vdlgi2bflelz7mmuxoqjfcr"); } -} \ No newline at end of file +} diff --git a/crates/scrobbler/src/handlers/mod.rs b/crates/scrobbler/src/handlers/mod.rs index 721a6777..0ef88a40 100644 --- a/crates/scrobbler/src/handlers/mod.rs +++ b/crates/scrobbler/src/handlers/mod.rs @@ -2,12 +2,12 @@ use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder}; use anyhow::Error; use scrobble::handle_scrobble; use sqlx::{Pool, Postgres}; -use v1::authenticate::authenticate; -use v1::nowplaying::nowplaying; -use v1::submission::submission; use std::collections::BTreeMap; use std::sync::Arc; use tokio_stream::StreamExt; +use v1::authenticate::authenticate; +use v1::nowplaying::nowplaying; +use v1::submission::submission; use crate::cache::Cache; use crate::listenbrainz::submit::submit_listens; @@ -19,19 +19,18 @@ pub mod v1; #[macro_export] macro_rules! read_payload { - ($payload:expr) => {{ - let mut body = Vec::new(); - while let Some(chunk) = $payload.next().await { - match chunk { - Ok(bytes) => body.extend_from_slice(&bytes), - Err(err) => return Err(err.into()), - } - } - body - }}; + ($payload:expr) => {{ + let mut body = Vec::new(); + while let Some(chunk) = $payload.next().await { + match chunk { + Ok(bytes) => body.extend_from_slice(&bytes), + Err(err) => return Err(err.into()), + } + } + body + }}; } - #[get("/")] pub async fn index( data: web::Data>>, @@ -42,13 +41,9 @@ pub async fn index( return Ok(HttpResponse::Ok().body(BANNER)); } - authenticate( - params.into_inner(), - cache.get_ref(), - data.get_ref(), - ) - .await - .map_err(actix_web::error::ErrorInternalServerError) + authenticate(params.into_inner(), cache.get_ref(), data.get_ref()) + .await + .map_err(actix_web::error::ErrorInternalServerError) } #[post("/nowplaying")] @@ -57,12 +52,8 @@ pub async fn handle_nowplaying( cache: web::Data, form: web::Form>, ) -> impl Responder { - nowplaying( - form.into_inner(), - cache.get_ref(), - data.get_ref(), - ) - .map_err(actix_web::error::ErrorInternalServerError) + nowplaying(form.into_inner(), cache.get_ref(), data.get_ref()) + .map_err(actix_web::error::ErrorInternalServerError) } #[post("/submission")] @@ -71,13 +62,9 @@ pub async fn handle_submission( cache: web::Data, form: web::Form>, ) -> impl Responder { - submission( - form.into_inner(), - cache.get_ref(), - data.get_ref(), - ) - .await - .map_err(actix_web::error::ErrorInternalServerError) + submission(form.into_inner(), cache.get_ref(), data.get_ref()) + .await + .map_err(actix_web::error::ErrorInternalServerError) } #[get("/2.0")] @@ -95,18 +82,19 @@ pub async fn handle_methods( let cache = cache.get_ref(); let method = form.get("method").unwrap_or(&"".to_string()).to_string(); - call_method(&method, conn, cache, form.into_inner()).await - .map_err(actix_web::error::ErrorInternalServerError) + call_method(&method, conn, cache, form.into_inner()) + .await + .map_err(actix_web::error::ErrorInternalServerError) } #[post("/1/submit-listens")] pub async fn handle_submit_listens( - req: HttpRequest, - data: web::Data>>, - cache: web::Data, - mut payload: web::Payload, + req: HttpRequest, + data: web::Data>>, + cache: web::Data, + mut payload: web::Payload, ) -> impl Responder { - let token = match req.headers().get("Authorization") { + let token = match req.headers().get("Authorization") { Some(header) => header.to_str().map_err(actix_web::error::ErrorBadRequest)?, None => return Ok(HttpResponse::Unauthorized().finish()), }; @@ -135,27 +123,22 @@ pub async fn handle_submit_listens( } #[get("/1/validate-token")] -pub async fn handle_validate_token( - _req: HttpRequest, -) -> impl Responder { - HttpResponse::Ok().json( - serde_json::json!({ - "code": 200, - "message": "Token valid.", - "valid": true, - }) - ) +pub async fn handle_validate_token(_req: HttpRequest) -> impl Responder { + HttpResponse::Ok().json(serde_json::json!({ + "code": 200, + "message": "Token valid.", + "valid": true, + })) } pub async fn call_method( method: &str, pool: &Arc>, cache: &Cache, - form: BTreeMap) -> Result { - match method { - "track.scrobble" => handle_scrobble(form, pool, cache).await, - _ => { - Err(Error::msg(format!("Unsupported method: {}", method))) + form: BTreeMap, +) -> Result { + match method { + "track.scrobble" => handle_scrobble(form, pool, cache).await, + _ => Err(Error::msg(format!("Unsupported method: {}", method))), } - } } diff --git a/crates/scrobbler/src/handlers/scrobble.rs b/crates/scrobbler/src/handlers/scrobble.rs index ba26d58f..24bf3428 100644 --- a/crates/scrobbler/src/handlers/scrobble.rs +++ b/crates/scrobbler/src/handlers/scrobble.rs @@ -1,20 +1,20 @@ -use std::collections::BTreeMap; use actix_web::HttpResponse; use anyhow::Error; use serde_json::json; use sqlx::Pool; +use std::collections::BTreeMap; -use crate::{auth::authenticate, cache::Cache, params::validate_scrobble_params, response::build_response, scrobbler::scrobble}; +use crate::{ + auth::authenticate, cache::Cache, params::validate_scrobble_params, response::build_response, + scrobbler::scrobble, +}; pub async fn handle_scrobble( - form: BTreeMap, - conn: &Pool, - cache: &Cache, + form: BTreeMap, + conn: &Pool, + cache: &Cache, ) -> Result { - let params = match validate_scrobble_params( - &form, - &["api_key", "api_sig", "sk", "method"], - ) { + let params = match validate_scrobble_params(&form, &["api_key", "api_sig", "sk", "method"]) { Ok(params) => params, Err(e) => { return Ok(HttpResponse::BadRequest().json(json!({ @@ -24,13 +24,7 @@ pub async fn handle_scrobble( } }; - if let Err(e) = authenticate( - conn, - ¶ms[0], - ¶ms[1], - ¶ms[2], - &form - ).await { + if let Err(e) = authenticate(conn, ¶ms[0], ¶ms[1], ¶ms[2], &form).await { return Ok(HttpResponse::Forbidden().json(json!({ "error": 2, "message": format!("Authentication failed: {}", e) @@ -46,11 +40,10 @@ pub async fn handle_scrobble( "message": e.to_string() }))); } - Ok( - HttpResponse::BadRequest().json(json!({ - "error": 4, - "message": format!("Failed to parse scrobbles: {}", e) - }))) + Ok(HttpResponse::BadRequest().json(json!({ + "error": 4, + "message": format!("Failed to parse scrobbles: {}", e) + }))) } } } diff --git a/crates/scrobbler/src/handlers/v1/authenticate.rs b/crates/scrobbler/src/handlers/v1/authenticate.rs index 70985da9..56fead04 100644 --- a/crates/scrobbler/src/handlers/v1/authenticate.rs +++ b/crates/scrobbler/src/handlers/v1/authenticate.rs @@ -4,20 +4,25 @@ use actix_web::HttpResponse; use anyhow::Error; use serde_json::json; -use crate::{auth::{authenticate_v1, generate_session_id}, cache::Cache, params::validate_required_params}; +use crate::{ + auth::{authenticate_v1, generate_session_id}, + cache::Cache, + params::validate_required_params, +}; pub async fn authenticate( params: BTreeMap, cache: &Cache, pool: &Arc>, ) -> Result { - match validate_required_params(¶ms, &["hs", "u", "t", "a"]) { + match validate_required_params(¶ms, &["hs", "u", "t", "a"]) { Ok(_) => { let u = params.get("u").unwrap().to_string(); let t = params.get("t").unwrap().to_string(); let a = params.get("a").unwrap().to_string(); - let scrobbler_origin_url = env::var("SCROBBLER_ORIGIN_URL").unwrap_or_else(|_| "https://audioscrobbler.rocksky.app".to_string()); + let scrobbler_origin_url = env::var("SCROBBLER_ORIGIN_URL") + .unwrap_or_else(|_| "https://audioscrobbler.rocksky.app".to_string()); if authenticate_v1(&pool, &u, &t, &a).await.is_err() { return Ok(HttpResponse::Unauthorized().json(json!({ @@ -26,11 +31,7 @@ pub async fn authenticate( }))); } - let session_id = generate_session_id( - pool, - cache, - &u, - ); + let session_id = generate_session_id(pool, cache, &u); let session_id = session_id.await; if session_id.is_err() { @@ -44,13 +45,14 @@ pub async fn authenticate( let now_playing_url = format!("{}/nowplaying", scrobbler_origin_url); let submission_url = format!("{}/submission", scrobbler_origin_url); - Ok(HttpResponse::Ok().body(format!("OK\n{}\n{}\n{}", session_id, now_playing_url, submission_url))) - } - Err(e) => { - Ok(HttpResponse::BadRequest().json(json!({ - "error": 5, - "message": format!("{}", e) - }))) + Ok(HttpResponse::Ok().body(format!( + "OK\n{}\n{}\n{}", + session_id, now_playing_url, submission_url + ))) } + Err(e) => Ok(HttpResponse::BadRequest().json(json!({ + "error": 5, + "message": format!("{}", e) + }))), } } diff --git a/crates/scrobbler/src/handlers/v1/submission.rs b/crates/scrobbler/src/handlers/v1/submission.rs index 5fe68795..73a997ac 100644 --- a/crates/scrobbler/src/handlers/v1/submission.rs +++ b/crates/scrobbler/src/handlers/v1/submission.rs @@ -1,10 +1,12 @@ -use std::{collections::BTreeMap, sync::Arc}; -use anyhow::Error; use actix_web::HttpResponse; +use anyhow::Error; use owo_colors::OwoColorize; use serde_json::json; +use std::{collections::BTreeMap, sync::Arc}; -use crate::{auth::verify_session_id, cache::Cache, params::validate_required_params, scrobbler::scrobble_v1}; +use crate::{ + auth::verify_session_id, cache::Cache, params::validate_required_params, scrobbler::scrobble_v1, +}; pub async fn submission( form: BTreeMap, @@ -29,7 +31,6 @@ pub async fn submission( let user_id = user_id.unwrap(); println!("Submission: {} - {} {} {} {}", a, t, i, user_id, s.cyan()); - match scrobble_v1(pool, cache, &form).await { Ok(_) => Ok(HttpResponse::Ok().body("OK\n")), Err(e) => Ok(HttpResponse::BadRequest().json(json!({ @@ -38,11 +39,9 @@ pub async fn submission( }))), } } - Err(e) => { - Ok(HttpResponse::BadRequest().json(json!({ - "error": 5, - "message": format!("{}", e) - }))) - } + Err(e) => Ok(HttpResponse::BadRequest().json(json!({ + "error": 5, + "message": format!("{}", e) + }))), } -} \ No newline at end of file +} diff --git a/crates/scrobbler/src/listenbrainz/submit.rs b/crates/scrobbler/src/listenbrainz/submit.rs index 33ee8620..8c19eff5 100644 --- a/crates/scrobbler/src/listenbrainz/submit.rs +++ b/crates/scrobbler/src/listenbrainz/submit.rs @@ -1,50 +1,43 @@ -use std::sync::Arc; -use anyhow::Error; use actix_web::HttpResponse; +use anyhow::Error; use owo_colors::OwoColorize; use serde_json::json; +use std::sync::Arc; use crate::{cache::Cache, scrobbler::scrobble_listenbrainz}; use super::types::SubmitListensRequest; pub async fn submit_listens( - payload: SubmitListensRequest, - cache: &Cache, - pool: &Arc>, - token: &str + payload: SubmitListensRequest, + cache: &Cache, + pool: &Arc>, + token: &str, ) -> Result { - if payload.listen_type != "playing_now" { - println!("skipping listen type: {}", payload.listen_type.cyan()); - return Ok(HttpResponse::Ok().json( - json!({ - "status": "ok", - "payload": { - "submitted_listens": 0, - "ignored_listens": 1 - }, - }) - )); - } - match scrobble_listenbrainz(pool, cache, payload, token) - .await { - Ok(_) => Ok(HttpResponse::Ok().json( - json!({ - "status": "ok", - "payload": { - "submitted_listens": 1, - "ignored_listens": 0 - }, - }) - )), - Err(e) => { - println!("Error submitting listens: {}", e); - Ok(HttpResponse::BadRequest().json( - serde_json::json!({ - "error": 4, - "message": format!("Failed to parse listens: {}", e) - }) - )) + if payload.listen_type != "playing_now" { + println!("skipping listen type: {}", payload.listen_type.cyan()); + return Ok(HttpResponse::Ok().json(json!({ + "status": "ok", + "payload": { + "submitted_listens": 0, + "ignored_listens": 1 + }, + }))); + } + match scrobble_listenbrainz(pool, cache, payload, token).await { + Ok(_) => Ok(HttpResponse::Ok().json(json!({ + "status": "ok", + "payload": { + "submitted_listens": 1, + "ignored_listens": 0 + }, + }))), + Err(e) => { + println!("Error submitting listens: {}", e); + Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "error": 4, + "message": format!("Failed to parse listens: {}", e) + }))) + } } - } } diff --git a/crates/scrobbler/src/listenbrainz/validate_token.rs b/crates/scrobbler/src/listenbrainz/validate_token.rs index 2b7f771c..c8c12939 100644 --- a/crates/scrobbler/src/listenbrainz/validate_token.rs +++ b/crates/scrobbler/src/listenbrainz/validate_token.rs @@ -3,26 +3,20 @@ use anyhow::Error; use crate::auth::decode_token; -pub async fn validate_token( - token: &str -) -> Result { - match decode_token(token) { - Ok(_) => Ok(HttpResponse::Ok().json( - serde_json::json!({ - "status": "ok", - "payload": { - "valid": true, - }, - }) - )), - Err(e) => { - println!("Error validating token: {}", e); - Ok(HttpResponse::BadRequest().json( - serde_json::json!({ - "error": 4, - "message": format!("Failed to validate token: {}", e) - }) - )) +pub async fn validate_token(token: &str) -> Result { + match decode_token(token) { + Ok(_) => Ok(HttpResponse::Ok().json(serde_json::json!({ + "status": "ok", + "payload": { + "valid": true, + }, + }))), + Err(e) => { + println!("Error validating token: {}", e); + Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "error": 4, + "message": format!("Failed to validate token: {}", e) + }))) + } } - } -} \ No newline at end of file +} diff --git a/crates/scrobbler/src/main.rs b/crates/scrobbler/src/main.rs index a4378794..c04714ed 100644 --- a/crates/scrobbler/src/main.rs +++ b/crates/scrobbler/src/main.rs @@ -1,29 +1,32 @@ +pub mod auth; +pub mod cache; +pub mod crypto; pub mod handlers; -pub mod signature; +pub mod listenbrainz; pub mod musicbrainz; -pub mod spotify; -pub mod xata; -pub mod cache; -pub mod auth; pub mod params; -pub mod scrobbler; +pub mod repo; pub mod response; -pub mod crypto; pub mod rocksky; -pub mod repo; +pub mod scrobbler; +pub mod signature; +pub mod spotify; pub mod types; -pub mod listenbrainz; +pub mod xata; -use actix_session::SessionExt as _; -use std::{env, sync::Arc, time::Duration}; use actix_limitation::{Limiter, RateLimiter}; -use actix_web::{dev::ServiceRequest, web::{self, Data}, App, HttpServer}; +use actix_session::SessionExt as _; +use actix_web::{ + dev::ServiceRequest, + web::{self, Data}, + App, HttpServer, +}; use anyhow::Error; use cache::Cache; use dotenv::dotenv; use owo_colors::OwoColorize; use sqlx::postgres::PgPoolOptions; - +use std::{env, sync::Arc, time::Duration}; pub const BANNER: &str = r#" ___ ___ _____ __ __ __ @@ -43,7 +46,10 @@ async fn main() -> Result<(), Error> { let cache = Cache::new()?; - let pool = PgPoolOptions::new().max_connections(5).connect(&env::var("XATA_POSTGRES_URL")?).await?; + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&env::var("XATA_POSTGRES_URL")?) + .await?; let conn = Arc::new(pool); let host = env::var("SCROBBLE_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); @@ -52,7 +58,10 @@ async fn main() -> Result<(), Error> { .parse::() .unwrap_or(7882); - println!("Starting Scrobble server @ {}", format!("{}:{}", host, port).green()); + println!( + "Starting Scrobble server @ {}", + format!("{}:{}", host, port).green() + ); let limiter = web::Data::new( Limiter::builder("redis://127.0.0.1") diff --git a/crates/scrobbler/src/musicbrainz/client.rs b/crates/scrobbler/src/musicbrainz/client.rs index 5b9fdf19..f68ef516 100644 --- a/crates/scrobbler/src/musicbrainz/client.rs +++ b/crates/scrobbler/src/musicbrainz/client.rs @@ -11,43 +11,28 @@ impl MusicbrainzClient { MusicbrainzClient {} } - pub async fn search( - &self, - query: &str, - ) -> Result { + pub async fn search(&self, query: &str) -> Result { let url = format!("{}/recording", BASE_URL); let client = reqwest::Client::new(); let response = client .get(&url) .header("Accept", "application/json") .header("User-Agent", USER_AGENT) - .query( - &[ - ("query", query), - ("inc", "artist-credits+releases"), - ], - ) + .query(&[("query", query), ("inc", "artist-credits+releases")]) .send() .await?; Ok(response.json().await?) } - pub async fn get_recording( - &self, - mbid: &str, - ) -> Result { + pub async fn get_recording(&self, mbid: &str) -> Result { let url = format!("{}/recording/{}", BASE_URL, mbid); let client = reqwest::Client::new(); let response = client .get(&url) .header("Accept", "application/json") .header("User-Agent", USER_AGENT) - .query( - &[ - ("inc", "artist-credits+releases"), - ], - ) + .query(&[("inc", "artist-credits+releases")]) .send() .await?; diff --git a/crates/scrobbler/src/repo/album.rs b/crates/scrobbler/src/repo/album.rs index dd99a446..820d6730 100644 --- a/crates/scrobbler/src/repo/album.rs +++ b/crates/scrobbler/src/repo/album.rs @@ -4,14 +4,16 @@ use sqlx::{Pool, Postgres}; use crate::xata::album::Album; pub async fn get_album_by_track_id(pool: &Pool, track_id: &str) -> Result { - let results: Vec = sqlx::query_as(r#" + let results: Vec = sqlx::query_as( + r#" SELECT * FROM albums LEFT JOIN album_tracks ON albums.xata_id = album_tracks.album_id WHERE album_tracks.track_id = $1 - "#) + "#, + ) .bind(track_id) .fetch_all(pool) .await?; - Ok(results[0].clone()) -} \ No newline at end of file + Ok(results[0].clone()) +} diff --git a/crates/scrobbler/src/repo/api_key.rs b/crates/scrobbler/src/repo/api_key.rs index ee23908e..c85ed494 100644 --- a/crates/scrobbler/src/repo/api_key.rs +++ b/crates/scrobbler/src/repo/api_key.rs @@ -3,21 +3,26 @@ use sqlx::{Pool, Postgres}; use crate::xata::api_key::ApiKey; - -pub async fn get_apikey(pool: &Pool, apikey: &str, did: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_apikey( + pool: &Pool, + apikey: &str, + did: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM api_keys LEFT JOIN users ON api_keys.user_id = users.xata_id WHERE api_keys.api_key = $1 AND users.did = $2 - "#) - .bind(apikey) - .bind(did) - .fetch_all(pool) - .await?; + "#, + ) + .bind(apikey) + .bind(did) + .fetch_all(pool) + .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) + Ok(Some(results[0].clone())) } diff --git a/crates/scrobbler/src/repo/artist.rs b/crates/scrobbler/src/repo/artist.rs index 8f455fb4..96e9938e 100644 --- a/crates/scrobbler/src/repo/artist.rs +++ b/crates/scrobbler/src/repo/artist.rs @@ -3,15 +3,20 @@ use sqlx::{Pool, Postgres}; use crate::xata::artist::Artist; -pub async fn get_artist_by_track_id(pool: &Pool, track_id: &str) -> Result { - let results: Vec = sqlx::query_as(r#" +pub async fn get_artist_by_track_id( + pool: &Pool, + track_id: &str, +) -> Result { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM artists LEFT JOIN artist_tracks ON artists.xata_id = artist_tracks.artist_id WHERE artist_tracks.track_id = $1 - "#) + "#, + ) .bind(track_id) .fetch_all(pool) .await?; - Ok(results[0].clone()) -} \ No newline at end of file + Ok(results[0].clone()) +} diff --git a/crates/scrobbler/src/repo/spotify_token.rs b/crates/scrobbler/src/repo/spotify_token.rs index b3201b04..f3c66190 100644 --- a/crates/scrobbler/src/repo/spotify_token.rs +++ b/crates/scrobbler/src/repo/spotify_token.rs @@ -3,35 +3,44 @@ use sqlx::{Pool, Postgres}; use crate::xata::spotify_token::SpotifyToken; - -pub async fn get_spotify_token(pool: &Pool, did: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_spotify_token( + pool: &Pool, + did: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM spotify_tokens LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id LEFT JOIN users ON spotify_accounts.user_id = users.xata_id WHERE users.did = $1 - "#) - .bind(did) - .fetch_all(pool) - .await?; + "#, + ) + .bind(did) + .fetch_all(pool) + .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) + Ok(Some(results[0].clone())) } -pub async fn get_spotify_tokens(pool: &Pool, limit: u32) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_spotify_tokens( + pool: &Pool, + limit: u32, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM spotify_tokens LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id LEFT JOIN users ON spotify_accounts.user_id = users.xata_id LIMIT $1 - "#) - .bind(limit as i32) - .fetch_all(pool) - .await?; + "#, + ) + .bind(limit as i32) + .fetch_all(pool) + .await?; - Ok(results) + Ok(results) } diff --git a/crates/scrobbler/src/repo/track.rs b/crates/scrobbler/src/repo/track.rs index abdb7c36..5ae8e0cb 100644 --- a/crates/scrobbler/src/repo/track.rs +++ b/crates/scrobbler/src/repo/track.rs @@ -3,35 +3,43 @@ use sqlx::{Pool, Postgres}; use crate::xata::track::Track; -pub async fn get_track(pool: &Pool, title: &str, artist: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_track( + pool: &Pool, + title: &str, + artist: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM tracks WHERE LOWER(title) = LOWER($1) AND (LOWER(artist) = LOWER($2) OR LOWER(album_artist) = LOWER($2)) - "#) + "#, + ) .bind(title) .bind(artist) .fetch_all(pool) .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) + Ok(Some(results[0].clone())) } pub async fn get_track_by_mbid(pool: &Pool, mbid: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" + let results: Vec = sqlx::query_as( + r#" SELECT * FROM tracks WHERE mb_id = $1 - "#) + "#, + ) .bind(mbid) .fetch_all(pool) .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) -} \ No newline at end of file + Ok(Some(results[0].clone())) +} diff --git a/crates/scrobbler/src/repo/user.rs b/crates/scrobbler/src/repo/user.rs index c56ebe91..6bdab47d 100644 --- a/crates/scrobbler/src/repo/user.rs +++ b/crates/scrobbler/src/repo/user.rs @@ -3,37 +3,45 @@ use sqlx::{Pool, Postgres}; use crate::xata::user::{User, UserWithoutSecret}; - -pub async fn get_user_by_apikey(pool: &Pool, apikey: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_user_by_apikey( + pool: &Pool, + apikey: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM users LEFT JOIN api_keys ON users.xata_id = api_keys.user_id WHERE api_keys.api_key = $1 - "#) - .bind(apikey) - .fetch_all(pool) - .await?; + "#, + ) + .bind(apikey) + .fetch_all(pool) + .await?; - if results.is_empty(){ - return Ok(None); - } + if results.is_empty() { + return Ok(None); + } - Ok(Some(results[0].clone())) + Ok(Some(results[0].clone())) } - -pub async fn get_user_by_did(pool: &Pool, did: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_user_by_did( + pool: &Pool, + did: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM users WHERE did = $1 - "#) - .bind(did) - .fetch_all(pool) - .await?; + "#, + ) + .bind(did) + .fetch_all(pool) + .await?; - if results.is_empty() { - return Ok(None); - } + if results.is_empty() { + return Ok(None); + } - Ok(Some(results[0].clone())) -} \ No newline at end of file + Ok(Some(results[0].clone())) +} diff --git a/crates/scrobbler/src/rocksky.rs b/crates/scrobbler/src/rocksky.rs index 9b900d70..816b6649 100644 --- a/crates/scrobbler/src/rocksky.rs +++ b/crates/scrobbler/src/rocksky.rs @@ -6,38 +6,45 @@ use crate::{auth::generate_token, cache::Cache, types::Track}; const ROCKSKY_API: &str = "https://api.rocksky.app"; pub async fn scrobble(cache: &Cache, did: &str, track: Track, timestamp: u64) -> Result<(), Error> { - let key = format!("{} - {}", track.artist.to_lowercase(), track.title.to_lowercase()); - - // Check if the track is already in the cache, if not add it - if !cache.exists(&key)? { - let value = serde_json::to_string(&track)?; - let ttl = 15 * 60; // 15 minutes - cache.setex(&key, &value, ttl)?; - } - - let mut track = track; - track.timestamp = Some(timestamp); - - let token = generate_token(did)?; - let client = Client::new(); - - println!("Scrobbling track: \n {:#?}", track); - - let response= client - .post(&format!("{}/now-playing", ROCKSKY_API)) - .bearer_auth(token) - .json(&track) - .send() - .await?; - - let status = response.status(); - println!("Response status: {}", status); - if !status.is_success() { - let response_text = response.text().await?; - println!("did: {}", did); - println!("Failed to scrobble track: {}", response_text); - return Err(Error::msg(format!("Failed to scrobble track: {}", response_text))); - } - - Ok(()) + let key = format!( + "{} - {}", + track.artist.to_lowercase(), + track.title.to_lowercase() + ); + + // Check if the track is already in the cache, if not add it + if !cache.exists(&key)? { + let value = serde_json::to_string(&track)?; + let ttl = 15 * 60; // 15 minutes + cache.setex(&key, &value, ttl)?; + } + + let mut track = track; + track.timestamp = Some(timestamp); + + let token = generate_token(did)?; + let client = Client::new(); + + println!("Scrobbling track: \n {:#?}", track); + + let response = client + .post(&format!("{}/now-playing", ROCKSKY_API)) + .bearer_auth(token) + .json(&track) + .send() + .await?; + + let status = response.status(); + println!("Response status: {}", status); + if !status.is_success() { + let response_text = response.text().await?; + println!("did: {}", did); + println!("Failed to scrobble track: {}", response_text); + return Err(Error::msg(format!( + "Failed to scrobble track: {}", + response_text + ))); + } + + Ok(()) } diff --git a/crates/scrobbler/src/scrobbler.rs b/crates/scrobbler/src/scrobbler.rs index a54af80d..b6d3f17c 100644 --- a/crates/scrobbler/src/scrobbler.rs +++ b/crates/scrobbler/src/scrobbler.rs @@ -6,10 +6,15 @@ use rand::Rng; use sqlx::{Pool, Postgres}; use crate::{ - auth::{decode_token, extract_did}, cache::Cache, crypto::decrypt_aes_256_ctr, listenbrainz::types::SubmitListensRequest, musicbrainz::client::MusicbrainzClient, repo, rocksky, spotify::{ - client::SpotifyClient, - refresh_token - }, types::{Scrobble, Track}, xata::user::User + auth::{decode_token, extract_did}, + cache::Cache, + crypto::decrypt_aes_256_ctr, + listenbrainz::types::SubmitListensRequest, + musicbrainz::client::MusicbrainzClient, + repo, rocksky, + spotify::{client::SpotifyClient, refresh_token}, + types::{Scrobble, Track}, + xata::user::User, }; fn parse_batch(form: &BTreeMap) -> Result, Error> { @@ -25,13 +30,16 @@ fn parse_batch(form: &BTreeMap) -> Result, Error> break; } - let album = form.get(&format!("album[{}]", index)) + let album = form + .get(&format!("album[{}]", index)) .cloned() .map(|x| x.trim().to_string()); - let context = form.get(&format!("context[{}]", index)) + let context = form + .get(&format!("context[{}]", index)) .cloned() .map(|x| x.trim().to_string()); - let stream_id = form.get(&format!("streamId[{}]", index)) + let stream_id = form + .get(&format!("streamId[{}]", index)) .and_then(|s| s.trim().parse().ok()); let chosen_by_user = form .get(&format!("chosenByUser[{}]", index)) @@ -40,14 +48,18 @@ fn parse_batch(form: &BTreeMap) -> Result, Error> .get(&format!("trackNumber[{}]", index)) .and_then(|s| s.trim().parse().ok()); let mbid = form.get(&format!("mbid[{}]", index)).cloned(); - let album_artist = form.get(&format!("albumArtist[{}]", index)).map(|x| x.trim().to_string()); + let album_artist = form + .get(&format!("albumArtist[{}]", index)) + .map(|x| x.trim().to_string()); let duration = form .get(&format!("duration[{}]", index)) .and_then(|s| s.trim().parse().ok()); - let timestamp = timestamp.unwrap().trim().parse().unwrap_or( - chrono::Utc::now().timestamp() as u64, - ); + let timestamp = timestamp + .unwrap() + .trim() + .parse() + .unwrap_or(chrono::Utc::now().timestamp() as u64); // validate timestamp, must be in the past (between 14 days before to present) let now = chrono::Utc::now().timestamp() as u64; @@ -80,7 +92,11 @@ fn parse_batch(form: &BTreeMap) -> Result, Error> Ok(result) } -pub async fn scrobble(pool: &Pool, cache: &Cache, form: &BTreeMap) -> Result, Error> { +pub async fn scrobble( + pool: &Pool, + cache: &Cache, + form: &BTreeMap, +) -> Result, Error> { let mut scrobbles = parse_batch(form)?; if scrobbles.is_empty() { @@ -99,18 +115,22 @@ pub async fn scrobble(pool: &Pool, cache: &Cache, form: &BTreeMap, cache: &Cache, form: &BTreeMap None, }, }; - track.release_date = album.release_date.map(|x| x.split("T").next().unwrap().to_string()); + track.release_date = album + .release_date + .map(|x| x.split("T").next().unwrap().to_string()); track.artist_picture = artist.picture.clone(); rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?; @@ -168,24 +190,32 @@ pub async fn scrobble(pool: &Pool, cache: &Cache, form: &BTreeMap, cache: &Cache, form: &BTreeMap, cache: &Cache, form: &BTreeMap) -> Result<(), Error> { +pub async fn scrobble_v1( + pool: &Pool, + cache: &Cache, + form: &BTreeMap, +) -> Result<(), Error> { let session_id = form.get("s").unwrap().to_string(); let artist = form.get("a[0]").unwrap().to_string(); let track = form.get("t[0]").unwrap().to_string(); @@ -269,7 +306,11 @@ pub async fn scrobble_v1(pool: &Pool, cache: &Cache, form: &BTreeMap, cache: &Cache, form: &BTreeMap None, }, }; - track.release_date = album.release_date.map(|x| x.split("T").next().unwrap().to_string()); + track.release_date = album + .release_date + .map(|x| x.split("T").next().unwrap().to_string()); track.artist_picture = artist.picture.clone(); rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?; @@ -327,24 +370,32 @@ pub async fn scrobble_v1(pool: &Pool, cache: &Cache, form: &BTreeMap, cache: &Cache, form: &BTreeMap, cache: &Cache, req: SubmitListensRequest, token: &str) -> Result<(), Error> { +pub async fn scrobble_listenbrainz( + pool: &Pool, + cache: &Cache, + req: SubmitListensRequest, + token: &str, +) -> Result<(), Error> { println!("Listenbrainz\n{:#?}", req); if req.payload.is_empty() { @@ -396,19 +457,20 @@ pub async fn scrobble_listenbrainz(pool: &Pool, cache: &Cache, req: Su if let Some(did) = user { did } else { - return Err(Error::msg(format!("Failed to decode token: {} {}", e, token))); + return Err(Error::msg(format!( + "Failed to decode token: {} {}", + e, token + ))); } } }; - let user = repo::user::get_user_by_did(pool, &did) - .await?; + let user = repo::user::get_user_by_did(pool, &did).await?; if user.is_none() { return Err(Error::msg("User not found")); } - cache.setex( &format!("listenbrainz:emby:{}:{}:{}", artist, track, did), "1", @@ -450,7 +512,11 @@ pub async fn scrobble_listenbrainz(pool: &Pool, cache: &Cache, req: Su 8. if it exists, scrobble 9. if it doesn't exist, skip unknown track */ - let key = format!("{} - {}", scrobble.artist.to_lowercase(), scrobble.track.to_lowercase()); + let key = format!( + "{} - {}", + scrobble.artist.to_lowercase(), + scrobble.track.to_lowercase() + ); let cached = cache.get(&key)?; if cached.is_some() { println!("{}", format!("Cached: {}", key).yellow()); @@ -489,7 +555,9 @@ pub async fn scrobble_listenbrainz(pool: &Pool, cache: &Cache, req: Su None => None, }, }; - track.release_date = album.release_date.map(|x| x.split("T").next().unwrap().to_string()); + track.release_date = album + .release_date + .map(|x| x.split("T").next().unwrap().to_string()); track.artist_picture = artist.picture.clone(); rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?; @@ -508,24 +576,32 @@ pub async fn scrobble_listenbrainz(pool: &Pool, cache: &Cache, req: Su let spotify_token = decrypt_aes_256_ctr( &spotify_token.refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, )?; let spotify_token = refresh_token(&spotify_token).await?; let spotify_client = SpotifyClient::new(&spotify_token.access_token); - let result = spotify_client.search(&format!(r#"track:"{}" artist:"{}""#, scrobble.track, scrobble.artist)).await?; + let result = spotify_client + .search(&format!( + r#"track:"{}" artist:"{}""#, + scrobble.track, scrobble.artist + )) + .await?; if let Some(track) = result.tracks.items.first() { println!("{}", "Spotify (track)".yellow()); scrobble.album = Some(track.album.name.clone()); let mut track = track.clone(); - if let Some(album) = spotify_client.get_album(&track.album.id).await? { + if let Some(album) = spotify_client.get_album(&track.album.id).await? { track.album = album; } - if let Some(artist) = spotify_client.get_artist(&track.album.artists[0].id).await? { + if let Some(artist) = spotify_client + .get_artist(&track.album.artists[0].id) + .await? + { track.album.artists[0] = artist; } @@ -544,12 +620,17 @@ pub async fn scrobble_listenbrainz(pool: &Pool, cache: &Cache, req: Su let result = mb_client.get_recording(&recording.id).await?; println!("{}", "Musicbrainz (recording)".yellow()); scrobble.album = Some(Track::from(result.clone()).album); - rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?; + rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?; tokio::time::sleep(std::time::Duration::from_secs(1)).await; return Ok(()); } - println!("{} {} - {}, skipping", "Track not found: ".yellow(), artist, track); + println!( + "{} {} - {}, skipping", + "Track not found: ".yellow(), + artist, + track + ); Ok(()) -} \ No newline at end of file +} diff --git a/crates/scrobbler/src/spotify/client.rs b/crates/scrobbler/src/spotify/client.rs index 1bae6e15..70788ea2 100644 --- a/crates/scrobbler/src/spotify/client.rs +++ b/crates/scrobbler/src/spotify/client.rs @@ -4,7 +4,7 @@ use anyhow::Error; pub const BASE_URL: &str = "https://api.spotify.com/v1"; pub struct SpotifyClient { - token: String, + token: String, } impl SpotifyClient { @@ -17,52 +17,53 @@ impl SpotifyClient { pub async fn search(&self, query: &str) -> Result { let url = format!("{}/search", BASE_URL); let client = reqwest::Client::new(); - let response = client.get(&url) - .bearer_auth(&self.token) - .query(&[ - ("type", "track"), - ("q", query), - ]) - .send().await?; + let response = client + .get(&url) + .bearer_auth(&self.token) + .query(&[("type", "track"), ("q", query)]) + .send() + .await?; let result = response.json().await?; Ok(result) } pub async fn get_album(&self, id: &str) -> Result, Error> { - let url = format!("{}/albums/{}", BASE_URL, id); - let client = reqwest::Client::new(); - let response = client.get(&url) - .bearer_auth(&self.token) - .send().await?; + let url = format!("{}/albums/{}", BASE_URL, id); + let client = reqwest::Client::new(); + let response = client.get(&url).bearer_auth(&self.token).send().await?; - let headers = response.headers().clone(); - let data = response.text().await?; + let headers = response.headers().clone(); + let data = response.text().await?; - if data == "Too many requests" { - println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); - println!("> {} [get_album]", data); - return Ok(None); - } + if data == "Too many requests" { + println!( + "> retry-after {}", + headers.get("retry-after").unwrap().to_str().unwrap() + ); + println!("> {} [get_album]", data); + return Ok(None); + } - Ok(Some(serde_json::from_str(&data)?)) - } + Ok(Some(serde_json::from_str(&data)?)) + } - pub async fn get_artist(&self, id: &str) -> Result, Error> { - let url = format!("{}/artists/{}", BASE_URL, id); - let client = reqwest::Client::new(); - let response = client.get(&url) - .bearer_auth(&self.token) - .send().await?; + pub async fn get_artist(&self, id: &str) -> Result, Error> { + let url = format!("{}/artists/{}", BASE_URL, id); + let client = reqwest::Client::new(); + let response = client.get(&url).bearer_auth(&self.token).send().await?; - let headers = response.headers().clone(); - let data = response.text().await?; + let headers = response.headers().clone(); + let data = response.text().await?; - if data == "Too many requests" { - println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); - println!("> {} [get_artist]", data); - return Ok(None); - } + if data == "Too many requests" { + println!( + "> retry-after {}", + headers.get("retry-after").unwrap().to_str().unwrap() + ); + println!("> {} [get_artist]", data); + return Ok(None); + } - Ok(Some(serde_json::from_str(&data)?)) - } + Ok(Some(serde_json::from_str(&data)?)) + } } diff --git a/crates/scrobbler/src/spotify/mod.rs b/crates/scrobbler/src/spotify/mod.rs index af1c17c6..5fb95c13 100644 --- a/crates/scrobbler/src/spotify/mod.rs +++ b/crates/scrobbler/src/spotify/mod.rs @@ -1,32 +1,32 @@ use std::env; +use anyhow::Error; use reqwest::Client; use types::AccessToken; -use anyhow::Error; pub mod client; pub mod types; - pub async fn refresh_token(token: &str) -> Result { - if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { - panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); - } + if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { + panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); + } - let client_id = env::var("SPOTIFY_CLIENT_ID")?; - let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; + let client_id = env::var("SPOTIFY_CLIENT_ID")?; + let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; - let client = Client::new(); + let client = Client::new(); - let response = client.post("https://accounts.spotify.com/api/token") - .basic_auth(&client_id, Some(client_secret)) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", token), - ("client_id", &client_id) - ]) - .send() - .await?; - let token = response.json::().await?; - Ok(token) -} \ No newline at end of file + let response = client + .post("https://accounts.spotify.com/api/token") + .basic_auth(&client_id, Some(client_secret)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", token), + ("client_id", &client_id), + ]) + .send() + .await?; + let token = response.json::().await?; + Ok(token) +} diff --git a/crates/spotify/src/main.rs b/crates/spotify/src/main.rs index 87b9bf65..3e2cbc0c 100644 --- a/crates/spotify/src/main.rs +++ b/crates/spotify/src/main.rs @@ -1,22 +1,32 @@ -use std::{collections::HashMap, env, sync::{atomic::AtomicBool, Arc, Mutex}, thread}; +use std::{ + collections::HashMap, + env, + sync::{atomic::AtomicBool, Arc, Mutex}, + thread, +}; +use anyhow::Error; +use async_nats::connect; use cache::Cache; use crypto::decrypt_aes_256_ctr; use dotenv::dotenv; +use owo_colors::OwoColorize; use reqwest::Client; -use anyhow::Error; use rocksky::{scrobble, update_library}; use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; use tokio_stream::StreamExt; -use types::{album_tracks::AlbumTracks, currently_playing::{Album, Artist, CurrentlyPlaying}, spotify_token::SpotifyTokenWithEmail, token::AccessToken}; -use owo_colors::OwoColorize; -use async_nats::connect; +use types::{ + album_tracks::AlbumTracks, + currently_playing::{Album, Artist, CurrentlyPlaying}, + spotify_token::SpotifyTokenWithEmail, + token::AccessToken, +}; -pub mod types; pub mod cache; pub mod crypto; pub mod rocksky; pub mod token; +pub mod types; const BASE_URL: &str = "https://spotify-api.rocksky.app/v1"; @@ -40,7 +50,8 @@ async fn main() -> Result<(), Box> { println!("Found {} users", users.len().bright_green()); // Shared HashMap to manage threads and their stop flags - let thread_map: Arc>>> = Arc::new(Mutex::new(HashMap::new())); + let thread_map: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); // Start threads for all users for user in users { @@ -51,18 +62,26 @@ async fn main() -> Result<(), Box> { let cache = cache.clone(); let thread_map = Arc::clone(&thread_map); - thread_map.lock().unwrap().insert(email.clone(), Arc::clone(&stop_flag)); + thread_map + .lock() + .unwrap() + .insert(email.clone(), Arc::clone(&stop_flag)); thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); match rt.block_on(async { - watch_currently_playing(email.clone(), token, did, stop_flag, cache.clone()).await?; + watch_currently_playing(email.clone(), token, did, stop_flag, cache.clone()) + .await?; Ok::<(), Error>(()) }) { - Ok(_) => { - } + Ok(_) => {} Err(e) => { - println!("{} Error starting thread for user: {} - {}", format!("[{}]", email).bright_green(), email.bright_green(), e.to_string().bright_red()); + println!( + "{} Error starting thread for user: {} - {}", + format!("[{}]", email).bright_green(), + email.bright_green(), + e.to_string().bright_red() + ); } } }); @@ -71,7 +90,10 @@ async fn main() -> Result<(), Box> { // Handle subscription messages while let Some(message) = sub.next().await { let user_id = String::from_utf8(message.payload.to_vec()).unwrap(); - println!("Received message to restart thread for user: {}", user_id.bright_green()); + println!( + "Received message to restart thread for user: {}", + user_id.bright_green() + ); let mut thread_map = thread_map.lock().unwrap(); @@ -87,8 +109,11 @@ async fn main() -> Result<(), Box> { let user = find_spotify_user(&pool, &user_id).await?; if user.is_none() { - println!("Spotify user not found: {}, skipping", user_id.bright_green()); - continue; + println!( + "Spotify user not found: {}, skipping", + user_id.bright_green() + ); + continue; } let user = user.unwrap(); @@ -101,41 +126,68 @@ async fn main() -> Result<(), Box> { thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); match rt.block_on(async { - watch_currently_playing(email.clone(), token, did, new_stop_flag, cache.clone()).await?; + watch_currently_playing( + email.clone(), + token, + did, + new_stop_flag, + cache.clone(), + ) + .await?; Ok::<(), Error>(()) }) { - Ok(_) => {}, - Err(e) => { - println!("{} Error restarting thread for user: {} - {}", format!("[{}]", email).bright_green(), email.bright_green(), e.to_string().bright_red()); - } + Ok(_) => {} + Err(e) => { + println!( + "{} Error restarting thread for user: {} - {}", + format!("[{}]", email).bright_green(), + email.bright_green(), + e.to_string().bright_red() + ); + } } }); println!("Restarted thread for user: {}", user_id.bright_green()); } else { - println!("No thread found for user: {}, starting new thread", user_id.bright_green()); + println!( + "No thread found for user: {}, starting new thread", + user_id.bright_green() + ); let user = find_spotify_user(&pool, &user_id).await?; if let Some(user) = user { - let email = user.0.clone(); - let token = user.1.clone(); - let did = user.2.clone(); - let stop_flag = Arc::new(AtomicBool::new(false)); - let cache = cache.clone(); - - thread_map.insert(email.clone(), Arc::clone(&stop_flag)); - - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - match rt.block_on(async { - watch_currently_playing(email.clone(), token, did, stop_flag, cache.clone()).await?; - Ok::<(), Error>(()) - }) { - Ok(_) => {}, - Err(e) => { - println!("{} Error starting thread for user: {} - {}", format!("[{}]", email).bright_green(), email.bright_green(), e.to_string().bright_red()); + let email = user.0.clone(); + let token = user.1.clone(); + let did = user.2.clone(); + let stop_flag = Arc::new(AtomicBool::new(false)); + let cache = cache.clone(); + + thread_map.insert(email.clone(), Arc::clone(&stop_flag)); + + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + match rt.block_on(async { + watch_currently_playing( + email.clone(), + token, + did, + stop_flag, + cache.clone(), + ) + .await?; + Ok::<(), Error>(()) + }) { + Ok(_) => {} + Err(e) => { + println!( + "{} Error starting thread for user: {} - {}", + format!("[{}]", email).bright_green(), + email.bright_green(), + e.to_string().bright_red() + ); + } } - } - }); + }); } } } @@ -144,401 +196,500 @@ async fn main() -> Result<(), Box> { } pub async fn refresh_token(token: &str) -> Result { - if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { - panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); - } - - let client_id = env::var("SPOTIFY_CLIENT_ID")?; - let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; - - let client = Client::new(); - - let response = client.post("https://accounts.spotify.com/api/token") - .basic_auth(&client_id, Some(client_secret)) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", token), - ("client_id", &client_id) - ]) - .send() - .await?; - let token = response.json::().await?; - Ok(token) -} - -pub async fn get_currently_playing(cache: Cache, user_id: &str, token: &str) -> Result, Error> { - if let Ok(Some(data)) = cache.get(user_id) { - println!("{} {}", format!("[{}]", user_id).bright_green(), "Using cache".cyan()); - if data == "No content" { - return Ok(None); + if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { + panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); } - let decoded_data = serde_json::from_str::(&data); - if decoded_data.is_err() { - println!("{} {} {}", format!("[{}]", user_id).bright_green(), "Cache is invalid".red(), data); - cache.setex(user_id, "No content", 10)?; - cache.del(&format!("{}:current", user_id))?; - return Ok(None); - } + let client_id = env::var("SPOTIFY_CLIENT_ID")?; + let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; - let data: CurrentlyPlaying = decoded_data.unwrap(); - // detect if the song has changed - let previous = cache.get(&format!("{}:previous", user_id))?; - let changed = match previous { - Some(previous) => { - let previous: CurrentlyPlaying = serde_json::from_str(&previous)?; - if previous.item.is_none() && data.item.is_some() { - return Ok(Some((data, true))); - } + let client = Client::new(); - if previous.item.is_some() && data.item.is_none() { - return Ok(Some((data, false))); - } + let response = client + .post("https://accounts.spotify.com/api/token") + .basic_auth(&client_id, Some(client_secret)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", token), + ("client_id", &client_id), + ]) + .send() + .await?; + let token = response.json::().await?; + Ok(token) +} - if previous.item.is_none() && data.item.is_none() { - return Ok(Some((data, false))); +pub async fn get_currently_playing( + cache: Cache, + user_id: &str, + token: &str, +) -> Result, Error> { + if let Ok(Some(data)) = cache.get(user_id) { + println!( + "{} {}", + format!("[{}]", user_id).bright_green(), + "Using cache".cyan() + ); + if data == "No content" { + return Ok(None); + } + let decoded_data = serde_json::from_str::(&data); + + if decoded_data.is_err() { + println!( + "{} {} {}", + format!("[{}]", user_id).bright_green(), + "Cache is invalid".red(), + data + ); + cache.setex(user_id, "No content", 10)?; + cache.del(&format!("{}:current", user_id))?; + return Ok(None); } - let previous_item = previous.item.unwrap(); - let data_item = data.clone().item.unwrap(); - previous_item.id != data_item.id && previous.progress_ms.unwrap_or(0) != data.progress_ms.unwrap_or(0) - }, - _ => true - }; - return Ok(Some((data, changed))); - } + let data: CurrentlyPlaying = decoded_data.unwrap(); + // detect if the song has changed + let previous = cache.get(&format!("{}:previous", user_id))?; + let changed = match previous { + Some(previous) => { + let previous: CurrentlyPlaying = serde_json::from_str(&previous)?; + if previous.item.is_none() && data.item.is_some() { + return Ok(Some((data, true))); + } + if previous.item.is_some() && data.item.is_none() { + return Ok(Some((data, false))); + } - let token = refresh_token(token).await?; - let client = Client::new(); - let response = client.get(format!("{}/me/player/currently-playing", BASE_URL)) - .bearer_auth(token.access_token) - .send() - .await?; + if previous.item.is_none() && data.item.is_none() { + return Ok(Some((data, false))); + } + let previous_item = previous.item.unwrap(); + let data_item = data.clone().item.unwrap(); + previous_item.id != data_item.id + && previous.progress_ms.unwrap_or(0) != data.progress_ms.unwrap_or(0) + } + _ => true, + }; + return Ok(Some((data, changed))); + } + + let token = refresh_token(token).await?; + let client = Client::new(); + let response = client + .get(format!("{}/me/player/currently-playing", BASE_URL)) + .bearer_auth(token.access_token) + .send() + .await?; - let headers = response.headers().clone(); - let status = response.status().as_u16(); - let data = response.text().await?; + let headers = response.headers().clone(); + let status = response.status().as_u16(); + let data = response.text().await?; - if status == 429 { - println!("{} Too many requests, retry-after {}", format!("[{}]", user_id).bright_green(), headers.get("retry-after").unwrap().to_str().unwrap().bright_green()); - return Ok(None); - } + if status == 429 { + println!( + "{} Too many requests, retry-after {}", + format!("[{}]", user_id).bright_green(), + headers + .get("retry-after") + .unwrap() + .to_str() + .unwrap() + .bright_green() + ); + return Ok(None); + } - let previous = cache.get(&format!("{}:previous", user_id))?; + let previous = cache.get(&format!("{}:previous", user_id))?; - // check if status code is 204 - if status == 204 { - println!("No content"); - cache.setex(user_id, "No content", match previous.is_none() { - true => 30, - false => 10, + // check if status code is 204 + if status == 204 { + println!("No content"); + cache.setex( + user_id, + "No content", + match previous.is_none() { + true => 30, + false => 10, + }, + )?; + cache.del(&format!("{}:current", user_id))?; + return Ok(None); } - )?; + + let data = serde_json::from_str::(&data)?; + + cache.setex( + user_id, + &serde_json::to_string(&data)?, + match previous.is_none() { + true => 30, + false => 15, + }, + )?; cache.del(&format!("{}:current", user_id))?; - return Ok(None); - } - - let data = serde_json::from_str::(&data)?; - - cache.setex(user_id, &serde_json::to_string(&data)?, match previous.is_none() { - true => 30, - false => 15, - })?; - cache.del(&format!("{}:current", user_id))?; - - // detect if the song has changed - let previous = cache.get(&format!("{}:previous", user_id))?; - let changed = match previous { - Some(previous) => { - let previous: CurrentlyPlaying = serde_json::from_str(&previous)?; - if previous.item.is_none() || data.item.is_none() { - return Ok(Some((data, false))); - } - - let previous_item = previous.item.unwrap(); - let data_item = data.clone().item.unwrap(); - - previous_item.id != data_item.id && previous.progress_ms.unwrap_or(0) != data.progress_ms.unwrap_or(0) - }, - _ => false - }; - - // save as previous song - cache.setex(&format!("{}:previous", user_id), &serde_json::to_string(&data)?, 600)?; - - Ok(Some((data, changed))) -} -pub async fn get_artist(cache: Cache, artist_id: &str, token: &str) -> Result, Error> { - if let Ok(Some(data)) = cache.get(artist_id) { - return Ok(Some(serde_json::from_str(&data)?)); - } + // detect if the song has changed + let previous = cache.get(&format!("{}:previous", user_id))?; + let changed = match previous { + Some(previous) => { + let previous: CurrentlyPlaying = serde_json::from_str(&previous)?; + if previous.item.is_none() || data.item.is_none() { + return Ok(Some((data, false))); + } - let token = refresh_token(token).await?; - let client = Client::new(); - let response = client.get(&format!("{}/artists/{}", BASE_URL, artist_id)) - .bearer_auth(token.access_token) - .send() - .await?; + let previous_item = previous.item.unwrap(); + let data_item = data.clone().item.unwrap(); + + previous_item.id != data_item.id + && previous.progress_ms.unwrap_or(0) != data.progress_ms.unwrap_or(0) + } + _ => false, + }; + + // save as previous song + cache.setex( + &format!("{}:previous", user_id), + &serde_json::to_string(&data)?, + 600, + )?; + Ok(Some((data, changed))) +} + +pub async fn get_artist( + cache: Cache, + artist_id: &str, + token: &str, +) -> Result, Error> { + if let Ok(Some(data)) = cache.get(artist_id) { + return Ok(Some(serde_json::from_str(&data)?)); + } - let headers = response.headers().clone(); - let data = response.text().await?; + let token = refresh_token(token).await?; + let client = Client::new(); + let response = client + .get(&format!("{}/artists/{}", BASE_URL, artist_id)) + .bearer_auth(token.access_token) + .send() + .await?; + + let headers = response.headers().clone(); + let data = response.text().await?; - if data == "Too many requests" { - println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); - println!("> {} [get_artist]", data); - return Ok(None); - } + if data == "Too many requests" { + println!( + "> retry-after {}", + headers.get("retry-after").unwrap().to_str().unwrap() + ); + println!("> {} [get_artist]", data); + return Ok(None); + } - cache.setex(artist_id, &data, 20)?; + cache.setex(artist_id, &data, 20)?; - Ok(Some(serde_json::from_str(&data)?)) + Ok(Some(serde_json::from_str(&data)?)) } pub async fn get_album(cache: Cache, album_id: &str, token: &str) -> Result, Error> { - if let Ok(Some(data)) = cache.get(album_id) { - return Ok(Some(serde_json::from_str(&data)?)); - } - - let token = refresh_token(token).await?; - let client = Client::new(); - let response = client.get(&format!("{}/albums/{}", BASE_URL, album_id)) - .bearer_auth(token.access_token) - .send() - .await?; + if let Ok(Some(data)) = cache.get(album_id) { + return Ok(Some(serde_json::from_str(&data)?)); + } + + let token = refresh_token(token).await?; + let client = Client::new(); + let response = client + .get(&format!("{}/albums/{}", BASE_URL, album_id)) + .bearer_auth(token.access_token) + .send() + .await?; let headers = response.headers().clone(); let data = response.text().await?; if data == "Too many requests" { - println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); - println!("> {} [get_album]", data); - return Ok(None); + println!( + "> retry-after {}", + headers.get("retry-after").unwrap().to_str().unwrap() + ); + println!("> {} [get_album]", data); + return Ok(None); } - cache.setex(album_id, &data, 20)?; + cache.setex(album_id, &data, 20)?; - Ok(Some(serde_json::from_str(&data)?)) + Ok(Some(serde_json::from_str(&data)?)) } -pub async fn get_album_tracks(cache: Cache, album_id: &str, token: &str) -> Result { - if let Ok(Some(data)) = cache.get(&format!("{}:tracks", album_id)) { - return Ok(serde_json::from_str(&data)?); - } - - let token = refresh_token(token).await?; - let client = Client::new(); - let mut all_tracks = Vec::new(); - let mut offset = 0; - let limit = 50; - - - loop { - let response = client.get(&format!("{}/albums/{}/tracks", BASE_URL, album_id)) - .bearer_auth(&token.access_token) - .query(&[("limit", &limit.to_string()), ("offset", &offset.to_string())]) - .send() - .await?; - - let headers = response.headers().clone(); - let data = response.text().await?; - if data == "Too many requests" { - println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); - println!("> {} [get_album_tracks]", data); - continue; - } - - let album_tracks: AlbumTracks = serde_json::from_str(&data)?; - - if album_tracks.items.is_empty() { - break; - } - - all_tracks.extend(album_tracks.items); - offset += limit; - } - - let all_tracks_json = serde_json::to_string(&all_tracks)?; - cache.setex(&format!("{}:tracks", album_id), &all_tracks_json, 20)?; - - Ok(AlbumTracks { - items: all_tracks, - ..Default::default() - }) +pub async fn get_album_tracks( + cache: Cache, + album_id: &str, + token: &str, +) -> Result { + if let Ok(Some(data)) = cache.get(&format!("{}:tracks", album_id)) { + return Ok(serde_json::from_str(&data)?); + } + + let token = refresh_token(token).await?; + let client = Client::new(); + let mut all_tracks = Vec::new(); + let mut offset = 0; + let limit = 50; + + loop { + let response = client + .get(&format!("{}/albums/{}/tracks", BASE_URL, album_id)) + .bearer_auth(&token.access_token) + .query(&[ + ("limit", &limit.to_string()), + ("offset", &offset.to_string()), + ]) + .send() + .await?; + + let headers = response.headers().clone(); + let data = response.text().await?; + if data == "Too many requests" { + println!( + "> retry-after {}", + headers.get("retry-after").unwrap().to_str().unwrap() + ); + println!("> {} [get_album_tracks]", data); + continue; + } + + let album_tracks: AlbumTracks = serde_json::from_str(&data)?; + + if album_tracks.items.is_empty() { + break; + } + + all_tracks.extend(album_tracks.items); + offset += limit; + } + + let all_tracks_json = serde_json::to_string(&all_tracks)?; + cache.setex(&format!("{}:tracks", album_id), &all_tracks_json, 20)?; + + Ok(AlbumTracks { + items: all_tracks, + ..Default::default() + }) } pub async fn find_spotify_users( - pool: &Pool, - offset: usize, - limit: usize + pool: &Pool, + offset: usize, + limit: usize, ) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" + let results: Vec = sqlx::query_as( + r#" SELECT * FROM spotify_tokens LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id LEFT JOIN users ON spotify_accounts.user_id = users.xata_id LIMIT $1 OFFSET $2 - "#) + "#, + ) .bind(limit as i64) .bind(offset as i64) .fetch_all(pool) .await?; - let mut user_tokens = vec![]; + let mut user_tokens = vec![]; - for result in &results { - let token = decrypt_aes_256_ctr( - &result.refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - user_tokens.push((result.email.clone(), token, result.did.clone())); - } + for result in &results { + let token = decrypt_aes_256_ctr( + &result.refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; + user_tokens.push((result.email.clone(), token, result.did.clone())); + } - Ok(user_tokens) + Ok(user_tokens) } pub async fn find_spotify_user( - pool: &Pool, - email: &str + pool: &Pool, + email: &str, ) -> Result, Error> { - let result: Vec = sqlx::query_as(r#" + let result: Vec = sqlx::query_as( + r#" SELECT * FROM spotify_tokens LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id LEFT JOIN users ON spotify_accounts.user_id = users.xata_id WHERE spotify_accounts.email = $1 - "#) + "#, + ) .bind(email) .fetch_all(pool) .await?; - match result.first() { - Some(result) => { - let token = decrypt_aes_256_ctr( - &result.refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - Ok(Some((result.email.clone(), token, result.did.clone()))) - }, - None => Ok(None) - } + match result.first() { + Some(result) => { + let token = decrypt_aes_256_ctr( + &result.refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; + Ok(Some((result.email.clone(), token, result.did.clone()))) + } + None => Ok(None), + } } -pub async fn watch_currently_playing(spotify_email: String, token: String, did: String, stop_flag: Arc, cache: Cache) -> Result<(), Error> { - println!("{} {}", format!("[{}]", spotify_email).bright_green(), "Checking currently playing".cyan()); +pub async fn watch_currently_playing( + spotify_email: String, + token: String, + did: String, + stop_flag: Arc, + cache: Cache, +) -> Result<(), Error> { + println!( + "{} {}", + format!("[{}]", spotify_email).bright_green(), + "Checking currently playing".cyan() + ); + + let stop_flag_clone = stop_flag.clone(); + let spotify_email_clone = spotify_email.clone(); + let cache_clone = cache.clone(); + thread::spawn(move || { + loop { + if stop_flag_clone.load(std::sync::atomic::Ordering::Relaxed) { + println!( + "{} Stopping Thread", + format!("[{}]", spotify_email_clone).bright_green() + ); + break; + } + if let Some(cached) = cache_clone.get(&format!("{}:current", spotify_email_clone))? { + if serde_json::from_str::(&cached).is_err() { + thread::sleep(std::time::Duration::from_millis(800)); + continue; + } + let mut current_song = serde_json::from_str::(&cached)?; + + if let Some(item) = current_song.item.clone() { + if current_song.is_playing + && current_song.progress_ms.unwrap_or(0) < item.duration_ms.into() + { + current_song.progress_ms = + Some(current_song.progress_ms.unwrap_or(0) + 800); + match cache_clone.setex( + &format!("{}:current", spotify_email_clone), + &serde_json::to_string(¤t_song)?, + 16, + ) { + Ok(_) => {} + Err(e) => { + println!( + "{} redis error: {}", + format!("[{}]", spotify_email_clone).bright_green(), + e.to_string().bright_red() + ); + } + } + thread::sleep(std::time::Duration::from_millis(800)); + continue; + } + } + continue; + } - let stop_flag_clone = stop_flag.clone(); - let spotify_email_clone = spotify_email.clone(); - let cache_clone = cache.clone(); - thread::spawn(move || { - loop { - if stop_flag_clone.load(std::sync::atomic::Ordering::Relaxed) { - println!("{} Stopping Thread", format!("[{}]", spotify_email_clone).bright_green()); - break; - } - if let Some(cached) = cache_clone.get(&format!("{}:current", spotify_email_clone))? { - if serde_json::from_str::(&cached).is_err() { - thread::sleep(std::time::Duration::from_millis(800)); - continue; - } - let mut current_song = serde_json::from_str::(&cached)?; - - if let Some(item) = current_song.item.clone() { - if current_song.is_playing && current_song.progress_ms.unwrap_or(0) < item.duration_ms.into() { - current_song.progress_ms = Some(current_song.progress_ms.unwrap_or(0) + 800); - match cache_clone.setex(&format!("{}:current", spotify_email_clone), &serde_json::to_string(¤t_song)?, 16) { - Ok(_) => {}, - Err(e) => { - println!("{} redis error: {}", format!("[{}]", spotify_email_clone).bright_green(), e.to_string().bright_red()); - } + if let Ok(Some(cached)) = cache_clone.get(&spotify_email_clone) { + if cached == "No content" { + thread::sleep(std::time::Duration::from_millis(800)); + continue; + } + match cache_clone.setex(&format!("{}:current", spotify_email_clone), &cached, 16) { + Ok(_) => {} + Err(e) => { + println!( + "{} redis error: {}", + format!("[{}]", spotify_email_clone).bright_green(), + e.to_string().bright_red() + ); + } + } } + thread::sleep(std::time::Duration::from_millis(800)); - continue; - } } - continue; - } + Ok::<(), Error>(()) + }); - if let Ok(Some(cached)) = cache_clone.get(&spotify_email_clone) { - if cached == "No content" { - thread::sleep(std::time::Duration::from_millis(800)); - continue; - } - match cache_clone.setex(&format!("{}:current", spotify_email_clone), &cached, 16) { - Ok(_) => {}, - Err(e) => { - println!("{} redis error: {}", format!("[{}]", spotify_email_clone).bright_green(), e.to_string().bright_red()); - } + loop { + if stop_flag.load(std::sync::atomic::Ordering::Relaxed) { + println!( + "{} Stopping Thread", + format!("[{}]", spotify_email).bright_green() + ); + break; } - } - - - thread::sleep(std::time::Duration::from_millis(800)); - } - Ok::<(), Error>(()) - }); - - loop { - if stop_flag.load(std::sync::atomic::Ordering::Relaxed) { - println!("{} Stopping Thread", format!("[{}]", spotify_email).bright_green()); - break; - } - let spotify_email = spotify_email.clone(); - let token = token.clone(); - let did = did.clone(); - let cache = cache.clone(); - - let currently_playing = get_currently_playing( - cache.clone(), - &spotify_email, - &token - ).await; - let currently_playing = match currently_playing { - Ok(currently_playing) => currently_playing, - Err(e) => { - println!("{} {}", format!("[{}]", spotify_email).bright_green(), e.to_string().bright_red()); - tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; - continue; - } - }; - - if let Some((data, changed)) = currently_playing { - if data.item.is_none() { - println!("{} {}", format!("[{}]", spotify_email).bright_green(), "No song playing".yellow()); - tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; - continue; - } - let data_item = data.item.unwrap(); - println!("{} {} is_playing: {} changed: {}", format!("[{}]", spotify_email).bright_green(), format!("{} - {}", data_item.name, data_item.artists[0].name).yellow(), data.is_playing, changed); - - if changed { - scrobble( - cache.clone(), - &spotify_email, - &did, - &token - ).await?; + let spotify_email = spotify_email.clone(); + let token = token.clone(); + let did = did.clone(); + let cache = cache.clone(); - thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - match rt.block_on(async { - get_album_tracks(cache.clone(), &data_item.album.id, &token).await?; - get_album(cache.clone(), &data_item.album.id, &token).await?; - update_library(cache.clone(), &spotify_email, &did, &token).await?; - Ok::<(), Error>(()) - }) { - Ok(_) => {}, + let currently_playing = get_currently_playing(cache.clone(), &spotify_email, &token).await; + let currently_playing = match currently_playing { + Ok(currently_playing) => currently_playing, Err(e) => { - println!("{} {}", format!("[{}]", spotify_email).bright_green(), e.to_string().bright_red()); + println!( + "{} {}", + format!("[{}]", spotify_email).bright_green(), + e.to_string().bright_red() + ); + tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; + continue; } - } - }); - } - } + }; + + if let Some((data, changed)) = currently_playing { + if data.item.is_none() { + println!( + "{} {}", + format!("[{}]", spotify_email).bright_green(), + "No song playing".yellow() + ); + tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; + continue; + } + let data_item = data.item.unwrap(); + println!( + "{} {} is_playing: {} changed: {}", + format!("[{}]", spotify_email).bright_green(), + format!("{} - {}", data_item.name, data_item.artists[0].name).yellow(), + data.is_playing, + changed + ); + + if changed { + scrobble(cache.clone(), &spotify_email, &did, &token).await?; + + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + match rt.block_on(async { + get_album_tracks(cache.clone(), &data_item.album.id, &token).await?; + get_album(cache.clone(), &data_item.album.id, &token).await?; + update_library(cache.clone(), &spotify_email, &did, &token).await?; + Ok::<(), Error>(()) + }) { + Ok(_) => {} + Err(e) => { + println!( + "{} {}", + format!("[{}]", spotify_email).bright_green(), + e.to_string().bright_red() + ); + } + } + }); + } + } - tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; - } + tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; + } - Ok(()) + Ok(()) } diff --git a/crates/spotify/src/rocksky.rs b/crates/spotify/src/rocksky.rs index 0c611578..a2920753 100644 --- a/crates/spotify/src/rocksky.rs +++ b/crates/spotify/src/rocksky.rs @@ -1,30 +1,51 @@ use anyhow::Error; use reqwest::Client; -use crate::{cache::Cache, get_artist, get_currently_playing, token::generate_token, types::{album_tracks::Track, currently_playing::{Album, CurrentlyPlaying}}}; +use crate::{ + cache::Cache, + get_artist, get_currently_playing, + token::generate_token, + types::{ + album_tracks::Track, + currently_playing::{Album, CurrentlyPlaying}, + }, +}; const ROCKSKY_API: &str = "https://api.rocksky.app"; -pub async fn scrobble(cache: Cache, spotify_email: &str, did: &str, refresh_token: &str) -> Result<(), Error> { - let cached = cache.get(spotify_email)?; - if cached.is_none() { - println!("No currently playing song is cached for {}, skipping", spotify_email); - return Ok(()); - } +pub async fn scrobble( + cache: Cache, + spotify_email: &str, + did: &str, + refresh_token: &str, +) -> Result<(), Error> { + let cached = cache.get(spotify_email)?; + if cached.is_none() { + println!( + "No currently playing song is cached for {}, skipping", + spotify_email + ); + return Ok(()); + } - let track = serde_json::from_str::(&cached.unwrap())?; - if track.item.is_none() { - println!("No currently playing song found, skipping"); - return Ok(()); - } + let track = serde_json::from_str::(&cached.unwrap())?; + if track.item.is_none() { + println!("No currently playing song found, skipping"); + return Ok(()); + } - let track_item = track.item.unwrap(); + let track_item = track.item.unwrap(); - let artist = get_artist(cache.clone(), &track_item.artists.first().unwrap().id, &refresh_token).await?; + let artist = get_artist( + cache.clone(), + &track_item.artists.first().unwrap().id, + &refresh_token, + ) + .await?; - let token = generate_token(did)?; - let client = Client::new(); - let response = client + let token = generate_token(did)?; + let client = Client::new(); + let response = client .post(&format!("{}/now-playing", ROCKSKY_API)) .bearer_auth(token) .json(&serde_json::json!({ @@ -58,48 +79,51 @@ pub async fn scrobble(cache: Cache, spotify_email: &str, did: &str, refresh_tok .send() .await?; + if !response.status().is_success() { + println!("Failed to scrobble: {}", response.text().await?); + } - if !response.status().is_success() { - println!("Failed to scrobble: {}", response.text().await?); - } - - Ok(()) + Ok(()) } -pub async fn update_library(cache: Cache, spotify_email: &str, did: &str, refresh_token: &str) -> Result<(), Error> { - let cached = cache.get(spotify_email)?; - if cached.is_none() { - println!("No currently playing song is cached for {}, refreshing", spotify_email); - get_currently_playing( - cache.clone(), - &spotify_email, - &refresh_token, - ).await?; - } - - let cached = cache.get(spotify_email)?; - let track = serde_json::from_str::(&cached.unwrap())?; - if track.item.is_none() { - println!("No currently playing song found, skipping"); - return Ok(()); - } - let track_item = track.item.unwrap(); - let cached = cache.get(&format!("{}:tracks", track_item.album.id))?; - if cached.is_none() { - println!("Album not cached {}, skipping", track_item.album.id); - return Ok(()); - } - - let tracks = serde_json::from_str::>(&cached.unwrap())?; - - let cached = cache.get(&track_item.album.id)?; - let album = serde_json::from_str::(&cached.unwrap())?; - - let token = generate_token(did)?; - - for track in tracks { - let client = Client::new(); - let response = client +pub async fn update_library( + cache: Cache, + spotify_email: &str, + did: &str, + refresh_token: &str, +) -> Result<(), Error> { + let cached = cache.get(spotify_email)?; + if cached.is_none() { + println!( + "No currently playing song is cached for {}, refreshing", + spotify_email + ); + get_currently_playing(cache.clone(), &spotify_email, &refresh_token).await?; + } + + let cached = cache.get(spotify_email)?; + let track = serde_json::from_str::(&cached.unwrap())?; + if track.item.is_none() { + println!("No currently playing song found, skipping"); + return Ok(()); + } + let track_item = track.item.unwrap(); + let cached = cache.get(&format!("{}:tracks", track_item.album.id))?; + if cached.is_none() { + println!("Album not cached {}, skipping", track_item.album.id); + return Ok(()); + } + + let tracks = serde_json::from_str::>(&cached.unwrap())?; + + let cached = cache.get(&track_item.album.id)?; + let album = serde_json::from_str::(&cached.unwrap())?; + + let token = generate_token(did)?; + + for track in tracks { + let client = Client::new(); + let response = client .post(&format!("{}/tracks", ROCKSKY_API)) .bearer_auth(&token) .json(&serde_json::json!({ @@ -130,13 +154,13 @@ pub async fn update_library(cache: Cache, spotify_email: &str, did: &str, refres .send() .await?; - // wait 50 seconds to avoid rate limiting - tokio::time::sleep(tokio::time::Duration::from_secs(50)).await; + // wait 50 seconds to avoid rate limiting + tokio::time::sleep(tokio::time::Duration::from_secs(50)).await; - if !response.status().is_success() { - println!("Failed to save track: {}", response.text().await?); + if !response.status().is_success() { + println!("Failed to save track: {}", response.text().await?); + } } - } - Ok(()) + Ok(()) } diff --git a/crates/storage/src/handlers/files.rs b/crates/storage/src/handlers/files.rs index 8dc2fb7e..aabed47b 100644 --- a/crates/storage/src/handlers/files.rs +++ b/crates/storage/src/handlers/files.rs @@ -2,6 +2,10 @@ use actix_web::{web, HttpRequest, HttpResponse}; use anyhow::Error; use s3::Bucket; -pub async fn presign_get(_payload: &mut web::Payload, _req: &HttpRequest, _bucket: Box) -> Result { - todo!() -} \ No newline at end of file +pub async fn presign_get( + _payload: &mut web::Payload, + _req: &HttpRequest, + _bucket: Box, +) -> Result { + todo!() +} diff --git a/crates/storage/src/handlers/folders.rs b/crates/storage/src/handlers/folders.rs index e6b15e3d..6f1e4959 100644 --- a/crates/storage/src/handlers/folders.rs +++ b/crates/storage/src/handlers/folders.rs @@ -6,26 +6,38 @@ use tokio_stream::StreamExt; use crate::{read_payload, types::params::ListParams}; -pub async fn list(payload: &mut web::Payload, _req: &HttpRequest, bucket: Box) -> Result { - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body)?; - let (res, _) = bucket.list_page(params.prefix, params.delimiter, params.continuation_token, params.start_after, params.max_keys).await?; +pub async fn list( + payload: &mut web::Payload, + _req: &HttpRequest, + bucket: Box, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let (res, _) = bucket + .list_page( + params.prefix, + params.delimiter, + params.continuation_token, + params.start_after, + params.max_keys, + ) + .await?; - Ok(HttpResponse::Ok().json(json!({ - "max_keys": res.max_keys, - "prefix": res.prefix, - "continuation_token": res.continuation_token, - "encoding_type": res.encoding_type, - "is_truncated": res.is_truncated, - "next_continuation_token": res.next_continuation_token, - "contents": res.contents.iter().map(|content| { - json!({ - "key": content.key, - "last_modified": content.last_modified, - "etag": content.e_tag, - "size": content.size, - "storage_class": content.storage_class, - }) - }).collect::>() - }))) + Ok(HttpResponse::Ok().json(json!({ + "max_keys": res.max_keys, + "prefix": res.prefix, + "continuation_token": res.continuation_token, + "encoding_type": res.encoding_type, + "is_truncated": res.is_truncated, + "next_continuation_token": res.next_continuation_token, + "contents": res.contents.iter().map(|content| { + json!({ + "key": content.key, + "last_modified": content.last_modified, + "etag": content.e_tag, + "size": content.size, + "storage_class": content.storage_class, + }) + }).collect::>() + }))) } diff --git a/crates/storage/src/handlers/mod.rs b/crates/storage/src/handlers/mod.rs index 749d4f48..0ca5acba 100644 --- a/crates/storage/src/handlers/mod.rs +++ b/crates/storage/src/handlers/mod.rs @@ -9,22 +9,27 @@ pub mod folders; #[macro_export] macro_rules! read_payload { - ($payload:expr) => {{ - let mut body = Vec::new(); - while let Some(chunk) = $payload.next().await { - match chunk { - Ok(bytes) => body.extend_from_slice(&bytes), - Err(err) => return Err(err.into()), - } - } - body - }}; + ($payload:expr) => {{ + let mut body = Vec::new(); + while let Some(chunk) = $payload.next().await { + match chunk { + Ok(bytes) => body.extend_from_slice(&bytes), + Err(err) => return Err(err.into()), + } + } + body + }}; } -pub async fn handle(method: &str, payload: &mut web::Payload, req: &HttpRequest, bucket: Box) -> Result { - match method { - "storage.list" => list(payload, req, bucket.clone()).await, - "storage.presignGet" => presign_get(payload, req, bucket.clone()).await, - _ => return Err(anyhow::anyhow!("Method not found")), - } +pub async fn handle( + method: &str, + payload: &mut web::Payload, + req: &HttpRequest, + bucket: Box, +) -> Result { + match method { + "storage.list" => list(payload, req, bucket.clone()).await, + "storage.presignGet" => presign_get(payload, req, bucket.clone()).await, + _ => return Err(anyhow::anyhow!("Method not found")), + } } diff --git a/crates/storage/src/server.rs b/crates/storage/src/server.rs index 512d64ed..9dd6bc02 100644 --- a/crates/storage/src/server.rs +++ b/crates/storage/src/server.rs @@ -1,73 +1,76 @@ use std::env; -use actix_web::{get, post, web::{self, Data}, App, HttpRequest, HttpResponse, HttpServer, Responder}; +use actix_web::{ + get, post, + web::{self, Data}, + App, HttpRequest, HttpResponse, HttpServer, Responder, +}; +use anyhow::Error; use owo_colors::OwoColorize; use s3::{creds::Credentials, Bucket, Region}; use serde_json::json; -use anyhow::Error; use crate::handlers::handle; - #[get("/")] async fn index(_req: HttpRequest) -> HttpResponse { - HttpResponse::Ok().json(json!({ - "server": "Rocksky Storage Server", - "version": "0.1.0", - })) + HttpResponse::Ok().json(json!({ + "server": "Rocksky Storage Server", + "version": "0.1.0", + })) } #[post("/{method}")] async fn call_method( - data: web::Data>, - mut payload: web::Payload, - req: HttpRequest) -> Result { - let method = req.match_info().get("method").unwrap_or("unknown"); - println!("Method: {}", method.bright_green()); + data: web::Data>, + mut payload: web::Payload, + req: HttpRequest, +) -> Result { + let method = req.match_info().get("method").unwrap_or("unknown"); + println!("Method: {}", method.bright_green()); - let bucket = data.get_ref().clone(); - handle(method, &mut payload, &req, bucket).await - .map_err(actix_web::error::ErrorInternalServerError) + let bucket = data.get_ref().clone(); + handle(method, &mut payload, &req, bucket) + .await + .map_err(actix_web::error::ErrorInternalServerError) } - pub async fn serve() -> Result<(), Error> { + let host = env::var("STORAGE_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = env::var("STORAGE_PORT").unwrap_or_else(|_| "7883".to_string()); + let addr = format!("{}:{}", host, port); - let host = env::var("STORAGE_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port = env::var("STORAGE_PORT").unwrap_or_else(|_| "7883".to_string()); - let addr = format!("{}:{}", host, port); - - let url = format!("http://{}", addr); - println!("Listening on {}", url.bright_green()); + let url = format!("http://{}", addr); + println!("Listening on {}", url.bright_green()); - let access_key = std::env::var("ACCESS_KEY").expect("ACCESS_KEY not set"); - let secret_key = std::env::var("SECRET_KEY").expect("SECRET_KEY not set"); - let bucket_name = std::env::var("BUCKET_NAME").expect("BUCKET_NAME not set"); - let account_id = std::env::var("ACCOUNT_ID").expect("ACCOUNT_ID not set"); + let access_key = std::env::var("ACCESS_KEY").expect("ACCESS_KEY not set"); + let secret_key = std::env::var("SECRET_KEY").expect("SECRET_KEY not set"); + let bucket_name = std::env::var("BUCKET_NAME").expect("BUCKET_NAME not set"); + let account_id = std::env::var("ACCOUNT_ID").expect("ACCOUNT_ID not set"); - let bucket = Bucket::new( - &bucket_name, - Region::R2 { account_id }, - Credentials::new( - Some(access_key.as_str()), - Some(secret_key.as_str()), - None, - None, - None - )?, - )? - .with_path_style(); + let bucket = Bucket::new( + &bucket_name, + Region::R2 { account_id }, + Credentials::new( + Some(access_key.as_str()), + Some(secret_key.as_str()), + None, + None, + None, + )?, + )? + .with_path_style(); - HttpServer::new(move || { - App::new() - .app_data(Data::new(bucket.clone())) - .service(index) - .service(call_method) - }) - .bind(&addr)? - .run() - .await - .map_err(Error::new)?; + HttpServer::new(move || { + App::new() + .app_data(Data::new(bucket.clone())) + .service(index) + .service(call_method) + }) + .bind(&addr)? + .run() + .await + .map_err(Error::new)?; - Ok(()) -} \ No newline at end of file + Ok(()) +} diff --git a/crates/webscrobbler/src/handlers.rs b/crates/webscrobbler/src/handlers.rs index 1b8ad90e..224c2670 100644 --- a/crates/webscrobbler/src/handlers.rs +++ b/crates/webscrobbler/src/handlers.rs @@ -1,99 +1,117 @@ -use std::sync::Arc; +use crate::{cache::Cache, repo, scrobbler::scrobble, types::ScrobbleRequest, BANNER}; use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder}; use owo_colors::OwoColorize; use sqlx::{Pool, Postgres}; -use crate::{cache::Cache, repo, scrobbler::scrobble, types::ScrobbleRequest, BANNER}; +use std::sync::Arc; use tokio_stream::StreamExt; #[macro_export] macro_rules! read_payload { - ($payload:expr) => {{ - let mut body = Vec::new(); - while let Some(chunk) = $payload.next().await { - match chunk { - Ok(bytes) => body.extend_from_slice(&bytes), - Err(err) => return Err(err.into()), - } - } - body - }}; + ($payload:expr) => {{ + let mut body = Vec::new(); + while let Some(chunk) = $payload.next().await { + match chunk { + Ok(bytes) => body.extend_from_slice(&bytes), + Err(err) => return Err(err.into()), + } + } + body + }}; } #[get("/")] pub async fn index() -> impl Responder { - HttpResponse::Ok().body(BANNER) + HttpResponse::Ok().body(BANNER) } #[post("/{id}")] async fn handle_scrobble( - data: web::Data>>, - cache: web::Data, - mut payload: web::Payload, - req: HttpRequest, + data: web::Data>>, + cache: web::Data, + mut payload: web::Payload, + req: HttpRequest, ) -> Result { - let id = req.match_info().get("id").unwrap(); - println!("Received scrobble for ID: {}", id.cyan()); - - let pool = data.get_ref().clone(); - - let user = repo::user::get_user_by_webscrobbler(&pool, id).await - .map_err(|err| actix_web::error::ErrorInternalServerError(format!("Database error: {}", err)))?; - - if user.is_none() { - return Ok(HttpResponse::NotFound().body("There is no user with this webscrobbler ID")); - } - let user = user.unwrap(); - - let body = read_payload!(payload); - let params = serde_json::from_slice::(&body) - .map_err(|err| { - let body = String::from_utf8_lossy(&body); - println!("Failed to parse JSON: {}", body); - println!("Failed to parse JSON: {}", err); - actix_web::error::ErrorBadRequest(format!("Failed to parse JSON: {}", err))})?; - - println!("Parsed scrobble request: {:#?}", params); - - if params.event_name != "scrobble" { - println!("Skipping non-scrobble event: {}", params.event_name.green()); - return Ok(HttpResponse::Ok().body("Skipping non-scrobble event")); - } - - // Check if connector is Spotify - if params.data.song.connector.id == "spotify" { - // Skip if the user has a Spotify token - let spotify_token = repo::spotify_token::get_spotify_token(&pool, &user.did).await - .map_err(|err| actix_web::error::ErrorInternalServerError(format!("Failed to get Spotify tokens: {}", err)))?; - - if spotify_token.is_some() { - println!("User has a Spotify token, skipping scrobble"); - return Ok(HttpResponse::Ok().body("User has a Spotify token, skipping scrobble")); - } - } + let id = req.match_info().get("id").unwrap(); + println!("Received scrobble for ID: {}", id.cyan()); + let pool = data.get_ref().clone(); - let cache = cache.get_ref().clone(); + let user = repo::user::get_user_by_webscrobbler(&pool, id) + .await + .map_err(|err| { + actix_web::error::ErrorInternalServerError(format!("Database error: {}", err)) + })?; - if params.data.song.connector.id == "emby" { - let artist = params.data.song.parsed.artist.clone(); - let track = params.data.song.parsed.track.clone(); - let cached = cache.get(&format!("listenbrainz:emby:{}:{}:{}", artist, track, user.did)); - - if cached.is_err() { - println!("Failed to check cache for Emby scrobble: {}", cached.unwrap_err()); - return Ok(HttpResponse::Ok().body("Failed to check cache for Emby scrobble")); + if user.is_none() { + return Ok(HttpResponse::NotFound().body("There is no user with this webscrobbler ID")); } + let user = user.unwrap(); + + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body).map_err(|err| { + let body = String::from_utf8_lossy(&body); + println!("Failed to parse JSON: {}", body); + println!("Failed to parse JSON: {}", err); + actix_web::error::ErrorBadRequest(format!("Failed to parse JSON: {}", err)) + })?; + + println!("Parsed scrobble request: {:#?}", params); - if cached.unwrap().is_some() { - println!("Skipping duplicate scrobble for Emby: {} - {}", artist, track); - return Ok(HttpResponse::Ok().body("Skipping duplicate scrobble for Emby")); + if params.event_name != "scrobble" { + println!("Skipping non-scrobble event: {}", params.event_name.green()); + return Ok(HttpResponse::Ok().body("Skipping non-scrobble event")); } - } + // Check if connector is Spotify + if params.data.song.connector.id == "spotify" { + // Skip if the user has a Spotify token + let spotify_token = repo::spotify_token::get_spotify_token(&pool, &user.did) + .await + .map_err(|err| { + actix_web::error::ErrorInternalServerError(format!( + "Failed to get Spotify tokens: {}", + err + )) + })?; + + if spotify_token.is_some() { + println!("User has a Spotify token, skipping scrobble"); + return Ok(HttpResponse::Ok().body("User has a Spotify token, skipping scrobble")); + } + } - scrobble(&pool, &cache, params, &user.did).await - .map_err(|err| actix_web::error::ErrorInternalServerError(format!("Failed to scrobble: {}", err)))?; + let cache = cache.get_ref().clone(); + + if params.data.song.connector.id == "emby" { + let artist = params.data.song.parsed.artist.clone(); + let track = params.data.song.parsed.track.clone(); + let cached = cache.get(&format!( + "listenbrainz:emby:{}:{}:{}", + artist, track, user.did + )); + + if cached.is_err() { + println!( + "Failed to check cache for Emby scrobble: {}", + cached.unwrap_err() + ); + return Ok(HttpResponse::Ok().body("Failed to check cache for Emby scrobble")); + } + + if cached.unwrap().is_some() { + println!( + "Skipping duplicate scrobble for Emby: {} - {}", + artist, track + ); + return Ok(HttpResponse::Ok().body("Skipping duplicate scrobble for Emby")); + } + } + scrobble(&pool, &cache, params, &user.did) + .await + .map_err(|err| { + actix_web::error::ErrorInternalServerError(format!("Failed to scrobble: {}", err)) + })?; - Ok(HttpResponse::Ok().body("Scrobble received")) + Ok(HttpResponse::Ok().body("Scrobble received")) } diff --git a/crates/webscrobbler/src/main.rs b/crates/webscrobbler/src/main.rs index 3bbc3f1c..93cddc6d 100644 --- a/crates/webscrobbler/src/main.rs +++ b/crates/webscrobbler/src/main.rs @@ -1,25 +1,29 @@ use std::{env, sync::Arc, time::Duration}; -use actix_session::SessionExt as _; use actix_limitation::{Limiter, RateLimiter}; -use actix_web::{dev::ServiceRequest, web::{self, Data}, App, HttpServer}; +use actix_session::SessionExt as _; +use actix_web::{ + dev::ServiceRequest, + web::{self, Data}, + App, HttpServer, +}; use anyhow::Error; use cache::Cache; use dotenv::dotenv; use owo_colors::OwoColorize; use sqlx::postgres::PgPoolOptions; -pub mod rocksky; +pub mod auth; pub mod cache; +pub mod crypto; pub mod handlers; -pub mod xata; -pub mod types; -pub mod repo; -pub mod auth; -pub mod spotify; pub mod musicbrainz; +pub mod repo; +pub mod rocksky; pub mod scrobbler; -pub mod crypto; +pub mod spotify; +pub mod types; +pub mod xata; pub const BANNER: &str = r#" _ __ __ _____ __ __ __ @@ -58,7 +62,7 @@ async fn main() -> Result<(), Error> { format!("{}:{}", host, port).green() ); - let limiter = web::Data::new( + let limiter = web::Data::new( Limiter::builder("redis://127.0.0.1") .key_by(|req: &ServiceRequest| { req.get_session() diff --git a/crates/webscrobbler/src/musicbrainz/client.rs b/crates/webscrobbler/src/musicbrainz/client.rs index 5b9fdf19..f68ef516 100644 --- a/crates/webscrobbler/src/musicbrainz/client.rs +++ b/crates/webscrobbler/src/musicbrainz/client.rs @@ -11,43 +11,28 @@ impl MusicbrainzClient { MusicbrainzClient {} } - pub async fn search( - &self, - query: &str, - ) -> Result { + pub async fn search(&self, query: &str) -> Result { let url = format!("{}/recording", BASE_URL); let client = reqwest::Client::new(); let response = client .get(&url) .header("Accept", "application/json") .header("User-Agent", USER_AGENT) - .query( - &[ - ("query", query), - ("inc", "artist-credits+releases"), - ], - ) + .query(&[("query", query), ("inc", "artist-credits+releases")]) .send() .await?; Ok(response.json().await?) } - pub async fn get_recording( - &self, - mbid: &str, - ) -> Result { + pub async fn get_recording(&self, mbid: &str) -> Result { let url = format!("{}/recording/{}", BASE_URL, mbid); let client = reqwest::Client::new(); let response = client .get(&url) .header("Accept", "application/json") .header("User-Agent", USER_AGENT) - .query( - &[ - ("inc", "artist-credits+releases"), - ], - ) + .query(&[("inc", "artist-credits+releases")]) .send() .await?; diff --git a/crates/webscrobbler/src/repo/album.rs b/crates/webscrobbler/src/repo/album.rs index dd99a446..820d6730 100644 --- a/crates/webscrobbler/src/repo/album.rs +++ b/crates/webscrobbler/src/repo/album.rs @@ -4,14 +4,16 @@ use sqlx::{Pool, Postgres}; use crate::xata::album::Album; pub async fn get_album_by_track_id(pool: &Pool, track_id: &str) -> Result { - let results: Vec = sqlx::query_as(r#" + let results: Vec = sqlx::query_as( + r#" SELECT * FROM albums LEFT JOIN album_tracks ON albums.xata_id = album_tracks.album_id WHERE album_tracks.track_id = $1 - "#) + "#, + ) .bind(track_id) .fetch_all(pool) .await?; - Ok(results[0].clone()) -} \ No newline at end of file + Ok(results[0].clone()) +} diff --git a/crates/webscrobbler/src/repo/artist.rs b/crates/webscrobbler/src/repo/artist.rs index 8f455fb4..96e9938e 100644 --- a/crates/webscrobbler/src/repo/artist.rs +++ b/crates/webscrobbler/src/repo/artist.rs @@ -3,15 +3,20 @@ use sqlx::{Pool, Postgres}; use crate::xata::artist::Artist; -pub async fn get_artist_by_track_id(pool: &Pool, track_id: &str) -> Result { - let results: Vec = sqlx::query_as(r#" +pub async fn get_artist_by_track_id( + pool: &Pool, + track_id: &str, +) -> Result { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM artists LEFT JOIN artist_tracks ON artists.xata_id = artist_tracks.artist_id WHERE artist_tracks.track_id = $1 - "#) + "#, + ) .bind(track_id) .fetch_all(pool) .await?; - Ok(results[0].clone()) -} \ No newline at end of file + Ok(results[0].clone()) +} diff --git a/crates/webscrobbler/src/repo/spotify_account.rs b/crates/webscrobbler/src/repo/spotify_account.rs index a8631d7d..c5eaf9b1 100644 --- a/crates/webscrobbler/src/repo/spotify_account.rs +++ b/crates/webscrobbler/src/repo/spotify_account.rs @@ -1,12 +1,17 @@ -use sqlx::{Pool, Postgres}; -use anyhow::Error; use crate::xata::spotify_account::SpotifyAccount; +use anyhow::Error; +use sqlx::{Pool, Postgres}; -pub async fn get_spotify_account(pool: &Pool, user_id: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_spotify_account( + pool: &Pool, + user_id: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM spotify_accounts WHERE user_id = $1 - "#) + "#, + ) .bind(user_id) .fetch_all(pool) .await?; diff --git a/crates/webscrobbler/src/repo/spotify_token.rs b/crates/webscrobbler/src/repo/spotify_token.rs index ca0a7505..f3c66190 100644 --- a/crates/webscrobbler/src/repo/spotify_token.rs +++ b/crates/webscrobbler/src/repo/spotify_token.rs @@ -3,35 +3,44 @@ use sqlx::{Pool, Postgres}; use crate::xata::spotify_token::SpotifyToken; - -pub async fn get_spotify_token(pool: &Pool, did: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_spotify_token( + pool: &Pool, + did: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM spotify_tokens LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id LEFT JOIN users ON spotify_accounts.user_id = users.xata_id WHERE users.did = $1 - "#) - .bind(did) - .fetch_all(pool) - .await?; + "#, + ) + .bind(did) + .fetch_all(pool) + .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) + Ok(Some(results[0].clone())) } -pub async fn get_spotify_tokens(pool: &Pool, limit: u32) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_spotify_tokens( + pool: &Pool, + limit: u32, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM spotify_tokens LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id LEFT JOIN users ON spotify_accounts.user_id = users.xata_id LIMIT $1 - "#) - .bind(limit as i32) - .fetch_all(pool) - .await?; + "#, + ) + .bind(limit as i32) + .fetch_all(pool) + .await?; - Ok(results) -} \ No newline at end of file + Ok(results) +} diff --git a/crates/webscrobbler/src/repo/track.rs b/crates/webscrobbler/src/repo/track.rs index abdb7c36..5ae8e0cb 100644 --- a/crates/webscrobbler/src/repo/track.rs +++ b/crates/webscrobbler/src/repo/track.rs @@ -3,35 +3,43 @@ use sqlx::{Pool, Postgres}; use crate::xata::track::Track; -pub async fn get_track(pool: &Pool, title: &str, artist: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_track( + pool: &Pool, + title: &str, + artist: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM tracks WHERE LOWER(title) = LOWER($1) AND (LOWER(artist) = LOWER($2) OR LOWER(album_artist) = LOWER($2)) - "#) + "#, + ) .bind(title) .bind(artist) .fetch_all(pool) .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) + Ok(Some(results[0].clone())) } pub async fn get_track_by_mbid(pool: &Pool, mbid: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" + let results: Vec = sqlx::query_as( + r#" SELECT * FROM tracks WHERE mb_id = $1 - "#) + "#, + ) .bind(mbid) .fetch_all(pool) .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) -} \ No newline at end of file + Ok(Some(results[0].clone())) +} diff --git a/crates/webscrobbler/src/repo/user.rs b/crates/webscrobbler/src/repo/user.rs index 28ec8618..02c6c608 100644 --- a/crates/webscrobbler/src/repo/user.rs +++ b/crates/webscrobbler/src/repo/user.rs @@ -3,20 +3,24 @@ use sqlx::{Pool, Postgres}; use crate::xata::user::User; - -pub async fn get_user_by_webscrobbler(pool: &Pool, uuid: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_user_by_webscrobbler( + pool: &Pool, + uuid: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM users LEFT JOIN webscrobblers ON users.xata_id = webscrobblers.user_id WHERE webscrobblers.uuid = $1 - "#) - .bind(uuid) - .fetch_all(pool) - .await?; + "#, + ) + .bind(uuid) + .fetch_all(pool) + .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) + Ok(Some(results[0].clone())) } diff --git a/crates/webscrobbler/src/repo/webscrobbler.rs b/crates/webscrobbler/src/repo/webscrobbler.rs index ef321f82..63aac50f 100644 --- a/crates/webscrobbler/src/repo/webscrobbler.rs +++ b/crates/webscrobbler/src/repo/webscrobbler.rs @@ -1,19 +1,24 @@ +use crate::xata::webscrobbler::Webscrobbler; use anyhow::Error; use sqlx::{Pool, Postgres}; -use crate::xata::webscrobbler::Webscrobbler; -pub async fn get_webscrobbler(pool: &Pool, uuid: &str) -> Result, Error> { - let results: Vec = sqlx::query_as(r#" +pub async fn get_webscrobbler( + pool: &Pool, + uuid: &str, +) -> Result, Error> { + let results: Vec = sqlx::query_as( + r#" SELECT * FROM webscrobblers WHERE uuid = $1 - "#) - .bind(uuid) - .fetch_all(pool) - .await?; + "#, + ) + .bind(uuid) + .fetch_all(pool) + .await?; - if results.len() == 0 { - return Ok(None); - } + if results.len() == 0 { + return Ok(None); + } - Ok(Some(results[0].clone())) + Ok(Some(results[0].clone())) } diff --git a/crates/webscrobbler/src/rocksky.rs b/crates/webscrobbler/src/rocksky.rs index bdca997b..1889ee8d 100644 --- a/crates/webscrobbler/src/rocksky.rs +++ b/crates/webscrobbler/src/rocksky.rs @@ -7,38 +7,45 @@ use crate::{auth::generate_token, cache::Cache, types::Track}; const ROCKSKY_API: &str = "https://api.rocksky.app"; pub async fn scrobble(cache: &Cache, did: &str, track: Track, timestamp: u64) -> Result<(), Error> { - let key = format!("{} - {}", track.artist.to_lowercase(), track.title.to_lowercase()); - - // Check if the track is already in the cache, if not add it - if !cache.exists(&key)? { - let value = serde_json::to_string(&track)?; - let ttl = 15 * 60; // 15 minutes - cache.setex(&key, &value, ttl)?; - } - - let mut track = track; - track.timestamp = Some(timestamp / 1000 as u64); - - let token = generate_token(did)?; - let client = Client::new(); - - println!("Scrobbling track: \n {:#?}", track); - - let response= client - .post(&format!("{}/now-playing", ROCKSKY_API)) - .bearer_auth(token) - .json(&track) - .send() - .await?; - - if !response.status().is_success() { - println!("Failed to scrobble track: {}", response.status().to_string()); - let text = response.text().await?; - println!("Response: {}", text); - return Err(Error::msg(format!("Failed to scrobble track: {}", text))); - } - - println!("Scrobbled track: {}", track.title.green()); - - Ok(()) + let key = format!( + "{} - {}", + track.artist.to_lowercase(), + track.title.to_lowercase() + ); + + // Check if the track is already in the cache, if not add it + if !cache.exists(&key)? { + let value = serde_json::to_string(&track)?; + let ttl = 15 * 60; // 15 minutes + cache.setex(&key, &value, ttl)?; + } + + let mut track = track; + track.timestamp = Some(timestamp / 1000 as u64); + + let token = generate_token(did)?; + let client = Client::new(); + + println!("Scrobbling track: \n {:#?}", track); + + let response = client + .post(&format!("{}/now-playing", ROCKSKY_API)) + .bearer_auth(token) + .json(&track) + .send() + .await?; + + if !response.status().is_success() { + println!( + "Failed to scrobble track: {}", + response.status().to_string() + ); + let text = response.text().await?; + println!("Response: {}", text); + return Err(Error::msg(format!("Failed to scrobble track: {}", text))); + } + + println!("Scrobbled track: {}", track.title.green()); + + Ok(()) } diff --git a/crates/webscrobbler/src/scrobbler.rs b/crates/webscrobbler/src/scrobbler.rs index d49095a4..b332d4dd 100644 --- a/crates/webscrobbler/src/scrobbler.rs +++ b/crates/webscrobbler/src/scrobbler.rs @@ -1,137 +1,171 @@ use std::env; -use owo_colors::OwoColorize; -use rand::Rng; -use sqlx::{Pool, Postgres}; -use anyhow::Error; use crate::cache::Cache; use crate::crypto::decrypt_aes_256_ctr; use crate::musicbrainz::client::MusicbrainzClient; use crate::spotify::client::SpotifyClient; use crate::spotify::refresh_token; -use crate::{repo, rocksky}; use crate::types::{ScrobbleRequest, Track}; +use crate::{repo, rocksky}; +use anyhow::Error; +use owo_colors::OwoColorize; +use rand::Rng; +use sqlx::{Pool, Postgres}; -pub async fn scrobble(pool: &Pool, cache: &Cache, scrobble: ScrobbleRequest, did: &str) -> Result<(), Error> { - let spofity_tokens = repo::spotify_token::get_spotify_tokens(pool, 100).await?; - - if spofity_tokens.is_empty() { - return Err(Error::msg("No Spotify tokens found")); - } - - let mb_client = MusicbrainzClient::new(); - - let key = format!("{} - {}", scrobble.data.song.parsed.artist.to_lowercase(), scrobble.data.song.parsed.track.to_lowercase()); - - let cached = cache.get(&key)?; - if cached.is_some() { - println!("{}", format!("Cached: {}", key).yellow()); - let track = serde_json::from_str::(&cached.unwrap())?; - rocksky::scrobble(cache, &did, track, scrobble.time).await?; - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - return Ok(()); - } - - let result = repo::track::get_track(pool, &scrobble.data.song.parsed.track, &scrobble.data.song.parsed.artist).await?; - - if let Some(track) = result { - println!("{}", "Xata (track)".yellow()); - let album = repo::album::get_album_by_track_id(pool, &track.xata_id).await?; - let artist = repo::artist::get_artist_by_track_id(pool, &track.xata_id).await?; - let mut track: Track = track.into(); - track.year = match album.year { - Some(year) => Some(year as u32), - None => match album.release_date.clone() { - Some(release_date) => { - let year = release_date.split("-").next(); - year.and_then(|x| x.parse::().ok()) - } - None => None, - }, - }; - track.release_date = album.release_date.map(|x| x.split("T").next().unwrap().to_string()); - track.artist_picture = artist.picture.clone(); - - rocksky::scrobble(cache, &did, track, scrobble.time).await?; - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - return Ok(()); +pub async fn scrobble( + pool: &Pool, + cache: &Cache, + scrobble: ScrobbleRequest, + did: &str, +) -> Result<(), Error> { + let spofity_tokens = repo::spotify_token::get_spotify_tokens(pool, 100).await?; + + if spofity_tokens.is_empty() { + return Err(Error::msg("No Spotify tokens found")); + } + + let mb_client = MusicbrainzClient::new(); + + let key = format!( + "{} - {}", + scrobble.data.song.parsed.artist.to_lowercase(), + scrobble.data.song.parsed.track.to_lowercase() + ); + + let cached = cache.get(&key)?; + if cached.is_some() { + println!("{}", format!("Cached: {}", key).yellow()); + let track = serde_json::from_str::(&cached.unwrap())?; + rocksky::scrobble(cache, &did, track, scrobble.time).await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } + + let result = repo::track::get_track( + pool, + &scrobble.data.song.parsed.track, + &scrobble.data.song.parsed.artist, + ) + .await?; + + if let Some(track) = result { + println!("{}", "Xata (track)".yellow()); + let album = repo::album::get_album_by_track_id(pool, &track.xata_id).await?; + let artist = repo::artist::get_artist_by_track_id(pool, &track.xata_id).await?; + let mut track: Track = track.into(); + track.year = match album.year { + Some(year) => Some(year as u32), + None => match album.release_date.clone() { + Some(release_date) => { + let year = release_date.split("-").next(); + year.and_then(|x| x.parse::().ok()) + } + None => None, + }, + }; + track.release_date = album + .release_date + .map(|x| x.split("T").next().unwrap().to_string()); + track.artist_picture = artist.picture.clone(); + + rocksky::scrobble(cache, &did, track, scrobble.time).await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } + + // we need to pick a random token to avoid Spotify rate limiting + // and to avoid using the same token for all scrobbles + // this is a simple way to do it, but we can improve it later + // by using a more sophisticated algorithm + // or by using a token pool + let mut rng = rand::rng(); + let random_index = rng.random_range(0..spofity_tokens.len()); + let spotify_token = &spofity_tokens[random_index]; + + let spotify_token = decrypt_aes_256_ctr( + &spotify_token.refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, + )?; + + let spotify_token = refresh_token(&spotify_token).await?; + let spotify_client = SpotifyClient::new(&spotify_token.access_token); + + let query = match scrobble.data.song.parsed.artist.contains(" x ") { + true => { + let artists = scrobble + .data + .song + .parsed + .artist + .split(" x ") + .map(|a| format!(r#"artist:"{}""#, a.trim())) + .collect::>() + .join(" "); + format!(r#"track:"{}" {}"#, scrobble.data.song.parsed.track, artists) } - - // we need to pick a random token to avoid Spotify rate limiting - // and to avoid using the same token for all scrobbles - // this is a simple way to do it, but we can improve it later - // by using a more sophisticated algorithm - // or by using a token pool - let mut rng = rand::rng(); - let random_index = rng.random_range(0..spofity_tokens.len()); - let spotify_token = &spofity_tokens[random_index]; - - let spotify_token = decrypt_aes_256_ctr( - &spotify_token.refresh_token, - &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? - )?; - - let spotify_token = refresh_token(&spotify_token).await?; - let spotify_client = SpotifyClient::new(&spotify_token.access_token); - - let query = match scrobble.data.song.parsed.artist.contains(" x ") { + false => match scrobble.data.song.parsed.artist.contains(", ") { true => { - let artists = scrobble.data.song.parsed.artist - .split(" x ") + let artists = scrobble + .data + .song + .parsed + .artist + .split(", ") .map(|a| format!(r#"artist:"{}""#, a.trim())) .collect::>() .join(" "); format!(r#"track:"{}" {}"#, scrobble.data.song.parsed.track, artists) } - false => { - match scrobble.data.song.parsed.artist.contains(", ") { - true => { - let artists = scrobble.data.song.parsed.artist - .split(", ") - .map(|a| format!(r#"artist:"{}""#, a.trim())) - .collect::>() - .join(" "); - format!(r#"track:"{}" {}"#, scrobble.data.song.parsed.track, artists) - } - false => format!(r#"track:"{}" artist:"{}""#, scrobble.data.song.parsed.track, scrobble.data.song.parsed.artist.trim()), - } - }, - }; - - let result = spotify_client.search(&query).await?; - - if let Some(track) = result.tracks.items.first() { - println!("{}", "Spotify (track)".yellow()); - let mut track = track.clone(); - - if let Some(album) = spotify_client.get_album(&track.album.id).await? { - track.album = album; - } - - if let Some(artist) = spotify_client.get_artist(&track.album.artists[0].id).await? { - track.album.artists[0] = artist; - } - - rocksky::scrobble(cache, &did, track.into(), scrobble.time).await?; - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - return Ok(()); + false => format!( + r#"track:"{}" artist:"{}""#, + scrobble.data.song.parsed.track, + scrobble.data.song.parsed.artist.trim() + ), + }, + }; + + let result = spotify_client.search(&query).await?; + + if let Some(track) = result.tracks.items.first() { + println!("{}", "Spotify (track)".yellow()); + let mut track = track.clone(); + + if let Some(album) = spotify_client.get_album(&track.album.id).await? { + track.album = album; } - let query = format!( - r#"recording:"{}" AND artist:"{}""#, - scrobble.data.song.parsed.track, scrobble.data.song.parsed.artist - ); - let result = mb_client.search(&query).await?; - - if let Some(recording) = result.recordings.first() { - let result = mb_client.get_recording(&recording.id).await?; - println!("{}", "Musicbrainz (recording)".yellow()); - rocksky::scrobble(cache, &did, result.into(), scrobble.time).await?; - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - return Ok(()); + if let Some(artist) = spotify_client + .get_artist(&track.album.artists[0].id) + .await? + { + track.album.artists[0] = artist; } - println!("{} {} - {}, skipping", "Track not found: ".yellow(), scrobble.data.song.parsed.artist, scrobble.data.song.parsed.track); - - Ok(()) -} \ No newline at end of file + rocksky::scrobble(cache, &did, track.into(), scrobble.time).await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } + + let query = format!( + r#"recording:"{}" AND artist:"{}""#, + scrobble.data.song.parsed.track, scrobble.data.song.parsed.artist + ); + let result = mb_client.search(&query).await?; + + if let Some(recording) = result.recordings.first() { + let result = mb_client.get_recording(&recording.id).await?; + println!("{}", "Musicbrainz (recording)".yellow()); + rocksky::scrobble(cache, &did, result.into(), scrobble.time).await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } + + println!( + "{} {} - {}, skipping", + "Track not found: ".yellow(), + scrobble.data.song.parsed.artist, + scrobble.data.song.parsed.track + ); + + Ok(()) +} diff --git a/crates/webscrobbler/src/spotify/client.rs b/crates/webscrobbler/src/spotify/client.rs index 1bae6e15..70788ea2 100644 --- a/crates/webscrobbler/src/spotify/client.rs +++ b/crates/webscrobbler/src/spotify/client.rs @@ -4,7 +4,7 @@ use anyhow::Error; pub const BASE_URL: &str = "https://api.spotify.com/v1"; pub struct SpotifyClient { - token: String, + token: String, } impl SpotifyClient { @@ -17,52 +17,53 @@ impl SpotifyClient { pub async fn search(&self, query: &str) -> Result { let url = format!("{}/search", BASE_URL); let client = reqwest::Client::new(); - let response = client.get(&url) - .bearer_auth(&self.token) - .query(&[ - ("type", "track"), - ("q", query), - ]) - .send().await?; + let response = client + .get(&url) + .bearer_auth(&self.token) + .query(&[("type", "track"), ("q", query)]) + .send() + .await?; let result = response.json().await?; Ok(result) } pub async fn get_album(&self, id: &str) -> Result, Error> { - let url = format!("{}/albums/{}", BASE_URL, id); - let client = reqwest::Client::new(); - let response = client.get(&url) - .bearer_auth(&self.token) - .send().await?; + let url = format!("{}/albums/{}", BASE_URL, id); + let client = reqwest::Client::new(); + let response = client.get(&url).bearer_auth(&self.token).send().await?; - let headers = response.headers().clone(); - let data = response.text().await?; + let headers = response.headers().clone(); + let data = response.text().await?; - if data == "Too many requests" { - println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); - println!("> {} [get_album]", data); - return Ok(None); - } + if data == "Too many requests" { + println!( + "> retry-after {}", + headers.get("retry-after").unwrap().to_str().unwrap() + ); + println!("> {} [get_album]", data); + return Ok(None); + } - Ok(Some(serde_json::from_str(&data)?)) - } + Ok(Some(serde_json::from_str(&data)?)) + } - pub async fn get_artist(&self, id: &str) -> Result, Error> { - let url = format!("{}/artists/{}", BASE_URL, id); - let client = reqwest::Client::new(); - let response = client.get(&url) - .bearer_auth(&self.token) - .send().await?; + pub async fn get_artist(&self, id: &str) -> Result, Error> { + let url = format!("{}/artists/{}", BASE_URL, id); + let client = reqwest::Client::new(); + let response = client.get(&url).bearer_auth(&self.token).send().await?; - let headers = response.headers().clone(); - let data = response.text().await?; + let headers = response.headers().clone(); + let data = response.text().await?; - if data == "Too many requests" { - println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); - println!("> {} [get_artist]", data); - return Ok(None); - } + if data == "Too many requests" { + println!( + "> retry-after {}", + headers.get("retry-after").unwrap().to_str().unwrap() + ); + println!("> {} [get_artist]", data); + return Ok(None); + } - Ok(Some(serde_json::from_str(&data)?)) - } + Ok(Some(serde_json::from_str(&data)?)) + } } diff --git a/crates/webscrobbler/src/spotify/mod.rs b/crates/webscrobbler/src/spotify/mod.rs index af1c17c6..5fb95c13 100644 --- a/crates/webscrobbler/src/spotify/mod.rs +++ b/crates/webscrobbler/src/spotify/mod.rs @@ -1,32 +1,32 @@ use std::env; +use anyhow::Error; use reqwest::Client; use types::AccessToken; -use anyhow::Error; pub mod client; pub mod types; - pub async fn refresh_token(token: &str) -> Result { - if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { - panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); - } + if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { + panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); + } - let client_id = env::var("SPOTIFY_CLIENT_ID")?; - let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; + let client_id = env::var("SPOTIFY_CLIENT_ID")?; + let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; - let client = Client::new(); + let client = Client::new(); - let response = client.post("https://accounts.spotify.com/api/token") - .basic_auth(&client_id, Some(client_secret)) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", token), - ("client_id", &client_id) - ]) - .send() - .await?; - let token = response.json::().await?; - Ok(token) -} \ No newline at end of file + let response = client + .post("https://accounts.spotify.com/api/token") + .basic_auth(&client_id, Some(client_secret)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", token), + ("client_id", &client_id), + ]) + .send() + .await?; + let token = response.json::().await?; + Ok(token) +}