Compare commits

...
11 Commits
11 changed files with 1649 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Currently needed by dev environment, will be replace by some kind of config file that's generated by nix
DISCORD_APP_ID=
LB_USER=
+4
View File
@@ -0,0 +1,4 @@
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
fi
use flake
+6
View File
@@ -0,0 +1,6 @@
.direnv/
.env
# Added by cargo
/target
Generated
+1128
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "lb-discord-rpc"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.79"
discord-presence = "3.1"
dotenv = "0.15.0"
env_logger = "0.11.1"
log = "0.4.20"
serde = "1.0.196"
serde_json = "1.0.113"
ureq = "2.9.5"
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1768395095,
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+27
View File
@@ -0,0 +1,27 @@
{
description = "Discord Rich Presence for ListenBrainz";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
cargo
clippy
rustc
rust-analyzer
openssl
pkg-config
];
shellHook = ''
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig"
'';
};
});
}
+183
View File
@@ -0,0 +1,183 @@
use std::{collections::HashMap, u8, usize};
use anyhow::anyhow;
use log::debug;
use serde::Deserialize;
use ureq::{Agent, AgentBuilder, Response};
use super::musicbrainz::{Recording, RecordingSearch};
#[derive(Deserialize)]
pub struct LBResponse<T> {
pub payload: T
}
impl<T> LBResponse<T> {
pub fn inner(self) -> T {
self.payload
}
}
#[derive(Deserialize)]
pub struct ListenResponsePayload {
pub count: u32,
pub user_id: String,
pub listens: Vec<Listen>,
pub playing_now: Option<bool>
}
#[derive(Deserialize)]
pub struct Listen {
pub track_metadata: TrackMetadata,
pub listened_at: Option<u64>,
pub playing_now: Option<bool>
}
#[derive(Deserialize)]
pub struct TrackMetadata {
pub additional_info: Option<AdditionalListenInfo>,
pub artist_name: String,
pub track_name: String,
pub release_name: Option<String>
}
#[derive(Deserialize)]
pub struct AdditionalListenInfo {
pub artist_mbids: Option<Vec<String>>,
pub release_group_mbid: Option<String>,
pub release_mbid: Option<String>,
pub recording_mbid: Option<String>,
pub track_mbid: Option<String>,
pub work_mbids: Option<Vec<String>>,
pub tracknumber: Option<u8>,
pub isrc: Option<String>,
pub spotify_id: Option<String>,
pub tags: Option<Vec<String>>,
pub media_player: Option<String>,
pub media_player_version: Option<String>,
pub submission_client: Option<String>,
pub submission_client_version: Option<String>,
pub music_service: Option<String>,
pub music_service_name: Option<String>,
pub origin_url: Option<String>,
pub duration_ms: Option<usize>,
pub duration: Option<usize>
}
pub struct Client {
user_token: Option<String>,
recording_cache: HashMap<String, Recording>,
agent: Agent
}
impl Client {
pub fn new() -> Self {
Self {
user_token: None,
recording_cache: HashMap::new(),
agent: AgentBuilder::new()
.user_agent("lb-discord-rpc/0.1.0 (contact@ixhby.dev)")
.build()
}
}
#[allow(dead_code)]
pub fn new_authorized(user_token: String) -> Self {
Self {
user_token: Some(user_token),
recording_cache: HashMap::new(),
agent: AgentBuilder::new()
.user_agent("lb-discord-rpc/0.1.0 (contact@ixhby.dev)")
.build()
}
}
fn get(&self, url: String) -> Result<Response, Box<ureq::Error>> {
let mut req = self.agent.get(&url).set("Accept", "application/json");
if self.user_token.is_some() {
req = req.set("Authorization", &format!("Token {}", self.user_token.clone().unwrap()));
}
Ok(req.call()?)
}
pub fn now_playing(&self, user: &str) -> anyhow::Result<ListenResponsePayload> {
// TODO: Allow custom lb/mb instances
let res = self.get(format!("https://api.listenbrainz.org/1/user/{user}/playing-now"));
match res {
Err(e) => {
Err(e.into())
},
Ok(a) => {
Ok(serde_json::from_str::<LBResponse<ListenResponsePayload>>(&a.into_string()?)?.inner())
},
}
}
fn search_track(&mut self, track: &str, artist: &str, release_name: Option<&str>) -> anyhow::Result<Recording> {
let hash_key = format!("{} - {}", &track, &artist);
if let Some(rec) = self.recording_cache.get(&hash_key) {
debug!("Got recording from cache");
return Ok(rec.clone());
}
let query = if let Some(release_name) = release_name {
format!("https://musicbrainz.org/ws/2/recording/?query=artist:{} AND recording:{} AND release:{} AND video:false",artist,track,release_name)
} else {
format!("https://musicbrainz.org/ws/2/recording/?query=artist:{} AND recording:{} AND video:false",artist,track)
};
let res = self.get(query);
match res {
Err(e) => {
Err(e.into())
},
Ok(a) => {
let search = serde_json::from_str::<RecordingSearch>(&a.into_string()?)?;
if let Some(rec) = search.recordings.first() {
if rec.releases.is_some() {
self.recording_cache.insert(hash_key, rec.clone());
Ok(rec.clone())
} else {
Err(anyhow!("Recording has no releases"))
}
} else {
Err(anyhow!("No recordings found"))
}
}
}
}
// TODO: Move caching here
pub fn cover_art(&mut self, track: &str, artist: &str, release_name: Option<&str>, index: usize) -> anyhow::Result<String> {
let found_track = self.search_track(track, artist, release_name)?;
let res = &found_track.releases.clone().unwrap();
if index >= res.len() {
return Err(anyhow!("No cover art found"))
}
let id = &res.get(index).unwrap().id;
let res = self.get(format!("https://coverartarchive.org/release/{}/front-250", id));
match res {
Err(_) => {
self.cover_art(track, artist, release_name, index + 1)
},
Ok(a) => {
if a.status() == 307 {
Ok(a.header("Location").unwrap().to_string())
} else if a.status() == 200 {
// TODOO: Don't follow redirect and instead always return the new location
// Maybe HEAD requests? idfk
Ok(a.get_url().to_string())
} else {
Err(anyhow!("idfk"))
}
},
}
}
}
+3
View File
@@ -0,0 +1,3 @@
// TODO: Make this whole thing a separate crate, because HOLY SHIT
pub mod listenbrainz;
pub mod musicbrainz;
+77
View File
@@ -0,0 +1,77 @@
use std::u8;
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize, Clone)]
pub struct RecordingSearch {
pub created: String,
pub count: usize,
pub offset: u8,
pub recordings: Vec<Recording>
}
#[derive(Deserialize, Clone)]
pub struct Recording {
pub id: String,
pub score: u8,
pub title: String,
pub length: Option<u64>,
pub video: Option<Value>, // What even is this?
#[serde(rename = "artist-credit")]
pub artist_credit: Vec<ArtistCredit>,
#[serde(rename = "first-release-date")]
pub first_release_date: Option<String>,
pub releases: Option<Vec<Release>>
}
#[derive(Deserialize, Clone)]
pub struct ArtistCredit {
pub joinphrase: Option<String>,
pub name: String,
pub artist: Artist
}
#[derive(Deserialize, Clone)]
pub struct Artist {
pub id: String,
pub name: String,
#[serde(rename = "sort-name")]
pub sort_name: String,
pub aliases: Option<Value>, // No, just no
pub disambiguation: Option<String>
}
#[derive(Deserialize, Clone)]
pub struct Release {
pub id: String,
#[serde(rename = "status-id")]
pub status_id: Option<String>,
pub count: u8,
pub title: String,
#[serde(rename = "artist-credit")]
pub artist_credit: Option<Vec<ArtistCredit>>,
pub status: Option<String>,
#[serde(rename = "release-group")]
pub release_group: ReleaseGroup,
pub date: Option<String>,
pub country: Option<String>,
#[serde(rename = "release-events")]
pub release_events: Option<Value>, // idgaf anymore
#[serde(rename = "track-count")]
pub track_count: u16,
pub media: Value // don't care
}
#[derive(Deserialize, Clone)]
pub struct ReleaseGroup {
pub id: String,
#[serde(rename = "type-id")]
pub type_id: Option<String>,
#[serde(rename = "primary-type-id")]
pub primary_type_id: Option<String>,
pub title: String,
#[serde(rename = "primary-type")]
pub primary_type: Option<String>
}
+141
View File
@@ -0,0 +1,141 @@
mod lb;
use core::time;
use std::{collections::{HashMap, HashSet}, env, error::Error, thread};
use discord_presence::{
models::{ActivityType, DisplayType},
Client, Event,
};
use dotenv::dotenv;
use env_logger::Env;
use lb::listenbrainz;
use log::{debug, error};
// TODOOO: Replace .env with some $XDG_CONFIG_DIRS file
// TODOO: Save HashMap cache to $XDG_CACHE_DIR file(s)
// TODOO: Nix packaging
// TODO: Packaging for other repos
fn main() -> Result<(), Box<dyn Error>> {
dotenv().ok();
if cfg!(debug_assertions) {
env_logger::init_from_env(Env::new().default_filter_or("debug"));
} else {
env_logger::init_from_env(Env::new().default_filter_or("info"));
}
let mut lb = listenbrainz::Client::new();
let mut drpc = Client::new(env::var("DISCORD_APP_ID")?.parse()?);
drpc.start();
let mut cover_cache: HashMap<String, String> = HashMap::new();
let mut cover_failure_cache: HashSet<String> = HashSet::new();
loop {
// TODO: Error handling lol
let np = lb.now_playing(&env::var("LB_USER")?)?;
let listen = np.listens.first();
if listen.is_none() {
drpc.clear_activity().unwrap();
thread::sleep(time::Duration::from_secs(15));
continue;
}
let metadata = &listen.unwrap().track_metadata;
let track = metadata.track_name.clone();
let artist = metadata.artist_name.clone();
let release_name = metadata.release_name.clone();
// TODOO: Change this to the release ID?
let hash_key = format!("{} - {}", &track, &artist);
let cover_art = if cover_cache.contains_key(&hash_key) {
debug!("Got cover art from cache");
Some(cover_cache.get(&hash_key).unwrap().clone())
} else if cover_failure_cache.contains(&hash_key) {
debug!("Cached cover failure");
None
} else {
let youtube_cover_url = metadata.additional_info.as_ref()
.and_then(|info| info.music_service_name.as_ref())
.filter(
|service|
["YouTube", "YouTube Music"].contains(&service.as_str())
)
.and_then(|_| metadata.additional_info.as_ref())
.and_then(|info| info.origin_url.as_ref())
.and_then(|url| extract_youtube_video_id(url))
.map(|video_id| format!(
"https://img.youtube.com/vi/{}/sddefault.jpg",
video_id
));
if let Some(url) = youtube_cover_url {
cover_cache.insert(hash_key.clone(), url.clone());
Some(url)
} else {
match lb.cover_art(&track, &artist, release_name.as_deref(), 0) {
Ok(url) => {
cover_cache.insert(hash_key.clone(), url.clone());
Some(url)
},
Err(e) => {
error!("Error fetching cover_art: {}", e);
cover_failure_cache.insert(hash_key);
None
},
}
}
};
drpc.set_activity(|act| {
// TODO: Add buttons
let mut t = act
.activity_type(ActivityType::Listening)
.status_display(DisplayType::State)
.state(artist.clone())
.details(track.clone());
if let Some(cover_url) = cover_art {
t = t.assets(|ass| {
ass
.large_image(cover_url)
.large_text(format!("{} - {}", &track, &artist))
})
}
// TODO: Provide backup image, no image looks kind of ugly
t
}).unwrap();
thread::sleep(time::Duration::from_secs(15));
}
}
fn extract_youtube_video_id(url: &str) -> Option<&str> {
let patterns = [
"youtube.com/watch?v=",
"youtu.be/",
"youtube.com/v/",
"youtube.com/embed/",
];
for pattern in patterns.iter() {
if let Some(pos) = url.find(pattern) {
let start = pos + pattern.len();
let end = url[start..]
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
.map(|end_pos| start + end_pos)
.unwrap_or(url.len());
let video_id = &url[start..end];
if !video_id.is_empty() {
return Some(video_id);
}
}
}
None
}