From 32cd2db1e873042df9914a7303495b90a3ce0614 Mon Sep 17 00:00:00 2001 From: allow Date: Sat, 11 May 2024 14:32:11 -0500 Subject: [PATCH 1/3] Squashed commit of the following: commit 0514d94270eb4b5acce4e36ff634a99d695659b8 Author: allow Date: Sat May 11 14:03:55 2024 -0500 resolve conversation 1 commit 1f58544ecc0d0c136667856f175bba5bdf365c29 Author: allow Date: Sat May 11 14:02:49 2024 -0500 fix build error commit 191dea96a92ae56fa6641d113fd98463d64fa3f4 Author: allow Date: Sat May 11 13:10:38 2024 -0500 clean up comments commit 2d647a78877d0fb23c7a79dbe1f630781f8bed70 Author: allow Date: Sat May 11 13:00:39 2024 -0500 restore: .ls commit 4e15fb0f1d568af178a1c0ab2475023452ff53f7 Author: allow Date: Sat May 11 12:11:25 2024 -0500 remove balloon commit 82b6356baea1c0ed683ab8f0f919c693f68656e7 Merge: a898877 bb6a864 Author: allow Date: Sat May 11 12:02:56 2024 -0500 Merge branch 'master-fix' into merge-master commit bb6a864e07338aeadf44c20df6294212a07d14cd Merge: 2e2e10e a639cac Author: allow Date: Sat May 11 10:21:44 2024 -0500 merge fix commit a639cac26064d19ae5d25aea46750f655e148414 Merge: 39107bc 50219b3 Author: allow Date: Sat May 11 02:17:01 2024 -0500 merge fix commit 39107bc540fc6f50390254f5a6cdc4166d6804a7 Author: allow Date: Sat May 11 01:48:01 2024 -0500 put a buffer behind stdout commit 8b3e514563a59b71c7cddc4ef7483fd32ea99032 Author: allow Date: Fri May 10 23:19:48 2024 -0500 fixes #158 for good commit 50219b36fddb211e045330ef4b85c6fd9ecd2f8f Author: allow Date: Thu May 9 08:16:35 2024 -0500 add comments about it commit a2762f9e3500ad071baac9dacc7695c991950da1 Author: allow Date: Thu May 9 08:14:27 2024 -0500 tame the zombies commit 8d1636b8929468aff6bc702dbb539313d3f6c5cf Author: allow Date: Wed May 8 22:02:09 2024 -0500 zproc guard, fix spotify-stream commit 0bfe347dc10441a2c743a43b8bf08cfcf4a6c3c1 Author: allow Date: Wed May 8 14:20:29 2024 -0500 fix: cffi + deadlock commit 4d737b9eeb300abd8014e26b100a2b4c08cfa085 Author: allow Date: Wed May 8 22:02:09 2024 -0500 zproc guard, fix spotify-stream commit 9fa42baac15ae0a24313e98a727de1b3f17ae560 Author: allow Date: Wed May 8 14:20:29 2024 -0500 fix: cffi + deadlock commit 804f91b333bd98e43d8e5f575f5cb0c275c36594 Author: Skarlett Date: Wed Nov 15 02:02:22 2023 -0600 add feature: `mockingbird-radio` add commands: `seed` `radio` `list` commit 610450069e33e6dec03cd925ee7b43b079871032 Author: Skarlett Date: Tue Nov 14 13:49:37 2023 -0600 template new commands commit 65db86a5fd6c04085c42617166ebbe2452099017 Author: Skarlett Date: Tue Nov 14 13:11:56 2023 -0600 fix: hanging ffmpeg process commit 07824a23fe9825e7b6d1b8c9be34e25b97ec34a2 Author: Lunarix <3759687+Skarlett@users.noreply.github.com> Date: Tue Nov 14 12:04:33 2023 -0600 fix deadlock (#157) Co-authored-by: Skarlett commit 05dbb3a8888aff6c600497d01e8ced4a7bb812b5 Author: Skarlett Date: Tue Nov 14 07:51:10 2023 -0600 refactor some ugly commit ee7acbbd18beced63da4841c756668ebbb0a965e Merge: 4956e84 1516513 Author: Skarlett Date: Fri Nov 10 11:11:23 2023 -0600 merge fix commit 4956e8439cb6bdae1b9af1d9ba95d81dc1f3e5fe Author: Skarlett Date: Sat Sep 23 10:12:58 2023 -0500 support cmdline or stdin commit 1e8262721e0ce8ce68bd77bb5d7dd29bce538ced Author: Skarlett Date: Wed Sep 20 15:53:12 2023 -0500 fix skip + unique seeds commit e693c39a42d9a4cd2ee5971fb1569a9ab61492d8 Author: Skarlett Date: Wed Sep 20 14:21:11 2023 -0500 fix bug commit ae1052e6fe9ea87d69e95e6a3a744e999f9ee436 Author: Skarlett Date: Wed Sep 20 14:01:35 2023 -0500 add radio-capabilities commit 789ec9d72a3fccdf97dc3ef11864f81bbff63409 Author: Skarlett Date: Tue Sep 19 12:12:49 2023 -0500 preloading capabilities commit 3eb9e6eee2a03c22e46931cb61fff7e1770ca6b9 Author: Skarlett Date: Tue Sep 19 04:06:41 2023 -0500 comments commit 9f5b5ca08d9aaa97706312ffafa5dbe25819d1e9 Author: Skarlett Date: Tue Sep 19 04:05:42 2023 -0500 add back cache commit ba33550885ddb0e1d6bb8b08cbdf863e4f265551 Author: Skarlett Date: Tue Sep 19 04:01:12 2023 -0500 add shebang commit 54bc28aae638737e7ff22824d611f683fc0ae15a Author: Skarlett Date: Tue Sep 19 03:59:36 2023 -0500 include spotipy in build commit dfb7285742b9936e6a80761dfae41063313b9bdb Author: Skarlett Date: Tue Sep 19 03:58:29 2023 -0500 rename: recommend -> spotify-recommend commit 723836ed32799f19f74b5cb5cc98593b9d1974b9 Author: Skarlett Date: Tue Sep 19 03:51:04 2023 -0500 cleanup imports commit 3dfd67c264a10537699bc0d941e1ce3c343a3d11 Author: Skarlett Date: Tue Sep 19 03:47:28 2023 -0500 make cobblestone tools commit 732c9c1924cea64ba9e127a19c6f930e5a6e2835 Author: Skarlett Date: Tue Sep 19 02:37:13 2023 -0500 initial commit commit c5d0edde506e34508bc0782166cc2d6d34bfe06f Merge: 4780328 4f83c4a Author: Skarlett Date: Tue Sep 19 02:31:48 2023 -0500 Merge branch 'master' into radio-station commit 47803283b3a034080687c4de274d2c27a3667afa Merge: 8c06595 b415334 Author: Skarlett Date: Tue Sep 19 02:28:30 2023 -0500 Merge branch 'balloon' into radio-station commit b4153348c190c9fb35187db13f9eb07140cd796f Author: Skarlett Date: Tue Sep 19 02:17:37 2023 -0500 balloon memory mechanism Processes which use streaming connections may timeout if not read. This is a problem for long-running processes which may not be able to read the data in time. `balloon` greedily reads from a stdin until exhaustion, and distributes the copied content from stdout commit f23f3b3466ef1638f885023cbdf7862e90370d5a Author: Skarlett Date: Mon Sep 18 11:44:00 2023 -0500 balloon: fix build commit 47bb332e0cbc34d7d7f66ec5c24cd37237a25257 Author: Skarlett Date: Mon Sep 18 10:04:18 2023 -0500 new crates: `balloon` + `cutils` commit 8c06595b1122485c435c1d5265328d0c58363917 Author: Skarlett Date: Mon Sep 18 08:58:40 2023 -0500 create DeemixCache commit 2e2e10e56be6dd4da2e5f8c7d713654150424dc0 Author: Skarlett Date: Tue Mar 21 22:01:38 2023 -0500 fix async blocks commit f9955528d537a4c202d6db06815fcf35660d41a5 Author: Skarlett Date: Tue Mar 21 21:28:42 2023 -0500 fix bookmark (final) commit 4ec91f3a9a63cda7d37848a53fc43beb3dd8338a Author: Skarlett Date: Tue Mar 21 21:27:08 2023 -0500 fix + vbump commit 5a4fc0333e719c98a7eea866624d46a88c99865b Author: Skarlett Date: Tue Mar 21 21:09:47 2023 -0500 fix bookmark commit 0c06c3efd5bb3dcce4f6d183b9eb8b0304049730 Author: Skarlett Date: Tue Mar 21 20:53:40 2023 -0500 vbump --- Cargo.lock | 73 +-- Cargo.toml | 1 - crates/balloon/Cargo.toml | 28 - crates/balloon/src/bin/balloon.rs | 25 - crates/balloon/src/bin/slowread.rs | 12 - crates/coggiebot/Cargo.toml | 3 +- crates/coggiebot/src/controllers/mod.rs | 18 +- crates/mockingbird/Cargo.toml | 1 + crates/mockingbird/src/deemix.rs | 234 ++++--- crates/mockingbird/src/lib.rs | 2 +- crates/mockingbird/src/player.rs | 796 ++++++++++++++++++------ crates/mockingbird/src/testsuite.rs | 7 +- flake.lock | 12 +- flake.nix | 2 + iac/coggiebot/default.nix | 7 +- sbin/deemix-stream/deemix-stream | 22 +- sbin/deemix-stream/default.nix | 2 +- sbin/deemix-stream/setup.py | 4 +- sbin/deemix-stream/spotify-recommend | 41 ++ 19 files changed, 895 insertions(+), 395 deletions(-) delete mode 100644 crates/balloon/Cargo.toml delete mode 100644 crates/balloon/src/bin/balloon.rs delete mode 100644 crates/balloon/src/bin/slowread.rs create mode 100755 sbin/deemix-stream/spotify-recommend diff --git a/Cargo.lock b/Cargo.lock index d3d5b3e..5dd054e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,7 +65,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] @@ -125,13 +125,6 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "balloon" -version = "0.1.0" -dependencies = [ - "cutils", -] - [[package]] name = "base64" version = "0.13.1" @@ -435,9 +428,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys 0.52.0", @@ -558,7 +551,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] @@ -1069,7 +1062,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] @@ -1151,7 +1144,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] @@ -1248,9 +1241,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.81" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" +checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" dependencies = [ "unicode-ident", ] @@ -1444,9 +1437,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" @@ -1506,15 +1499,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" +checksum = "092474d1a01ea8278f69e6a358998405fae5b8b963ddaeb2b0b04a128bf1dfb0" [[package]] name = "ryu" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "salsa20" @@ -1582,9 +1575,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.200" +version = "1.0.201" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" +checksum = "780f1cebed1629e4753a1a38a3c72d30b97ec044f0aef68cb26650a3c5cf363c" dependencies = [ "serde_derive", ] @@ -1601,20 +1594,20 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.200" +version = "1.0.201" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" +checksum = "c5e405930b9796f1c00bee880d03fc7e0bb4b9a11afc776885ffe84320da2865" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] name = "serde_json" -version = "1.0.116" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" dependencies = [ "itoa", "ryu", @@ -1629,7 +1622,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] @@ -1866,9 +1859,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.60" +version = "2.0.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" +checksum = "9f660c3bfcefb88c538776b6685a0c472e3128b51e74d48793dc2a488196e8eb" dependencies = [ "proc-macro2", "quote", @@ -1925,22 +1918,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.59" +version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa" +checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.59" +version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66" +checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] @@ -2025,7 +2018,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] @@ -2098,7 +2091,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", ] [[package]] @@ -2340,7 +2333,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", "wasm-bindgen-shared", ] @@ -2374,7 +2367,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.60", + "syn 2.0.62", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index 3d668fd..53bed0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,5 +3,4 @@ members = [ "crates/coggiebot", "crates/mockingbird", "crates/cutils", - "crates/balloon" ] diff --git a/crates/balloon/Cargo.toml b/crates/balloon/Cargo.toml deleted file mode 100644 index 5e3e7b7..0000000 --- a/crates/balloon/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "balloon" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -cutils = { path = "../cutils", features = ["stdio"] } - - -# Example of customizing binaries in Cargo.toml. -[[bin]] -name = "balloon" -path = "src/bin/balloon.rs" -test = false -bench = false -required_features = ["debug"] - -[[bin]] -name = "slowread" -path = "src/bin/slowread.rs" -test = false -bench = false - -[features] -default = ["debug"] -debug = [] \ No newline at end of file diff --git a/crates/balloon/src/bin/balloon.rs b/crates/balloon/src/bin/balloon.rs deleted file mode 100644 index b611d8c..0000000 --- a/crates/balloon/src/bin/balloon.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::io::{Read, Write}; - -const CHUNK_SIZE: usize = 4096 * 4; -const BUFFER_SIZE: usize = 1024^2 * 5; - -fn main() -> Result<(), std::io::Error> { - let mut chunk = [0u8; CHUNK_SIZE]; - let mut buffer = dbg!(Vec::with_capacity(BUFFER_SIZE)); - let mut reader = std::io::stdin(); - let mut writer = std::io::stdout(); - - while let Ok(n) = reader.read(&mut chunk) { - if n == 0 { - break; - } - buffer.extend(&chunk[..n]); - } - - for it in buffer.chunks(CHUNK_SIZE) { - chunk.copy_from_slice(it); - writer.write_all(&chunk)?; - } - - return Ok(()) -} \ No newline at end of file diff --git a/crates/balloon/src/bin/slowread.rs b/crates/balloon/src/bin/slowread.rs deleted file mode 100644 index e80d5b9..0000000 --- a/crates/balloon/src/bin/slowread.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::io::Read; - -fn main() { - let mut stdin = std::io::stdin(); - let mut chunk = [0u8; 1024]; - - loop { - let n = stdin.read(&mut chunk).unwrap(); - println!("[SLOWREAD] read: {}", n); - std::thread::sleep(std::time::Duration::from_millis(100)); - } -} \ No newline at end of file diff --git a/crates/coggiebot/Cargo.toml b/crates/coggiebot/Cargo.toml index 2e6dd52..a416f5c 100644 --- a/crates/coggiebot/Cargo.toml +++ b/crates/coggiebot/Cargo.toml @@ -14,7 +14,7 @@ tracing-subscriber = "0.3" mockingbird = { path = "../mockingbird", optional=true } [features] -default = [] +default = ["mockingbird-radio", "mockingbird-deemix"] list-feature-cmd = [] basic-cmds = [] bookmark = [] @@ -26,6 +26,7 @@ mockingbird-core = ["dep:mockingbird"] mockingbird-arl-cmd = ["mockingbird?/arl-cmd"] mockingbird-ctrl = ["mockingbird?/controller"] mockingbird-debug = ["mockingbird?/debug"] +mockingbird-radio = ["mockingbird?/radio"] ################ # mockingbird is compatiable with youtube & soundcloud diff --git a/crates/coggiebot/src/controllers/mod.rs b/crates/coggiebot/src/controllers/mod.rs index 2416e63..908534a 100644 --- a/crates/coggiebot/src/controllers/mod.rs +++ b/crates/coggiebot/src/controllers/mod.rs @@ -38,7 +38,8 @@ pub fn setup_framework(mut cfg: StandardFramework) -> StandardFramework { ["help-cmd"] => [features::HELP_GROUP], ["mockingbird-arl-cmd"] => [mockingbird::check::ARL_GROUP], ["mockingbird-set-arl-cmd"] => [mockingbird::player::DANGEROUS_GROUP], - ["mockingbird-ctrl"] => [mockingbird::player::BETTERPLAYER_GROUP] + ["mockingbird-ctrl"] => [mockingbird::player::BETTERPLAYER_GROUP], + ["mockingbird-ctrl", "mockingbird-radio"] => [mockingbird::player::RADIO_GROUP] } ); cfg @@ -70,6 +71,21 @@ impl EventHandler for EvHandler { }); } + // #[allow(unused_variables)] + // async fn message(&self, ctx: Context, msg: Message) { + // #[cfg(feature="enable-dj-room")] + // tokio::spawn(async move { + // const DJ_CHANNEL: u64 = 960044319476179055; + // let bot_id = ctx.cache.current_user_id().0; + // if msg.channel_id.0 == DJ_CHANNEL && msg.author.id.0 != bot_id { + // match mockingbird::on_dj_channel(&ctx, &msg).await { + // Ok(_) => {}, + // Err(e) => { msg.channel_id.say(&ctx.http, format!("Error: {}", e)).await.unwrap(); }, + // } + // } + // }); + // } + async fn ready(&self, _: Context, ready: Ready) { println!("{} is connected!", ready.user.name); } diff --git a/crates/mockingbird/Cargo.toml b/crates/mockingbird/Cargo.toml index 01a285e..665fb96 100644 --- a/crates/mockingbird/Cargo.toml +++ b/crates/mockingbird/Cargo.toml @@ -23,6 +23,7 @@ cutils = { path = "../cutils", features = ["tokio"], optional=true } default = [] controller = [] debug = [] +radio = [] check = ["dep:chrono", "dep:reqwest", "dep:serde", "dep:serde_json"] ytdl = ["songbird/yt-dlp"] diff --git a/crates/mockingbird/src/deemix.rs b/crates/mockingbird/src/deemix.rs index 55d609f..8dbcb68 100644 --- a/crates/mockingbird/src/deemix.rs +++ b/crates/mockingbird/src/deemix.rs @@ -1,4 +1,10 @@ -use std::io::{BufReader, BufRead, Read}; +use std::{ + io::{BufReader, BufRead, Read}, + os::fd::RawFd, + mem, + process::{Child, Stdio}, + time::Duration +}; use songbird::{ constants::SAMPLE_RATE_RAW, input::{ @@ -8,17 +14,17 @@ use songbird::{ Container, Metadata, Input, - restartable::Restart + restartable::Restart, + Reader, }, }; -use std::{ - process::Stdio, - time::Duration -}; use serde_json::Value; use std::os::fd::AsRawFd; use tokio::io::AsyncReadExt; use cutils::{availbytes, bigpipe, max_pipe_size, PipeError}; +use tokio::runtime::Handle; +use tracing::debug; + #[derive(Debug)] pub enum DeemixError { @@ -99,22 +105,33 @@ where async fn call_restart(&mut self, time: Option) -> Result { if let Some(time) = time { let ts = format!("{:.3}", time.as_secs_f64()); - _deemix(self.uri.as_ref(), &["-ss", &ts]) + _deemix(self.uri.as_ref(), &["-ss", &ts], true) .await .map_err(DeemixError::into) + .map(|(i, _)| i) } else { deemix(self.uri.as_ref()) .await .map_err(DeemixError::into) + .map(|(i, _)| i) } } async fn lazy_init(&mut self) -> Result<(Option, Codec, Container), SongbirdError> { - Ok(( Some(deemix_metadata(self.uri.as_ref()).await.unwrap()), Codec::FloatPcm, Container::Raw)) + Ok( + ( + Some(deemix_metadata(self.uri.as_ref()) + .await + .map(DeemixMetadata::into) + .map_err(SongbirdError::from)? + ), + Codec::FloatPcm, Container::Raw) + ) } } -pub async fn deemix_metadata(uri: &str) -> std::io::Result { + +pub async fn deemix_metadata(uri: &str) -> std::io::Result { let deemix = tokio::process::Command::new("deemix-metadata") .arg(uri.trim()) .stdin(Stdio::null()) @@ -161,33 +178,14 @@ fn process_stderr(s: &mut std::process::ChildStderr) -> Result Result { - _deemix(uri, &[]) +) -> Result<(Input, Option), DeemixError> { + _deemix(uri, &[], true) .await } -pub async fn _deemix( - uri: &str, - pre_args: &[&str], -) -> Result -{ - let pipesize = max_pipe_size().await.unwrap(); - let ffmpeg_args = [ - "-f", - "s16le", - "-ac", - "2", - "-ar", - "48000", - "-acodec", - "pcm_f32le", - "-", - ]; - - tracing::info!("Running: deemix-stream {} {}", pre_args.join(" "), uri); +async fn _deemix_stream(uri: &str, pipesize: i32) -> Result<(std::process::Child, DeemixMetadata), DeemixError> +{ let mut deemix = std::process::Command::new("deemix-stream") - .arg("-hq") - .arg("1") .arg(uri.trim()) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -196,7 +194,7 @@ pub async fn _deemix( let deemix_out = deemix.stdout.as_ref().unwrap().as_raw_fd(); unsafe { bigpipe(deemix_out, pipesize); } - + let stderr = deemix.stderr.take(); // Read first line of stderr // for metadata, but read entire buffer if error. @@ -207,105 +205,173 @@ pub async fn _deemix( }) .await?; - let (returned_stderr, value) = threadout; + let (returned_stderr, metadata_raw) = threadout; deemix.stderr = Some(returned_stderr); - let metadata_raw = value?; + let metadata_raw = metadata_raw?; if let Some(_) = metadata_raw.get("error") { return Err(DeemixError::Metadata); } let _filesize = metadata_raw["filesize"].as_u64(); - let metadata = Some(metadata_from_deemix_output(&metadata_raw)); - tracing::info!("running ffmpeg"); + Ok((deemix, metadata_from_deemix_output(&metadata_raw))) +} + +fn _ffmpeg(proc: &mut std::process::Child, pre_args: &[&str], pipesize: i32) -> Result { + let ffmpeg_args = [ + "-f", + "s16le", + "-ac", + "2", + "-ar", + "48000", + "-acodec", + "pcm_f32le", + "-", + ]; + let ffmpeg = std::process::Command::new("ffmpeg") .args(pre_args) .arg("-i") .arg("-") .args(&ffmpeg_args) - .stdin(deemix.stdout.take().ok_or(SongbirdError::Stdout)?) + .stdin( + proc.stdout + .take() + .ok_or(SongbirdError::Stdout)? + ) .stderr(Stdio::null()) .stdout(Stdio::piped()) .spawn() .expect("Failed to start child process"); - tracing::info!("deezer metadata {:?}", metadata); - let ffmpeg_ptr = ffmpeg.stdout.as_ref().ok_or(SongbirdError::Stdout)?.as_raw_fd(); + let ffmpeg_ptr = ffmpeg.stdout.as_ref() + .ok_or(SongbirdError::Stdout)? + .as_raw_fd(); + unsafe { bigpipe(ffmpeg_ptr, pipesize); } - let now = std::time::Instant::now(); - let pipesize = max_pipe_size().await.unwrap(); + Ok(ffmpeg) +} + +pub async fn _deemix( + uri: &str, + pre_args: &[&str], + wait: bool, +) -> Result<(Input, Option), DeemixError> +{ let pipe_threshold = std::env::var("MKBIRD_PIPE_THRESHOLD") .unwrap_or_else(|_| "0.8".to_string()) .parse::() .unwrap_or(0.8); - loop { - let avail = unsafe { availbytes(ffmpeg_ptr) }; - let mut percentage = 0.0; - if 0 > avail { - break - } - if avail > 0 { - percentage = pipesize as f32 / avail as f32; - } + let pipesize = max_pipe_size().await.unwrap(); - if pipe_threshold > percentage { - tokio::time::sleep(std::time::Duration::from_micros(200)).await; - tracing::debug!("availbytes: {}", avail); - tracing::debug!("pipesize: {}", pipesize); - } - else { - tracing::info!("load time: {}", now.elapsed().as_secs_f64()); - tracing::debug!("availbytes: {}", avail); - tracing::debug!("pipesize: {}", pipesize); - break + tracing::info!("Running: deemix-stream {} {}", pre_args.join(" "), uri); + let (mut deemix, metadata) = _deemix_stream(uri, pipesize).await?; + + let ffmpeg = _ffmpeg(&mut deemix, pre_args, pipesize)?; + let stdout_fd = ffmpeg.stdout.as_ref() + .ok_or(SongbirdError::Stdout)? + .as_raw_fd(); + + if wait { + let now = std::time::Instant::now(); + loop { + let avail = unsafe { availbytes(stdout_fd) }; + let mut percentage = 0.0; + if 0 > avail { + break + } + if avail > 0 { + percentage = pipesize as f32 / avail as f32; + } + + if pipe_threshold > percentage { + tokio::time::sleep(std::time::Duration::from_micros(200)).await; + tracing::debug!("availbytes: {}", avail); + tracing::debug!("pipesize: {}", pipesize); + } + else { + tracing::info!("load time: {}", now.elapsed().as_secs_f64()); + tracing::debug!("availbytes: {}", avail); + tracing::debug!("pipesize: {}", pipesize); + break + } } - } - - Ok(Input::new( - true, - children_to_reader::(vec![deemix, ffmpeg]), - Codec::FloatPcm, - Container::Raw, - metadata, + } + + Ok(( + Input::new( + true, + children_to_reader::(vec![deemix, ffmpeg]), + Codec::FloatPcm, + Container::Raw, + Some(metadata.clone().into()), + ), + Some(metadata.clone()) )) } -fn metadata_from_deemix_output(val: &serde_json::Value) -> Metadata +#[derive(Debug, Clone)] +pub struct DeemixMetadata { + pub isrc: Option, + pub metadata: Metadata, +} + +impl Into for DeemixMetadata { + fn into(self) -> Metadata { + self.metadata + } +} + +fn metadata_from_deemix_output(val: &serde_json::Value) -> DeemixMetadata { let obj = val.as_object(); let track = obj .and_then(|m| m.get("title")) .and_then(Value::as_str) - .map(str::to_string); + .map(str::to_string) + .clone(); let artist = obj .and_then(|m| m.get("artist")) .and_then(|x| x.get("name")) .and_then(Value::as_str) - .map(str::to_string); + .map(str::to_string) + .clone(); let duration = obj .and_then(|m| m.get("duration")) .and_then(Value::as_f64) - .map(Duration::from_secs_f64); + .map(Duration::from_secs_f64) + .clone(); - let source_url = obj + let source_url = obj .and_then(|m| m.get("link")) .and_then(Value::as_str) - .map(str::to_string); - - Metadata { - track, - artist, - channels: Some(2), - duration, - source_url, - sample_rate: Some(SAMPLE_RATE_RAW as u32), - ..Default::default() + .map(str::to_string) + .clone(); + + let isrc = obj + .and_then(|m| m.get("isrc")) + .and_then(Value::as_str) + .map(str::to_string) + .clone(); + + DeemixMetadata { + isrc, + metadata: Metadata { + track, + artist, + channels: Some(2), + duration, + source_url, + sample_rate: Some(SAMPLE_RATE_RAW as u32), + ..Default::default() + } } } diff --git a/crates/mockingbird/src/lib.rs b/crates/mockingbird/src/lib.rs index 347bfe4..e0dfebf 100644 --- a/crates/mockingbird/src/lib.rs +++ b/crates/mockingbird/src/lib.rs @@ -3,7 +3,7 @@ pub mod player; #[cfg(feature = "deemix")] -mod deemix; +pub mod deemix; // #[cfg(feature = "http-get")] diff --git a/crates/mockingbird/src/player.rs b/crates/mockingbird/src/player.rs index 7eb1038..0817846 100644 --- a/crates/mockingbird/src/player.rs +++ b/crates/mockingbird/src/player.rs @@ -17,14 +17,19 @@ use songbird::{ Songbird, Call, create_player, - input::{ffmpeg, Input, error::Error as SongbirdError}, - tracks::{TrackHandle, Track}, + input::{ + Input, + error::Error as SongbirdError, + Metadata, + }, + tracks::TrackHandle, TrackEvent }; use std::{ process::Stdio, - time::Duration, collections::VecDeque, + time::{Duration, Instant}, + collections::VecDeque, sync::Arc, collections::HashMap, path::PathBuf, @@ -33,27 +38,272 @@ use std::{ use tokio::{ io::AsyncBufReadExt, process::Command, + }; use tokio::io::AsyncWriteExt; use serenity::futures::StreamExt; +use songbird::input::cached::Compressed; +use std::sync::{Mutex}; + + use cutils::{availbytes, bigpipe, max_pipe_size}; +#[cfg(feature = "deemix")] +use crate::deemix::{DeemixMetadata, _deemix}; + +#[group] +#[commands(join, leave, queue, now_playing, skip, list)] +pub struct BetterPlayer; + +#[group] +#[commands(seed, radio)] +pub struct Radio; + + const TS_PRELOAD_OFFSET: Duration = Duration::from_secs(20); const TS_ABANDONED_HB: Duration = Duration::from_secs(720); -// const MAX_TRACK_LENGTH: Duration = Duration::from_secs(360*6); // 30 minutes -// const MAX_ENQUEUED: u16 = 300; +const HASPLAYED_MAX_LEN: usize = 10; -#[group] -#[commands(join, leave, queue, now_playing, skip, shuffle)] -struct BetterPlayer; +struct DeemixPreloadCache; + +impl TypeMapKey for DeemixPreloadCache { + type Value = Arc>>; +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum EventEnd { + Skipped, + Finished, + UnMarked +} + +type LazyQueue = HashMap>; +pub struct LazyQueueKey; +impl TypeMapKey for LazyQueueKey { + type Value = LazyQueue; +} + +#[derive(Debug, Clone)] +struct TrackRecord { + // keep this for spotify recommendations + metadata: MetadataType, + stop_event: EventEnd, + start: Instant, + end: Instant, +} + +struct ColdQueue { + pub queue: VecDeque, + pub has_played: VecDeque, + pub use_radio: bool, + // urls + pub radio_queue: VecDeque, + pub radio_next: Option<(Compressed, Option)>, +} -async fn next_track(call: &mut Call, uri: &str, guild_id: u64) -> Result { - tracing::info!("Now playing: {}", uri); - let player = Players::from_str(&uri) - .ok_or_else(|| HandlerError::NotImplemented)?; +pub struct QueueContext { + crossfade: Duration, + guild_id: GuildId, + invited_from: ChannelId, + voice_chan_id: GuildChannel, + cache: Arc, + data: Arc>, + http: Arc, + manager: Arc, + cold_queue: Arc>, +} + +#[derive(Debug, Clone)] +enum MetadataType { + #[cfg(feature = "deemix")] + Deemix(crate::deemix::DeemixMetadata), - player.play(call, &uri, guild_id).await + Standard(Metadata), +} + +impl From for MetadataType { + fn from(meta: Metadata) -> Self { + Self::Standard(meta) + } +} + +impl Into for MetadataType { + fn into(self) -> Metadata { + match self { + Self::Standard(meta) => meta, + + #[cfg(feature = "deemix")] + Self::Deemix(meta) => meta.into() + } + } +} + +#[cfg(feature = "deemix")] +impl From for MetadataType { + fn from(meta: crate::deemix::DeemixMetadata) -> Self { + Self::Deemix(meta) + } +} + +async fn seed_from_history(has_played: &VecDeque) -> std::io::Result> { + let seeds = + has_played + .iter() + // Don't include skipped tracks + .filter(|x| x.stop_event != EventEnd::Skipped) + .filter_map(|x| + match &x.metadata { + MetadataType::Deemix(meta) => meta.isrc.clone(), + _ => None + }) + .collect::>(); + + + if seeds.is_empty() { + return Ok(seeds.into()); + } + + return recommend(&seeds, 5).await; + +} + +async fn preload_radio_track( + cold_queue: &mut ColdQueue +) -> Result<(), String> { + // pop seeds in radio + let mut tries = 5; + // attempts/tries loop + loop { + let uri = match cold_queue.radio_queue.pop_front() { + Some(x) => Some(x), + None => { + cold_queue.radio_queue.clear(); + cold_queue.radio_queue.extend(seed_from_history(&cold_queue.has_played).await.unwrap_or_else(|_| VecDeque::new())); + cold_queue.radio_queue.pop_front() + } + }; + + if let Some(uri) = uri { + match _deemix(&uri, &[], false).await { + Ok((preload_input, metadata)) => { + cold_queue.radio_next = Some((Compressed::new( + preload_input, + songbird::driver::Bitrate::BitsPerSecond(128_000) + ).unwrap(), + + metadata.map(|x| x.into()) + )); + return Ok(()) + } + + Err(why) => { + tries -= 1; + tracing::error!("Error preloading radio track: {}", why); + if 0 >= tries { + return Err("Exceeded max tries".to_string()); + } + continue + } + } + } + return Err("Fall through".to_string()); + } +} + +async fn play_preload_radio_track( + call: &mut Call, + radio_preload: Compressed, + metadata: Option, + qctx: Arc +) +{ + let preload_result = Players::play_preload(call, radio_preload.new_handle().into(), metadata).await; + match preload_result { + Err(why) =>{ + tracing::error!("Failed to play radio track: {}", why); + } + Ok((handle, _)) => handle.add_event( + Event::Delayed( + handle.metadata() + .duration + .unwrap() + - TS_PRELOAD_OFFSET + ), + PreemptLoader(qctx.clone()), + ).unwrap() + } +} + +struct TrackEndLoader(Arc); + +#[async_trait] +impl VoiceEventHandler for TrackEndLoader { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + if let Some(call) = self.0.manager.get(self.0.guild_id) { + let mut call = call.lock().await; + let mut cold_queue = self.0.cold_queue.write().await; + + // `PreemptLoader` may have placed a track (from the user queue) + // before this event was fired. + // If true, we clear our trackers. + if let Some(_current_track_handle) = call.queue().current() { + // do nothing + } + + // `PreemptLoader` has not placed anything, + // lets fire it's routine on our thread. + else if let Ok(true) = user_queue_routine(&mut call, &mut cold_queue, self.0.clone()).await { + // do nothing. + } + + // If all else fails, play the preloaded track on radio + else if cold_queue.use_radio { + // if the user queue is empty, try the preloaded radio track + if let Some((radio_preload, metadata)) = cold_queue.radio_next.take() { + play_preload_radio_track(&mut call, radio_preload, metadata, self.0.clone()).await; + let _ = preload_radio_track(&mut cold_queue).await; + return None; + } + } + + cold_queue.radio_next = None; + let _ = preload_radio_track(&mut cold_queue).await; + } + None + } +} + +struct AbandonedChannel(Arc); +#[async_trait] +impl VoiceEventHandler for AbandonedChannel { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + let members = self.0.voice_chan_id.members(&self.0.cache).await.unwrap(); + if members.iter().filter(|x| !x.user.bot).count() > 0 { + return None; + } + + leave_routine( + self.0.data.clone(), + self.0.guild_id.clone(), + self.0.manager.clone() + ).await.unwrap(); + + Some(Event::Cancel) + } +} + +struct PreemptLoader(Arc); +#[async_trait] +impl VoiceEventHandler for PreemptLoader { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + if let Some(call) = self.0.manager.get(self.0.guild_id) { + let mut call = call.lock().await; + let mut cold_queue = self.0.cold_queue.write().await; + let _ = user_queue_routine(&mut call, &mut cold_queue, self.0.clone()).await; + } + None + } } #[allow(unused_variables)] @@ -108,7 +358,6 @@ impl From for HandlerError { } } - impl std::fmt::Display for HandlerError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -188,12 +437,12 @@ async fn fan_ytdl(uri: &str, buf: &mut VecDeque) -> Result) -> Result<(), HandlerError> { +async fn fan_deezer(uri: &str, buf: &mut VecDeque) -> Result { return Err(HandlerError::NotImplemented) } #[cfg(not(feature="ytdl"))] -async fn fan_ytdl(uri: &str, buf: &mut VecDeque) -> Result { +async fn fan_ytdl(_uri: &str, _buf: &mut VecDeque) -> Result { return Err(HandlerError::NotImplemented) } @@ -227,30 +476,41 @@ async fn ph_httpget_player( } #[cfg(feature = "deemix")] -async fn ph_deemix_player(uri: &str) -> Result { - tracing::info!("[Deemix] Streaming: {}", uri); - crate::deemix::deemix(uri).await.map_err(HandlerError::from) -} +async fn ph_deemix_player(uri: &str) -> Result<(Input, Option), HandlerError> { + crate::deemix::deemix(uri).await + .map_err(HandlerError::from) + .map(|(input, meta)| (input, meta.map(|x| x.into()))) + } #[cfg(feature = "ytdl")] -async fn ph_ytdl_player(uri: &str) -> Result { - tracing::info!("[YTDLP] Streaming: {}", uri); +async fn ph_ytdl_player(uri: &str) -> Result<(Input, Option), HandlerError> { return songbird::ytdl(uri).await.map_err(HandlerError::from) + .map(|input| (input, None)) } #[cfg(not(feature = "deemix"))] -async fn ph_deemix_player(uri: &str) -> Result { +struct FakeMeta(Metadata); + +#[cfg(not(feature = "deemix"))] +impl Into for FakeMeta { + fn into(self) -> Metadata { + self.0 + } +} + +#[cfg(not(feature = "deemix"))] +async fn ph_deemix_player(uri: &str) -> Result<(Input, Option), HandlerError> { return Err(HandlerError::NotImplemented) } #[cfg(not(feature = "ytdl"))] -async fn ph_ytdl_player(uri: &str) -> Result { +async fn ph_ytdl_player(_uri: &str) -> Result<(Input, Option), HandlerError> { return Err(HandlerError::NotImplemented) } #[cfg(not(feature = "http-get"))] -async fn ph_httpget_player(uri: &str) -> (FilePath, Result) { - return (FilePath::new(), Err(HandlerError::NotImplemented)) +async fn ph_httpget_player(_uri: &str) -> (PathBuf, Result) { + return (PathBuf::new(), Err(HandlerError::NotImplemented)) } async fn _urls(cmd: &str, args: &[&str], buf: &mut Vec) -> std::io::Result<()> { @@ -264,31 +524,41 @@ async fn _urls(cmd: &str, args: &[&str], buf: &mut Vec) -> st let mut lines = stdout.stdout.lines(); while let Some(line) = lines.next_line().await? { - let json = serde_json::from_str(&line).unwrap(); + let json = + serde_json::from_str(&line).unwrap(); buf.push(json); } Ok(()) } -// #[cfg(feature = "http-get")] -// // exiftool -b -Duration "$file" -// async fn get_duration_file(file: &str) -> std::io::Result { -// let child = Command::new("exiftool") -// .args(&["-b", "-Duration", file]) -// .stdout(Stdio::piped()) -// .spawn() -// .unwrap(); - -// let stdout = child.wait_with_output().await.unwrap(); -// let mut lines = stdout.stdout.lines(); - -// if let Some(line) = lines.next_line().await? { -// return std::time::Duration::from_secs_f32(line.parse::()) -// } -// Ok(()) -// } - +async fn recommend(isrcs: &Vec, limit: u8) -> std::io::Result> { + let mut buffer = std::collections::HashSet::new(); + tracing::info!("running spotify-recommend -l {} {}", limit, isrcs.join(" ")); + let recommend = tokio::process::Command::new("spotify-recommend") + .arg("-l") + .arg(format!("{}", limit)) + .args(isrcs.iter()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let output = recommend.wait_with_output() + .await?; + + let mut lines = output.stdout.lines(); + + while let Some(x) = lines.next_line().await? { + buffer.insert(x); + } + tracing::info!("spotify-stream finished [{}]", buffer.len()); + let mut ret = VecDeque::new(); + for x in buffer { + ret.push_back(x); + } + Ok(ret) +} #[derive(PartialEq, Eq)] pub enum Players { @@ -314,42 +584,51 @@ impl Players { else { return None } } - async fn play(&self, handler: &mut Call, uri: &str, guild_id: u64) -> Result + async fn play(&self, handler: &mut Call, uri: &str, guild_id: GuildId) -> Result<(TrackHandle, Option), HandlerError> { - let mut is_tempfile = false; - - let input = match self { + let (input, metadata) = match self { Self::Deemix => ph_deemix_player(uri).await, Self::Ytdl => ph_ytdl_player(uri).await, Self::HttpGet => { - let (fp, result) = ph_httpget_player( - uri, - guild_id - ).await; + let (fp, result) = ph_httpget_player(uri, guild_id.0).await; match result { Ok(input) => { - let (track, track_handle) = create_player(input); - track_handle.add_event(Event::Track(TrackEvent::End), RemoveTempFile(fp)); - handler.enqueue(track); - return Ok(track_handle) + let (_track, track_handle) = create_player(input); + let _ = track_handle.add_event(Event::Track(TrackEvent::End), RemoveTempFile(fp)); + // TODO FIXME ADD METADATA + return Ok((track_handle, None)) } Err(e) => { - if let Ok(true) = tokio::fs::try_exists(&fp).await { - tokio::fs::remove_file(&fp).await; + let _ = tokio::fs::remove_file(&fp).await; } - + // TODO FIXME ADD METADATA return Err(e) } } } }?; + let (track, track_handle) = create_player(input); handler.enqueue(track); - Ok(track_handle) + Ok((track_handle, metadata)) + } + + async fn play_preload( + handler: &mut Call, + preload: Input, // &mut Vec, + metadata: Option + ) + -> Result<(TrackHandle, Option), HandlerError> + { + let (track, track_handle) = create_player(preload); + handler.enqueue(track); + Ok((track_handle, metadata + //TODO: FIXME!: preload.metadata.map(|x| x.into()) + )) } async fn fan_collection(&self, uri: &str) -> Result, HandlerError> { @@ -428,23 +707,6 @@ pub async fn get_file( } } -type LazyQueue = HashMap>; -pub struct LazyQueueKey; -impl TypeMapKey for LazyQueueKey { - type Value = LazyQueue; -} - -pub struct QueueContext { - guild_id: GuildId, - invited_from: ChannelId, - voice_chan_id: GuildChannel, - cache: Arc, - data: Arc>, - http: Arc, - manager: Arc, - cold_queue: Arc>>, -} - struct RemoveTempFile(PathBuf); #[async_trait] impl VoiceEventHandler for RemoveTempFile { @@ -454,37 +716,20 @@ impl VoiceEventHandler for RemoveTempFile { } } -struct AbandonedChannel(Arc); -#[async_trait] -impl VoiceEventHandler for AbandonedChannel { - async fn act(&self, _ctx: &EventContext<'_>) -> Option { - let members = self.0.voice_chan_id.members(&self.0.cache).await.unwrap(); - if members.iter().filter(|x| !x.user.bot).count() > 0 { - return None; - } - - leave_routine( - self.0.data.clone(), - self.0.guild_id.clone(), - self.0.manager.clone() - ).await.unwrap(); - - Some(Event::Cancel) - } -} - -async fn play_routine(qctx: Arc) -> Result<(), HandlerError> { +async fn user_queue_routine( + call: &mut Call, + cold_queue: &mut ColdQueue, + qctx_arc: Arc +) -> Result { let mut tries = 4; - let handler = qctx.manager.get(qctx.guild_id) - .ok_or_else(|| HandlerError::NoCall)?; - - let mut call = handler.lock().await; - while let Some(uri) = qctx.cold_queue.write().await.pop_front() { - let uri = dbg!(uri); - match next_track(&mut call, &uri, qctx.guild_id.0).await { - Ok(track) => { - let track = dbg!(track); + while let Some(uri) = cold_queue.queue.pop_front() { + tracing::info!("Now playing: {}", uri); + let player = Players::from_str(&uri) + .ok_or_else(|| HandlerError::NotImplemented)?; + + match player.play(call, &uri, qctx_arc.guild_id).await { + Ok((track, metadata)) => { if let Some(duration) = track.metadata().duration { if duration < TS_PRELOAD_OFFSET { tracing::warn!("No duration provided, preloading disabled"); @@ -494,14 +739,50 @@ async fn play_routine(qctx: Arc) -> Result<(), HandlerError> { tracing::info!("Preload Event Added from Duration"); track.add_event( Event::Delayed(duration - TS_PRELOAD_OFFSET), - PreemptLoader(qctx.clone()) + PreemptLoader(qctx_arc.clone()) ).unwrap(); } - break + + if cold_queue.has_played.len() > HASPLAYED_MAX_LEN { + let _ = cold_queue.has_played.pop_back(); + } + + // --- START + // This portion of code marks songs as finished or not. + // Under normal circumstances, this would be placed on the "EndTrack" + // Event. It also happens that pausing, skipping, and leaving + // all cause this event to fire. + // So instead, its placed here to avoid those. + if let Some(x) = cold_queue.has_played.front_mut() { + if let EventEnd::UnMarked = x.stop_event { + x.stop_event = EventEnd::Finished; + x.end = Instant::now(); + } + } + + let data = TrackRecord { + metadata: metadata.unwrap_or(MetadataType::from(track.metadata().clone())), + stop_event: EventEnd::UnMarked, + start: Instant::now(), + end: Instant::now(), + }; + + cold_queue.has_played.push_front(data); + // --- END + + // Preemptively load the next audio track + // `TS_PRELOAD_OFFSET` seconds before this `track` + // ends. + track.add_event( + Event::Delayed(track.metadata().duration.unwrap() - TS_PRELOAD_OFFSET), + PreemptLoader(qctx_arc) + ).unwrap(); + + return Ok(true); }, + Err(e) => { tracing::error!("Failed to play next track: {}", e); - let response = match e { HandlerError::NotImplemented => "Not implemented/enabled".to_string(), @@ -524,11 +805,9 @@ async fn play_routine(qctx: Arc) -> Result<(), HandlerError> { #[cfg(feature = "deemix")] HandlerError::DeemixError(crate::deemix::DeemixError::BadJson(text)) => { - qctx.invited_from.send_files( - &qctx.http, - vec![ - (text.as_bytes(), "error.txt") - ], + qctx_arc.invited_from.send_files( + &qctx_arc.http, + vec![ (text.as_bytes(), "error.txt") ], |m| m ).await?; "Json Error".to_string() @@ -538,48 +817,21 @@ async fn play_routine(qctx: Arc) -> Result<(), HandlerError> { }; if tries == 0 { - let _ = qctx.invited_from - .say(&qctx.http, format!("Halting. Last try: {}", &uri)) + let _ = qctx_arc.invited_from + .say(&qctx_arc.http, format!("Halting. Last try: {}", &uri)) .await; break } - let _ = qctx.invited_from - .say(&qctx.http, format!("Couldn't play track {}\n{}", &uri, &response)) + let _ = qctx_arc.invited_from + .say(&qctx_arc.http, format!("Couldn't play track {}\n{}", &uri, &response)) .await; tries -= 1; } } } - Ok(()) -} - -struct TrackEndLoader(Arc); -#[async_trait] -impl VoiceEventHandler for TrackEndLoader { - async fn act(&self, _ctx: &EventContext<'_>) -> Option { - let mut run = false; - - if let Some(call) = self.0.manager.get(self.0.guild_id) { - let call = call.lock().await; - run = call.queue().current().is_none(); - } - - if run { - let _ = play_routine(self.0.clone()).await; - } - None - } -} - -struct PreemptLoader(Arc); -#[async_trait] -impl VoiceEventHandler for PreemptLoader { - async fn act(&self, _ctx: &EventContext<'_>) -> Option { - let _ = play_routine(self.0.clone()).await; - None - } + Ok(false) } async fn leave_routine ( @@ -631,7 +883,7 @@ async fn join_routine(ctx: &Context, msg: &Message) -> Result, }, }; - let chan: Channel = connect_to.to_channel(&ctx.http).await.unwrap(); + let chan: Channel = connect_to.to_channel(&ctx.http).await.unwrap(); let gchan = match chan { Channel::Guild(ref gchan) => gchan, @@ -699,12 +951,19 @@ async fn join_routine(ctx: &Context, msg: &Message) -> Result, QueueContext { guild_id, voice_chan_id, + crossfade: Duration::from_secs(0), invited_from: msg.channel_id, cache: ctx.cache.clone(), data: ctx.data.clone(), manager: manager.clone(), http: ctx.http.clone(), - cold_queue: Arc::new(RwLock::new(VecDeque::new())), + cold_queue: Arc::new(RwLock::new(ColdQueue { + queue: VecDeque::new(), + has_played: VecDeque::new(), + use_radio: false, + radio_next: None, + radio_queue: VecDeque::new(), + })), } } else { tracing::error!("Expected voice channel (GuildChannel), got {:?}", chan); @@ -727,7 +986,7 @@ async fn join_routine(ctx: &Context, msg: &Message) -> Result, Event::Track(TrackEvent::End), TrackEndLoader(queuectx.clone()) ); - + call.add_global_event( Event::Periodic(TS_ABANDONED_HB, None), AbandonedChannel(queuectx.clone()) @@ -896,12 +1155,23 @@ async fn queue(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let qctx: Arc; + // grab the call object from guild ID. let call = match manager.get(guild_id) { Some(call_lock) => { - qctx = ctx.data.write().await.get_mut::().unwrap().get_mut(&guild_id).unwrap().clone(); + qctx = ctx.data.write() + .await + .get_mut::() + .unwrap() + .get_mut(&guild_id) + .unwrap() + .clone(); + call_lock }, + None => { + // Join the VC the user is in, + // then try again. let tmp = join_routine(ctx, msg).await; if let Err(ref e) = tmp { @@ -935,23 +1205,33 @@ async fn queue(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { uris.clear(); uris.push_back(url.clone()); } - - qctx.cold_queue.write().await.extend(uris.drain(..)); - let maybe_hot = { - let call = call.lock().await; - call.queue().len() > 0 - }; + // --- START + // WARNING: removing these curly braces will cause a deadlock. + // amount of hours spent on this: 5 + { + qctx.cold_queue.write().await.queue.extend(uris.drain(..)); - drop(call); // probably not needed, but just in case - if !maybe_hot { - play_routine(qctx.clone()).await?; + // check for hot loaded track + let hot_loaded = { + let call = call.lock().await; + call.queue().len() > 0 + }; + + + let mut call = call.lock().await; + let mut cold_queue = qctx.cold_queue.write().await; + if hot_loaded == false { + user_queue_routine(&mut call, &mut cold_queue, qctx.clone()).await?; + } } + // --- END + let content = format!( "Added {} Song(s) [{}] queued", added, - qctx.cold_queue.read().await.len() + qctx.cold_queue.read().await.queue.len() ); msg.channel_id @@ -984,12 +1264,16 @@ async fn skip(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let qctx = ctx.data.write().await .get_mut::().unwrap() .get_mut(&guild_id).unwrap().clone(); + + let cold_queue_len = qctx.cold_queue.read().await.queue.len(); let skipn = args.remains() .unwrap_or("1") .parse::() .unwrap_or(1); + // stop_event: EventEnd::UnMarked, + if 1 > skipn { msg.channel_id .say(&ctx.http, "Must skip at least 1 song") @@ -997,24 +1281,43 @@ async fn skip(ctx: &Context, msg: &Message, args: Args) -> CommandResult { return Ok(()) } - else if skipn >= qctx.cold_queue.read().await.len() as isize + 1 { - qctx.cold_queue.write().await.clear(); + else if skipn >= cold_queue_len as isize + 1 { + qctx.cold_queue.write().await.queue.clear(); } else { - let mut write_lock = qctx.cold_queue.write().await; - let bottom = write_lock.split_off(skipn as usize - 1); - write_lock.clear(); - write_lock.extend(bottom); + let mut cold_queue = qctx.cold_queue.write().await; + let bottom = cold_queue.queue.split_off(skipn as usize - 1); + cold_queue.queue.clear(); + cold_queue.queue.extend(bottom); + } + + // --- START + // stand alone section, writes historical actions. + { + let mut cold_queue = qctx.cold_queue.write().await; + if let Some(x) = cold_queue.has_played.front_mut() + { + if let EventEnd::UnMarked = x.stop_event + { + x.stop_event = EventEnd::Skipped; + x.end = Instant::now(); + } + } } + // -- END let manager = songbird::get(ctx) .await .expect("Songbird Voice client placed in at initialisation.") .clone(); - let handler_lock = match manager.get(guild_id) { - Some(x) => x, + match manager.get(guild_id) { + Some(call) => { + let call = call.lock().await; + let queue = call.queue(); + let _ = queue.skip(); + } None => { msg.channel_id .say(&ctx.http, "Not in a voice channel to play in") @@ -1023,22 +1326,105 @@ async fn skip(ctx: &Context, msg: &Message, args: Args) -> CommandResult { } }; - let cold_queue_len = qctx.cold_queue.read().await.len(); - msg.channel_id .say( &ctx.http, - format!("Song skipped [{}]: {} in queue.", skipn, cold_queue_len), + format!("Song skipped [{}]: {} in queue.", skipn, skipn-cold_queue_len as isize), ) .await?; - let mut call = handler_lock.lock().await; - let queue = call.queue(); - let _ = queue.skip(); - Ok(()) } +#[command] +#[only_in(guilds)] +#[aliases("ls", "l")] +/// @bot list +async fn list(ctx: &Context, msg: &Message) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let mut _qctx_lock = ctx.data.write().await; + let mut _qctx = _qctx_lock + .get_mut::() + .expect("Expected LazyQueueKey in TypeMap"); + + if let None = _qctx.get(&guild_id) { + msg.channel_id + .say(&ctx.http, "Not in a voice channel") + .await?; + return Ok(()) + } + let qctx = _qctx.get_mut(&guild_id).unwrap(); + let cold_queue = qctx.cold_queue.read().await; + + msg.channel_id + .say(&ctx.http, + format!( + "{}\n[{}] songs in queue", + cold_queue + .queue.clone() + .drain(..) + .chain(cold_queue.radio_queue.clone().drain(..)) + .chain( + cold_queue.radio_next + .iter() + .filter_map( + |(_next, metadata)| + metadata + .clone() + .map(|x| { + let metadata: Metadata = x.into(); + metadata.source_url.unwrap_or("Unknown".to_string()) + }) + ) + ) + .collect::>() + .join("\n"), + + cold_queue.queue.len() + ) + ).await?; + + return Ok(()); +} + +#[command] +#[only_in(guilds)] +/// @bot seed [on/off/(default: status)/uri] +async fn seed(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let mut _qctx_lock = ctx.data.write().await; + let mut _qctx = _qctx_lock + .get_mut::() + .expect("Expected LazyQueueKey in TypeMap"); + + if let None = _qctx.get(&guild_id) { + msg.channel_id + .say(&ctx.http, "Not in a voice channel") + .await?; + return Ok(()) + } + + let qctx = _qctx.get_mut(&guild_id).unwrap(); + let act = args.remains() + .unwrap_or("status"); + + match act { + "status" => + { msg.channel_id + .say( + &ctx.http, + qctx.cold_queue.read().await.radio_queue.clone().into_iter().collect::>().join("\n") + ).await?; }, + + _ => {} + } + + Ok(()) +} #[command] #[only_in(guilds)] @@ -1061,11 +1447,12 @@ async fn shuffle(ctx: &Context, msg: &Message, args: Args) -> CommandResult { use rand::seq::SliceRandom; let mut write_lock = qctx.cold_queue.write().await; - let mut vec = write_lock.iter().cloned().collect::>(); + + let mut vec = write_lock.queue.iter().cloned().collect::>(); vec.shuffle(&mut thread_rng()); - write_lock.clear(); - write_lock.extend(vec); + write_lock.queue.clear(); + write_lock.queue.extend(vec); } let manager = songbird::get(ctx) @@ -1143,3 +1530,54 @@ async fn getarl(ctx: &Context, msg: &Message) -> CommandResult { } return Ok(()) } + +#[command] +#[only_in(guilds)] +/// @bot radio [on/off/(default: status)] +async fn radio(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let mut _qctx_lock = ctx.data.write().await; + let mut _qctx = _qctx_lock + .get_mut::() + .expect("Expected LazyQueueKey in TypeMap"); + + if let None = _qctx.get(&guild_id) { + msg.channel_id + .say(&ctx.http, "Not in a voice channel") + .await?; + return Ok(()) + } + let qctx = _qctx.get_mut(&guild_id).unwrap(); + let act = args.remains() + .unwrap_or("status"); + + match act { + "status" => + { msg.channel_id + .say( + &ctx.http, + if qctx.cold_queue.read().await.use_radio + { "on" } else { "off" }, + ).await?; }, + + "on" => { + qctx.cold_queue.write().await.use_radio = true; + msg.channel_id + .say(&ctx.http, "Radio enabled") + .await?; + } + "off" => { + let mut lock = qctx.cold_queue.write().await; + lock.radio_queue.clear(); + lock.use_radio = false; + + msg.channel_id + .say(&ctx.http, "Radio disabled") + .await?; + } + _ => {} + } + Ok(()) +} diff --git a/crates/mockingbird/src/testsuite.rs b/crates/mockingbird/src/testsuite.rs index dcd5b33..d44c7aa 100644 --- a/crates/mockingbird/src/testsuite.rs +++ b/crates/mockingbird/src/testsuite.rs @@ -3,13 +3,14 @@ use std::path::PathBuf; fn binexists(file: &str) { let paths = var("PATH").unwrap(); - assert!(paths.split(':').filter(|p| PathBuf::from(p).join(file).exists()).count() == 1); + assert!(paths.split(':').filter(|p| PathBuf::from(p).join(file).exists()).count() >= 1); } // #[test] // #[cfg(feature="deemix")] -// fn path_deemix() { -// binexists("deemix") +// fn path_balloon() { +// println!("{}", var("PATH").unwrap()); +// binexists("balloon") // } #[test] diff --git a/flake.lock b/flake.lock index aff4b10..207277d 100644 --- a/flake.lock +++ b/flake.lock @@ -38,11 +38,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1714912032, - "narHash": "sha256-clkcOIkg8G4xuJh+1onLG4HPMpbtzdLv4rHxFzgsH9c=", + "lastModified": 1715346633, + "narHash": "sha256-A9vSieOHR7B41QoWZcb7fEY7r29E4Vq3liXE0h0edf0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ee4a6e0f566fe5ec79968c57a9c2c3c25f2cf41d", + "rev": "d42c1c8d447a388e1f2776d22c77f5642d703da6", "type": "github" }, "original": { @@ -52,11 +52,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1714912032, - "narHash": "sha256-clkcOIkg8G4xuJh+1onLG4HPMpbtzdLv4rHxFzgsH9c=", + "lastModified": 1715346633, + "narHash": "sha256-A9vSieOHR7B41QoWZcb7fEY7r29E4Vq3liXE0h0edf0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ee4a6e0f566fe5ec79968c57a9c2c3c25f2cf41d", + "rev": "d42c1c8d447a388e1f2776d22c77f5642d703da6", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index fed4390..9ed7583 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,7 @@ deemix-stream = pkgs.python3Packages.callPackage ./sbin/deemix-stream {}; cogpkgs = pkgs.callPackage ./iac/coggiebot/default.nix { inherit naerk-lib self recursiveMerge; inherit deemix-stream; }; + stable-features = (with cogpkgs.features; [ basic-cmds bookmark @@ -35,6 +36,7 @@ mockingbird-debug mockingbird-set-arl-cmd mockingbird-http + mockingbird-radio ]); coggiebot-stable = cogpkgs.mkCoggiebot { diff --git a/iac/coggiebot/default.nix b/iac/coggiebot/default.nix index 2cfce30..b60b7bf 100644 --- a/iac/coggiebot/default.nix +++ b/iac/coggiebot/default.nix @@ -83,6 +83,9 @@ let }); } + { name = "mockingbird-radio"; + dependencies = ["mockingbird-deemix" "mockingbird-core" "mockingbird-ctrl"]; + } { name = "mockingbird-deemix"; pkg-override = (prev: { buildInputs = prev.buildInputs ++ [ pkgs.python39Packages.deemix deemix-stream ]; @@ -249,8 +252,8 @@ rec { pkg-override = (prev: { postInstall = prev.postInstall + '' wrapProgram $out/bin/${prev.name} \ - --prefix PATH : ${lib.makeBinPath prev.buildInputs} - ''; + --prefix PATH : ${lib.makeBinPath prev.buildInputs}:$out/bin + ''; nativeBuildInputs = prev.nativeBuildInputs ++ [ pkgs.makeWrapper ]; }); } diff --git a/sbin/deemix-stream/deemix-stream b/sbin/deemix-stream/deemix-stream index bec4505..c6b4449 100755 --- a/sbin/deemix-stream/deemix-stream +++ b/sbin/deemix-stream/deemix-stream @@ -23,6 +23,9 @@ from deezer.errors import WrongLicense, WrongGeolocation from deemix.utils import USER_AGENT_HEADER from deemix.utils.crypto import generateBlowfishKey, decryptChunk +import os +import io + dz = Deezer() settings = DEFAULT_SETTINGS plugins = {} @@ -44,9 +47,9 @@ def streamTrack(outputStream, track, trackAPI=None, start=0, downloadObject=None trackAPI["filesize"] = complete; json.dump(trackAPI, sys.stderr) print("", file=sys.stderr) - + if complete == 0: raise DownloadEmpty - + isStart = True for chunk in request.iter_content(2048 * 3): if isCryptedStream: @@ -62,7 +65,7 @@ def streamTrack(outputStream, track, trackAPI=None, start=0, downloadObject=None outputStream.write(chunk) chunkLength += len(chunk) -def stream_stdout(downloadObject, extraData, bitrate=TrackFormats.MP3_320): +def stream_stdout(fd, downloadObject, extraData, bitrate=TrackFormats.MP3_320): trackAPI = extraData.get('trackAPI') albumAPI = extraData.get('albumAPI') playlistAPI = extraData.get('playlistAPI') @@ -108,11 +111,11 @@ def stream_stdout(downloadObject, extraData, bitrate=TrackFormats.MP3_320): track.album.bitrate = selectedFormat # Apply settings track.applySettings(DEFAULT_SETTINGS) - track.downloadURL = track.urls[formatsName[track.bitrate]] + track.downloadURL = track.urls[formatsName[track.bitrate]] if not track.downloadURL: raise DownloadFailed('notAvailable', track) try: - streamTrack(stdout, track, downloadObject=downloadObject, trackAPI=trackAPI) + streamTrack(fd, track, downloadObject=downloadObject, trackAPI=trackAPI) except requests.exceptions.HTTPError as e: raise DownloadFailed('notAvailable', track) from e @@ -128,7 +131,7 @@ def stream(url, arl, spt_id, spt_secret, spt_cache, hq): assert dz.login_via_arl(arl.strip()), 'Invalid ARL' settings = DEFAULT_SETTINGS - + plugins = {"spotify": SpotifyStreamer(spt_id, spt_secret, spt_cache)} plugins["spotify"].setup() @@ -142,9 +145,10 @@ def stream(url, arl, spt_id, spt_secret, spt_cache, hq): 'albumAPI': downloadObject.single.get('albumAPI'), } - stream_stdout(downloadObject, extras) - + backing = io.BytesIO() + stream_stdout(backing, downloadObject, extras) + stdout.write(backing.getvalue()) stdout.close() - + if __name__ == '__main__': stream(auto_envvar_prefix='DEEMIX') diff --git a/sbin/deemix-stream/default.nix b/sbin/deemix-stream/default.nix index 2e304be..6b20555 100644 --- a/sbin/deemix-stream/default.nix +++ b/sbin/deemix-stream/default.nix @@ -1,7 +1,7 @@ { lib, buildPythonApplication, deemix, spotipy }: buildPythonApplication { pname = "deemix-stream"; - version = "0.0.4"; + version = "0.0.5"; propagatedBuildInputs = [ deemix spotipy ]; src = lib.cleanSource ./.; diff --git a/sbin/deemix-stream/setup.py b/sbin/deemix-stream/setup.py index 1352c19..6b3ff1f 100644 --- a/sbin/deemix-stream/setup.py +++ b/sbin/deemix-stream/setup.py @@ -6,7 +6,7 @@ python_requires='>=3.7', # Modules to import from other scripts: packages=find_packages(), - install_requires=["click", "requests", "deemix>=3.6.6"], + install_requires=["click", "requests", "deemix>=3.6.6", "spotipy>=2.16.1"], # Executables - scripts=["deemix-stream", "deemix-metadata"], + scripts=["deemix-stream", "deemix-metadata", "spotify-recommend"], ) diff --git a/sbin/deemix-stream/spotify-recommend b/sbin/deemix-stream/spotify-recommend new file mode 100755 index 0000000..41062bd --- /dev/null +++ b/sbin/deemix-stream/spotify-recommend @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +import spotipy +import click + +SpotifyClientCredentials = spotipy.oauth2.SpotifyClientCredentials +CacheFileHandler = spotipy.cache_handler.CacheFileHandler + +def checkCredentials(id, secret, cache): + cache_handler = CacheFileHandler(cache) + + client_credentials_manager = SpotifyClientCredentials( + client_id=id, + client_secret=secret, + cache_handler=cache_handler + ) + + spotify = spotipy.Spotify(client_credentials_manager=client_credentials_manager) + spotify.user_playlists('spotify') + + return spotify + +@click.command() +@click.option('-s', '--spt-id', type=str, help='Path to the config folder') +@click.option('-ss', '--spt-secret', type=str, help='Path to the config folder') +@click.option('-sc', '--spt-cache', type=str, help='Path to the config folder') +@click.option('-l', '--limit', type=str, default="5", help='Path to the config folder') +@click.argument('isrcs', nargs=-1, required=True) +def main(spt_id, spt_secret, spt_cache, limit, isrcs): + + sp = checkCredentials(spt_id, spt_secret, spt_cache) + tracks = [ sp.search("isrc:"+x) for x in isrcs ]; + + rec = sp.recommendations(seed_tracks=[ + x['tracks']['items'][0]["uri"] for x in tracks + ], limit=limit) + + for x in rec["tracks"]: + print(x["external_urls"]["spotify"]) + +if __name__ == '__main__': + main(auto_envvar_prefix='DEEMIX') \ No newline at end of file From bf1fabff43cf90824a77aba2dc425e778419ea39 Mon Sep 17 00:00:00 2001 From: Lunarix <3759687+Skarlett@users.noreply.github.com> Date: Fri, 24 May 2024 18:13:19 -0500 Subject: [PATCH 2/3] +crossfade v1.5.0 (#219) * +play_source cmd: play via mixer * rename: `TrackEndLoader` -> `RadioInvoker` depreciate `Players::play` in preference for `players::into_input` * wip * wip * wip * wip * break apart the mega file * rename crates/mockingbird/src/auth.rs crates/mockingbird/src/usersettoken.rs * fix subgroup check * wip: cleanup * revert commit to origin/next-release * +crossplay: fix audio playing * fix no play bug * add debugging info * trim `use` * add crossfade command group * bugging out v1.5.0 * nerd rapper * fix silly bug --------- Co-authored-by: allow --- Cargo.lock | 54 +- crates/coggiebot/Cargo.toml | 12 +- crates/coggiebot/src/controllers/basic.rs | 7 + crates/coggiebot/src/controllers/mod.rs | 7 +- crates/mockingbird/Cargo.toml | 9 +- crates/mockingbird/src/compat.rs | 167 +++ crates/mockingbird/src/controller.rs | 487 +++++++ crates/mockingbird/src/crossfade.rs | 157 +++ crates/mockingbird/src/dev.rs | 43 + crates/mockingbird/src/events.rs | 88 ++ crates/mockingbird/src/lib.rs | 11 +- crates/mockingbird/src/models.rs | 208 +++ crates/mockingbird/src/player.rs | 1446 ++++----------------- crates/mockingbird/src/radio.rs | 282 ++++ crates/mockingbird/src/usersettoken.rs | 60 + flake.nix | 2 + iac/coggiebot/default.nix | 6 +- 17 files changed, 1805 insertions(+), 1241 deletions(-) create mode 100644 crates/mockingbird/src/compat.rs create mode 100644 crates/mockingbird/src/controller.rs create mode 100644 crates/mockingbird/src/crossfade.rs create mode 100644 crates/mockingbird/src/dev.rs create mode 100644 crates/mockingbird/src/events.rs create mode 100644 crates/mockingbird/src/models.rs create mode 100644 crates/mockingbird/src/radio.rs create mode 100644 crates/mockingbird/src/usersettoken.rs diff --git a/Cargo.lock b/Cargo.lock index 5dd054e..4170282 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -239,7 +251,7 @@ dependencies = [ [[package]] name = "coggiebot" -version = "1.4.17" +version = "1.5.0" dependencies = [ "mockingbird", "serenity", @@ -895,6 +907,16 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "metrics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be3cbd384d4e955b231c895ce10685e3d8260c5ccffae898c96c723b0772835" +dependencies = [ + "ahash", + "portable-atomic", +] + [[package]] name = "mime" version = "0.3.17" @@ -933,10 +955,12 @@ dependencies = [ [[package]] name = "mockingbird" -version = "0.0.6" +version = "0.1.0" dependencies = [ "chrono", "cutils", + "metrics", + "parking_lot", "rand", "reqwest", "serde", @@ -1203,6 +1227,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" + [[package]] name = "powerfmt" version = "0.2.0" @@ -2629,6 +2659,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.62", +] + [[package]] name = "zeroize" version = "1.3.0" diff --git a/crates/coggiebot/Cargo.toml b/crates/coggiebot/Cargo.toml index a416f5c..e3193b9 100644 --- a/crates/coggiebot/Cargo.toml +++ b/crates/coggiebot/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coggiebot" -version = "1.4.17" +version = "1.5.0" edition = "2021" [dependencies] @@ -14,7 +14,13 @@ tracing-subscriber = "0.3" mockingbird = { path = "../mockingbird", optional=true } [features] -default = ["mockingbird-radio", "mockingbird-deemix"] +default = [ + "mockingbird-radio", + "mockingbird-deemix", + "mockingbird-ctrl", + "mockingbird-core", + "mockingbird-http" +] list-feature-cmd = [] basic-cmds = [] bookmark = [] @@ -43,6 +49,8 @@ mockingbird-deemix = ["mockingbird?/deemix"] ################ mockingbird-deemix-check = ["mockingbird?/check"] +mockingbird-crossfade = ["mockingbird?/crossfade"] + ################ # NOTE: enabling this is dangerous # only use for per-guild instances diff --git a/crates/coggiebot/src/controllers/basic.rs b/crates/coggiebot/src/controllers/basic.rs index a13fe02..3856ca3 100644 --- a/crates/coggiebot/src/controllers/basic.rs +++ b/crates/coggiebot/src/controllers/basic.rs @@ -39,6 +39,13 @@ async fn rev_cmd(ctx: &Context, msg: &Message) -> CommandResult { Ok(()) } +// #[command] +// async fn dmsg(ctx: &Context, msg: &Message, args: Args) -> CommandResult { +// let x = args.single::(); +// msg.channel.delete_messages(&ctx.http, x).await?; +// Ok(()) +// } + #[command] async fn contribute(ctx: &Context, msg: &Message) -> CommandResult { msg.channel_id diff --git a/crates/coggiebot/src/controllers/mod.rs b/crates/coggiebot/src/controllers/mod.rs index 908534a..74956f5 100644 --- a/crates/coggiebot/src/controllers/mod.rs +++ b/crates/coggiebot/src/controllers/mod.rs @@ -37,9 +37,10 @@ pub fn setup_framework(mut cfg: StandardFramework) -> StandardFramework { ["list-feature-cmd"] => [features::FEATURES_GROUP], ["help-cmd"] => [features::HELP_GROUP], ["mockingbird-arl-cmd"] => [mockingbird::check::ARL_GROUP], - ["mockingbird-set-arl-cmd"] => [mockingbird::player::DANGEROUS_GROUP], - ["mockingbird-ctrl"] => [mockingbird::player::BETTERPLAYER_GROUP], - ["mockingbird-ctrl", "mockingbird-radio"] => [mockingbird::player::RADIO_GROUP] + ["mockingbird-set-arl-cmd"] => [mockingbird::usersettoken::DANGEROUS_GROUP], + ["mockingbird-ctrl"] => [mockingbird::controller::BETTERPLAYER_GROUP], + ["mockingbird-ctrl", "mockingbird-radio"] => [mockingbird::radio::RADIO_GROUP], + ["mockingbird-ctrl", "mockingbird-crossfade"] => [mockingbird::crossfade::CROSSFADE_GROUP] } ); cfg diff --git a/crates/mockingbird/Cargo.toml b/crates/mockingbird/Cargo.toml index 665fb96..cac5aab 100644 --- a/crates/mockingbird/Cargo.toml +++ b/crates/mockingbird/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mockingbird" -version = "0.0.6" +version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -11,6 +11,7 @@ serenity = { version = "0.11", default-features=false, features = ["standard_fra tracing = { version = "0.1"} tokio = {version = "1.0", default-features=false, features = ["time", "rt"]} rand = { version = "0.8" } +parking_lot = "*" #### serde = { version = "1.0", optional=true } @@ -18,14 +19,18 @@ serde_json = { version = "1.0", optional=true } reqwest = { version = "0.11", optional = true, features = ["cookies"]} chrono = {version = "^0.4.26", optional = true } cutils = { path = "../cutils", features = ["tokio"], optional=true } +metrics = { version = "0.22", optional=true } + [features] default = [] controller = [] debug = [] radio = [] +crossfade = [] +metrics = ["dep:metrics"] -check = ["dep:chrono", "dep:reqwest", "dep:serde", "dep:serde_json"] +check = ["dep:chrono", "dep:reqwest", "dep:serde", "dep:serde_json", "metrics"] ytdl = ["songbird/yt-dlp"] deemix = ["dep:serde", "dep:serde_json", "cutils"] http-get = ["dep:reqwest"] diff --git a/crates/mockingbird/src/compat.rs b/crates/mockingbird/src/compat.rs new file mode 100644 index 0000000..7feca78 --- /dev/null +++ b/crates/mockingbird/src/compat.rs @@ -0,0 +1,167 @@ +use songbird::input::Input; + +use std::{ + process::Stdio, + collections::VecDeque, + path::PathBuf, +}; + +use tokio::{ + io::AsyncBufReadExt, + process::Command, +}; + +use crate::models::*; + +#[cfg(not(feature = "deemix"))] +pub struct FakeMeta(Metadata); + +#[cfg(not(feature = "deemix"))] +impl Into for FakeMeta { + fn into(self) -> Metadata { + self.0 + } +} + +fn process_fan_output(buf: &mut VecDeque, json_buf: Vec, err_cnt: &mut usize, key: &str){ + for x in json_buf { + if let Some(jmap) = x.as_object() { + if !jmap.contains_key(key) { + tracing::error!("{} not found in json", key); + *err_cnt += 1; + continue + } + + buf.push_back(jmap[key].as_str().unwrap().to_owned()); + } + else { + tracing::error!("{} not found in json", key); + *err_cnt += 1; + continue + } + } + tracing::info!("{} tracks found", buf.len()); +} + +async fn _urls(cmd: &str, args: &[&str], buf: &mut Vec) -> std::io::Result<()> { + let child = Command::new(cmd) + .args(args) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + + let stdout = child.wait_with_output().await.unwrap(); + let mut lines = stdout.stdout.lines(); + + while let Some(line) = lines.next_line().await? { + let json = + serde_json::from_str(&line).unwrap(); + buf.push(json); + } + Ok(()) +} + +/* + * Some ugly place holders for + * feature generated code. +*/ +#[cfg(feature="deemix")] +pub async fn fan_deezer(uri: &str, buf: &mut VecDeque) -> Result { + let mut json_buf = Vec::new(); + let mut err_cnt = 0; + _urls("deemix-metadata", &[uri], &mut json_buf).await?; + + process_fan_output(buf, json_buf, &mut err_cnt, "link"); + Ok(err_cnt) +} + +#[cfg(feature="ytdl")] +pub async fn fan_ytdl(uri: &str, buf: &mut VecDeque) -> Result { + let mut json_buf = Vec::new(); + let mut err_cnt = 0; + _urls("yt-dlp", &["--flat-playlist", "-j", uri], &mut json_buf).await?; + + process_fan_output(buf, json_buf, &mut err_cnt, "url"); + Ok(err_cnt) +} + +#[cfg(not(feature="deemix"))] +pub async fn fan_deezer(uri: &str, buf: &mut VecDeque) -> Result { + return Err(HandlerError::NotImplemented) +} + +#[cfg(not(feature="ytdl"))] +pub async fn fan_ytdl(_uri: &str, _buf: &mut VecDeque) -> Result { + return Err(HandlerError::NotImplemented) +} + +#[cfg(feature = "http-get")] +pub async fn ph_httpget_player( + uri: &str, + guild_id: u64, + ref_fp: &mut PathBuf, +) -> (Result<(Input, Option), HandlerError>) { + tracing::info!("[HTTP-GET] Downloading: {}", uri); + + // let fp = tempfile::tempfile()?; + use rand::Rng; + let id: String = (0..12) + .map(|_| char::from(rand::thread_rng().gen_range(97..123))) + .collect(); + + let fp = std::env::temp_dir() + .join("coggiebot") + .join(guild_id.to_string()); + + match tokio::fs::create_dir_all(&fp).await { + Ok(_) => {} + Err(e) => { + tracing::error!("Failed to create temp dir: {}", e); + return (Err(HandlerError::IOError(e))); + } + } + let fp = fp.join(format!("{}", id)); + + match crate::player::get_file(uri, guild_id, &fp).await.map_err(HandlerError::from) { + Ok(input) => Ok((input, Some(MetadataType::Disk(fp.clone())))), + Err(e) => { + if let Ok(true) = tokio::fs::try_exists(&fp).await { + let _ = tokio::fs::remove_file(&fp).await; + } + Err(e) + } + } +} + +#[cfg(feature = "deemix")] +pub async fn ph_deemix_player(uri: &str) -> Result<(Input, Option), HandlerError> { + crate::deemix::deemix(uri).await + .map_err(HandlerError::from) + .map(|(input, meta)| (input, meta.map(|x| x.into()))) +} + +#[cfg(feature = "ytdl")] +pub async fn ph_ytdl_player(uri: &str) -> Result<(Input, Option), HandlerError> { + return songbird::ytdl(uri).await.map_err(HandlerError::from) + .map(|input| (input, None)) +} + +#[cfg(not(feature = "deemix"))] +pub async fn ph_deemix_player(uri: &str) -> Result<(Input, Option), HandlerError> { + return Err(HandlerError::NotImplemented) +} + +#[cfg(not(feature = "ytdl"))] +pub async fn ph_ytdl_player(_uri: &str) -> Result<(Input, Option), HandlerError> { + return Err(HandlerError::NotImplemented) +} + +#[cfg(not(feature = "http-get"))] +pub async fn ph_httpget_player( + _uri: &str, + _guild_id: u64, + _ref_fp: &mut PathBuf, +) -> Result<(Input, Option), HandlerError> +{ + return Err(HandlerError::NotImplemented) +} diff --git a/crates/mockingbird/src/controller.rs b/crates/mockingbird/src/controller.rs new file mode 100644 index 0000000..6a3be5f --- /dev/null +++ b/crates/mockingbird/src/controller.rs @@ -0,0 +1,487 @@ +use crate::{models::*, player::play}; + +use serenity::{ + framework::standard::{ + macros::{command, group}, Args, CommandResult + }, + model::{channel::Message, prelude::*}, + prelude::* +}; + +use songbird::{ + error::JoinError, + input::Metadata, +}; + +use std::{ + time::Instant, + sync::Arc, +}; + +use core::sync::atomic::Ordering; + + +#[group] +#[commands(join, leave, queue, now_playing, skip, list, shuffle)] +pub struct BetterPlayer; + +#[command] +#[aliases("np", "playing", "now-playing", "playing-now", "nowplaying")] +#[only_in(guilds)] +async fn now_playing(ctx: &Context, msg: &Message) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + tracing::info!( + "[{}::{}] asked what track is playing in [{}::{:?}]", + msg.author.id, msg.author.name, + msg.channel_id, msg.channel_id.name(&ctx).await + ); + + + let qctx = { + let mut glob = ctx.data.write().await; + let queue = glob.get_mut::() + .expect("Expected LazyQueueKey in TypeMap"); + queue.get(&guild_id).cloned() + }; + + let qctx = match qctx { + Some(qctx) => qctx, + None => { + msg.channel_id + .say(&ctx.http, "Not in a voice channel") + .await?; + return Ok(()); + } + }; + + let call_lock = qctx.manager + .get(qctx.guild_id) + .unwrap(); + + let call = call_lock.lock().await; + + match call.queue().current() { + Some(ref x) => { + msg.channel_id + .say(&ctx.http, + format!( + "{}: {}", qctx.voice_chan_id.mention(), + x.metadata() + .clone() + .source_url + .unwrap_or("Unknown".to_string()) + ) + ).await?; + } + None => { + msg.channel_id + .say(&ctx.http, "Nothing is currently playing") + .await?; + } + } + + Ok(()) +} + +#[command] +#[only_in(guilds)] +async fn join(ctx: &Context, msg: &Message) -> CommandResult { + let connect_to = crate::player::join_routine(&ctx, msg).await; + + if let Err(ref e) = connect_to { + msg.channel_id + .say(&ctx.http, format!("Failed to join voice channel: {:?}", e)) + .await?; + } + + msg.channel_id + .say(&ctx.http, format!("Joined {}", connect_to.unwrap().voice_chan_id.mention())) + .await?; + + Ok(()) +} + +#[command] +#[only_in(guilds)] +async fn leave(ctx: &Context, msg: &Message) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let manager = songbird::get(ctx) + .await + .expect("songbird voice client placed in at initialisation.") + .clone(); + + let handler = manager.get(guild_id); + + if handler.is_none() { + msg.reply(ctx, "Not in a voice channel").await?; + return Ok(()) + } + + let handler = handler.unwrap(); + + { + let mut call = handler.lock().await; + call.remove_all_global_events(); + call.stop(); + let _ = call.deafen(false).await; + } + + if let Err(e) = manager.remove(guild_id).await { + msg.channel_id + .say(&ctx.http, format!("Failed: {:?}", e)) + .await?; + } + + { + let mut glob = ctx.data.write().await; + let queue = glob.get_mut::().expect("Expected LazyQueueKey in TypeMap"); + queue.remove(&guild_id); + } + + msg.channel_id.say(&ctx.http, "Left voice channel").await?; + Ok(()) +} + +#[command] +#[aliases("play", "p", "q")] +#[only_in(guilds)] +async fn queue(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + tracing::info!( + "[{}::{}] queued track in [{}::{:?}]", + msg.author.id, msg.author.name, + msg.channel_id, msg.channel_id.name(&ctx).await + ); + + let url = match args.single::() { + Ok(url) => url, + Err(_) => { + msg.channel_id + .say(&ctx.http, "Must provide a URL to a video or audio") + .await + .unwrap(); + return Ok(()); + }, + }; + + if !url.starts_with("http") { + msg.channel_id + .say(&ctx.http, "Must provide a valid URL") + .await + .unwrap(); + return Ok(()); + }; + + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let qctx: Arc; + + // grab the call object from guild ID. + let call = match manager.get(guild_id) { + Some(call_lock) => { + qctx = ctx.data.write() + .await + .get_mut::() + .unwrap() + .get_mut(&guild_id) + .unwrap() + .clone(); + + call_lock + }, + + None => { + // Join the VC the user is in, + // then try again. + let tmp = crate::player::join_routine(ctx, msg).await; + + if let Err(ref e) = tmp { + msg.channel_id + .say(&ctx.http, format!("Failed to join voice channel: {:?}", e)) + .await + .unwrap(); + return Ok(()); + }; + qctx = tmp.unwrap(); + msg.channel_id + .say(&ctx.http, format!("Joined: {}", qctx.voice_chan_id.mention())) + .await + .unwrap(); + + let call = manager.get(guild_id).ok_or_else(|| JoinError::NoCall); + call? + } + }; + + match crate::player::Players::from_str(&url) + .ok_or_else(|| String::from("Failed to select extractor for URL")) + { + Ok(player) => { + let mut uris = player.fan_collection(url.as_str()).await?; + let added = uris.len(); + + // YTDLP singles don't work. + // so instead, use the original URI. + if uris.len() == 1 && player == crate::player::Players::Ytdl { + uris.clear(); + uris.push_back(url.clone()); + } + + // --- START + // WARNING: removing these curly braces will cause a deadlock. + // amount of hours spent on this: 5 + { + let crossfading = qctx.crossfade.load(Ordering::Relaxed); + qctx.cold_queue.write().await.queue.extend(uris.drain(..)); + + // check for hot loaded track + let hot_loaded = { + let call = call.lock().await; + if crossfading { + let lock = qctx.cold_queue.write().await; + lock.crossfade_rhs.is_some() || lock.crossfade_lhs.is_some() + } + else { call.queue().len() > 0 } + }; + + if hot_loaded == false { + let mut call = call.lock().await; + let mut cold_queue = qctx.cold_queue.write().await; + let next = crate::player::next_track_handle(&mut cold_queue, qctx.clone(), crossfading).await; + + if let Ok(Some((track, handle, _metadata))) = next { + play(&mut call, track, &handle, &mut cold_queue, crossfading).await?; + } + } + } + // --- END + + + let content = format!( + "Added {} Song(s) [{}] queued", + added, + qctx.cold_queue.read().await.queue.len() + ); + + msg.channel_id + .say(&ctx.http, &content) + .await?; + }, + + Err(_) => { + msg.channel_id + .say(&ctx.http, format!("Failed to select extractor for URL: {}", url)) + .await?; + } + } + + Ok(()) +} + +#[command] +#[only_in(guilds)] +async fn skip(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + tracing::info!( + "[{}::{}] skipped track in [{}::{:?}]", + msg.author.id, msg.author.name, + msg.channel_id, msg.channel_id.name(&ctx).await + ); + + let qctx = ctx.data.write().await + .get_mut::().unwrap() + .get_mut(&guild_id).unwrap().clone(); + + let cold_queue_len = qctx.cold_queue.read().await.queue.len(); + + let skipn = args.remains() + .unwrap_or("1") + .parse::() + .unwrap_or(1); + + // stop_event: EventEnd::UnMarked, + + if 1 > skipn { + msg.channel_id + .say(&ctx.http, "Must skip at least 1 song") + .await?; + return Ok(()) + } + + else if skipn >= cold_queue_len as isize + 1 { + qctx.cold_queue.write().await.queue.clear(); + } + + else { + let mut cold_queue = qctx.cold_queue.write().await; + let bottom = cold_queue.queue.split_off(skipn as usize - 1); + cold_queue.queue.clear(); + cold_queue.queue.extend(bottom); + } + + // --- START + // stand alone section, writes historical actions. + { + let mut cold_queue = qctx.cold_queue.write().await; + if let Some(x) = cold_queue.has_played.front_mut() + { + if let EventEnd::UnMarked = x.stop_event + { + x.stop_event = EventEnd::Skipped; + x.end = Instant::now(); + } + } + } + // -- END + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + match manager.get(guild_id) { + Some(call) => { + let call = call.lock().await; + let queue = call.queue(); + let _ = queue.skip(); + } + None => { + msg.channel_id + .say(&ctx.http, "Not in a voice channel to play in") + .await?; + return Ok(()) + } + }; + + msg.channel_id + .say( + &ctx.http, + format!("Song skipped [{}]: {} in queue.", skipn, skipn-cold_queue_len as isize), + ) + .await?; + + Ok(()) +} + +#[command] +#[only_in(guilds)] +#[aliases("ls", "l")] +/// @bot list +async fn list(ctx: &Context, msg: &Message) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let mut _qctx_lock = ctx.data.write().await; + let mut _qctx = _qctx_lock + .get_mut::() + .expect("Expected LazyQueueKey in TypeMap"); + + if let None = _qctx.get(&guild_id) { + msg.channel_id + .say(&ctx.http, "Not in a voice channel") + .await?; + return Ok(()) + } + let qctx = _qctx.get_mut(&guild_id).unwrap(); + let cold_queue = qctx.cold_queue.read().await; + + msg.channel_id + .say(&ctx.http, + format!( + "{}\n[{}] songs in queue", + cold_queue + .queue.clone() + .drain(..) + .chain(cold_queue.radio_queue.clone().drain(..)) + .chain( + cold_queue.radio_next + .iter() + .filter_map( + |(_next, metadata)| + metadata + .clone() + .map(|x| { + let metadata: Metadata = x.into(); + metadata.source_url.unwrap_or("Unknown".to_string()) + }) + ) + ) + .collect::>() + .join("\n"), + + cold_queue.queue.len() + ) + ).await?; + + return Ok(()); +} + + +#[command] +#[only_in(guilds)] +async fn shuffle(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + tracing::info!( + "[{}::{}] shuffled playlist in [{}::{:?}]", + msg.author.id, msg.author.name, + msg.channel_id, msg.channel_id.name(&ctx).await + ); + + let qctx = ctx.data.write().await + .get_mut::().unwrap() + .get_mut(&guild_id).unwrap().clone(); + + { + use rand::thread_rng; + use rand::seq::SliceRandom; + + let mut write_lock = qctx.cold_queue.write().await; + + let mut vec = write_lock.queue.iter().cloned().collect::>(); + + vec.shuffle(&mut thread_rng()); + write_lock.queue.clear(); + write_lock.queue.extend(vec); + } + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let handler_lock = match manager.get(guild_id) { + Some(x) => x, + None => { + msg.channel_id + .say(&ctx.http, "Not in a voice channel to play in") + .await?; + return Ok(()) + } + }; + + msg.channel_id + .say( + &ctx.http, + format!("shuffled."), + ) + .await?; + + let mut call = handler_lock.lock().await; + let queue = call.queue(); + let _ = queue.skip(); + + Ok(()) +} diff --git a/crates/mockingbird/src/crossfade.rs b/crates/mockingbird/src/crossfade.rs new file mode 100644 index 0000000..e7ac176 --- /dev/null +++ b/crates/mockingbird/src/crossfade.rs @@ -0,0 +1,157 @@ +use serenity::{ + async_trait, framework::standard::{ + macros::{command, group}, Args, CommandResult + }, + model::channel::Message, + prelude::*, +}; + +use songbird::{ + events::{Event, EventContext}, input::Metadata, tracks::{TrackHandle, TrackState}, EventHandler as VoiceEventHandler +}; + +use std::sync::Arc; +use std::time::Duration; + +use core::sync::atomic::Ordering; + +use crate::models::*; + +trait CrossFadeHandler { + fn handler( + &self, + current: (&TrackHandle, &TrackState), + upcoming: (&TrackHandle, &TrackState), + + ) -> Result<(), HandlerError> { + Ok(()) + } + + fn start_at(&self, metadata: &Metadata) -> Duration { + metadata.duration + .map(|x| x - Duration::from_secs(10)) + .unwrap_or(Duration::from_secs(0)) + } +} + + +#[group] +#[commands(crossfade)] +struct Crossfade; + +pub struct CrossFadeInvoker(pub Arc); +#[async_trait] +impl VoiceEventHandler for CrossFadeInvoker { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + if let None = self.0.manager.get(self.0.guild_id) { + return Some(Event::Cancel) + } + + let mut cold_queue = self.0.cold_queue.write().await; + let peak = 10000; + let root : i32 = (peak as f32).sqrt() as i32; + // let step = 1; + let mut cold_queue = tokio::task::block_in_place(move || { + for x in 25 ..= root { + let fade_out = peak - x.pow(2); + let fade_out_normal = fade_out as f32 / 10000.0; + let fade_in_normal = (peak - fade_out) as f32 / 10000.0; + + tracing::info!("crossfade: fade_out: {}, fade_in: {}", fade_out_normal, fade_in_normal); + + match (cold_queue.crossfade_lhs.as_ref(), cold_queue.crossfade_rhs.as_ref()) { + (Some(lhs), Some(rhs)) => { + let _ = rhs.play(); + let _ = lhs.set_volume(fade_out_normal); + let _ = rhs.set_volume(fade_in_normal); + } + + (Some(_lhs), None) => { + // let _ = lhs.set_volume(fade_out); + }, + + (None, Some(rhs)) => { + let _ = rhs.set_volume(fade_in_normal); + }, + + (None, None) => { + break + } + // return Some(Event::Cancel) + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } + cold_queue + }); + + if let Some(rhs) = cold_queue.crossfade_rhs.take() { + if let Some(lhs) = cold_queue.crossfade_lhs.take() { + cold_queue.crossfade_lhs.replace(rhs); + let _ = lhs.stop(); + return None; + } + + cold_queue.crossfade_lhs.replace(rhs); + } + else { + if let Some(lhs) = cold_queue.crossfade_lhs.take() { + let _ = lhs.stop(); + } + } + return None + } +} + + +#[command] +#[only_in(guilds)] +/// @bot radio [on/off/(default: status)] +async fn crossfade(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let mut _qctx_lock = ctx.data.write().await; + let mut _qctx = _qctx_lock + .get_mut::() + .expect("Expected LazyQueueKey in TypeMap"); + + if let None = _qctx.get(&guild_id) { + msg.channel_id + .say(&ctx.http, "Not in a voice channel") + .await?; + return Ok(()) + } + + let qctx = _qctx.get_mut(&guild_id).unwrap(); + let act = args.remains() + .unwrap_or("status"); + + match act { + "status" => + { msg.channel_id + .say( + &ctx.http, + if qctx.crossfade.load(Ordering::Relaxed) + { "on" } else { "off" }, + ).await?; }, + + "on" => { + qctx.crossfade.swap(true, Ordering::Relaxed); + msg.channel_id + .say(&ctx.http, "crossfade enabled") + .await?; + } + "off" => { + let mut lock = qctx.cold_queue.write().await; + lock.radio_queue.clear(); + lock.use_radio = false; + + msg.channel_id + .say(&ctx.http, "crossfade disabled") + .await?; + } + _ => {} + } + Ok(()) +} diff --git a/crates/mockingbird/src/dev.rs b/crates/mockingbird/src/dev.rs new file mode 100644 index 0000000..5c2157c --- /dev/null +++ b/crates/mockingbird/src/dev.rs @@ -0,0 +1,43 @@ + +#[command] +#[only_in(guilds)] +/// @bot radio [on/off/(default: status)] +async fn play_source(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let manager = songbird::get(ctx) + .await + .expect("songbird voice client placed in at initialisation.") + .clone(); + + let handler = manager.get(guild_id); + + if handler.is_none() { + msg.reply(ctx, "Not in a voice channel").await?; + return Ok(()) + } + + let handler = handler.unwrap(); + + let url = match args.single::() { + Ok(url) => url.to_owned(), + Err(_) => { + msg.channel_id + .say(&ctx.http, "Must provide a URL to a video or audio") + .await + .unwrap(); + return Ok(()); + }, + }; + + let player = Players::from_str(&url).unwrap(); + let mut call = handler.lock().await; + let guild = msg.guild(&ctx.cache).unwrap(); + + let (input, metadata) = player.into_input(&url, guild.id).await?; + call.play_source(input); + // call.enqueue(track); + // Ok((track_handle, metadata)) + Ok(()) +} diff --git a/crates/mockingbird/src/events.rs b/crates/mockingbird/src/events.rs new file mode 100644 index 0000000..96cb8cb --- /dev/null +++ b/crates/mockingbird/src/events.rs @@ -0,0 +1,88 @@ +use serenity::async_trait; + +use songbird::{ + events::{Event, EventContext}, + EventHandler as VoiceEventHandler, +}; + +use std::{ + sync::Arc, + path::PathBuf, +}; + +use core::sync::atomic::Ordering; +use crate::models::*; + +pub struct AbandonedChannel(pub Arc); +#[async_trait] +impl VoiceEventHandler for AbandonedChannel { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + let members = self.0.voice_chan_id.members(&self.0.cache).await.unwrap(); + if members.iter().filter(|x| !x.user.bot).count() > 0 { + return None; + } + + crate::player::leave_routine( + self.0.data.clone(), + self.0.guild_id.clone(), + self.0.manager.clone() + ).await.unwrap(); + + Some(Event::Cancel) + } +} + +pub struct PreloadInvoker(Arc); +impl PreloadInvoker { + pub fn new(qctx: Arc) -> Self { + Self(qctx) + } +} + +#[async_trait] +impl VoiceEventHandler for PreloadInvoker { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + if let Some(call) = self.0.manager.get(self.0.guild_id) { + let mut call = call.lock().await; + let mut cold_queue = self.0.cold_queue.write().await; + let crossfade = self.0.crossfade.load(Ordering::Relaxed); + + if let Ok(Some((track, handle, _metadata))) = crate::player::next_track_handle( + &mut cold_queue, + self.0.clone(), + crossfade + ).await + { + crate::player::play(&mut call, track, &handle, &mut cold_queue, crossfade).await; + } + } + None + } +} + +pub struct RemoveTempFile(pub PathBuf); +#[async_trait] +impl VoiceEventHandler for RemoveTempFile { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + let _ = tokio::fs::remove_file(&self.0).await; + None + } +} + +pub struct EndLog; +#[async_trait] +impl VoiceEventHandler for EndLog { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + tracing::info!("End of track"); + None + } +} +pub struct StartLog; + +#[async_trait] +impl VoiceEventHandler for StartLog { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + tracing::info!("Starting track"); + None + } +} \ No newline at end of file diff --git a/crates/mockingbird/src/lib.rs b/crates/mockingbird/src/lib.rs index e0dfebf..5ac1fd9 100644 --- a/crates/mockingbird/src/lib.rs +++ b/crates/mockingbird/src/lib.rs @@ -5,6 +5,13 @@ pub mod player; #[cfg(feature = "deemix")] pub mod deemix; +pub mod events; +pub mod models; +pub mod controller; +pub mod radio; +pub mod compat; +pub mod usersettoken; +pub mod crossfade; // #[cfg(feature = "http-get")] // pub mod httpget; @@ -20,11 +27,11 @@ pub async fn init(mut cfg: ClientBuilder) -> ClientBuilder { tracing::info!("Mockingbird initializing..."); use songbird::SerenityInit; - #[cfg(feature = "controller")] { use std::collections::HashMap; - cfg = cfg.type_map_insert::(HashMap::new()); + cfg = cfg.type_map_insert::(HashMap::new()); + } cfg.register_songbird() diff --git a/crates/mockingbird/src/models.rs b/crates/mockingbird/src/models.rs new file mode 100644 index 0000000..06b7266 --- /dev/null +++ b/crates/mockingbird/src/models.rs @@ -0,0 +1,208 @@ +// this is the rat nest +// be prepared +// to see how lazy i can be. +use serenity::{ + client::Cache, + http::Http, model::prelude::*, prelude::*, +}; + +use songbird::{ + input::{ + error::Error as SongbirdError, Metadata + }, tracks::TrackHandle, Songbird, +}; + +use std::{ + time::{Duration, Instant}, + collections::VecDeque, + sync::Arc, + collections::HashMap, + path::PathBuf, +}; + +use std::sync::atomic::AtomicBool; +use parking_lot::Mutex; + +use songbird::input::cached::Compressed; + + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum EventEnd { + Skipped, + Finished, + UnMarked +} + +pub type LazyQueue = HashMap>; +pub struct LazyQueueKey; +impl TypeMapKey for LazyQueueKey { + type Value = LazyQueue; +} + +#[derive(Debug, Clone)] +pub struct TrackRecord { + // keep this for spotify recommendations + pub metadata: MetadataType, + pub stop_event: EventEnd, + pub start: Instant, + pub end: Instant, +} + +pub struct ColdQueue { + pub queue: VecDeque, + pub has_played: VecDeque, + pub use_radio: bool, + pub queue_next: Option<(Compressed, Option)>, + pub crossfade_lhs: Option, + pub crossfade_rhs: Option, + // urls + pub radio_queue: VecDeque, + pub radio_next: Option<(Compressed, Option)>, +} + +struct GuildConfig { + pub crossfade: bool, + pub use_radio: bool, +} + +pub struct QueueContext { + pub guild_id: GuildId, + pub invited_from: ChannelId, + pub voice_chan_id: GuildChannel, + pub cache: Arc, + pub data: Arc>, + pub http: Arc, + pub manager: Arc, + pub cold_queue: Arc>, + pub crossfade: AtomicBool, + pub crossfade_step: Mutex +} + +#[derive(Debug, Clone)] +pub enum MetadataType { + #[cfg(feature = "deemix")] + Deemix(crate::deemix::DeemixMetadata), + Disk(PathBuf), + Standard(Metadata), +} + +impl From for MetadataType { + fn from(meta: Metadata) -> Self { + Self::Standard(meta) + } +} + +impl Into for MetadataType { + fn into(self) -> Metadata { + match self { + Self::Standard(meta) => meta, + + #[cfg(feature = "deemix")] + Self::Deemix(meta) => meta.into(), + + Self::Disk(fp) => Metadata { source_url: fp.into_os_string().into_string().ok(), ..Default::default() }, + } + } +} + +#[cfg(feature = "deemix")] +impl From for MetadataType { + fn from(meta: crate::deemix::DeemixMetadata) -> Self { + Self::Deemix(meta) + } +} + + +#[allow(unused_variables)] +#[derive(Debug)] +pub enum HandlerError { + Songbird(SongbirdError), + IOError(std::io::Error), + Serenity(serenity::Error), + + #[cfg(feature = "http-get")] + Reqwest(reqwest::Error), + + #[cfg(feature = "http-get")] + UnsupportedMediaType(String), + + #[cfg(feature = "deemix")] + DeemixError(crate::deemix::DeemixError), + + WrongMetadataType, + + CrossFadeHandleExhaust, + + NotImplemented, + NoCall +} + +impl From for HandlerError { + fn from(err: serenity::Error) -> Self { + HandlerError::Serenity(err) + } +} + +impl From for HandlerError { + fn from(err: SongbirdError) -> Self { + HandlerError::Songbird(err) + } +} + +impl From for HandlerError { + fn from(err: std::io::Error) -> Self { + HandlerError::IOError(err) + } +} + +#[cfg(feature = "http-get")] +impl From for HandlerError { + fn from(err: reqwest::Error) -> Self { + HandlerError::Reqwest(err) + } +} + +#[cfg(feature = "deemix")] +impl From for HandlerError { + fn from(err: crate::deemix::DeemixError) -> Self { + HandlerError::DeemixError(err) + } +} + +impl std::fmt::Display for HandlerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Songbird(err) => write!(f, "Songbird error: {}", err), + Self::NotImplemented => write!(f, "This feature is not implemented."), + + Self::IOError(err) + => write!(f, "IO error: (most likely deemix-metadata failed) {}", err), + + Self::Serenity(err) + => write!(f, "Serenity error: {}", err), + + Self::NoCall + => write!(f, "Not in a voice channel to play in"), + + Self::CrossFadeHandleExhaust => write!(f, "Crossfade handle exhausted"), + + Self::WrongMetadataType + => write!(f, "Programming bug, got a different MetadataType than expected"), + + #[cfg(feature = "http-get")] + Self::UnsupportedMediaType(content_type) + => write!(f, "Content type is not supported [{}]", content_type), + + #[cfg(feature = "http-get")] + Self::Reqwest(err) + => write!(f, "Reqwest error: {}", err), + + #[cfg(feature = "deemix")] + Self::DeemixError(crate::deemix::DeemixError::BadJson(err)) + => write!(f, "Deemix error: {}", err), + + _ => write!(f, "Unknown error") + } + } +} +impl std::error::Error for HandlerError {} diff --git a/crates/mockingbird/src/player.rs b/crates/mockingbird/src/player.rs index 0817846..f3233c2 100644 --- a/crates/mockingbird/src/player.rs +++ b/crates/mockingbird/src/player.rs @@ -1,565 +1,39 @@ use serenity::{ - async_trait, - model::channel::Message, - framework::standard::{ - macros::{command, group}, - CommandResult, Args, - }, - client::Cache, - prelude::*, - model::prelude::*, http::Http, json + model::{channel::Message, prelude::*}, + prelude::*, }; use songbird::{ - error::{JoinResult, JoinError}, - events::{Event, EventContext}, - EventHandler as VoiceEventHandler, - Songbird, - Call, create_player, - input::{ - Input, - error::Error as SongbirdError, - Metadata, - }, - tracks::TrackHandle, - TrackEvent + error::{JoinError, JoinResult}, + events::Event, +input::Input, + tracks::{Track, TrackHandle}, Call, + Songbird, TrackEvent }; use std::{ - process::Stdio, time::{Duration, Instant}, collections::VecDeque, sync::Arc, - collections::HashMap, path::PathBuf, }; -use tokio::{ - io::AsyncBufReadExt, - process::Command, - -}; +use std::sync::atomic::AtomicBool; +use parking_lot::{Mutex}; use tokio::io::AsyncWriteExt; use serenity::futures::StreamExt; -use songbird::input::cached::Compressed; -use std::sync::{Mutex}; - - -use cutils::{availbytes, bigpipe, max_pipe_size}; - -#[cfg(feature = "deemix")] -use crate::deemix::{DeemixMetadata, _deemix}; - -#[group] -#[commands(join, leave, queue, now_playing, skip, list)] -pub struct BetterPlayer; - -#[group] -#[commands(seed, radio)] -pub struct Radio; +use core::sync::atomic::Ordering; +use crate::models::*; +use crate::compat::*; const TS_PRELOAD_OFFSET: Duration = Duration::from_secs(20); +const TS_CROSSFADE_OFFSET: Duration = Duration::from_secs(11); const TS_ABANDONED_HB: Duration = Duration::from_secs(720); const HASPLAYED_MAX_LEN: usize = 10; -struct DeemixPreloadCache; - -impl TypeMapKey for DeemixPreloadCache { - type Value = Arc>>; -} - -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -enum EventEnd { - Skipped, - Finished, - UnMarked -} - -type LazyQueue = HashMap>; -pub struct LazyQueueKey; -impl TypeMapKey for LazyQueueKey { - type Value = LazyQueue; -} - -#[derive(Debug, Clone)] -struct TrackRecord { - // keep this for spotify recommendations - metadata: MetadataType, - stop_event: EventEnd, - start: Instant, - end: Instant, -} - -struct ColdQueue { - pub queue: VecDeque, - pub has_played: VecDeque, - pub use_radio: bool, - // urls - pub radio_queue: VecDeque, - pub radio_next: Option<(Compressed, Option)>, -} - -pub struct QueueContext { - crossfade: Duration, - guild_id: GuildId, - invited_from: ChannelId, - voice_chan_id: GuildChannel, - cache: Arc, - data: Arc>, - http: Arc, - manager: Arc, - cold_queue: Arc>, -} - -#[derive(Debug, Clone)] -enum MetadataType { - #[cfg(feature = "deemix")] - Deemix(crate::deemix::DeemixMetadata), - - Standard(Metadata), -} - -impl From for MetadataType { - fn from(meta: Metadata) -> Self { - Self::Standard(meta) - } -} - -impl Into for MetadataType { - fn into(self) -> Metadata { - match self { - Self::Standard(meta) => meta, - - #[cfg(feature = "deemix")] - Self::Deemix(meta) => meta.into() - } - } -} - -#[cfg(feature = "deemix")] -impl From for MetadataType { - fn from(meta: crate::deemix::DeemixMetadata) -> Self { - Self::Deemix(meta) - } -} - -async fn seed_from_history(has_played: &VecDeque) -> std::io::Result> { - let seeds = - has_played - .iter() - // Don't include skipped tracks - .filter(|x| x.stop_event != EventEnd::Skipped) - .filter_map(|x| - match &x.metadata { - MetadataType::Deemix(meta) => meta.isrc.clone(), - _ => None - }) - .collect::>(); - - - if seeds.is_empty() { - return Ok(seeds.into()); - } - - return recommend(&seeds, 5).await; - -} - -async fn preload_radio_track( - cold_queue: &mut ColdQueue -) -> Result<(), String> { - // pop seeds in radio - let mut tries = 5; - // attempts/tries loop - loop { - let uri = match cold_queue.radio_queue.pop_front() { - Some(x) => Some(x), - None => { - cold_queue.radio_queue.clear(); - cold_queue.radio_queue.extend(seed_from_history(&cold_queue.has_played).await.unwrap_or_else(|_| VecDeque::new())); - cold_queue.radio_queue.pop_front() - } - }; - - if let Some(uri) = uri { - match _deemix(&uri, &[], false).await { - Ok((preload_input, metadata)) => { - cold_queue.radio_next = Some((Compressed::new( - preload_input, - songbird::driver::Bitrate::BitsPerSecond(128_000) - ).unwrap(), - - metadata.map(|x| x.into()) - )); - return Ok(()) - } - - Err(why) => { - tries -= 1; - tracing::error!("Error preloading radio track: {}", why); - if 0 >= tries { - return Err("Exceeded max tries".to_string()); - } - continue - } - } - } - return Err("Fall through".to_string()); - } -} - -async fn play_preload_radio_track( - call: &mut Call, - radio_preload: Compressed, - metadata: Option, - qctx: Arc -) -{ - let preload_result = Players::play_preload(call, radio_preload.new_handle().into(), metadata).await; - match preload_result { - Err(why) =>{ - tracing::error!("Failed to play radio track: {}", why); - } - Ok((handle, _)) => handle.add_event( - Event::Delayed( - handle.metadata() - .duration - .unwrap() - - TS_PRELOAD_OFFSET - ), - PreemptLoader(qctx.clone()), - ).unwrap() - } -} - -struct TrackEndLoader(Arc); - -#[async_trait] -impl VoiceEventHandler for TrackEndLoader { - async fn act(&self, _ctx: &EventContext<'_>) -> Option { - if let Some(call) = self.0.manager.get(self.0.guild_id) { - let mut call = call.lock().await; - let mut cold_queue = self.0.cold_queue.write().await; - - // `PreemptLoader` may have placed a track (from the user queue) - // before this event was fired. - // If true, we clear our trackers. - if let Some(_current_track_handle) = call.queue().current() { - // do nothing - } - - // `PreemptLoader` has not placed anything, - // lets fire it's routine on our thread. - else if let Ok(true) = user_queue_routine(&mut call, &mut cold_queue, self.0.clone()).await { - // do nothing. - } - - // If all else fails, play the preloaded track on radio - else if cold_queue.use_radio { - // if the user queue is empty, try the preloaded radio track - if let Some((radio_preload, metadata)) = cold_queue.radio_next.take() { - play_preload_radio_track(&mut call, radio_preload, metadata, self.0.clone()).await; - let _ = preload_radio_track(&mut cold_queue).await; - return None; - } - } - - cold_queue.radio_next = None; - let _ = preload_radio_track(&mut cold_queue).await; - } - None - } -} - -struct AbandonedChannel(Arc); -#[async_trait] -impl VoiceEventHandler for AbandonedChannel { - async fn act(&self, _ctx: &EventContext<'_>) -> Option { - let members = self.0.voice_chan_id.members(&self.0.cache).await.unwrap(); - if members.iter().filter(|x| !x.user.bot).count() > 0 { - return None; - } - - leave_routine( - self.0.data.clone(), - self.0.guild_id.clone(), - self.0.manager.clone() - ).await.unwrap(); - - Some(Event::Cancel) - } -} - -struct PreemptLoader(Arc); -#[async_trait] -impl VoiceEventHandler for PreemptLoader { - async fn act(&self, _ctx: &EventContext<'_>) -> Option { - if let Some(call) = self.0.manager.get(self.0.guild_id) { - let mut call = call.lock().await; - let mut cold_queue = self.0.cold_queue.write().await; - let _ = user_queue_routine(&mut call, &mut cold_queue, self.0.clone()).await; - } - None - } -} - -#[allow(unused_variables)] -#[derive(Debug)] -pub enum HandlerError { - Songbird(SongbirdError), - IOError(std::io::Error), - Serenity(serenity::Error), - - #[cfg(feature = "http-get")] - Reqwest(reqwest::Error), - - #[cfg(feature = "http-get")] - UnsupportedMediaType(String), - - #[cfg(feature = "deemix")] - DeemixError(crate::deemix::DeemixError), - - NotImplemented, - NoCall -} - -impl From for HandlerError { - fn from(err: serenity::Error) -> Self { - HandlerError::Serenity(err) - } -} - -impl From for HandlerError { - fn from(err: SongbirdError) -> Self { - HandlerError::Songbird(err) - } -} - -impl From for HandlerError { - fn from(err: std::io::Error) -> Self { - HandlerError::IOError(err) - } -} - -#[cfg(feature = "http-get")] -impl From for HandlerError { - fn from(err: reqwest::Error) -> Self { - HandlerError::Reqwest(err) - } -} - -#[cfg(feature = "deemix")] -impl From for HandlerError { - fn from(err: crate::deemix::DeemixError) -> Self { - HandlerError::DeemixError(err) - } -} - -impl std::fmt::Display for HandlerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Songbird(err) => write!(f, "Songbird error: {}", err), - Self::NotImplemented => write!(f, "This feature is not implemented."), - - Self::IOError(err) - => write!(f, "IO error: (most likely deemix-metadata failed) {}", err), - - Self::Serenity(err) - => write!(f, "Serenity error: {}", err), - - Self::NoCall - => write!(f, "Not in a voice channel to play in"), - - #[cfg(feature = "http-get")] - Self::UnsupportedMediaType(content_type) - => write!(f, "Content type is not supported [{}]", content_type), - - #[cfg(feature = "http-get")] - Self::Reqwest(err) - => write!(f, "Reqwest error: {}", err), - - #[cfg(feature = "deemix")] - Self::DeemixError(crate::deemix::DeemixError::BadJson(err)) - => write!(f, "Deemix error: {}", err), - - _ => write!(f, "Unknown error") - } - } -} -impl std::error::Error for HandlerError {} - -fn process_fan_output(buf: &mut VecDeque, json_buf: Vec, err_cnt: &mut usize, key: &str){ - for x in json_buf { - if let Some(jmap) = x.as_object() { - if !jmap.contains_key(key) { - tracing::error!("{} not found in json", key); - *err_cnt += 1; - continue - } - - buf.push_back(jmap[key].as_str().unwrap().to_owned()); - } - else { - - tracing::error!("{} not found in json", key); - *err_cnt += 1; - continue - } - } - tracing::info!("{} tracks found", buf.len()); -} - -/* - * Some ugly place holders for - * feature generated code. -*/ -#[cfg(feature="deemix")] -async fn fan_deezer(uri: &str, buf: &mut VecDeque) -> Result { - let mut json_buf = Vec::new(); - let mut err_cnt = 0; - _urls("deemix-metadata", &[uri], &mut json_buf).await?; - - process_fan_output(buf, json_buf, &mut err_cnt, "link"); - Ok(err_cnt) -} - -#[cfg(feature="ytdl")] -async fn fan_ytdl(uri: &str, buf: &mut VecDeque) -> Result { - let mut json_buf = Vec::new(); - let mut err_cnt = 0; - _urls("yt-dlp", &["--flat-playlist", "-j", uri], &mut json_buf).await?; - - process_fan_output(buf, json_buf, &mut err_cnt, "url"); - Ok(err_cnt) -} - -#[cfg(not(feature="deemix"))] -async fn fan_deezer(uri: &str, buf: &mut VecDeque) -> Result { - return Err(HandlerError::NotImplemented) -} - -#[cfg(not(feature="ytdl"))] -async fn fan_ytdl(_uri: &str, _buf: &mut VecDeque) -> Result { - return Err(HandlerError::NotImplemented) -} - -#[cfg(feature = "http-get")] -async fn ph_httpget_player( - uri: &str, - guild_id: u64, -) -> (PathBuf, Result) { - tracing::info!("[HTTP-GET] Downloading: {}", uri); - - // let fp = tempfile::tempfile()?; - use rand::Rng; - let id: String = (0..12) - .map(|_| char::from(rand::thread_rng().gen_range(97..123))) - .collect(); - - let fp = std::env::temp_dir() - .join("coggiebot") - .join(guild_id.to_string()); - - match tokio::fs::create_dir_all(&fp).await { - Ok(_) => {} - Err(e) => { - tracing::error!("Failed to create temp dir: {}", e); - return (fp, Err(HandlerError::IOError(e))); - } - } - let fp = fp.join(format!("{}", id)); - - (fp.clone(), get_file(uri, guild_id, &fp).await.map_err(HandlerError::from)) -} - -#[cfg(feature = "deemix")] -async fn ph_deemix_player(uri: &str) -> Result<(Input, Option), HandlerError> { - crate::deemix::deemix(uri).await - .map_err(HandlerError::from) - .map(|(input, meta)| (input, meta.map(|x| x.into()))) - } - -#[cfg(feature = "ytdl")] -async fn ph_ytdl_player(uri: &str) -> Result<(Input, Option), HandlerError> { - return songbird::ytdl(uri).await.map_err(HandlerError::from) - .map(|input| (input, None)) -} - -#[cfg(not(feature = "deemix"))] -struct FakeMeta(Metadata); - -#[cfg(not(feature = "deemix"))] -impl Into for FakeMeta { - fn into(self) -> Metadata { - self.0 - } -} - -#[cfg(not(feature = "deemix"))] -async fn ph_deemix_player(uri: &str) -> Result<(Input, Option), HandlerError> { - return Err(HandlerError::NotImplemented) -} - -#[cfg(not(feature = "ytdl"))] -async fn ph_ytdl_player(_uri: &str) -> Result<(Input, Option), HandlerError> { - return Err(HandlerError::NotImplemented) -} - -#[cfg(not(feature = "http-get"))] -async fn ph_httpget_player(_uri: &str) -> (PathBuf, Result) { - return (PathBuf::new(), Err(HandlerError::NotImplemented)) -} - -async fn _urls(cmd: &str, args: &[&str], buf: &mut Vec) -> std::io::Result<()> { - let child = Command::new(cmd) - .args(args) - .stdout(Stdio::piped()) - .spawn() - .unwrap(); - - let stdout = child.wait_with_output().await.unwrap(); - let mut lines = stdout.stdout.lines(); - - while let Some(line) = lines.next_line().await? { - let json = - serde_json::from_str(&line).unwrap(); - buf.push(json); - } - Ok(()) -} - -async fn recommend(isrcs: &Vec, limit: u8) -> std::io::Result> { - let mut buffer = std::collections::HashSet::new(); - - tracing::info!("running spotify-recommend -l {} {}", limit, isrcs.join(" ")); - let recommend = tokio::process::Command::new("spotify-recommend") - .arg("-l") - .arg(format!("{}", limit)) - .args(isrcs.iter()) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - - let output = recommend.wait_with_output() - .await?; - - let mut lines = output.stdout.lines(); - - while let Some(x) = lines.next_line().await? { - buffer.insert(x); - } - tracing::info!("spotify-stream finished [{}]", buffer.len()); - let mut ret = VecDeque::new(); - for x in buffer { - ret.push_back(x); - } - Ok(ret) -} - #[derive(PartialEq, Eq)] pub enum Players { Ytdl, @@ -568,7 +42,7 @@ pub enum Players { } impl Players { - fn from_str(data : &str) -> Option + pub fn from_str(data : &str) -> Option { const DEEMIX: [&'static str; 4] = ["deezer.page.link", "deezer.com", "open.spotify", "spotify.link"]; const YTDL: [&'static str; 4] = ["youtube.com", "youtu.be", "music.youtube.com", "soundcloud.com"]; @@ -584,54 +58,62 @@ impl Players { else { return None } } - async fn play(&self, handler: &mut Call, uri: &str, guild_id: GuildId) -> Result<(TrackHandle, Option), HandlerError> + pub async fn into_input(&self, uri: &str, guild_id: GuildId) -> Result<(Input, Option), HandlerError> { - let (input, metadata) = match self { + match self { Self::Deemix => ph_deemix_player(uri).await, Self::Ytdl => ph_ytdl_player(uri).await, Self::HttpGet => { - let (fp, result) = ph_httpget_player(uri, guild_id.0).await; - + let mut pathbuf = PathBuf::new(); + let result = ph_httpget_player(uri, guild_id.0, &mut pathbuf).await; match result { - Ok(input) => { - let (_track, track_handle) = create_player(input); - let _ = track_handle.add_event(Event::Track(TrackEvent::End), RemoveTempFile(fp)); - // TODO FIXME ADD METADATA - return Ok((track_handle, None)) + Ok((input, metadata)) => { + // let (_track, track_handle) = create_player(input); + // let fp = match metadata { + // Some(MetadataType::Disk(fp)) => fp, + // _ => { return Err(HandlerError::WrongMetadataType) } + // }; + + // let _ = track_handle.add_event(Event::Track(TrackEvent::End), RemoveTempFile(fp)); + + // // TODO FIXME ADD METADATA + return Ok((input, metadata)) } + Err(e) => { - if let Ok(true) = tokio::fs::try_exists(&fp).await { - let _ = tokio::fs::remove_file(&fp).await; - } - // TODO FIXME ADD METADATA + // cleanup(fp) + // TODO FIXME return Err(e) } } } - }?; - - - let (track, track_handle) = create_player(input); - handler.enqueue(track); - - Ok((track_handle, metadata)) + } } - async fn play_preload( - handler: &mut Call, - preload: Input, // &mut Vec, - metadata: Option - ) - -> Result<(TrackHandle, Option), HandlerError> + /// turn a uri into a loaded process + pub async fn create_player(&self, uri: &str, guild_id: GuildId) -> Result<(Track, TrackHandle, Option), HandlerError> { - let (track, track_handle) = create_player(preload); - handler.enqueue(track); - Ok((track_handle, metadata - //TODO: FIXME!: preload.metadata.map(|x| x.into()) - )) + let input = self.into_input(uri, guild_id).await; + match input { + Ok((input, metadata)) => { + let (track, track_handle) = create_player(input); + // (track, track_handle, metadata) + match (self, metadata.as_ref()) { + #[cfg(feature = "http-get")] + (Self::HttpGet, Some(MetadataType::Disk(fp))) => { + let _ = track_handle.add_event(Event::Track(TrackEvent::End), crate::events::RemoveTempFile(fp.clone())); + } + (Self::HttpGet, _) => return Err(HandlerError::WrongMetadataType), + _ => {} + + } + return Ok((track, track_handle, metadata)) + } + Err(e) => return Err(e) + }; } - async fn fan_collection(&self, uri: &str) -> Result, HandlerError> { + pub async fn fan_collection(&self, uri: &str) -> Result, HandlerError> { let mut buf = VecDeque::new(); match self { Self::HttpGet => {buf.push_back(uri.to_owned()); Ok(1)}, @@ -643,6 +125,54 @@ impl Players { } } +pub async fn play( + call: &mut Call, + mut track: Track, + handle: &TrackHandle, + cold_queue: &mut ColdQueue, + crossfade: bool, +) -> Result<(), HandlerError> +{ + + if ! crossfade { + call.enqueue(track); + tracing::info!("playing track with builtin-queue"); + return Ok(()); + } + + track.pause(); + call.play(track); + tracing::info!("playing track with crossfading"); + + match (cold_queue.crossfade_lhs.take(), cold_queue.crossfade_rhs.take()) { + (Some(lhs), Some(rhs)) => { + cold_queue.crossfade_lhs = Some(lhs); + cold_queue.crossfade_rhs = Some(rhs); + return Err(HandlerError::CrossFadeHandleExhaust); + } + + (Some(lhs), None) => { + cold_queue.crossfade_lhs = Some(lhs); + // let _ = handle.make_playable(); + cold_queue.crossfade_rhs = Some(handle.clone()); + } + + (None, None) => { + // let _ = handle.make_playable(); + let _ = handle.play(); + cold_queue.crossfade_lhs = Some(handle.clone()); + } + (None, Some(rhs)) => { + cold_queue.crossfade_lhs = Some(rhs); + + // let _ = handle.make_playable(); + cold_queue.crossfade_rhs = Some(handle.clone()); + } + } + + Ok(()) +} + #[cfg(feature = "http-get")] pub fn human_filesize(n: u64) -> String { let base: u64 = 1024; @@ -660,7 +190,6 @@ pub async fn get_file( fp: &PathBuf, // key: [u8; 16] ) -> Result { - use songbird::input::Metadata; let client = reqwest::ClientBuilder::new() .https_only(false) @@ -707,79 +236,112 @@ pub async fn get_file( } } -struct RemoveTempFile(PathBuf); -#[async_trait] -impl VoiceEventHandler for RemoveTempFile { - async fn act(&self, _ctx: &EventContext<'_>) -> Option { - let _ = tokio::fs::remove_file(&self.0).await; - None +async fn add_events(handle: &TrackHandle, qctx_arc: Arc, crossfading: bool) +{ + if let Some(duration) = handle.metadata().duration { + if duration < TS_PRELOAD_OFFSET { + tracing::warn!("No duration provided, preloading disabled"); + } + tracing::info!("Preload Event Added from Duration"); + + handle.add_event( + Event::Delayed(duration - TS_PRELOAD_OFFSET), + crate::events::PreloadInvoker::new(qctx_arc.clone()) + ).unwrap(); + + if crossfading { + tracing::info!("CrossFade Event Added from Duration"); + + handle.add_event( + Event::Delayed(duration - TS_CROSSFADE_OFFSET), + crate::crossfade::CrossFadeInvoker(qctx_arc.clone()) + ).unwrap(); + } + } + + else { + tracing::warn!("No duration provided, preloading disabled"); + if qctx_arc.crossfade.load(Ordering::Relaxed) { + tracing::warn!("No duration provided, crossfade disabled"); + } } } -async fn user_queue_routine( - call: &mut Call, - cold_queue: &mut ColdQueue, - qctx_arc: Arc -) -> Result { - let mut tries = 4; +async fn history_completed_track(has_played: &mut VecDeque, metadata: MetadataType) { + if has_played.len() > HASPLAYED_MAX_LEN { + let _ = has_played.pop_back(); + } + // --- START + // This portion of code marks songs as finished or not. + // Under normal circumstances, this would be placed on the "EndTrack" + // Event. It also happens that pausing, skipping, and leaving + // all cause this event to fire. + // So instead, its placed here to avoid those. + if let Some(x) = has_played.front_mut() { + if let EventEnd::UnMarked = x.stop_event { + x.stop_event = EventEnd::Finished; + x.end = Instant::now(); + } + } - while let Some(uri) = cold_queue.queue.pop_front() { - tracing::info!("Now playing: {}", uri); - let player = Players::from_str(&uri) - .ok_or_else(|| HandlerError::NotImplemented)?; + let data = TrackRecord { + metadata, + stop_event: EventEnd::UnMarked, + start: Instant::now(), + end: Instant::now(), + }; - match player.play(call, &uri, qctx_arc.guild_id).await { - Ok((track, metadata)) => { - if let Some(duration) = track.metadata().duration { - if duration < TS_PRELOAD_OFFSET { - tracing::warn!("No duration provided, preloading disabled"); - break - } + has_played.push_front(data); +} - tracing::info!("Preload Event Added from Duration"); - track.add_event( - Event::Delayed(duration - TS_PRELOAD_OFFSET), - PreemptLoader(qctx_arc.clone()) - ).unwrap(); - } +pub async fn next_track_handle( + cold_queue: &mut ColdQueue, + qctx: Arc, + crossfade: bool +) -> Result)>, HandlerError> +{ + if let Some((preload, metadata)) = cold_queue.queue_next.take() { + tracing::info!("Pulling track from user-preload"); + let (track, handle) = create_player(preload.into()); + add_events(&handle, qctx.clone(), crossfade).await; + Ok(Some((track, handle, metadata))) + } - if cold_queue.has_played.len() > HASPLAYED_MAX_LEN { - let _ = cold_queue.has_played.pop_back(); - } + else if let Ok(Some((track, handle, metadata))) = invoke_cold_queue(cold_queue, qctx.clone()).await { + tracing::info!("Pulling track from user-queue"); + add_events(&handle, qctx.clone(), crossfade).await; - // --- START - // This portion of code marks songs as finished or not. - // Under normal circumstances, this would be placed on the "EndTrack" - // Event. It also happens that pausing, skipping, and leaving - // all cause this event to fire. - // So instead, its placed here to avoid those. - if let Some(x) = cold_queue.has_played.front_mut() { - if let EventEnd::UnMarked = x.stop_event { - x.stop_event = EventEnd::Finished; - x.end = Instant::now(); - } - } + Ok(Some((track, handle, metadata))) + } - let data = TrackRecord { - metadata: metadata.unwrap_or(MetadataType::from(track.metadata().clone())), - stop_event: EventEnd::UnMarked, - start: Instant::now(), - end: Instant::now(), - }; + else if cold_queue.use_radio { + if let Some((radio_preload, metadata)) = cold_queue.radio_next.take() { + tracing::info!("Pulling track from radio"); + let (track, handle) = create_player(radio_preload.into()); + add_events(&handle, qctx.clone(), crossfade).await; + Ok(Some((track, handle, metadata))) + } + else { Ok(None) } + } + else { Ok(None) } +} - cold_queue.has_played.push_front(data); - // --- END +pub async fn invoke_cold_queue( + cold_queue: &mut ColdQueue, + qctx_arc: Arc +) -> Result)>, HandlerError> { + let mut tries = 4; - // Preemptively load the next audio track - // `TS_PRELOAD_OFFSET` seconds before this `track` - // ends. - track.add_event( - Event::Delayed(track.metadata().duration.unwrap() - TS_PRELOAD_OFFSET), - PreemptLoader(qctx_arc) - ).unwrap(); + while let Some(uri) = cold_queue.queue.pop_front() { + tracing::info!("Now playing: {}", uri); + let player = Players::from_str(&uri) + .ok_or_else(|| HandlerError::NotImplemented)?; - return Ok(true); - }, + // turn realization to live + match player.create_player(&uri, qctx_arc.guild_id).await + { + Ok((track, handle, metadata)) => + return Ok(Some((track, handle, metadata))), Err(e) => { tracing::error!("Failed to play next track: {}", e); @@ -830,11 +392,11 @@ async fn user_queue_routine( tries -= 1; } } - } - Ok(false) + } + Ok(None) } -async fn leave_routine ( +pub async fn leave_routine ( data: Arc>, guild_id: GuildId, manager: Arc @@ -860,7 +422,7 @@ async fn leave_routine ( Ok(()) } -async fn join_routine(ctx: &Context, msg: &Message) -> Result, JoinError> { +pub async fn join_routine(ctx: &Context, msg: &Message) -> Result, JoinError> { let guild = msg.guild(&ctx.cache).unwrap(); let guild_id = guild.id; @@ -898,7 +460,8 @@ async fn join_routine(ctx: &Context, msg: &Message) -> Result, } }; - match gchan.bitrate { + match gchan.bitrate + { Some(x) if x > 90_000 => {} None => { tracing::info!( @@ -951,7 +514,7 @@ async fn join_routine(ctx: &Context, msg: &Message) -> Result, QueueContext { guild_id, voice_chan_id, - crossfade: Duration::from_secs(0), + crossfade: AtomicBool::new(false), invited_from: msg.channel_id, cache: ctx.cache.clone(), data: ctx.data.clone(), @@ -961,9 +524,13 @@ async fn join_routine(ctx: &Context, msg: &Message) -> Result, queue: VecDeque::new(), has_played: VecDeque::new(), use_radio: false, + queue_next: None, //TODO: implement me radio_next: None, radio_queue: VecDeque::new(), + crossfade_lhs: None, + crossfade_rhs: None, })), + crossfade_step: Mutex::new(1), } } else { tracing::error!("Expected voice channel (GuildChannel), got {:?}", chan); @@ -984,600 +551,23 @@ async fn join_routine(ctx: &Context, msg: &Message) -> Result, call.add_global_event( Event::Track(TrackEvent::End), - TrackEndLoader(queuectx.clone()) + crate::radio::RadioInvoker::new(queuectx.clone()) ); call.add_global_event( - Event::Periodic(TS_ABANDONED_HB, None), - AbandonedChannel(queuectx.clone()) - ); - - Ok(queuectx) -} - -#[command] -#[aliases("np", "playing", "now-playing", "playing-now", "nowplaying")] -#[only_in(guilds)] -async fn now_playing(ctx: &Context, msg: &Message) -> CommandResult { - let guild = msg.guild(&ctx.cache).unwrap(); - let guild_id = guild.id; - - tracing::info!( - "[{}::{}] asked what track is playing in [{}::{:?}]", - msg.author.id, msg.author.name, - msg.channel_id, msg.channel_id.name(&ctx).await - ); - - - let qctx = { - let mut glob = ctx.data.write().await; - let queue = glob.get_mut::() - .expect("Expected LazyQueueKey in TypeMap"); - queue.get(&guild_id).cloned() - }; - - let qctx = match qctx { - Some(qctx) => qctx, - None => { - msg.channel_id - .say(&ctx.http, "Not in a voice channel") - .await?; - return Ok(()); - } - }; - - let call_lock = qctx.manager - .get(qctx.guild_id) - .unwrap(); - - let call = call_lock.lock().await; - - match call.queue().current() { - Some(ref x) => { - msg.channel_id - .say(&ctx.http, - format!( - "{}: {}", qctx.voice_chan_id.mention(), - x.metadata() - .clone() - .source_url - .unwrap_or("Unknown".to_string()) - ) - ).await?; - } - None => { - msg.channel_id - .say(&ctx.http, "Nothing is currently playing") - .await?; - } - } - - Ok(()) -} - -#[command] -#[only_in(guilds)] -async fn join(ctx: &Context, msg: &Message) -> CommandResult { - let connect_to = join_routine(&ctx, msg).await; - - if let Err(ref e) = connect_to { - msg.channel_id - .say(&ctx.http, format!("Failed to join voice channel: {:?}", e)) - .await?; - } - - msg.channel_id - .say(&ctx.http, format!("Joined {}", connect_to.unwrap().voice_chan_id.mention())) - .await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -async fn leave(ctx: &Context, msg: &Message) -> CommandResult { - let guild = msg.guild(&ctx.cache).unwrap(); - let guild_id = guild.id; - - let manager = songbird::get(ctx) - .await - .expect("songbird voice client placed in at initialisation.") - .clone(); - - let handler = manager.get(guild_id); - - if handler.is_none() { - msg.reply(ctx, "Not in a voice channel").await?; - return Ok(()) - } - - let handler = handler.unwrap(); - - { - let mut call = handler.lock().await; - call.remove_all_global_events(); - call.stop(); - let _ = call.deafen(false).await; - } - - if let Err(e) = manager.remove(guild_id).await { - msg.channel_id - .say(&ctx.http, format!("Failed: {:?}", e)) - .await?; - } - - { - let mut glob = ctx.data.write().await; - let queue = glob.get_mut::().expect("Expected LazyQueueKey in TypeMap"); - queue.remove(&guild_id); - } - - msg.channel_id.say(&ctx.http, "Left voice channel").await?; - Ok(()) -} - -#[command] -#[aliases("play", "p", "q")] -#[only_in(guilds)] -async fn queue(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - tracing::info!( - "[{}::{}] queued track in [{}::{:?}]", - msg.author.id, msg.author.name, - msg.channel_id, msg.channel_id.name(&ctx).await - ); - - let url = match args.single::() { - Ok(url) => url, - Err(_) => { - msg.channel_id - .say(&ctx.http, "Must provide a URL to a video or audio") - .await - .unwrap(); - return Ok(()); - }, - }; - - if !url.starts_with("http") { - msg.channel_id - .say(&ctx.http, "Must provide a valid URL") - .await - .unwrap(); - return Ok(()); - }; - - let guild = msg.guild(&ctx.cache).unwrap(); - let guild_id = guild.id; - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let qctx: Arc; - - // grab the call object from guild ID. - let call = match manager.get(guild_id) { - Some(call_lock) => { - qctx = ctx.data.write() - .await - .get_mut::() - .unwrap() - .get_mut(&guild_id) - .unwrap() - .clone(); - - call_lock - }, - - None => { - // Join the VC the user is in, - // then try again. - let tmp = join_routine(ctx, msg).await; - - if let Err(ref e) = tmp { - msg.channel_id - .say(&ctx.http, format!("Failed to join voice channel: {:?}", e)) - .await - .unwrap(); - return Ok(()); - }; - qctx = tmp.unwrap(); - msg.channel_id - .say(&ctx.http, format!("Joined: {}", qctx.voice_chan_id.mention())) - .await - .unwrap(); - - let call = manager.get(guild_id).ok_or_else(|| JoinError::NoCall); - call? - } - }; - - match Players::from_str(&url) - .ok_or_else(|| String::from("Failed to select extractor for URL")) - { - Ok(player) => { - let mut uris = player.fan_collection(url.as_str()).await?; - let added = uris.len(); - - // YTDLP singles don't work. - // so instead, use the original URI. - if uris.len() == 1 && player == Players::Ytdl { - uris.clear(); - uris.push_back(url.clone()); - } - - // --- START - // WARNING: removing these curly braces will cause a deadlock. - // amount of hours spent on this: 5 - { - qctx.cold_queue.write().await.queue.extend(uris.drain(..)); - - // check for hot loaded track - let hot_loaded = { - let call = call.lock().await; - call.queue().len() > 0 - }; - - - let mut call = call.lock().await; - let mut cold_queue = qctx.cold_queue.write().await; - if hot_loaded == false { - user_queue_routine(&mut call, &mut cold_queue, qctx.clone()).await?; - } - } - // --- END - - - let content = format!( - "Added {} Song(s) [{}] queued", - added, - qctx.cold_queue.read().await.queue.len() - ); - - msg.channel_id - .say(&ctx.http, &content) - .await?; - }, - - Err(_) => { - msg.channel_id - .say(&ctx.http, format!("Failed to select extractor for URL: {}", url)) - .await?; - } - } - - Ok(()) -} - -#[command] -#[only_in(guilds)] -async fn skip(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let guild = msg.guild(&ctx.cache).unwrap(); - let guild_id = guild.id; - - tracing::info!( - "[{}::{}] skipped track in [{}::{:?}]", - msg.author.id, msg.author.name, - msg.channel_id, msg.channel_id.name(&ctx).await + Event::Track(TrackEvent::Play), + crate::events::StartLog, ); - - let qctx = ctx.data.write().await - .get_mut::().unwrap() - .get_mut(&guild_id).unwrap().clone(); - - let cold_queue_len = qctx.cold_queue.read().await.queue.len(); - let skipn = args.remains() - .unwrap_or("1") - .parse::() - .unwrap_or(1); - - // stop_event: EventEnd::UnMarked, - - if 1 > skipn { - msg.channel_id - .say(&ctx.http, "Must skip at least 1 song") - .await?; - return Ok(()) - } - - else if skipn >= cold_queue_len as isize + 1 { - qctx.cold_queue.write().await.queue.clear(); - } - - else { - let mut cold_queue = qctx.cold_queue.write().await; - let bottom = cold_queue.queue.split_off(skipn as usize - 1); - cold_queue.queue.clear(); - cold_queue.queue.extend(bottom); - } - - // --- START - // stand alone section, writes historical actions. - { - let mut cold_queue = qctx.cold_queue.write().await; - if let Some(x) = cold_queue.has_played.front_mut() - { - if let EventEnd::UnMarked = x.stop_event - { - x.stop_event = EventEnd::Skipped; - x.end = Instant::now(); - } - } - } - // -- END - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - match manager.get(guild_id) { - Some(call) => { - let call = call.lock().await; - let queue = call.queue(); - let _ = queue.skip(); - } - None => { - msg.channel_id - .say(&ctx.http, "Not in a voice channel to play in") - .await?; - return Ok(()) - } - }; - - msg.channel_id - .say( - &ctx.http, - format!("Song skipped [{}]: {} in queue.", skipn, skipn-cold_queue_len as isize), - ) - .await?; - - Ok(()) -} - -#[command] -#[only_in(guilds)] -#[aliases("ls", "l")] -/// @bot list -async fn list(ctx: &Context, msg: &Message) -> CommandResult { - let guild = msg.guild(&ctx.cache).unwrap(); - let guild_id = guild.id; - - let mut _qctx_lock = ctx.data.write().await; - let mut _qctx = _qctx_lock - .get_mut::() - .expect("Expected LazyQueueKey in TypeMap"); - - if let None = _qctx.get(&guild_id) { - msg.channel_id - .say(&ctx.http, "Not in a voice channel") - .await?; - return Ok(()) - } - let qctx = _qctx.get_mut(&guild_id).unwrap(); - let cold_queue = qctx.cold_queue.read().await; - - msg.channel_id - .say(&ctx.http, - format!( - "{}\n[{}] songs in queue", - cold_queue - .queue.clone() - .drain(..) - .chain(cold_queue.radio_queue.clone().drain(..)) - .chain( - cold_queue.radio_next - .iter() - .filter_map( - |(_next, metadata)| - metadata - .clone() - .map(|x| { - let metadata: Metadata = x.into(); - metadata.source_url.unwrap_or("Unknown".to_string()) - }) - ) - ) - .collect::>() - .join("\n"), - - cold_queue.queue.len() - ) - ).await?; - - return Ok(()); -} - -#[command] -#[only_in(guilds)] -/// @bot seed [on/off/(default: status)/uri] -async fn seed(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let guild = msg.guild(&ctx.cache).unwrap(); - let guild_id = guild.id; - - let mut _qctx_lock = ctx.data.write().await; - let mut _qctx = _qctx_lock - .get_mut::() - .expect("Expected LazyQueueKey in TypeMap"); - - if let None = _qctx.get(&guild_id) { - msg.channel_id - .say(&ctx.http, "Not in a voice channel") - .await?; - return Ok(()) - } - - let qctx = _qctx.get_mut(&guild_id).unwrap(); - let act = args.remains() - .unwrap_or("status"); - - match act { - "status" => - { msg.channel_id - .say( - &ctx.http, - qctx.cold_queue.read().await.radio_queue.clone().into_iter().collect::>().join("\n") - ).await?; }, - - _ => {} - } - - Ok(()) -} - -#[command] -#[only_in(guilds)] -async fn shuffle(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let guild = msg.guild(&ctx.cache).unwrap(); - let guild_id = guild.id; - - tracing::info!( - "[{}::{}] shuffled playlist in [{}::{:?}]", - msg.author.id, msg.author.name, - msg.channel_id, msg.channel_id.name(&ctx).await + call.add_global_event( + Event::Track(TrackEvent::End), + crate::events::EndLog, ); - let qctx = ctx.data.write().await - .get_mut::().unwrap() - .get_mut(&guild_id).unwrap().clone(); - - { - use rand::thread_rng; - use rand::seq::SliceRandom; - - let mut write_lock = qctx.cold_queue.write().await; - - let mut vec = write_lock.queue.iter().cloned().collect::>(); - - vec.shuffle(&mut thread_rng()); - write_lock.queue.clear(); - write_lock.queue.extend(vec); - } - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let handler_lock = match manager.get(guild_id) { - Some(x) => x, - None => { - msg.channel_id - .say(&ctx.http, "Not in a voice channel to play in") - .await?; - return Ok(()) - } - }; - - msg.channel_id - .say( - &ctx.http, - format!("shuffled."), - ) - .await?; - - let mut call = handler_lock.lock().await; - let queue = call.queue(); - let _ = queue.skip(); - - Ok(()) -} - -#[group] -#[commands(setarl, getarl)] -struct Dangerous; - -#[command] -#[only_in(guilds)] -async fn setarl(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - tracing::info!("[{}::{}] set a new arl", msg.author.id, msg.author.name); - - let arl = args.single::()?; - - if !(arl.trim().len() == 192 && arl.chars().all(|c| c.is_ascii_hexdigit())) { - msg.channel_id.say(&ctx.http, "Invalid ARL").await?; - return Ok(()) - } - - std::env::set_var("DEEMIX_ARL", arl); - msg.channel_id.say(&ctx.http, "**ARL has been set**").await?; - - return Ok(()) -} - -#[command] -#[only_in(guilds)] -async fn getarl(ctx: &Context, msg: &Message) -> CommandResult { - tracing::info!("[{}::{}] requested arl", msg.author.id, msg.author.name); - - let arl = std::env::var("DEEMIX_ARL"); - tracing::info!("getarl: {:?}", arl); - match arl { - Err(e) => { msg.channel_id.say(&ctx.http, format!("Error: {}", e)).await?; } - Ok(arl) if arl.is_empty() => { msg.channel_id.say(&ctx.http, "ARL not set").await?; } - Ok(arl) => { - #[cfg(feature = "check")] - { - msg.channel_id.say(&ctx.http, format!("getting arl data...")).await?; - use serenity::framework::standard::{Args, Delimiter}; - let mut args = Args::new(arl.as_str(), &[Delimiter::Single(' ')]); - crate::check::arl_check(ctx, msg, args).await?; - } - #[cfg(not(feature = "check"))] - msg.channel_id.say(&ctx.http, format!("ARL: {}", &arl)).await?; - } - } - return Ok(()) -} - -#[command] -#[only_in(guilds)] -/// @bot radio [on/off/(default: status)] -async fn radio(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let guild = msg.guild(&ctx.cache).unwrap(); - let guild_id = guild.id; - - let mut _qctx_lock = ctx.data.write().await; - let mut _qctx = _qctx_lock - .get_mut::() - .expect("Expected LazyQueueKey in TypeMap"); + call.add_global_event( + Event::Periodic(TS_ABANDONED_HB, None), + crate::events::AbandonedChannel(queuectx.clone()) + ); - if let None = _qctx.get(&guild_id) { - msg.channel_id - .say(&ctx.http, "Not in a voice channel") - .await?; - return Ok(()) - } - let qctx = _qctx.get_mut(&guild_id).unwrap(); - let act = args.remains() - .unwrap_or("status"); - - match act { - "status" => - { msg.channel_id - .say( - &ctx.http, - if qctx.cold_queue.read().await.use_radio - { "on" } else { "off" }, - ).await?; }, - - "on" => { - qctx.cold_queue.write().await.use_radio = true; - msg.channel_id - .say(&ctx.http, "Radio enabled") - .await?; - } - "off" => { - let mut lock = qctx.cold_queue.write().await; - lock.radio_queue.clear(); - lock.use_radio = false; - - msg.channel_id - .say(&ctx.http, "Radio disabled") - .await?; - } - _ => {} - } - Ok(()) + Ok(queuectx) } diff --git a/crates/mockingbird/src/radio.rs b/crates/mockingbird/src/radio.rs new file mode 100644 index 0000000..b902cae --- /dev/null +++ b/crates/mockingbird/src/radio.rs @@ -0,0 +1,282 @@ +// this is the rat nest +// be prepared +// to see how lazy i can be. +use serenity::{ + async_trait, framework::standard::{ + macros::{command, group}, Args, CommandResult + }, model::{channel::Message, prelude::*}, prelude::* +}; + +use songbird::{ + events::{Event, EventContext}, + EventHandler as VoiceEventHandler +}; + +use std::{ + process::Stdio, + time::{Duration, Instant}, + collections::VecDeque, + sync::Arc, +}; + +use tokio::io::AsyncBufReadExt; + +use songbird::input::cached::Compressed; +use core::sync::atomic::Ordering; + + +#[cfg(feature = "deemix")] +use crate::deemix::_deemix; + +use crate::models::*; +#[group] +#[commands(radio, seed)] +pub struct Radio; + + +#[command] +#[only_in(guilds)] +/// @bot radio [on/off/(default: status)] +async fn radio(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let mut _qctx_lock = ctx.data.write().await; + let mut _qctx = _qctx_lock + .get_mut::() + .expect("Expected LazyQueueKey in TypeMap"); + + if let None = _qctx.get(&guild_id) { + msg.channel_id + .say(&ctx.http, "Not in a voice channel") + .await?; + return Ok(()) + } + let qctx = _qctx.get_mut(&guild_id).unwrap(); + let act = args.remains() + .unwrap_or("status"); + + match act { + "status" => + { msg.channel_id + .say( + &ctx.http, + if qctx.cold_queue.read().await.use_radio + { "on" } else { "off" }, + ).await?; }, + + "on" => { + qctx.cold_queue.write().await.use_radio = true; + msg.channel_id + .say(&ctx.http, "Radio enabled") + .await?; + } + "off" => { + let mut lock = qctx.cold_queue.write().await; + lock.radio_queue.clear(); + lock.use_radio = false; + + msg.channel_id + .say(&ctx.http, "Radio disabled") + .await?; + } + _ => {} + } + Ok(()) +} + +#[command] +#[only_in(guilds)] +/// @bot seed [on/off/(default: status)/uri] +async fn seed(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + let guild = msg.guild(&ctx.cache).unwrap(); + let guild_id = guild.id; + + let mut _qctx_lock = ctx.data.write().await; + let mut _qctx = _qctx_lock + .get_mut::() + .expect("Expected LazyQueueKey in TypeMap"); + + if let None = _qctx.get(&guild_id) { + msg.channel_id + .say(&ctx.http, "Not in a voice channel") + .await?; + return Ok(()) + } + + let qctx = _qctx.get_mut(&guild_id).unwrap(); + let act = args.remains() + .unwrap_or("status"); + + match act { + "status" => + { msg.channel_id + .say( + &ctx.http, + qctx.cold_queue.read().await.radio_queue.clone().into_iter().collect::>().join("\n") + ).await?; }, + + _ => {} + } + + Ok(()) +} + +async fn recommend(isrcs: &Vec, limit: u8) -> std::io::Result> { + let mut buffer = std::collections::HashSet::new(); + + tracing::info!("running spotify-recommend -l {} {}", limit, isrcs.join(" ")); + let recommend = tokio::process::Command::new("spotify-recommend") + .arg("-l") + .arg(format!("{}", limit)) + .args(isrcs.iter()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let output = recommend.wait_with_output() + .await?; + + let mut lines = output.stdout.lines(); + + while let Some(x) = lines.next_line().await? { + buffer.insert(x); + } + tracing::info!("spotify-stream finished [{}]", buffer.len()); + let mut ret = VecDeque::new(); + for x in buffer { + ret.push_back(x); + } + Ok(ret) +} + +async fn seed_from_history(has_played: &VecDeque) -> std::io::Result> { + let seeds = + has_played + .iter() + // Don't include skipped tracks + .filter(|x| x.stop_event != EventEnd::Skipped) + .filter_map(|x| + match &x.metadata { + MetadataType::Deemix(meta) => meta.isrc.clone(), + _ => None + }) + .collect::>(); + + + if seeds.is_empty() { + return Ok(seeds.into()); + } + + return recommend(&seeds, 5).await; + +} + +async fn preload_radio_track( + cold_queue: &mut ColdQueue +) -> Result<(), String> { + // pop seeds in radio + let mut tries = 5; + // attempts/tries loop + loop { + let uri = match cold_queue.radio_queue.pop_front() { + Some(x) => Some(x), + None => { + cold_queue.radio_queue.clear(); + cold_queue.radio_queue.extend(seed_from_history(&cold_queue.has_played).await.unwrap_or_else(|_| VecDeque::new())); + cold_queue.radio_queue.pop_front() + } + }; + + if let Some(uri) = uri { + match _deemix(&uri, &[], false).await { + Ok((preload_input, metadata)) => { + cold_queue.radio_next = Some((Compressed::new( + preload_input, + songbird::driver::Bitrate::BitsPerSecond(128_000) + ).unwrap(), + + metadata.map(|x| x.into()) + )); + return Ok(()) + } + + Err(why) => { + tries -= 1; + tracing::error!("Error preloading radio track: {}", why); + if 0 >= tries { + return Err("Exceeded max tries".to_string()); + } + continue + } + } + } + return Err("Fall through".to_string()); + } +} + +pub struct RadioInvoker(Arc); +impl RadioInvoker { + pub fn new(qctx: Arc) -> Self { + Self(qctx) + } +} + +#[async_trait] +impl VoiceEventHandler for RadioInvoker { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + if let Some(call) = self.0.manager.get(self.0.guild_id) { + let mut call = call.lock().await; + let mut cold_queue = self.0.cold_queue.write().await; + let crossfade = self.0.crossfade.load(Ordering::Relaxed); + + tracing::info!("Invoking radio queue check"); + // `PreloadInvoker` may have placed a track (from the user queue) + // before this event was fired. + // If true, we clear our trackers. + + if ! crossfade { + if let Some(current_track_handle) = call.queue().current() { + tracing::info!("Invoking radio queue check: got {:?}", current_track_handle); + tracing::info!("skipping radio"); + return None; + } + } + + + tracing::info!("radio queue check: invoke next_track_handle"); + let next_track = crate::player::next_track_handle( + &mut cold_queue, + self.0.clone(), + crossfade + ).await; + + // `PreloadInvoker` has not placed anything, + // lets fire it's routine on our thread. + if let Ok(Some((track, handle, metadata))) = next_track { + + tracing::info!("radio queue check: invoke play on next_track_handle"); + let _ = crate::player::play(&mut call, track, &handle, &mut cold_queue, crossfade).await; + // do nothing. + } + + + // else + // // If all else fails, play the preloaded track on radio + // else if cold_queue.use_radio { + // // if the user queue is empty, try the preloaded radio track + // if let Some((radio_preload, metadata)) = cold_queue.radio_next.take() { + + // // play_preload_radio_track(&mut call, radio_preload, metadata, self.0.clone()).await; + // // let _ = preload_radio_track(&mut cold_queue).await; + // return None; + // } + // } + + // cold_queue.radio_next = None; + // let _ = preload_radio_track(&mut cold_queue).await; + } + None + } +} diff --git a/crates/mockingbird/src/usersettoken.rs b/crates/mockingbird/src/usersettoken.rs new file mode 100644 index 0000000..d3207ce --- /dev/null +++ b/crates/mockingbird/src/usersettoken.rs @@ -0,0 +1,60 @@ +use serenity::{ + framework::standard::{ + macros::{command, group}, Args, CommandResult + }, + model::{channel::Message, prelude::*}, + prelude::*, +}; + +#[cfg(feature = "deemix")] +use crate::deemix::{DeemixMetadata, _deemix}; + +use crate::models::*; + + +#[group] +#[commands(setarl, getarl)] +struct Dangerous; + +#[command] +#[only_in(guilds)] +async fn setarl(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + tracing::info!("[{}::{}] set a new arl", msg.author.id, msg.author.name); + + let arl = args.single::()?; + + if !(arl.trim().len() == 192 && arl.chars().all(|c| c.is_ascii_hexdigit())) { + msg.channel_id.say(&ctx.http, "Invalid ARL").await?; + return Ok(()) + } + + std::env::set_var("DEEMIX_ARL", arl); + msg.channel_id.say(&ctx.http, "**ARL has been set**").await?; + + return Ok(()) +} + +#[command] +#[only_in(guilds)] +async fn getarl(ctx: &Context, msg: &Message) -> CommandResult { + tracing::info!("[{}::{}] requested arl", msg.author.id, msg.author.name); + + let arl = std::env::var("DEEMIX_ARL"); + tracing::info!("getarl: {:?}", arl); + match arl { + Err(e) => { msg.channel_id.say(&ctx.http, format!("Error: {}", e)).await?; } + Ok(arl) if arl.is_empty() => { msg.channel_id.say(&ctx.http, "ARL not set").await?; } + Ok(arl) => { + #[cfg(feature = "check")] + { + msg.channel_id.say(&ctx.http, format!("getting arl data...")).await?; + use serenity::framework::standard::{Args, Delimiter}; + let mut args = Args::new(arl.as_str(), &[Delimiter::Single(' ')]); + crate::check::arl_check(ctx, msg, args).await?; + } + #[cfg(not(feature = "check"))] + msg.channel_id.say(&ctx.http, format!("ARL: {}", &arl)).await?; + } + } + return Ok(()) +} diff --git a/flake.nix b/flake.nix index 9ed7583..e26a438 100644 --- a/flake.nix +++ b/flake.nix @@ -37,6 +37,7 @@ mockingbird-set-arl-cmd mockingbird-http mockingbird-radio + mockingbird-crossfade ]); coggiebot-stable = cogpkgs.mkCoggiebot { @@ -88,6 +89,7 @@ }; packages.cache-target = coggiebot-stable; + packages.arange = pkgs.python3.withPackages(ps: with ps; [ numpy ]); })) packages; nixosModules.coggiebot = {pkgs, lib, config, ...}: diff --git a/iac/coggiebot/default.nix b/iac/coggiebot/default.nix index b60b7bf..7f3c55a 100644 --- a/iac/coggiebot/default.nix +++ b/iac/coggiebot/default.nix @@ -71,7 +71,9 @@ let { name = "mockingbird-set-arl-cmd"; dependencies = [ "mockingbird-core" ]; } - + { name = "mockingbird-crossfade"; + dependencies = ["mockingbird-core"]; + } { name = "mockingbird-debug"; dependencies = []; } @@ -129,7 +131,7 @@ let coggiebot-default-args = features-list: { name = "coggiebot"; pname = "coggiebot"; - version = "1.4.15"; + version = "1.5.0"; nativeBuildInputs = []; buildInputs = [ pkgs.pkg-config From b97adb8568b200cb8f216779335f32ef99ee8378 Mon Sep 17 00:00:00 2001 From: allow Date: Mon, 27 May 2024 21:41:44 -0500 Subject: [PATCH 3/3] fixes #223 (cherry picked from commit 2450b3c39fb2f571a7317e3d8b7306e5ca509fae) --- crates/mockingbird/src/crossfade.rs | 12 ++++-------- crates/mockingbird/src/player.rs | 11 ++--------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/crates/mockingbird/src/crossfade.rs b/crates/mockingbird/src/crossfade.rs index e7ac176..5c69297 100644 --- a/crates/mockingbird/src/crossfade.rs +++ b/crates/mockingbird/src/crossfade.rs @@ -47,17 +47,16 @@ impl VoiceEventHandler for CrossFadeInvoker { return Some(Event::Cancel) } - let mut cold_queue = self.0.cold_queue.write().await; + let cold_queue = self.0.cold_queue.write().await; let peak = 10000; let root : i32 = (peak as f32).sqrt() as i32; - // let step = 1; let mut cold_queue = tokio::task::block_in_place(move || { - for x in 25 ..= root { + for x in 20 ..= root { let fade_out = peak - x.pow(2); let fade_out_normal = fade_out as f32 / 10000.0; let fade_in_normal = (peak - fade_out) as f32 / 10000.0; - tracing::info!("crossfade: fade_out: {}, fade_in: {}", fade_out_normal, fade_in_normal); + tracing::debug!("crossfade: fade_out: {}, fade_in: {}", fade_out_normal, fade_in_normal); match (cold_queue.crossfade_lhs.as_ref(), cold_queue.crossfade_rhs.as_ref()) { (Some(lhs), Some(rhs)) => { @@ -88,16 +87,13 @@ impl VoiceEventHandler for CrossFadeInvoker { if let Some(rhs) = cold_queue.crossfade_rhs.take() { if let Some(lhs) = cold_queue.crossfade_lhs.take() { cold_queue.crossfade_lhs.replace(rhs); - let _ = lhs.stop(); return None; } cold_queue.crossfade_lhs.replace(rhs); } else { - if let Some(lhs) = cold_queue.crossfade_lhs.take() { - let _ = lhs.stop(); - } + let _ = cold_queue.crossfade_lhs.take(); } return None } diff --git a/crates/mockingbird/src/player.rs b/crates/mockingbird/src/player.rs index f3233c2..af71d3d 100644 --- a/crates/mockingbird/src/player.rs +++ b/crates/mockingbird/src/player.rs @@ -68,15 +68,6 @@ impl Players { let result = ph_httpget_player(uri, guild_id.0, &mut pathbuf).await; match result { Ok((input, metadata)) => { - // let (_track, track_handle) = create_player(input); - // let fp = match metadata { - // Some(MetadataType::Disk(fp)) => fp, - // _ => { return Err(HandlerError::WrongMetadataType) } - // }; - - // let _ = track_handle.add_event(Event::Track(TrackEvent::End), RemoveTempFile(fp)); - - // // TODO FIXME ADD METADATA return Ok((input, metadata)) } @@ -300,6 +291,8 @@ pub async fn next_track_handle( crossfade: bool ) -> Result)>, HandlerError> { + + if let Some((preload, metadata)) = cold_queue.queue_next.take() { tracing::info!("Pulling track from user-preload"); let (track, handle) = create_player(preload.into());