4 Commits
0.8.0 ... 0.9.0

Author SHA1 Message Date
c2e653b3ea [0.9.0] new parser 2024-08-04 18:03:21 +02:00
78a1937210 [0.8.2] lyra package update 2024-08-03 00:12:44 +02:00
fc42427437 updated packages and closing #2
Signed-off-by: Michael Czyż <mike@c2yz.com>
2024-05-18 23:46:25 +02:00
9474577233 Lyra is now open source! 2024-02-22 13:55:11 +01:00
17 changed files with 3792 additions and 481 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
/target /target
**/target
.env .env

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "src/spotify-parser"]
path = src/spotify-parser
url = https://github.com/eRgo35/spotify-parser

1836
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "lyra" name = "lyra"
version = "0.8.0" version = "0.9.0"
authors = ["Michał Czyż <mike@c2yz.com>"] authors = ["Michał Czyż <mike@c2yz.com>"]
edition = "2021" edition = "2021"
description = "A featureful Discord bot written in Rust." description = "A featureful Discord bot written in Rust."
@@ -12,22 +12,35 @@ keywords = ["discord", "bot", "rust", "music", "featureful"]
[dependencies] [dependencies]
lib-spotify-parser = { path = "libs/spotify-parser" }
dotenv = "0.15.0" dotenv = "0.15.0"
fancy-regex = "0.13.0" fancy-regex = "0.13.0"
json = "0.12.4" json = "0.12.4"
openssl = { version = "0.10.63", features = ["vendored"] } openssl = { version = "0.10.66", features = ["vendored"] }
owoify = "0.1.5" owoify = "0.1.5"
poise = "0.6.1" poise = "0.6.1"
rand = "0.8.5" rand = "0.8.5"
regex = "1.10.3" regex = "1.10.6"
reqwest = { version = "0.11.23", features = ["json"]} reqwest = { version = "0.11.27", features = ["json"] }
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.114" serde_json = "1.0.122"
serenity = { version = "0.12.0", features = ["cache", "framework", "standard_framework", "voice"] } serenity = { version = "0.12.2", features = [
songbird = { version = "0.4.0", features = ["builtin-queue", "serenity"] } "cache",
symphonia = { version = "0.5.3", features = ["aac", "adpcm", "alac", "flac", "mpa", "isomp4"] } "framework",
tokio = { version = "1.35.1", features = ["macros", "full", "signal"] } "standard_framework",
"voice",
] }
songbird = { version = "0.4.3", features = ["builtin-queue", "serenity"] }
symphonia = { version = "0.5.4", features = [
"aac",
"adpcm",
"alac",
"flac",
"mpa",
"isomp4",
] }
tokio = { version = "1.39.2", features = ["macros", "full", "signal"] }
tracing = "0.1.40" tracing = "0.1.40"
tracing-futures = "0.2.5" tracing-futures = "0.2.5"
tracing-subscriber = "0.3.18" tracing-subscriber = "0.3.18"
url = "2.5.0" url = "2.5.2"

View File

@@ -1,19 +0,0 @@
FROM rust:1.75.0-alpine
RUN apk add --update \
alpine-sdk \
ffmpeg \
youtube-dl \
pkgconfig \
cmake \
openssl-dev \
musl-dev \
openssl
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/lyra"]

View File

