Compare commits
11
Commits
75b038bb06
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f80b99e4e | ||
|
|
3d6ba1ef09 | ||
|
|
3530c755ce | ||
|
|
2867d69305 | ||
|
|
2de26dd9e6 | ||
|
|
fa79f9a39c | ||
|
|
9642af40d0 | ||
|
|
b55a79356f | ||
|
|
8003d98e1e | ||
|
|
5b4972f03d | ||
|
|
f369ffb220 |
@@ -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=
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
.direnv/
|
||||
.env
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
Generated
+1128
File diff suppressed because it is too large
Load Diff
+16
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// TODO: Make this whole thing a separate crate, because HOLY SHIT
|
||||
pub mod listenbrainz;
|
||||
pub mod musicbrainz;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user