initial commit

This commit is contained in:
zack 2025-07-27 15:29:04 -04:00
commit 3222d5f8ba
Signed by: zoey
GPG key ID: 81FB9FECDD6A33E2
8 changed files with 2719 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

2095
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
resolver = "2"
members = ["crates/ciderd"]

16
crates/ciderd/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "ciderd"
version = "0.1.0"
edition = "2024"
[dependencies]
ctrlc = "3.4.7"
crossbeam-channel = "0.5"
rust_socketio = { version = "0.6.0", features = ["async"] }
tracing = "0.1.41"
serde_json = "1"
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = {version = "1", features = ["derive"]}
tracing-subscriber = "0.3.19"
anyhow = "1"
color-eyre = "0.6"

349
crates/ciderd/src/main.rs Normal file
View file

@ -0,0 +1,349 @@
use std::{
sync::{LazyLock, RwLock},
time::Duration,
};
use crossbeam_channel::unbounded;
use rust_socketio::{ClientBuilder, Payload};
use serde::{Deserialize, Serialize};
use tracing::{error, info};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlaybackUpdate {
pub data: Data,
#[serde(rename = "type")]
pub type_field: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Data {
#[serde(rename = "currentPlaybackDuration")]
pub current_playback_duration: Option<f64>,
#[serde(rename = "currentPlaybackTime")]
pub current_playback_time: Option<f64>,
#[serde(rename = "currentPlaybackTimeRemaining")]
pub current_playback_time_remaining: Option<f64>,
#[serde(rename = "isPlaying")]
pub is_playing: Option<bool>,
#[serde(rename = "albumName")]
pub album_name: Option<String>,
#[serde(rename = "artistName")]
pub artist_name: Option<String>,
pub artwork: Option<Artwork>,
#[serde(rename = "audioLocale")]
pub audio_locale: Option<String>,
#[serde(rename = "audioTraits")]
pub audio_traits: Option<Vec<String>>,
#[serde(rename = "composerName")]
pub composer_name: Option<String>,
#[serde(rename = "discNumber")]
pub disc_number: Option<i64>,
#[serde(rename = "durationInMillis")]
pub duration_in_millis: Option<i64>,
#[serde(rename = "genreNames")]
#[serde(default)]
pub genre_names: Vec<String>,
#[serde(rename = "hasLyrics")]
pub has_lyrics: Option<bool>,
#[serde(rename = "hasTimeSyncedLyrics")]
pub has_time_synced_lyrics: Option<bool>,
#[serde(rename = "isAppleDigitalMaster")]
pub is_apple_digital_master: Option<bool>,
#[serde(rename = "isMasteredForItunes")]
pub is_mastered_for_itunes: Option<bool>,
#[serde(rename = "isVocalAttenuationAllowed")]
pub is_vocal_attenuation_allowed: Option<bool>,
pub isrc: Option<String>,
pub name: Option<String>,
#[serde(rename = "playParams")]
pub play_params: Option<PlayParams>,
#[serde(default)]
pub previews: Vec<Preview>,
#[serde(rename = "releaseDate")]
pub release_date: Option<String>,
#[serde(rename = "remainingTime")]
pub remaining_time: Option<f64>,
#[serde(rename = "trackNumber")]
pub track_number: Option<i64>,
pub url: Option<String>,
pub attributes: Option<Attributes>,
pub state: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Artwork {
pub height: i64,
pub url: String,
pub width: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlayParams {
pub id: String,
pub kind: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Preview {
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Attributes {
#[serde(rename = "albumName")]
pub album_name: String,
#[serde(rename = "artistName")]
pub artist_name: String,
pub artwork: Artwork2,
#[serde(rename = "audioLocale")]
pub audio_locale: String,
#[serde(rename = "audioTraits")]
pub audio_traits: Vec<String>,
#[serde(rename = "composerName")]
pub composer_name: String,
#[serde(rename = "currentPlaybackTime")]
pub current_playback_time: f64,
#[serde(rename = "discNumber")]
pub disc_number: i64,
#[serde(rename = "durationInMillis")]
pub duration_in_millis: i64,
#[serde(rename = "genreNames")]
pub genre_names: Vec<String>,
#[serde(rename = "hasLyrics")]
pub has_lyrics: bool,
#[serde(rename = "hasTimeSyncedLyrics")]
pub has_time_synced_lyrics: bool,
#[serde(rename = "isAppleDigitalMaster")]
pub is_apple_digital_master: bool,
#[serde(rename = "isMasteredForItunes")]
pub is_mastered_for_itunes: bool,
#[serde(rename = "isVocalAttenuationAllowed")]
pub is_vocal_attenuation_allowed: bool,
pub isrc: String,
pub name: String,
#[serde(rename = "playParams")]
pub play_params: PlayParams2,
pub previews: Vec<Preview2>,
#[serde(rename = "releaseDate")]
pub release_date: String,
#[serde(rename = "remainingTime")]
pub remaining_time: f64,
#[serde(rename = "trackNumber")]
pub track_number: i64,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Artwork2 {
pub height: i64,
pub url: String,
pub width: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlayParams2 {
pub id: String,
pub kind: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Preview2 {
pub url: String,
}
#[derive(Debug, Deserialize)]
struct NowPlayingResponse {
info: Data,
}
#[derive(Debug, Deserialize)]
struct IsPlayingResponse {
is_playing: bool,
}
#[derive(Clone, Serialize, Deserialize)]
struct StatusUpdate {
text: String,
tooltip: String,
class: Vec<String>,
}
impl Default for StatusUpdate {
fn default() -> Self {
Self {
text: "Not playing".to_owned(),
tooltip: "Open cider to start playing".to_owned(),
class: vec!["paused".to_owned()],
}
}
}
static STATE: LazyLock<RwLock<StatusUpdate>> =
LazyLock::new(|| RwLock::new(StatusUpdate::default()));
fn parse_payload(payload: Payload) -> color_eyre::Result<bool> {
let values = match payload {
Payload::Text(values) => values,
_ => return Ok(false),
};
let Some(value) = values.first() else {
return Ok(false);
};
// Note: No .clone() needed here, we deserialize from a reference.
let update_data: PlaybackUpdate = serde_json::from_value(value.clone())?;
let mut state_changed = false;
let mut state_guard = STATE.write().unwrap();
match update_data.type_field.as_str() {
"playbackStatus.nowPlayingItemDidChange" => {
let new_text = format!(
"{} - {}",
update_data.data.name.as_deref().unwrap_or_default(),
update_data.data.artist_name.as_deref().unwrap_or_default()
);
let new_tooltip = format!(
"On the album **{}**",
update_data.data.album_name.as_deref().unwrap_or_default()
);
if state_guard.text != new_text {
state_guard.text = new_text;
state_changed = true;
}
if state_guard.tooltip != new_tooltip {
state_guard.tooltip = new_tooltip;
state_changed = true;
}
}
"playbackStatus.playbackStateDidChange" => {
if let Some(new_state) = update_data.data.state {
// Check if the class vec already contains this state
if state_guard.class.first() != Some(&new_state) {
state_guard.class = vec![new_state];
state_changed = true;
}
}
}
_ => {}
}
Ok(state_changed)
}
fn setup_ctrlc_handler() -> crossbeam_channel::Receiver<()> {
let (sender, receiver) = unbounded();
// The `move` keyword transfers ownership of the sender to the closure.
ctrlc::set_handler(move || {
println!("\n🛑 Ctrl-C received, sending shutdown signal...");
// Sending a message on the channel to notify the main loop.
sender.send(()).expect("Could not send signal on channel.");
})
.expect("Error setting Ctrl-C handler");
receiver
}
fn get_now_playing() -> Result<NowPlayingResponse, reqwest::Error> {
const URL: &str = "http://localhost:10767/api/v1/playback/now-playing";
// This call blocks until the request is complete.
let response = reqwest::blocking::get(URL)?;
let data: NowPlayingResponse = response.json()?;
Ok(data)
}
fn get_is_playing() -> Result<IsPlayingResponse, reqwest::Error> {
const URL: &str = "http://localhost:10767/api/v1/playback/is-playing";
// This call blocks until the request is complete.
let response = reqwest::blocking::get(URL)?;
let data: IsPlayingResponse = response.json()?;
Ok(data)
}
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
tracing_subscriber::fmt().pretty().init();
let ctrlc_receiver = setup_ctrlc_handler();
let socket = ClientBuilder::new("http://localhost:10767")
.on("connect", |_, _| {
info!("connecting!");
})
.on("API:Playback", |payload, _| {
if let Ok(true) = parse_payload(payload) {
let update_str = serde_json::to_string(&*STATE.read().unwrap()).unwrap();
println!("{update_str}")
}
})
.connect();
'main_loop: loop {
if let Err(e) = socket.as_ref() {
error!("An error occurred: {e}, retrying...");
match ctrlc_receiver.recv_timeout(Duration::from_secs(1)) {
Ok(_) => {
info!("Shutting down");
break 'main_loop;
}
Err(_) => continue 'main_loop,
}
} else {
if let Ok(d) = get_now_playing() {
let new_text = format!(
"{} - {}",
d.info.name.as_deref().unwrap_or_default(),
d.info.artist_name.as_deref().unwrap_or_default()
);
let new_tooltip = format!(
"On the album **{}**",
d.info.album_name.as_deref().unwrap_or_default()
);
let mut state_guard = STATE.write().unwrap();
if state_guard.text != new_text {
state_guard.text = new_text;
}
if state_guard.tooltip != new_tooltip {
state_guard.tooltip = new_tooltip;
}
drop(state_guard);
}
if let Ok(d) = get_is_playing() {
let new_text = match d.is_playing {
true => "playing".to_owned(),
false => "paused".to_owned(),
};
let mut state_guard = STATE.write().unwrap();
if state_guard.text != new_text {
state_guard.class = vec![new_text];
}
drop(state_guard);
}
let update_str = serde_json::to_string(&*STATE.read().unwrap()).unwrap();
println!("{update_str}");
ctrlc_receiver.recv().unwrap();
socket.as_ref().unwrap().disconnect()?;
break 'main_loop;
}
}
Ok(())
}

147
flake.lock generated Normal file
View file

@ -0,0 +1,147 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1753316655,
"narHash": "sha256-tzWa2kmTEN69OEMhxFy+J2oWSvZP5QhEgXp3TROOzl0=",
"owner": "ipetkov",
"repo": "crane",
"rev": "f35a3372d070c9e9ccb63ba7ce347f0634ddf3d2",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1753121425,
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1753432016,
"narHash": "sha256-cnL5WWn/xkZoyH/03NNUS7QgW5vI7D1i74g48qplCvg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6027c30c8e9810896b92429f0092f624f7b1aace",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1751159883,
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1747958103,
"narHash": "sha256-qmmFCrfBwSHoWw7cVK4Aj+fns+c54EBP8cGqp/yK410=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "fe51d34885f7b5e3e7b59572796e1bcb427eccb1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay",
"treefmt-nix": "treefmt-nix"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1753584741,
"narHash": "sha256-i147iFSy4K4PJvID+zoszLbRi2o+YV8AyG4TUiDQ3+I=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "69dfe029679e73b8d159011c9547f6148a85ca6b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1753439394,
"narHash": "sha256-Bv9h1AJegLI8uAhiJ1sZ4XAndYxhgf38tMgCQwiEpmc=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "2673921c03d6e75fdf4aa93e025772608d1482cf",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

107
flake.nix Normal file
View file

@ -0,0 +1,107 @@
# SPDX-License-Identifier: Unlicense
{
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
# systems.url = "github:nix-systems/default";
treefmt-nix.url = "github:numtide/treefmt-nix";
rust-overlay.url = "github:oxalica/rust-overlay";
crane.url = "github:ipetkov/crane";
};
# Add settings for your binary cache.
# nixConfig = {
# extra-substituters = [
# ];
# extra-trusted-public-keys = [
# ];
# };
outputs = inputs @ {
nixpkgs,
flake-parts,
...
}: let
# For details on these options, See
# https://github.com/oxalica/rust-overlay?tab=readme-ov-file#cheat-sheet-common-usage-of-rust-bin
#
# Channel of the Rust toolchain (stable or beta).
rustChannel = "stable";
# Version (latest or specific date/semantic version)
rustVersion = "latest";
# Profile (default or minimal)
rustProfile = "default";
in
flake-parts.lib.mkFlake {inherit inputs;} {
systems = nixpkgs.lib.systems.flakeExposed;
imports = [
inputs.treefmt-nix.flakeModule
];
perSystem = {
config,
system,
pkgs,
lib,
craneLib,
commonArgs,
...
}: {
_module.args = {
pkgs = import nixpkgs {
inherit system;
overlays = [inputs.rust-overlay.overlays.default];
};
craneLib = (inputs.crane.mkLib pkgs).overrideToolchain (
pkgs: pkgs.rust-bin.${rustChannel}.${rustVersion}.${rustProfile}
);
commonArgs = {
# Depending on your code base, you may have to customize the
# source filtering to include non-standard files during the build.
# See
# https://crane.dev/source-filtering.html?highlight=source#source-filtering
src = craneLib.cleanCargoSource (craneLib.path ./.);
nativeBuildInputs = with pkgs; [
pkg-config
];
buildInputs = with pkgs; [
openssl
];
};
};
# Build the executable package.
packages.default = craneLib.buildPackage (
commonArgs
// {
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
}
);
devShells.default = craneLib.devShell {
packages =
(commonArgs.nativeBuildInputs or [])
++ (commonArgs.buildInputs or [])
++ [pkgs.rust-analyzer-unwrapped];
RUST_SRC_PATH = "${
pkgs.rust-bin.${rustChannel}.${rustVersion}.rust-src
}/lib/rustlib/src/rust/library";
};
treefmt = {
projectRootFile = "Cargo.toml";
programs = {
actionlint.enable = true;
nixfmt.enable = true;
rustfmt.enable = true;
};
};
};
};
}