@@ -1,7 +1,21 @@
# License MIT License
Copyright (C) 2024 Michał Czyż Copyright (c) 2024 Michał Czyż
All Rights Reserved. Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This build is private and shall not be distributed nor modified without permission. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -3,10 +3,79 @@
![](assets/lyra-256.png) ![](assets/lyra-256.png)
Lyra is a music bot written in Rust. Lyra is a music bot written in Rust.
More features coming soon!
## Getting Started ## Getting Started
## Building Lyra is an open source, discord music bot written in Rust.
The idea behind this project is to allow a user to self-host one's own instance of the bot.
User no longer has to rely on 3rd parties to provide them an invite link.
The bot can be run even on a desktop or a phone because after compilation, it's just a simple binary.
As of now, the bot supports spotify url track recognition through a separate nodejs script. I plan to write the actual parser inside the bot iteself but as of now I postponed it into future release.
Slash commands are still work in progress! Currently bot is still heavily in development!
## Setting up
To compile the source code on your own, you need `rust` and `cargo`
To run a dev version use
```bash
$ cargo run
```
To build a production version use
```bash
$ cargo build --release
```
If you need an ARM version and just don't want to wait for ages for the program to compile, use
```bash
$ cross build -r --target aarch64-unknown-linux-gnu
```
To run a program, just type
```bash
$ ./lyra
```
if you want to disown it from the shell, I recommend using the script I provided in `scripts` folder
## Commands ## Commands
As of now, working commands are:
```
Music:
/deafen Deafens itself while in a voice channel; aliases: deafen, undeaden, shuush
/join Joins your voice channel
/leave Leaves the voice channel; aliases: leave, qa!
/mute Mutes itself while in a voice channel; aliases: mute, unmute, shhh
/pause Pauses the currently playing song
/play Plays a song; you can search by query or paste an url; aliases: play, p, enqueue
/queue Shows next tracks in queue; aliases: queue, q
/repeat Loops currently playing song provided amount of times; aliases: repeat, loop, while, for
/resume Resumes currently paused song
/seek Seeks a track by provided seconds
/skip Skips the currently playing song
/stop Stops playback and destroys the queue; aliases: stop, end
/volume Changes output volume
/effect Plays one of available audio effects
/stream Hijacks output and plays audio; search by query or paste an url; aliases: stream, override, hijack
Tools:
/ai Asks AI
/dice Rolls a dice
/owoify Owoifies whatever you want uwu
/ping Pings you backs with a response time
/posix Prints current time in POSIX format
/qr Creates a qr code from text
/verse Reference Bible by verse
Help:
/help Prints this help message; aliases: help, huh, welp
```

View File

@@ -1,6 +0,0 @@
version: '2'
services:
lyra:
container_name: lyra
build: .

1906
libs/spotify-parser/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
[package]
name = "lib-spotify-parser"
version = "1.0.0"
edition = "2021"
[dependencies]
regex = "1.10.6"
reqwest = { version = "0.12.5", features = ["blocking", "rustls-tls"] }
scraper = "0.19.1"
serde = "1.0.204"
serde_json = "1.0.122"
tokio = { version = "1.39.2", features = ["macros"] }

View File

@@ -0,0 +1,14 @@
use std::fmt;
#[derive(Debug)]
pub enum ParseError {
InvalidUrl,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Invalid URL")
}
}
impl std::error::Error for ParseError {}

View File

