├── .taplo.toml ├── REUSE.toml ├── src ├── cmd │ ├── mod.rs │ ├── discord │ │ ├── agent.rs │ │ └── mod.rs │ └── now.rs ├── http.rs ├── rich_presence │ ├── errors.rs │ ├── pack_unpack.rs │ ├── mod.rs │ ├── ipc_impl.rs │ ├── ipc_trait.rs │ └── activity.rs ├── music │ ├── models.rs │ ├── metadata.rs │ └── mod.rs ├── format.rs └── main.rs ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ ├── build.yml │ └── check.yml ├── flake.lock ├── Cargo.toml ├── flake.nix ├── README.md ├── LICENSES ├── CC0-1.0.txt └── GPL-3.0-or-later.txt └── LICENSE /.taplo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryan Cao 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | [formatting] 6 | column_width = 200 7 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[annotations]] 4 | path = ["Cargo.lock", "flake.lock"] 5 | SPDX-FileCopyrightText = "2025 Ryan Cao " 6 | SPDX-License-Identifier = "CC0-1.0" 7 | -------------------------------------------------------------------------------- /src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | pub mod discord; 6 | pub mod now; 7 | 8 | pub use discord::*; 9 | pub use now::*; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryan Cao 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | # Rust 6 | target/ 7 | 8 | # Nix 9 | result* 10 | .direnv/ 11 | 12 | # IDEs 13 | .vscode/ 14 | .idea/ 15 | -------------------------------------------------------------------------------- /src/http.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | use std::sync::LazyLock; 6 | 7 | use reqwest::Client; 8 | 9 | pub static HTTP: LazyLock = 10 | LazyLock::new(|| Client::builder().https_only(true).build().unwrap()); 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryan Cao 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | version: 2 6 | 7 | updates: 8 | - package-ecosystem: "cargo" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /src/rich_presence/errors.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // SPDX-FileCopyrightText: 2022 sardonicism-04 3 | // 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use thiserror::Error; 7 | 8 | #[derive(Error, Debug)] 9 | pub enum RichPresenceError { 10 | #[error("Could not connect to IPC socket")] 11 | CouldNotConnect, 12 | #[error("Received invalid packet")] 13 | RecvInvalidPacket, 14 | #[error("Failed to write to socket")] 15 | WriteSocketFailed, 16 | #[error("Failed to read from socket")] 17 | ReadSocketFailed, 18 | #[error("Failed to flush socket")] 19 | FlushSocketFailed, 20 | 21 | #[error("Invalid value when creating button")] 22 | ButtonCreateInvalidValue, 23 | #[error("Too many ({0}) buttons provided to activity")] 24 | TooManyButtons(usize), 25 | } 26 | -------------------------------------------------------------------------------- /src/rich_presence/pack_unpack.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // SPDX-FileCopyrightText: 2022 sardonicism-04 3 | // 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::errors::RichPresenceError; 7 | use std::convert::TryInto; 8 | 9 | // Re-implement some packing methods in Rust 10 | pub fn pack(opcode: u32, data_len: u32) -> Vec { 11 | let mut bytes = Vec::new(); 12 | 13 | for byte_array in &[opcode.to_le_bytes(), data_len.to_le_bytes()] { 14 | bytes.extend_from_slice(byte_array); 15 | } 16 | 17 | bytes 18 | } 19 | 20 | pub fn unpack(data: &[u8]) -> Result<(u32, u32), RichPresenceError> { 21 | let (opcode, header) = data.split_at(std::mem::size_of::()); 22 | 23 | let opcode = u32::from_le_bytes( 24 | opcode 25 | .try_into() 26 | .map_err(|_| RichPresenceError::RecvInvalidPacket)?, 27 | ); 28 | let header = u32::from_le_bytes( 29 | header 30 | .try_into() 31 | .map_err(|_| RichPresenceError::RecvInvalidPacket)?, 32 | ); 33 | 34 | Ok((opcode, header)) 35 | } 36 | -------------------------------------------------------------------------------- /src/rich_presence/mod.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // SPDX-FileCopyrightText: 2022 sardonicism-04 3 | // 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | //! This library provides easy access to the Discord IPC. 7 | //! 8 | //! It provides implementations for both Unix and Windows 9 | //! operating systems, with both implementations using the 10 | //! same API. Thus, this crate can be used in a platform-agnostic 11 | //! manner. 12 | //! 13 | //! # Hello world 14 | //! ``` 15 | //! use crate::rich_presence::{activity, DiscordIpc, DiscordIpcClient}; 16 | //! 17 | //! fn main() -> Result<(), Box> { 18 | //! let mut client = DiscordIpcClient::new("")?; 19 | //! client.connect()?; 20 | //! 21 | //! let payload = activity::Activity::new().state("Hello world!"); 22 | //! client.set_activity(payload)?; 23 | //! } 24 | //! ``` 25 | 26 | mod errors; 27 | pub use errors::RichPresenceError; 28 | 29 | mod ipc_trait; 30 | mod pack_unpack; 31 | 32 | pub use ipc_trait::*; 33 | pub mod activity; 34 | 35 | mod ipc_impl; 36 | pub use ipc_impl::DiscordIpcClient; 37 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "ferrix": { 4 | "locked": { 5 | "lastModified": 1761331410, 6 | "narHash": "sha256-NFmP5KQ7M0NKPuV6qqGlD7T9d/n9+hnaL0280lKNoPA=", 7 | "owner": "ryanccn", 8 | "repo": "ferrix", 9 | "rev": "c0fa128ca47c1c117a05b8f8f390ca42cfd3ebf8", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ryanccn", 14 | "repo": "ferrix", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1761236834, 21 | "narHash": "sha256-+pthv6hrL5VLW2UqPdISGuLiUZ6SnAXdd2DdUE+fV2Q=", 22 | "owner": "NixOS", 23 | "repo": "nixpkgs", 24 | "rev": "d5faa84122bc0a1fd5d378492efce4e289f8eac1", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "NixOS", 29 | "ref": "nixpkgs-unstable", 30 | "repo": "nixpkgs", 31 | "type": "github" 32 | } 33 | }, 34 | "root": { 35 | "inputs": { 36 | "ferrix": "ferrix", 37 | "nixpkgs": "nixpkgs" 38 | } 39 | } 40 | }, 41 | "root": "root", 42 | "version": 7 43 | } 44 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryan Cao 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | [package] 6 | name = "am" 7 | version = "0.6.3" 8 | edition = "2024" 9 | 10 | description = "A beautiful and feature-packed Apple Music CLI" 11 | categories = ["command-line-utilities"] 12 | keywords = ["apple", "macos", "music", "apple-music"] 13 | authors = ["Ryan Cao "] 14 | license = "GPL-3.0-or-later" 15 | homepage = "https://github.com/ryanccn/am" 16 | repository = "https://github.com/ryanccn/am.git" 17 | 18 | [dependencies] 19 | anstream = "0.6.20" 20 | async-trait = "0.1.89" 21 | chrono = "0.4.42" 22 | clap = { version = "4.5.48", features = ["derive"] } 23 | clap_complete = "4.5.58" 24 | color-eyre = "0.6.5" 25 | crossterm = { version = "0.29.0", features = ["event-stream"] } 26 | eyre = "0.6.12" 27 | futures = { version = "0.3.31", default-features = false, features = ["std", "async-await"] } 28 | owo-colors = "4.2.2" 29 | regex = "1.11.2" 30 | reqwest = { version = "0.12.23", default-features = false, features = ["charset", "http2", "macos-system-configuration", "rustls-tls", "json", "deflate", "gzip", "brotli", "zstd"] } 31 | serde = { version = "1.0.226", features = ["derive"] } 32 | serde_json = "1.0.145" 33 | thiserror = "2.0.16" 34 | tokio = { version = "1.47.1", features = ["full"] } 35 | uuid = { version = "1.18.1", features = ["v4"] } 36 | 37 | [lints.clippy] 38 | all = { level = "warn", priority = -1 } 39 | pedantic = { level = "warn", priority = -1 } 40 | perf = { level = "warn", priority = -1 } 41 | 42 | module_name_repetitions = "allow" 43 | too_many_lines = "allow" 44 | 45 | [lints.rust] 46 | unsafe_code = "forbid" 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryan Cao 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | name: Release 6 | 7 | on: 8 | push: 9 | tags: ["v*.*.*"] 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | build: 15 | permissions: 16 | contents: read 17 | uses: ./.github/workflows/build.yml 18 | with: 19 | disable-cache: true 20 | 21 | publish: 22 | permissions: 23 | contents: read 24 | id-token: write 25 | needs: ["build"] 26 | runs-on: macos-latest 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 31 | with: 32 | persist-credentials: false 33 | 34 | - name: Install Rust toolchain 35 | uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # ratchet:dtolnay/rust-toolchain@stable 36 | with: 37 | toolchain: stable 38 | 39 | - name: Authenticate with crates.io 40 | uses: rust-lang/crates-io-auth-action@041cce5b4b821e6b0ebc9c9c38b58cac4e34dcc2 # ratchet:rust-lang/crates-io-auth-action@v1 41 | id: auth 42 | 43 | - name: Publish 44 | run: cargo publish 45 | env: 46 | CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} 47 | 48 | release: 49 | needs: ["build"] 50 | runs-on: macos-latest 51 | 52 | permissions: 53 | contents: write 54 | 55 | steps: 56 | - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # ratchet:actions/download-artifact@v6 57 | with: 58 | path: artifacts 59 | 60 | - name: Upload to release 61 | uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # ratchet:softprops/action-gh-release@v2 62 | with: 63 | files: artifacts/**/* 64 | -------------------------------------------------------------------------------- /src/music/models.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | #[derive(serde::Deserialize)] 6 | pub struct AppleMusicData { 7 | pub results: AppleMusicDataResults, 8 | } 9 | 10 | #[derive(serde::Deserialize)] 11 | pub struct AppleMusicDataResults { 12 | pub song: AppleMusicDataResultsSong, 13 | } 14 | 15 | #[derive(serde::Deserialize)] 16 | pub struct AppleMusicDataResultsSong { 17 | pub data: Vec, 18 | } 19 | 20 | #[derive(serde::Deserialize)] 21 | pub struct AppleMusicDataResultsSongData { 22 | pub id: String, 23 | pub attributes: AppleMusicDataResultsSongDataAttributes, 24 | pub relationships: AppleMusicDataResultsSongDataRelationships, 25 | } 26 | 27 | #[derive(serde::Deserialize)] 28 | pub struct AppleMusicDataResultsSongDataAttributes { 29 | pub url: String, 30 | pub artwork: AppleMusicDataResultsSongDataAttributesArtwork, 31 | } 32 | 33 | #[derive(serde::Deserialize)] 34 | pub struct AppleMusicDataResultsSongDataAttributesArtwork { 35 | pub url: String, 36 | } 37 | 38 | #[derive(serde::Deserialize)] 39 | pub struct AppleMusicDataResultsSongDataRelationships { 40 | pub artists: AppleMusicDataResultsSongDataRelationshipsArtists, 41 | } 42 | 43 | #[derive(serde::Deserialize)] 44 | pub struct AppleMusicDataResultsSongDataRelationshipsArtists { 45 | pub data: Vec, 46 | } 47 | 48 | #[derive(serde::Deserialize)] 49 | pub struct AppleMusicDataResultsSongDataRelationshipsArtistsData { 50 | pub attributes: AppleMusicDataResultsSongDataRelationshipsArtistsDataAttributes, 51 | } 52 | 53 | #[derive(serde::Deserialize)] 54 | pub struct AppleMusicDataResultsSongDataRelationshipsArtistsDataAttributes { 55 | pub artwork: AppleMusicDataResultsSongDataRelationshipsArtistsDataAttributesArtwork, 56 | } 57 | 58 | #[derive(serde::Deserialize)] 59 | pub struct AppleMusicDataResultsSongDataRelationshipsArtistsDataAttributesArtwork { 60 | pub url: String, 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryan Cao 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | name: Build 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | pull_request: 11 | branches: ["main"] 12 | workflow_call: 13 | inputs: 14 | disable-cache: 15 | type: boolean 16 | default: false 17 | 18 | permissions: 19 | contents: read 20 | 21 | jobs: 22 | build: 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | target: 27 | - "x86_64-apple-darwin" 28 | - "aarch64-apple-darwin" 29 | 30 | runs-on: macos-latest 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 35 | with: 36 | persist-credentials: false 37 | 38 | - name: Install Rust toolchain 39 | uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # ratchet:dtolnay/rust-toolchain@stable 40 | with: 41 | toolchain: stable 42 | targets: ${{ matrix.target }} 43 | 44 | - name: Setup Rust cache 45 | uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # ratchet:Swatinem/rust-cache@v2 46 | if: ${{ inputs.disable-cache != true }} 47 | 48 | - name: Install cargo-auditable 49 | uses: taiki-e/install-action@1c7b1d35fcc8f6525be0cbdacbf5977079a3f94c # ratchet:taiki-e/install-action@v2 50 | with: 51 | tool: cargo-auditable 52 | 53 | - name: Build 54 | run: cargo auditable build --release --locked --target ${{ matrix.target }} 55 | env: 56 | CARGO_PROFILE_RELEASE_LTO: "fat" 57 | CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "1" 58 | 59 | - name: Rename artifact 60 | run: cp ./target/${{ matrix.target }}/release/am ./am-${{ matrix.target }} 61 | 62 | - name: Upload artifacts 63 | uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # ratchet:actions/upload-artifact@v5 64 | with: 65 | name: am-${{ matrix.target }} 66 | path: ./am-${{ matrix.target }} 67 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryan Cao 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | name: Check 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | pull_request: 11 | branches: ["main"] 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | collect: 18 | runs-on: macos-latest 19 | outputs: 20 | checks: ${{ steps.checks.outputs.checks }} 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 25 | with: 26 | persist-credentials: false 27 | 28 | - name: Install Nix 29 | uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # ratchet:cachix/install-nix-action@v31 30 | 31 | - name: Collect checks 32 | id: checks 33 | run: | 34 | echo "checks=$(nix eval --impure --json --expr 'builtins.attrNames (builtins.getFlake (toString ./.)).checks.${builtins.currentSystem}')" >> "$GITHUB_OUTPUT" 35 | 36 | check: 37 | needs: collect 38 | strategy: 39 | matrix: 40 | check: ${{ fromJson(needs.collect.outputs.checks) }} 41 | fail-fast: false 42 | 43 | runs-on: macos-latest 44 | permissions: 45 | contents: read 46 | security-events: write 47 | 48 | steps: 49 | - name: Checkout repository 50 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 51 | with: 52 | persist-credentials: false 53 | 54 | - name: Install Nix 55 | uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # ratchet:cachix/install-nix-action@v31 56 | 57 | - name: Check 58 | run: nix build --fallback --print-build-logs '.#checks.aarch64-darwin.${{ matrix.check }}' 59 | 60 | - name: Upload Clippy results 61 | if: ${{ matrix.check == 'clippy' }} 62 | uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # ratchet:github/codeql-action/upload-sarif@v4 63 | with: 64 | sarif_file: result 65 | wait-for-processing: true 66 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryan Cao 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | { 6 | inputs = { 7 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 8 | ferrix.url = "github:ryanccn/ferrix"; 9 | }; 10 | 11 | outputs = 12 | { nixpkgs, ferrix, ... }@inputs: 13 | ferrix.lib.mkFlake inputs { 14 | root = ./.; 15 | systems = nixpkgs.lib.platforms.darwin; 16 | 17 | flake.homeModules = { 18 | am-discord = 19 | { 20 | lib, 21 | config, 22 | pkgs, 23 | ... 24 | }: 25 | let 26 | cfg = config.services.am-discord; 27 | inherit (lib) 28 | mkEnableOption 29 | mkIf 30 | mkOption 31 | mkPackageOption 32 | types 33 | ; 34 | in 35 | { 36 | options.services.am-discord = { 37 | enable = mkEnableOption "am-discord"; 38 | package = mkPackageOption pkgs "am" { }; 39 | 40 | logFile = mkOption { 41 | type = types.nullOr types.path; 42 | default = null; 43 | description = '' 44 | Path to where am's Discord presence will store its log file 45 | ''; 46 | example = ''''${config.xdg.cacheHome}/am-discord.log''; 47 | }; 48 | }; 49 | 50 | config = mkIf cfg.enable { 51 | assertions = [ 52 | (lib.hm.assertions.assertPlatform "launchd.agents.am-discord" pkgs lib.platforms.darwin) 53 | ]; 54 | 55 | launchd.agents.am-discord = { 56 | enable = true; 57 | 58 | config = { 59 | ProgramArguments = [ 60 | "${lib.getExe cfg.package}" 61 | "discord" 62 | ]; 63 | KeepAlive = true; 64 | RunAtLoad = true; 65 | 66 | StandardOutPath = cfg.logFile; 67 | StandardErrorPath = cfg.logFile; 68 | }; 69 | }; 70 | }; 71 | }; 72 | }; 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /src/format.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | use owo_colors::OwoColorize as _; 6 | 7 | const HOUR: i32 = 60 * 60; 8 | const MINUTE: i32 = 60; 9 | 10 | pub fn format_duration(duration_secs: i32, cyan: bool) -> String { 11 | let mut duration_secs = duration_secs; 12 | let mut str = String::new(); 13 | let mut has_started = false; 14 | 15 | if has_started || duration_secs >= HOUR { 16 | let hours = duration_secs / HOUR; 17 | 18 | if cyan { 19 | str = format!("{}{:.0}{}", str, hours, "h".dimmed()); 20 | } else { 21 | str = format!("{}{:.0}{}", str, hours.cyan(), "h".cyan().dimmed()); 22 | } 23 | 24 | duration_secs -= hours * HOUR; 25 | has_started = true; 26 | } 27 | 28 | if has_started || duration_secs >= MINUTE { 29 | let mins = duration_secs / MINUTE; 30 | 31 | if cyan { 32 | str = format!("{}{:.0}{}", str, mins.cyan(), "m".cyan().dimmed()); 33 | } else { 34 | str = format!("{}{:.0}{}", str, mins, "m".dimmed()); 35 | } 36 | 37 | duration_secs -= mins * MINUTE; 38 | // has_started = true; 39 | } 40 | 41 | if cyan { 42 | str = format!("{}{:.0}{}", str, duration_secs.cyan(), "s".cyan().dimmed()); 43 | } else { 44 | str = format!("{}{:.0}{}", str, duration_secs, "s".dimmed()); 45 | } 46 | 47 | str 48 | } 49 | 50 | pub fn format_duration_plain(duration_secs: i32) -> String { 51 | let mut duration_secs = duration_secs; 52 | let mut str = String::new(); 53 | let mut has_started = false; 54 | 55 | if has_started || duration_secs > HOUR { 56 | let hours = duration_secs / HOUR; 57 | 58 | str = format!("{}{:.0}{}", str, hours, "h"); 59 | duration_secs -= hours * HOUR; 60 | has_started = true; 61 | } 62 | 63 | if has_started || duration_secs > MINUTE { 64 | let mins = duration_secs / MINUTE; 65 | 66 | str = format!("{}{:.0}{}", str, mins, "m"); 67 | duration_secs -= mins * MINUTE; 68 | // has_started = true; 69 | } 70 | 71 | str = format!("{}{:.0}{}", str, duration_secs, "s"); 72 | str 73 | } 74 | -------------------------------------------------------------------------------- /src/cmd/discord/agent.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | use std::path::{Path, PathBuf}; 6 | use tokio::{fs, process::Command}; 7 | 8 | use anstream::println; 9 | use eyre::Result; 10 | use owo_colors::OwoColorize as _; 11 | 12 | const AGENT_ID: &str = "dev.ryanccn.am.discord"; 13 | 14 | fn get_agent_path() -> Result { 15 | Ok(Path::new(&std::env::var("HOME")?) 16 | .join("Library") 17 | .join("LaunchAgents") 18 | .join(format!("{AGENT_ID}.plist"))) 19 | } 20 | 21 | fn get_plist() -> Result { 22 | let executable_path = std::env::current_exe()?; 23 | let executable = executable_path.to_string_lossy(); 24 | 25 | let log_file_path = PathBuf::from(std::env::var("HOME")?) 26 | .join("Library") 27 | .join("Logs") 28 | .join("am-discord.log"); 29 | let log_file = log_file_path.to_string_lossy(); 30 | 31 | Ok(format!(r#" 32 | 33 | 34 | 35 | 36 | KeepAlive 37 | 38 | Label 39 | {AGENT_ID} 40 | ProgramArguments 41 | 42 | {executable} 43 | discord 44 | 45 | RunAtLoad 46 | 47 | StandardOutPath 48 | {log_file} 49 | StandardErrorPath 50 | {log_file} 51 | 52 | 53 | "#).trim().to_owned() + "\n") 54 | } 55 | 56 | pub async fn install() -> Result<()> { 57 | let path = get_agent_path()?; 58 | 59 | if path.exists() { 60 | uninstall().await?; 61 | } 62 | 63 | fs::write(&path, get_plist()?).await?; 64 | 65 | Command::new("launchctl") 66 | .args(["load", "-w", &path.to_string_lossy()]) 67 | .status() 68 | .await?; 69 | 70 | Ok(()) 71 | } 72 | 73 | pub async fn uninstall() -> Result<()> { 74 | let path = get_agent_path()?; 75 | 76 | if !path.exists() { 77 | println!("{}", "Launch agent is not installed".yellow()); 78 | return Ok(()); 79 | } 80 | 81 | Command::new("launchctl") 82 | .args(["unload", &path.to_string_lossy()]) 83 | .status() 84 | .await?; 85 | 86 | fs::remove_file(&path).await?; 87 | 88 | Ok(()) 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # `am` 8 | 9 | A beautiful and feature-packed Apple Music CLI, written in [Rust](https://www.rust-lang.org/). 10 | 11 | ## Installation 12 | 13 | ### Cargo 14 | 15 | You can install `am` with `cargo install` or `cargo binstall` from crates.io. 16 | 17 | ```bash 18 | cargo binstall am 19 | ``` 20 | 21 | ### Nix 22 | 23 | This GitHub repository contains a flake. Add `github:ryanccn/am` to your flake inputs: 24 | 25 | ```nix 26 | { 27 | am = { 28 | url = "github:ryanccn/am"; 29 | inputs.nixpkgs.follows = "nixpkgs"; 30 | } 31 | } 32 | ``` 33 | 34 | Then, use the overlay from `overlays.default` and add `am` to your packages. Alternatively, you can use `packages.{default,am}` directly. 35 | 36 | ### Manual download 37 | 38 | Download the [`aarch64`](https://github.com/ryanccn/am/releases/latest/download/am-aarch64-apple-darwin) (Apple Silicon) or the [`x86_64`](https://github.com/ryanccn/am/releases/latest/download/am-x86_64-apple-darwin) (Intel) version of the binary. 39 | 40 | Dequarantine them with `xattr -d com.apple.quarantine ` and make them executable with `chmod +x `. 41 | 42 | ## Features 43 | 44 | - Beautiful now playing display 45 | - Playback controls (play, pause, toggle, resume, back, forward, next, previous) 46 | - Song.link generation 47 | - Discord rich presence 48 | - Launch agent installation 49 | - Shell completions 50 | 51 | ## Discord presence launch agent 52 | 53 | Through a macOS launch agent, the Discord rich presence can be made to run in the background as long as you are logged in. 54 | 55 | ### Standard installation 56 | 57 | You can install the Discord presence as a launch agent by running `am discord install`. Note that this depends on the executable/symlink staying in the same place; if it moves to a different place, run the command again. 58 | 59 | The `am` process running in the launch agent will log to `~/Library/Logs/am-discord.log`. 60 | 61 | You can uninstall the launch agent with `am discord uninstall`. 62 | 63 | ### Home Manager 64 | 65 | This repository's flake also provides a Home Manager module at `homeModules.am-discord`. This module exposes a service `am-discord` that you can enable. 66 | 67 | ```nix 68 | { 69 | services.am-discord = { 70 | enable = true; 71 | # logFile = "${config.xdg.cacheHome}/am-discord.log"; 72 | } 73 | } 74 | ``` 75 | 76 | ## Thanks to... 77 | 78 | - [Raycast's Apple Music extension](https://github.com/raycast/extensions/tree/main/extensions/music) for a helpful reference of Apple Music's AppleScript interface usage 79 | - [sardonicism-04/discord-rich-presence](https://github.com/sardonicism-04/discord-rich-presence) for the original Rust crate for connecting to Discord 80 | - [caarlos0/discord-applemusic-rich-presence](https://github.com/caarlos0/discord-applemusic-rich-presence) for inspiring the Discord presence part of this CLI 81 | - [@ajaxm](https://github.com/ajaxm) for ceding ownership of the `am` package on crates.io 82 | 83 | ## License 84 | 85 | GPLv3 86 | -------------------------------------------------------------------------------- /src/music/metadata.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | use std::sync::{LazyLock, OnceLock}; 6 | 7 | use eyre::{Result, eyre}; 8 | use regex::Regex; 9 | 10 | use super::{Track, models::AppleMusicData}; 11 | use crate::http::HTTP; 12 | 13 | static TOKEN_CACHE: OnceLock = OnceLock::new(); 14 | 15 | static USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; 16 | 17 | static BUNDLE_REGEX: LazyLock = LazyLock::new(|| { 18 | Regex::new(r#""#).unwrap() 19 | }); 20 | 21 | static TOKEN_REGEX: LazyLock = LazyLock::new(|| { 22 | Regex::new( 23 | r#"Promise.allSettled\([A-Za-z_$][\w$]*\)}const [A-Za-z_$][\w$]*="([A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+)""#, 24 | ) 25 | .unwrap() 26 | }); 27 | 28 | #[derive(Debug)] 29 | pub struct Metadata { 30 | pub album_artwork: String, 31 | pub artist_artwork: Option, 32 | pub share_url: String, 33 | pub song_link: String, 34 | } 35 | 36 | pub async fn fetch_token() -> Result { 37 | if let Some(token) = TOKEN_CACHE.get() { 38 | return Ok(token.to_owned()); 39 | } 40 | 41 | let html = HTTP 42 | .get("https://music.apple.com/") 43 | .send() 44 | .await? 45 | .error_for_status()? 46 | .text() 47 | .await?; 48 | 49 | let bundle_path = &BUNDLE_REGEX 50 | .captures(&html) 51 | .ok_or_else(|| eyre!("could not obtain bundle for API token"))?[1]; 52 | 53 | let mut bundle_url = "https://music.apple.com/".parse::()?; 54 | bundle_url.set_path(bundle_path); 55 | 56 | let bundle = HTTP 57 | .get(bundle_url) 58 | .send() 59 | .await? 60 | .error_for_status()? 61 | .text() 62 | .await?; 63 | 64 | let token = &TOKEN_REGEX 65 | .captures(&bundle) 66 | .ok_or_else(|| eyre!("could not find API token in bundle"))?[1]; 67 | 68 | TOKEN_CACHE.set(token.to_owned()).unwrap(); 69 | 70 | Ok(token.to_owned()) 71 | } 72 | 73 | pub async fn fetch_metadata(track: &Track) -> Result { 74 | let token = fetch_token().await?; 75 | let song_key = track.name.clone() + " " + &track.album + " " + &track.artist; 76 | 77 | let mut api_url = 78 | "https://amp-api-edge.music.apple.com/v1/catalog/us/search".parse::()?; 79 | api_url 80 | .query_pairs_mut() 81 | .append_pair("platform", "web") 82 | .append_pair("l", "en-US") 83 | .append_pair("limit", "1") 84 | .append_pair("with", "serverBubbles") 85 | .append_pair("types", "songs") 86 | .append_pair("term", &song_key) 87 | .append_pair("include[songs]", "artists"); 88 | 89 | let data: AppleMusicData = HTTP 90 | .get(api_url) 91 | .bearer_auth(&token) 92 | .header("accept", "*/*") 93 | .header("accept-language", "en-US,en;q=0.9") 94 | .header("user-agent", USER_AGENT) 95 | .header("origin", "https://music.apple.com") 96 | .send() 97 | .await? 98 | .error_for_status()? 99 | .json() 100 | .await?; 101 | 102 | let track_data = data 103 | .results 104 | .song 105 | .data 106 | .first() 107 | .ok_or_else(|| eyre!("could not find track metadata"))?; 108 | 109 | let album_artwork = track_data 110 | .attributes 111 | .artwork 112 | .url 113 | .replace("{w}x{h}", "512x512"); 114 | 115 | let artist_artwork = track_data 116 | .relationships 117 | .artists 118 | .data 119 | .first() 120 | .map(|data| data.attributes.artwork.url.replace("{w}x{h}", "512x512")); 121 | 122 | Ok(Metadata { 123 | album_artwork, 124 | artist_artwork, 125 | share_url: track_data.attributes.url.clone(), 126 | song_link: format!("https://song.link/i/{}", track_data.id), 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /src/rich_presence/ipc_impl.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // SPDX-FileCopyrightText: 2022 sardonicism-04 3 | // 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::errors::RichPresenceError; 7 | use crate::rich_presence::DiscordIpc; 8 | 9 | use serde_json::json; 10 | 11 | use std::{env, path::PathBuf}; 12 | 13 | use tokio::{ 14 | io::{AsyncReadExt, AsyncWriteExt}, 15 | net::UnixStream, 16 | }; 17 | 18 | use async_trait::async_trait; 19 | 20 | // Environment keys to search for the Discord pipe 21 | const ENV_KEYS: [&str; 4] = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]; 22 | 23 | /// A wrapper struct for the functionality contained in the 24 | /// underlying [`DiscordIpc`](trait@DiscordIpc) trait. 25 | pub struct DiscordIpcClient { 26 | /// Client ID of the IPC client. 27 | pub client_id: String, 28 | socket: Option, 29 | } 30 | 31 | impl DiscordIpcClient { 32 | /// Creates a new `DiscordIpcClient`. 33 | /// 34 | /// # Examples 35 | /// ``` 36 | /// let ipc_client = DiscordIpcClient::new("")?; 37 | /// ``` 38 | pub fn new(client_id: &str) -> Self { 39 | Self { 40 | client_id: client_id.to_string(), 41 | socket: None, 42 | } 43 | } 44 | 45 | fn get_pipe_pattern() -> Result { 46 | for key in &ENV_KEYS { 47 | if let Ok(val) = env::var(key) { 48 | return Ok(PathBuf::from(val)); 49 | } 50 | } 51 | 52 | Err(RichPresenceError::CouldNotConnect) 53 | } 54 | } 55 | 56 | #[async_trait] 57 | impl DiscordIpc for DiscordIpcClient { 58 | async fn connect_ipc(&mut self) -> Result<(), RichPresenceError> { 59 | for i in 0..10 { 60 | let path = DiscordIpcClient::get_pipe_pattern()?.join(format!("discord-ipc-{i}")); 61 | 62 | if let Ok(socket) = UnixStream::connect(&path).await { 63 | self.socket = Some(socket); 64 | return Ok(()); 65 | } 66 | } 67 | 68 | Err(RichPresenceError::CouldNotConnect) 69 | } 70 | 71 | async fn write_once(&mut self, data: &[u8]) -> Result<(), RichPresenceError> { 72 | let socket = self 73 | .socket 74 | .as_mut() 75 | .ok_or_else(|| RichPresenceError::CouldNotConnect)?; 76 | 77 | socket 78 | .write_all(data) 79 | .await 80 | .map_err(|_| RichPresenceError::WriteSocketFailed)?; 81 | 82 | Ok(()) 83 | } 84 | 85 | async fn write(&mut self, data: &[u8]) -> Result<(), RichPresenceError> { 86 | match self.write_once(data).await { 87 | Err(RichPresenceError::CouldNotConnect | RichPresenceError::WriteSocketFailed) => { 88 | self.connect().await?; 89 | self.write_once(data).await?; 90 | Ok(()) 91 | } 92 | rest => rest, 93 | }?; 94 | 95 | Ok(()) 96 | } 97 | 98 | async fn read_once(&mut self, buffer: &mut [u8]) -> Result<(), RichPresenceError> { 99 | let socket = self 100 | .socket 101 | .as_mut() 102 | .ok_or_else(|| RichPresenceError::CouldNotConnect)?; 103 | 104 | socket 105 | .read_exact(buffer) 106 | .await 107 | .map_err(|_| RichPresenceError::ReadSocketFailed)?; 108 | 109 | Ok(()) 110 | } 111 | 112 | async fn read(&mut self, buffer: &mut [u8]) -> Result<(), RichPresenceError> { 113 | match self.read_once(buffer).await { 114 | Err(RichPresenceError::CouldNotConnect | RichPresenceError::WriteSocketFailed) => { 115 | self.connect().await?; 116 | self.read_once(buffer).await?; 117 | Ok(()) 118 | } 119 | rest => rest, 120 | }?; 121 | 122 | Ok(()) 123 | } 124 | 125 | async fn close(&mut self) -> Result<(), RichPresenceError> { 126 | let data = json!({}); 127 | self.send(data, 2).await?; 128 | 129 | let socket = self.socket.as_mut().unwrap(); 130 | 131 | socket 132 | .flush() 133 | .await 134 | .map_err(|_| RichPresenceError::FlushSocketFailed)?; 135 | match socket.shutdown().await { 136 | Ok(()) => (), 137 | Err(_err) => (), 138 | } 139 | 140 | Ok(()) 141 | } 142 | 143 | fn get_client_id(&self) -> &String { 144 | &self.client_id 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/music/mod.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | use std::process::Stdio; 6 | use tokio::process::Command; 7 | 8 | use eyre::{Result, bail, eyre}; 9 | 10 | mod metadata; 11 | mod models; 12 | 13 | pub use metadata::*; 14 | 15 | pub async fn is_running() -> Result { 16 | Ok(Command::new("pgrep") 17 | .arg(r"^Music$") 18 | .stdout(Stdio::null()) 19 | .stderr(Stdio::null()) 20 | .status() 21 | .await? 22 | .success()) 23 | } 24 | 25 | #[derive(Debug, Clone)] 26 | pub struct Track { 27 | pub id: String, 28 | pub name: String, 29 | pub album: String, 30 | pub artist: String, 31 | pub duration: f64, 32 | } 33 | 34 | #[derive(Debug, Clone)] 35 | pub struct Playlist { 36 | pub name: String, 37 | pub duration: i32, 38 | } 39 | 40 | pub async fn tell_raw(applescript: &[&str]) -> Result { 41 | let mut osascript = Command::new("osascript"); 42 | 43 | osascript.args(applescript.iter().flat_map(|expr| ["-e", expr])); 44 | 45 | let output = osascript.output().await?; 46 | let success = output.status.success(); 47 | 48 | let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); 49 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); 50 | 51 | if !success { 52 | bail!(stderr); 53 | } 54 | 55 | Ok(stdout) 56 | } 57 | 58 | pub async fn tell(applescript: &str) -> Result { 59 | tell_raw(&[r#"tell application "Music""#, applescript, r"end tell"]).await 60 | } 61 | 62 | #[derive(Clone, Copy, Debug, PartialEq)] 63 | pub enum PlayerState { 64 | Stopped, 65 | Playing, 66 | Paused, 67 | Forwarding, 68 | Rewinding, 69 | Unknown, 70 | } 71 | 72 | impl PlayerState { 73 | pub fn to_icon(self) -> String { 74 | match self { 75 | Self::Stopped => "", 76 | Self::Playing => "", 77 | Self::Paused => "", 78 | Self::Forwarding => "", 79 | Self::Rewinding => "", 80 | Self::Unknown => "?", 81 | } 82 | .into() 83 | } 84 | } 85 | 86 | impl std::fmt::Display for PlayerState { 87 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 88 | write!( 89 | f, 90 | "{}", 91 | match self { 92 | Self::Stopped => "stopped", 93 | Self::Playing => "playing", 94 | Self::Paused => "paused", 95 | Self::Forwarding => "fast forwarding", 96 | Self::Rewinding => "rewinding", 97 | Self::Unknown => "unknown", 98 | } 99 | ) 100 | } 101 | } 102 | 103 | impl std::str::FromStr for PlayerState { 104 | type Err = eyre::Report; 105 | 106 | fn from_str(s: &str) -> Result { 107 | match s { 108 | "stopped" => Ok(Self::Stopped), 109 | "playing" => Ok(Self::Playing), 110 | "paused" => Ok(Self::Paused), 111 | "fast forwarding" => Ok(Self::Forwarding), 112 | "rewinding" => Ok(Self::Rewinding), 113 | _ => Ok(Self::Unknown), 114 | } 115 | } 116 | } 117 | 118 | pub async fn get_player_state() -> Result { 119 | tell("get player state").await?.parse::() 120 | } 121 | 122 | pub async fn get_current_track() -> Result> { 123 | let player_state = get_player_state().await?; 124 | 125 | if player_state == PlayerState::Stopped { 126 | Ok(None) 127 | } else { 128 | let track_data = tell_raw(&[ 129 | r#"set output to """#, 130 | r#"tell application "Music""#, 131 | r"set t_id to database id of current track", 132 | r"set t_name to name of current track", 133 | r"set t_album to album of current track", 134 | r"set t_artist to artist of current track", 135 | r"set t_duration to duration of current track", 136 | r#"set output to "" & t_id & "\n" & t_name & "\n" & t_album & "\n" & t_artist & "\n" & t_duration"#, 137 | r"end tell", 138 | r"return output" 139 | ]) 140 | .await?; 141 | 142 | let mut track_data = track_data.split('\n'); 143 | 144 | let id = track_data 145 | .next() 146 | .ok_or_else(|| eyre!("Could not obtain track ID"))? 147 | .to_owned(); 148 | let name = track_data 149 | .next() 150 | .ok_or_else(|| eyre!("Could not obtain track name"))? 151 | .to_owned(); 152 | let album = track_data 153 | .next() 154 | .ok_or_else(|| eyre!("Could not obtain track album"))? 155 | .to_owned(); 156 | let artist = track_data 157 | .next() 158 | .ok_or_else(|| eyre!("Could not obtain track artist"))? 159 | .to_owned(); 160 | let duration = track_data 161 | .next() 162 | .ok_or_else(|| eyre!("Could not obtain track duration"))? 163 | .to_owned() 164 | .replace(',', ".") 165 | .parse::()?; 166 | 167 | Ok(Some(Track { 168 | id, 169 | name, 170 | album, 171 | artist, 172 | duration, 173 | })) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/cmd/discord/mod.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | use std::time::Duration; 6 | use tokio::{signal, time}; 7 | 8 | use anstream::{eprintln, println}; 9 | use eyre::{Result, eyre}; 10 | use owo_colors::OwoColorize as _; 11 | 12 | use crate::{ 13 | music, 14 | rich_presence::{ 15 | DiscordIpc, DiscordIpcClient, RichPresenceError, 16 | activity::{Activity, Assets, Button, Timestamps}, 17 | }, 18 | }; 19 | 20 | pub mod agent; 21 | 22 | #[derive(Debug, Clone)] 23 | struct ActivityState { 24 | last_song_id: Option, 25 | last_position: Option, 26 | is_idle: bool, 27 | } 28 | 29 | #[expect(clippy::cast_precision_loss, clippy::cast_possible_truncation)] 30 | async fn update_presence(client: &mut DiscordIpcClient, state: &mut ActivityState) -> Result<()> { 31 | if !music::is_running().await? { 32 | if !state.is_idle { 33 | println!("{} any songs", "Not playing".yellow()); 34 | state.last_position = None; 35 | state.last_song_id = None; 36 | state.is_idle = true; 37 | } 38 | 39 | client.clear_activity().await?; 40 | return Ok(()); 41 | } 42 | 43 | let initial_state = music::tell("get {player position, player state}").await?; 44 | let mut initial_state = initial_state.split(", "); 45 | 46 | let position = initial_state 47 | .next() 48 | .ok_or_else(|| eyre!("Could not obtain player position"))?; 49 | let player_state = initial_state 50 | .next() 51 | .ok_or_else(|| eyre!("Could not obtain player state"))? 52 | .parse::()?; 53 | 54 | if player_state != music::PlayerState::Playing { 55 | if !state.is_idle { 56 | println!("{} any songs", "Not playing".yellow()); 57 | state.last_position = None; 58 | state.last_song_id = None; 59 | state.is_idle = true; 60 | } 61 | 62 | client.clear_activity().await?; 63 | return Ok(()); 64 | } 65 | 66 | let position = position.replace(',', ".").parse::()?; 67 | 68 | let track = music::get_current_track() 69 | .await? 70 | .ok_or_else(|| eyre!("Could not obtain track information"))?; 71 | 72 | let mut ongoing = false; 73 | 74 | if let Some(last_song_id) = &state.last_song_id 75 | && *last_song_id == track.id 76 | && let Some(last_position) = &state.last_position 77 | && last_position <= &position 78 | { 79 | ongoing = true; 80 | } 81 | 82 | if !ongoing { 83 | let metadata = match music::fetch_metadata(&track).await { 84 | Ok(v) => Some(v), 85 | Err(e) => { 86 | eprintln!("failed to fetch metadata: {e:?}"); 87 | None 88 | } 89 | }; 90 | 91 | let now_ts = chrono::offset::Local::now().timestamp(); 92 | let start_ts = (now_ts as f64) - position; 93 | let end_ts = (now_ts as f64) + track.duration - position; 94 | 95 | let activity_state = format!("{} · {}", &track.artist, &track.album); 96 | 97 | let mut activity = Activity::new().details(&track.name).state(&activity_state); 98 | 99 | if let Some(metadata) = &metadata { 100 | let mut activity_assets = Assets::new() 101 | .large_image(&metadata.album_artwork) 102 | .large_text(&track.name); 103 | 104 | if let Some(artist_artwork) = &metadata.artist_artwork { 105 | activity_assets = activity_assets 106 | .small_image(artist_artwork) 107 | .small_text(&track.artist); 108 | } 109 | 110 | activity = activity.assets(activity_assets); 111 | } 112 | 113 | activity = activity.timestamps( 114 | Timestamps::new() 115 | .start(start_ts.floor() as i64) 116 | .end(end_ts.ceil() as i64), 117 | ); 118 | 119 | if let Some(metadata) = &metadata { 120 | activity = activity.buttons(vec![ 121 | Button::new("Listen on Apple Music", &metadata.share_url)?, 122 | Button::new("View on SongLink", &metadata.song_link)?, 123 | ])?; 124 | } 125 | 126 | client.set_activity(activity).await?; 127 | 128 | println!( 129 | "{} {} {} {}", 130 | "Song updated".blue(), 131 | &track.name, 132 | "·".dimmed(), 133 | &track.artist, 134 | ); 135 | 136 | state.last_position = Some(position); 137 | state.last_song_id = Some(track.id.clone()); 138 | state.is_idle = false; 139 | } 140 | 141 | Ok(()) 142 | } 143 | 144 | pub async fn discord() -> Result<()> { 145 | let mut client = DiscordIpcClient::new("861702238472241162"); 146 | if client.connect().await.is_ok() { 147 | println!("{} to Discord", "Connected".green()); 148 | } 149 | 150 | let mut state = ActivityState { 151 | last_position: None, 152 | last_song_id: None, 153 | is_idle: false, 154 | }; 155 | 156 | let mut last_connect_failed = false; 157 | let mut intvl = time::interval(Duration::from_secs(5)); 158 | 159 | loop { 160 | tokio::select! { 161 | _ = intvl.tick() => { 162 | if let Err(err) = update_presence(&mut client, &mut state).await { 163 | match err.downcast_ref::() { 164 | Some(RichPresenceError::CouldNotConnect | RichPresenceError::WriteSocketFailed) => { 165 | if !last_connect_failed { 166 | eprintln!("{} from Discord", "Disconnected".red()); 167 | last_connect_failed = true; 168 | } 169 | }, 170 | _ => { 171 | eprintln!("{} {}", "Error".red(), err); 172 | }, 173 | } 174 | } else if last_connect_failed { 175 | last_connect_failed = false; 176 | eprintln!("{} to Discord", "Connected".green()); 177 | } 178 | } 179 | 180 | _ = signal::ctrl_c() => { 181 | break; 182 | } 183 | } 184 | } 185 | 186 | println!("{} Discord presence", "Shutting down".yellow()); 187 | client.clear_activity().await?; 188 | client.close().await?; 189 | 190 | Ok(()) 191 | } 192 | -------------------------------------------------------------------------------- /src/rich_presence/ipc_trait.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // SPDX-FileCopyrightText: 2022 sardonicism-04 3 | // 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use crate::rich_presence::{ 7 | activity::Activity, 8 | errors::RichPresenceError, 9 | pack_unpack::{pack, unpack}, 10 | }; 11 | use async_trait::async_trait; 12 | use serde_json::{Value, json}; 13 | 14 | use uuid::Uuid; 15 | 16 | /// A client that connects to and communicates with the Discord IPC. 17 | /// 18 | /// Implemented via the [`DiscordIpcClient`](struct@crate::rich_presence::DiscordIpcClient) struct. 19 | #[expect(clippy::cast_possible_truncation)] 20 | #[async_trait] 21 | pub trait DiscordIpc { 22 | /// Connects the client to the Discord IPC. 23 | /// 24 | /// This method attempts to first establish a connection, 25 | /// and then sends a handshake. 26 | /// 27 | /// # Errors 28 | /// 29 | /// Returns an `Err` variant if the client 30 | /// fails to connect to the socket, or if it fails to 31 | /// send a handshake. 32 | /// 33 | /// # Examples 34 | /// ``` 35 | /// let mut client = crate::rich_presence::new_client("")?; 36 | /// client.connect()?; 37 | /// ``` 38 | async fn connect(&mut self) -> Result<(), RichPresenceError> { 39 | self.connect_ipc().await?; 40 | self.send_handshake().await?; 41 | 42 | Ok(()) 43 | } 44 | 45 | #[doc(hidden)] 46 | fn get_client_id(&self) -> &String; 47 | 48 | #[doc(hidden)] 49 | async fn connect_ipc(&mut self) -> Result<(), RichPresenceError>; 50 | 51 | /// Handshakes the Discord IPC. 52 | /// 53 | /// This method sends the handshake signal to the IPC. 54 | /// It is usually not called manually, as it is automatically 55 | /// called by [`connect`] and/or [`reconnect`]. 56 | /// 57 | /// [`connect`]: #method.connect 58 | /// [`reconnect`]: #method.reconnect 59 | /// 60 | /// # Errors 61 | /// 62 | /// Returns an `Err` variant if sending the handshake failed. 63 | async fn send_handshake(&mut self) -> Result<(), RichPresenceError> { 64 | self.send( 65 | json!({ 66 | "v": 1, 67 | "client_id": self.get_client_id() 68 | }), 69 | 0, 70 | ) 71 | .await?; 72 | 73 | // TODO: Return an Err if the handshake is rejected 74 | self.recv().await?; 75 | 76 | Ok(()) 77 | } 78 | 79 | /// Sends JSON data to the Discord IPC. 80 | /// 81 | /// This method takes data (`serde_json::Value`) and 82 | /// an opcode as its parameters. 83 | /// 84 | /// # Errors 85 | /// Returns an `Err` variant if writing to the socket failed 86 | /// 87 | /// # Examples 88 | /// ``` 89 | /// let payload = serde_json::json!({ "field": "value" }); 90 | /// client.send(payload, 0).await?; 91 | /// ``` 92 | async fn send(&mut self, data: Value, opcode: u8) -> Result<(), RichPresenceError> { 93 | let data_string = data.to_string(); 94 | let header = pack(opcode.into(), data_string.len() as u32); 95 | 96 | self.write(&header).await?; 97 | self.write(data_string.as_bytes()).await?; 98 | 99 | Ok(()) 100 | } 101 | 102 | #[doc(hidden)] 103 | async fn write_once(&mut self, data: &[u8]) -> Result<(), RichPresenceError>; 104 | #[doc(hidden)] 105 | async fn write(&mut self, data: &[u8]) -> Result<(), RichPresenceError>; 106 | 107 | /// Receives an opcode and JSON data from the Discord IPC. 108 | /// 109 | /// This method returns any data received from the IPC. 110 | /// It returns a tuple containing the opcode, and the JSON data. 111 | /// 112 | /// # Errors 113 | /// Returns an `Err` variant if reading the socket was 114 | /// unsuccessful. 115 | /// 116 | /// # Examples 117 | /// ``` 118 | /// client.connect_ipc().await?; 119 | /// client.send_handshake().await?; 120 | /// 121 | /// println!("{:?}", client.recv().await?); 122 | /// ``` 123 | async fn recv(&mut self) -> Result<(u32, Value), RichPresenceError> { 124 | let mut header = [0; 8]; 125 | 126 | self.read(&mut header).await?; 127 | let (op, length) = unpack(&header)?; 128 | 129 | let mut data = vec![0u8; length as usize]; 130 | self.read(&mut data).await?; 131 | 132 | let response = 133 | String::from_utf8(data.clone()).map_err(|_| RichPresenceError::RecvInvalidPacket)?; 134 | let json_data = serde_json::from_str::(&response) 135 | .map_err(|_| RichPresenceError::RecvInvalidPacket)?; 136 | 137 | Ok((op, json_data)) 138 | } 139 | 140 | #[doc(hidden)] 141 | async fn read_once(&mut self, buffer: &mut [u8]) -> Result<(), RichPresenceError>; 142 | #[doc(hidden)] 143 | async fn read(&mut self, buffer: &mut [u8]) -> Result<(), RichPresenceError>; 144 | 145 | /// Sets a Discord activity. 146 | /// 147 | /// This method is an abstraction of [`send`], 148 | /// wrapping it such that only an activity payload 149 | /// is required. 150 | /// 151 | /// [`send`]: #method.send 152 | /// 153 | /// # Errors 154 | /// Returns an `Err` variant if sending the payload failed. 155 | async fn set_activity(&mut self, activity_payload: Activity) -> Result<(), RichPresenceError> { 156 | let data = json!({ 157 | "cmd": "SET_ACTIVITY", 158 | "args": { 159 | "pid": std::process::id(), 160 | "activity": activity_payload 161 | }, 162 | "nonce": Uuid::new_v4().to_string() 163 | }); 164 | self.send(data, 1).await?; 165 | 166 | Ok(()) 167 | } 168 | 169 | /// Works the same as as [`set_activity`] but clears activity instead. 170 | /// 171 | /// [`set_activity`]: #method.set_activity 172 | /// 173 | /// # Errors 174 | /// Returns an `Err` variant if sending the payload failed. 175 | async fn clear_activity(&mut self) -> Result<(), RichPresenceError> { 176 | let data = json!({ 177 | "cmd": "SET_ACTIVITY", 178 | "args": { 179 | "pid": std::process::id(), 180 | "activity": None::<()> 181 | }, 182 | "nonce": Uuid::new_v4().to_string() 183 | }); 184 | 185 | self.send(data, 1).await?; 186 | 187 | Ok(()) 188 | } 189 | 190 | /// Closes the Discord IPC connection. Implementation is dependent on platform. 191 | async fn close(&mut self) -> Result<(), RichPresenceError>; 192 | } 193 | -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | use anstream::println; 6 | use std::io::stdout; 7 | 8 | use eyre::{Result, eyre}; 9 | use owo_colors::OwoColorize as _; 10 | 11 | use clap::{CommandFactory, Parser, Subcommand}; 12 | use clap_complete::{Shell, generate}; 13 | 14 | mod cmd; 15 | mod format; 16 | mod http; 17 | mod music; 18 | mod rich_presence; 19 | 20 | /// Beautiful and feature-packed Apple Music CLI 21 | #[derive(Parser, Debug)] 22 | #[command(author, version, about, long_about = None)] 23 | struct Cli { 24 | #[command(subcommand)] 25 | command: Commands, 26 | } 27 | 28 | #[derive(Subcommand, Debug)] 29 | enum Commands { 30 | /// Show now playing 31 | Now(cmd::NowOptions), 32 | 33 | /// Play the current track 34 | Play, 35 | /// Pause playback 36 | Pause, 37 | 38 | /// Toggle playing status 39 | #[command(visible_aliases = ["p"])] 40 | Toggle, 41 | 42 | /// Disable fast forward/rewind and resume playback 43 | Resume, 44 | 45 | /// Reposition to beginning of current track or go to previous track if already at start of current track 46 | Back, 47 | 48 | /// Skip forward in the current track 49 | Forward, 50 | 51 | /// Advance to the next track in the current playlist 52 | Next, 53 | 54 | /// Return to the previous track in the current playlist 55 | #[command(visible_aliases = ["prev"])] 56 | Previous, 57 | 58 | /// Show the Song.link for the current track 59 | SongLink, 60 | 61 | /// Connect to Discord rich presence 62 | Discord { 63 | #[command(subcommand)] 64 | command: Option, 65 | }, 66 | 67 | /// Generate shell completions 68 | Completions { 69 | /// Shell 70 | #[arg(value_enum)] 71 | shell: Shell, 72 | }, 73 | } 74 | 75 | #[derive(Subcommand, Debug)] 76 | enum DiscordCommands { 77 | /// Install Discord presence launch agent 78 | Install, 79 | /// Uninstall Discord presence launch agent 80 | Uninstall, 81 | } 82 | 83 | #[cfg(not(target_os = "macos"))] 84 | compile_error!("am doesn't work on non-macOS platforms!"); 85 | 86 | #[expect(clippy::cast_possible_truncation)] 87 | async fn concise_now_playing() -> Result<()> { 88 | let track_data = music::tell_raw(&[ 89 | r#"set output to """#, 90 | r#"tell application "Music""#, 91 | r"set t_name to name of current track", 92 | r"set t_album to album of current track", 93 | r"set t_artist to artist of current track", 94 | r"set t_duration to duration of current track", 95 | r#"set output to "" & t_name & "\n" & t_album & "\n" & t_artist & "\n" & t_duration"#, 96 | r"end tell", 97 | r"return output", 98 | ]) 99 | .await?; 100 | 101 | let mut track_data = track_data.split('\n'); 102 | 103 | let name = track_data 104 | .next() 105 | .ok_or_else(|| eyre!("Could not obtain track name"))? 106 | .to_owned(); 107 | let album = track_data 108 | .next() 109 | .ok_or_else(|| eyre!("Could not obtain track album"))? 110 | .to_owned(); 111 | let artist = track_data 112 | .next() 113 | .ok_or_else(|| eyre!("Could not obtain track artist"))? 114 | .to_owned(); 115 | let duration = track_data 116 | .next() 117 | .ok_or_else(|| eyre!("Could not obtain track duration"))? 118 | .to_owned() 119 | .replace(',', ".") 120 | .parse::()?; 121 | 122 | println!( 123 | "{} {}\n{} · {}", 124 | name.bold(), 125 | format::format_duration_plain(duration as i32).dimmed(), 126 | artist.blue(), 127 | album.magenta(), 128 | ); 129 | 130 | Ok(()) 131 | } 132 | 133 | #[tokio::main] 134 | async fn main() -> Result<()> { 135 | color_eyre::install()?; 136 | 137 | let args = Cli::parse(); 138 | 139 | match args.command { 140 | Commands::Play => { 141 | music::tell("play").await?; 142 | println!("{} playing music", "Started".green()); 143 | concise_now_playing().await?; 144 | } 145 | 146 | Commands::Pause => { 147 | music::tell("pause").await?; 148 | println!("{} playing music", "Stopped".red()); 149 | concise_now_playing().await?; 150 | } 151 | 152 | Commands::Toggle => { 153 | let player_state = music::tell("player state").await?; 154 | 155 | if player_state == "paused" { 156 | music::tell("play").await?; 157 | println!("{} playing music", "Started".green()); 158 | } else { 159 | music::tell("pause").await?; 160 | println!("{} playing music", "Stopped".red()); 161 | } 162 | 163 | concise_now_playing().await?; 164 | } 165 | 166 | Commands::Back => { 167 | music::tell("back track").await?; 168 | println!("{} to current or previous track", "Back tracked".cyan()); 169 | concise_now_playing().await?; 170 | } 171 | 172 | Commands::Forward => { 173 | music::tell("fast forward").await?; 174 | println!("{} in current track", "Fast forwarded".cyan()); 175 | concise_now_playing().await?; 176 | } 177 | 178 | Commands::Next => { 179 | music::tell("next track").await?; 180 | println!("{} to next track", "Advanced".magenta()); 181 | concise_now_playing().await?; 182 | } 183 | 184 | Commands::Previous => { 185 | music::tell("previous track").await?; 186 | println!("{} to previous track", "Returned".magenta()); 187 | concise_now_playing().await?; 188 | } 189 | 190 | Commands::Resume => { 191 | music::tell("resume").await?; 192 | println!("{} normal playback", "Resumed".magenta()); 193 | concise_now_playing().await?; 194 | } 195 | 196 | Commands::Now(options) => { 197 | cmd::now(options).await?; 198 | } 199 | 200 | Commands::SongLink => { 201 | if let Some(track) = music::get_current_track().await? { 202 | let metadata = music::fetch_metadata(&track).await?; 203 | println!("{}", metadata.song_link); 204 | } else { 205 | println!("{} playing music", "Not".red()); 206 | } 207 | } 208 | 209 | Commands::Discord { command } => match command { 210 | Some(command) => match command { 211 | DiscordCommands::Install => { 212 | cmd::discord::agent::install().await?; 213 | println!("{} Discord presence launch agent", "Installed".green()); 214 | } 215 | DiscordCommands::Uninstall => { 216 | cmd::discord::agent::uninstall().await?; 217 | println!("{} Discord presence launch agent", "Uninstalled".green()); 218 | } 219 | }, 220 | 221 | None => { 222 | cmd::discord().await?; 223 | } 224 | }, 225 | 226 | Commands::Completions { shell } => { 227 | let cli = &mut Cli::command(); 228 | generate(shell, cli, cli.get_name().to_string(), &mut stdout()); 229 | } 230 | } 231 | 232 | Ok(()) 233 | } 234 | -------------------------------------------------------------------------------- /src/rich_presence/activity.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Ryan Cao 2 | // SPDX-FileCopyrightText: 2022 sardonicism-04 3 | // 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | //! Provides an interface for building activities to send 7 | //! to Discord via [`DiscordIpc::set_activity`](crate::rich_presence::DiscordIpc::set_activity). 8 | use super::RichPresenceError; 9 | use serde::Serialize; 10 | 11 | /// A struct representing a Discord rich presence activity 12 | /// 13 | /// Note that all methods return `Self`, and can be chained for fluency 14 | #[derive(Serialize, Clone, Debug)] 15 | pub struct Activity { 16 | #[serde(skip_serializing_if = "Option::is_none")] 17 | state: Option, 18 | 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | details: Option, 21 | 22 | #[serde(skip_serializing_if = "Option::is_none")] 23 | timestamps: Option, 24 | 25 | #[serde(skip_serializing_if = "Option::is_none")] 26 | party: Option, 27 | 28 | #[serde(skip_serializing_if = "Option::is_none")] 29 | assets: Option, 30 | 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | secrets: Option, 33 | 34 | #[serde(skip_serializing_if = "skip_serializing_buttons")] 35 | buttons: Option>, 36 | } 37 | 38 | #[expect(clippy::ref_option)] 39 | fn skip_serializing_buttons(value: &Option>) -> bool { 40 | value.clone().is_none_or(|v| v.is_empty()) 41 | } 42 | 43 | /// A struct representing an `Activity`'s timestamps 44 | /// 45 | /// Note that all methods return `Self`, and can be chained for fluency 46 | #[derive(Serialize, Clone, Debug)] 47 | pub struct Timestamps { 48 | #[serde(skip_serializing_if = "Option::is_none")] 49 | start: Option, 50 | 51 | #[serde(skip_serializing_if = "Option::is_none")] 52 | end: Option, 53 | } 54 | 55 | /// A struct representing an `Activity`'s game party 56 | /// 57 | /// Note that all methods return `Self`, and can be chained for fluency 58 | #[derive(Serialize, Clone, Debug)] 59 | pub struct Party { 60 | #[serde(skip_serializing_if = "Option::is_none")] 61 | id: Option, 62 | 63 | #[serde(skip_serializing_if = "Option::is_none")] 64 | size: Option<[i32; 2]>, 65 | } 66 | 67 | /// A struct representing the art assets and hover text used by an `Activity` 68 | /// 69 | /// Note that all methods return `Self`, and can be chained for fluency 70 | #[derive(Serialize, Clone, Debug)] 71 | pub struct Assets { 72 | #[serde(skip_serializing_if = "Option::is_none")] 73 | large_image: Option, 74 | 75 | #[serde(skip_serializing_if = "Option::is_none")] 76 | large_text: Option, 77 | 78 | #[serde(skip_serializing_if = "Option::is_none")] 79 | small_image: Option, 80 | 81 | #[serde(skip_serializing_if = "Option::is_none")] 82 | small_text: Option, 83 | } 84 | 85 | /// A struct representing the secrets used by an `Activity` 86 | /// 87 | /// Note that all methods return `Self`, and can be chained for fluency 88 | #[derive(Serialize, Clone, Debug)] 89 | pub struct Secrets { 90 | #[serde(skip_serializing_if = "Option::is_none")] 91 | join: Option, 92 | 93 | #[serde(skip_serializing_if = "Option::is_none")] 94 | spectate: Option, 95 | 96 | #[serde(skip_serializing_if = "Option::is_none")] 97 | r#match: Option, 98 | } 99 | 100 | /// A struct representing the buttons that are 101 | /// attached to an `Activity` 102 | /// 103 | /// An activity may have a maximum of 2 buttons 104 | #[derive(Serialize, Clone, Debug)] 105 | pub struct Button { 106 | label: String, 107 | url: String, 108 | } 109 | 110 | #[expect(dead_code)] 111 | impl Activity { 112 | /// Creates a new `Activity` 113 | pub fn new() -> Self { 114 | Activity { 115 | state: None, 116 | details: None, 117 | assets: None, 118 | buttons: None, 119 | party: None, 120 | secrets: None, 121 | timestamps: None, 122 | } 123 | } 124 | 125 | /// Sets the state of the activity 126 | pub fn state(mut self, state: &str) -> Self { 127 | self.state = Some(state.to_owned()); 128 | self 129 | } 130 | 131 | /// Sets the details of the activity 132 | pub fn details(mut self, details: &str) -> Self { 133 | self.details = Some(details.to_owned()); 134 | self 135 | } 136 | 137 | /// Add a `Timestamps` to this activity 138 | pub fn timestamps(mut self, timestamps: Timestamps) -> Self { 139 | self.timestamps = Some(timestamps); 140 | self 141 | } 142 | 143 | /// Add a `Party` to this activity 144 | pub fn party(mut self, party: Party) -> Self { 145 | self.party = Some(party); 146 | self 147 | } 148 | 149 | /// Add an `Assets` to this activity 150 | pub fn assets(mut self, assets: Assets) -> Self { 151 | self.assets = Some(assets); 152 | self 153 | } 154 | 155 | /// Add a `Secrets` to this activity 156 | pub fn secrets(mut self, secrets: Secrets) -> Self { 157 | self.secrets = Some(secrets); 158 | self 159 | } 160 | 161 | /// Add a `Vec` of `Button`s to this activity 162 | /// 163 | /// An activity may contain no more than 2 buttons. 164 | pub fn buttons(mut self, buttons: Vec