@@ -0,0 +1,154 @@
mod error;
mod parser;
mod retriever;
use error::ParseError;
use parser::{parse_list, parse_single, USER_AGENT};
use regex::Regex;
use reqwest;
use retriever::{retrieve_async_track, retrieve_async_tracks, retrieve_track, retrieve_tracks};
pub fn retrieve_url(url: &str) -> Result<Vec<String>, ParseError> {
let spotify_regex: Regex = Regex::new(r"https?:\/\/(?:embed\.|open\.)spotify\.com\/(track|album|playlist)\/([a-zA-Z0-9]+)(?:\?si=[\w-]+)?").unwrap();
if let Some(captures) = spotify_regex.captures(url) {
let category = captures.get(1).unwrap().as_str();
let id = captures.get(2).unwrap().as_str();
match category {
"track" => {
let track = retrieve_track(category, id).unwrap();
Ok(vec![track])
}
"playlist" | "album" => {
let tracks = retrieve_tracks(category, id).unwrap();
Ok(tracks)
}
_ => Err(ParseError::InvalidUrl),
}
} else {
Err(ParseError::InvalidUrl)
}
}
pub async fn retrieve_async_url(url: &str) -> Result<Vec<String>, ParseError> {
let spotify_regex: Regex = Regex::new(r"https?:\/\/(?:embed\.|open\.)spotify\.com\/(track|album|playlist)\/([a-zA-Z0-9]+)(?:\?si=[\w-]+)?").unwrap();
if let Some(captures) = spotify_regex.captures(url) {
let category = captures.get(1).unwrap().as_str();
let id = captures.get(2).unwrap().as_str();
match category {
"track" => {
let track = retrieve_async_track(category, id).await.unwrap();
Ok(vec![track])
}
"playlist" | "album" => {
let tracks = retrieve_async_tracks(category, id).await.unwrap();
Ok(tracks)
}
_ => Err(ParseError::InvalidUrl),
}
} else {
Err(ParseError::InvalidUrl)
}
}
#[cfg(test)]
mod tests {
use super::*;
const TRACK: &str = "https://open.spotify.com/track/4PTG3Z6ehGkBFwjybzWkR8?si=e0a8c8ada8284e43";
const MULTIPLE_ARTISTS_TRACK: &str =
"https://open.spotify.com/track/1O0SdrryPeGp6eSSUibdgo?si=4ae58febe9e74eae";
const PLAYLIST: &str =
"https://open.spotify.com/playlist/37i9dQZF1DZ06evO05tE88?si=e0c6f44d176f44e6";
const ALBUM: &str =
"https://open.spotify.com/album/6eUW0wxWtzkFdaEFsTJto6?si=_grLtlNySNyfJTZr8tP44Q";
#[test]
fn check_track() {
let track: Vec<&str> = vec!["Rick Astley - Never Gonna Give You Up"];
assert_eq!(retrieve_url(TRACK).unwrap(), track);
}
#[test]
fn check_multiple_artists_track() {
let track: Vec<&str> = vec!["Will o' the wisp, Rick Astley - Blood on My Tie"];
assert_eq!(retrieve_url(MULTIPLE_ARTISTS_TRACK).unwrap(), track);
}
#[test]
fn check_playlist() {
let playlist: Vec<&str> = vec![
"Rick Astley - Never Gonna Give You Up",
"Rick Astley - Take Me to Your Heart (2023 Remaster)",
"Rick Astley - Cry for Help - Single Edit",
"Rick Astley - Never Gonna Stop",
"Rick Astley - Together Forever",
"Rick Astley - Hold Me in Your Arms (7\" Version)",
"Rick Astley - Angels on My Side",
"New Kids On The Block, Salt-N-Pepa, Rick Astley, En Vogue - Bring Back The Time",
"Rick Astley - Whenever You Need Somebody",
"Rick Astley - She Wants to Dance with Me (2023 Remaster)",
"Rick Astley - Dippin My Feet",
"Rick Astley - My Arms Keep Missing You",
"Rick Astley - Don't Say Goodbye",
"Rick Astley - Dance",
"Rick Astley - Giving Up On Love (7'' Pop Version)",
"Rick Astley - Never Gonna Give You Up (Cake Mix)",
"Rick Astley - It Would Take a Strong Strong Man",
"Rick Astley - Driving Me Crazy",
"Rick Astley - Beautiful Life",
"Rick Astley - I Don't Want to Lose Her",
"Rick Astley - Keep Singing",
"Rick Astley - Forever and More",
"Rick Astley - Hopelessly",
"Rick Astley - When I Fall in Love",
"Rick Astley - Every One of Us",
"Rick Astley - High Enough",
"Rick Astley - Ain't Too Proud to Beg (2023 Remaster)",
"Rick Astley - I'll Never Let You Down",
"Trevor Horn, Rick Astley - Owner Of A Lonely Heart",
"Rick Astley - Letting Go",
"Rick Astley - Never Knew Love",
"Rick Astley - Lights Out - Radio Edit",
"Rick Astley - Try",
"Rick Astley - Dial My Number (2023 Remaster)",
"Rick Astley - Wish Away",
"Rick Astley - Giant",
"Rick Astley - Move Right Out",
"Rick Astley - Till Then (Time Stands Still) (2023 Remaster)",
"Rick Astley - Pray with Me",
"Rick Astley - I Don't Want to Be Your Lover",
"Will o' the wisp, Rick Astley - Blood on My Tie",
"Rick Astley - Can't Help Falling in Love",
"Rick Astley - I Like the Sun",
"Rick Astley - She Makes Me",
"Rick Astley - Body and Soul",
"Rick Astley - (They Long to Be) Close to You",
"Rick Astley - Unwanted (Official Song from the Podcast)",
"Rick Astley - Last Night on Earth",
"Rick Astley - Everlong - Acoustic Version",
"Rick Astley - Superman",
];
assert_eq!(retrieve_url(PLAYLIST).unwrap(), playlist);
}
#[test]
fn check_album() {
let album: Vec<&str> = vec![
"Rick Astley - Never Gonna Give You Up",
"Rick Astley - Whenever You Need Somebody",
"Rick Astley - Together Forever",
"Rick Astley - It Would Take a Strong Strong Man",
"Rick Astley - The Love Has Gone",
"Rick Astley - Don't Say Goodbye",
"Rick Astley - Slipping Away",
"Rick Astley - No More Looking for Love",
"Rick Astley - You Move Me",
"Rick Astley - When I Fall in Love",
];
assert_eq!(retrieve_url(ALBUM).unwrap(), album);
}
}

View File

@@ -0,0 +1,70 @@
use scraper::{Html, Selector};
use serde_json::Value;
use std::error::Error;
#[derive(Debug, Clone)]
pub(crate) struct SpotifyTrack {
pub title: String,
pub artist: String,
}
pub(crate) const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";
pub(crate) fn parse_single(content: String) -> Result<SpotifyTrack, Box<dyn Error>> {
let document = Html::parse_document(&content);
let selector = Selector::parse("#__NEXT_DATA__").unwrap();
if let Some(script_element) = document.select(&selector).next() {
let json_str = script_element.inner_html();
let json_value: Value = serde_json::from_str(&json_str)?;
let metadata = &json_value["props"]["pageProps"]["state"]["data"]["entity"];
// println!("{metadata:?}");
let title = metadata["title"].as_str().unwrap().to_string();
let artists = metadata["artists"].as_array().unwrap();
let artists_list: Vec<String> = artists
.iter()
.map(|artist| artist["name"].as_str().unwrap().to_string())
.collect();
let artist: String = artists_list.join(", ").to_string();
let track = SpotifyTrack { title, artist };
Ok(track)
} else {
Err("Could not find element".into())
}
}
pub(crate) fn parse_list(content: String) -> Result<Vec<SpotifyTrack>, Box<dyn Error>> {
let document = Html::parse_document(&content);
let selector = Selector::parse("#__NEXT_DATA__").unwrap();
if let Some(script_element) = document.select(&selector).next() {
let json_str = script_element.inner_html();
let json_value: Value = serde_json::from_str(&json_str)?;
let metadata = &json_value["props"]["pageProps"]["state"]["data"]["entity"]["trackList"]
.as_array()
.unwrap();
// println!("{metadata:?}");
let tracks: Vec<SpotifyTrack> = metadata
.iter()
.map(|track| {
let title = track["title"].as_str().unwrap().to_string();
let artist = track["subtitle"].as_str().unwrap().to_string();
SpotifyTrack { title, artist }
})
.collect();
// println!("{tracks:?}");
Ok(tracks)
} else {
Err("Could not find element".into())
}
}

View File

@@ -0,0 +1,85 @@
use crate::*;
pub fn retrieve_track(category: &str, id: &str) -> Result<String, String> {
let embed_url = format!("https://embed.spotify.com/?uri=spotify:{}:{}", category, id);
let client = reqwest::blocking::Client::builder()
.use_rustls_tls()
.user_agent(USER_AGENT)
.build()
.unwrap();
let response = client.get(&embed_url).send().unwrap();
let content = response.text().unwrap();
let parsed_content = parse_single(content).unwrap();
Ok(format!(
"{} - {}",
parsed_content.artist, parsed_content.title
))
}
pub fn retrieve_tracks(category: &str, id: &str) -> Result<Vec<String>, String> {
let embed_url = format!("https://embed.spotify.com/?uri=spotify:{}:{}", category, id);
let client = reqwest::blocking::Client::builder()
.use_rustls_tls()
.user_agent(USER_AGENT)
.build()
.unwrap();
let response = client.get(&embed_url).send().unwrap();
let content = response.text().unwrap();
let parsed_content = parse_list(content).unwrap();
let tracks: Vec<String> = parsed_content
.iter()
.map(|track| format!("{} - {}", track.artist, track.title))
.collect();
Ok(tracks)
}
pub async fn retrieve_async_track(category: &str, id: &str) -> Result<String, String> {
let embed_url = format!("https://embed.spotify.com/?uri=spotify:{}:{}", category, id);
let client = reqwest::Client::builder()
.use_rustls_tls()
.user_agent(USER_AGENT)
.build()
.unwrap();
let response = client.get(&embed_url).send().await.unwrap();
let content = response.text().await.unwrap();
let parsed_content = parse_single(content).unwrap();
Ok(format!(
"{} - {}",
parsed_content.artist, parsed_content.title
))
}
pub async fn retrieve_async_tracks(category: &str, id: &str) -> Result<Vec<String>, String> {
let embed_url = format!("https://embed.spotify.com/?uri=spotify:{}:{}", category, id);
let client = reqwest::Client::builder()
.use_rustls_tls()
.user_agent(USER_AGENT)
.build()
.unwrap();
let response = client.get(&embed_url).send().await.unwrap();
let content = response.text().await.unwrap();
let parsed_content = parse_list(content).unwrap();
let tracks: Vec<String> = parsed_content
.iter()
.map(|track| format!("{} - {}", track.artist, track.title))
.collect();
Ok(tracks)
}

View File

@@ -2,6 +2,7 @@ use crate::commands::music::metadata::Metadata;
use crate::{commands::embeds::error_embed, Context, Error}; use crate::{commands::embeds::error_embed, Context, Error};
use fancy_regex::Regex; use fancy_regex::Regex;
use lib_spotify_parser;
use poise::serenity_prelude::model::Timestamp; use poise::serenity_prelude::model::Timestamp;
use poise::serenity_prelude::Colour; use poise::serenity_prelude::Colour;
use poise::serenity_prelude::CreateEmbed; use poise::serenity_prelude::CreateEmbed;
@@ -87,14 +88,7 @@ pub async fn play(
handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier);
if is_playlist && is_spotify { if is_playlist && is_spotify {
let raw_list = Command::new("node") let tracks: Vec<String> = lib_spotify_parser::retrieve_async_url(&song).await.unwrap();
.args(["./src/spotify-parser", &song])
.output()
.expect("failed to execute process")
.stdout;
let list = String::from_utf8(raw_list.clone()).expect("Invalid UTF-8");
let tracks: Vec<String> = list.split("\n").map(str::to_string).collect();
for (index, url) in tracks.clone().iter().enumerate() { for (index, url) in tracks.clone().iter().enumerate() {
if url.is_empty() { if url.is_empty() {
@@ -161,13 +155,14 @@ pub async fn play(
} }
if is_spotify { if is_spotify {
let query = Command::new("node") song = format!(
.args(["./src/spotify-parser", &song]) "ytsearch:{}",
.output() lib_spotify_parser::retrieve_async_url(&song)
.expect("failed to execute process") .await
.stdout; .unwrap()
let query_str = String::from_utf8(query.clone()).expect("Invalid UTF-8"); .first()
song = format!("ytsearch:{}", query_str.to_string()); .unwrap()
);
} }
if is_query { if is_query {

View File

@@ -12,8 +12,14 @@ use serenity::{
}; };
use songbird::{input::AuxMetadata, tracks::TrackHandle}; use songbird::{input::AuxMetadata, tracks::TrackHandle};
/// Skips the currently playing song /// Skips the currently playing song; \
#[poise::command(prefix_command, slash_command, category = "Music")] /// aliases: skip, :skipper:
#[poise::command(
prefix_command,
slash_command,
aliases("skipper:"),
category = "Music"
)]
pub async fn skip(ctx: Context<'_>) -> Result<(), Error> { pub async fn skip(ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap(); let guild_id = ctx.guild_id().unwrap();