nixos/home-manager/fred.nix
rope 8b348d8c47 spotify-player: back to own client_id, cut per-action request burst
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:25:53 +01:00

189 lines
7.7 KiB
Nix

# home-manager/fred.nix
{ config, pkgs, lib, inputs, osConfig, ... }:
let
isDesktop = lib.elem osConfig.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ];
in
{
home.stateVersion = "25.11";
home.packages = with pkgs; [
#
];
home.file.".config/nixpkgs/config.nix".text = ''
{ allowUnfree = true; }
'';
# Wallpaper source — consumed by stylix's image option (set in
# settings/stylix.nix) and by quickshell's wallpaper layer.
home.file.".local/share/backgrounds/wallpaper.png".source =
"${inputs.self}/walls/wallpaper.png";
# btop — stylix's btop target writes the colour theme; we keep our
# non-colour preferences here.
programs.btop = {
enable = true;
settings = {
theme_background = false;
vim_keys = false;
proc_filter_kernel = true;
};
};
# Ghostty — stylix's ghostty target writes the colour theme.
programs.ghostty = lib.mkIf isDesktop {
enable = true;
settings = {
font-family = "CaskaydiaCove NF";
font-size = 11;
font-thicken = true;
window-padding-x = 10;
window-padding-y = 10;
window-padding-balance = true;
background-opacity = 0.98;
confirm-close-surface = false;
gtk-titlebar = false;
gtk-single-instance = true;
# keeps the warm instance alive with zero windows
quit-after-last-window-closed = false;
cursor-style = "bar";
cursor-style-blink = true;
};
};
# spotify-player — native librespot TUI replacing the Electron Spotify app.
# stylix's spotify-player target writes the colour theme.
# MPRIS (quickshell's media card), notifications and streaming are all on by
# default on Linux, so only the off-by-default visualiser needs setting.
programs.spotify-player = lib.mkIf isDesktop {
enable = true;
# 26.05 ships 0.23.0, whose Web API token is cached with refresh_token =
# null: once it expires (~1h) nothing can renew it and the library just
# spins until the app is restarted. 0.24.1 treats a non-renewable cached
# token as unusable and re-runs the PKCE flow instead. Drop this override
# once the pinned channel reaches 0.24.1.
package = pkgs.spotify-player.overrideAttrs (old: rec {
version = "0.24.1";
src = pkgs.fetchFromGitHub {
owner = "aome510";
repo = "spotify-player";
tag = "v${version}";
hash = "sha256-+GADmRl4XMwV8TfYZjEeyKDDfda3bDPzeerhYryX6vA=";
};
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
inherit src;
hash = "sha256-CSZ5sZ+d7Jhi43ipaWXKupYPFgWCbCx4RMTQN8emu9o=";
};
# Give initialize_playback's retry loop a much longer window, so we don't
# have to pick our own device from the popup on every launch. Upstream
# retries the transfer 5 times, once a second; our librespot device isn't
# registered with the Spotify backend until ~20s after startup, so all
# five attempts 404 and it gives up:
# Trying to connect to device (id=…)
# Connection failed (device_id=…): http error: status code 404 Not Found
# The loop breaks on the first success, so a bigger bound costs nothing.
# The CLI is no help here: `connect --name` resolves names against the Web
# API device list, which doesn't contain the integrated device at all.
#
# Widen it by slowing the delay rather than by adding rounds: each round
# costs up to three Web API calls, and we now share the bundled client ID
# with every other spotify-player/ncspot user, so 60 one-second rounds was
# ~180 requests per launch into a shared rate-limit bucket (429s). 12
# rounds five seconds apart covers the same 60s for a fifth of the calls.
# Both loops in this file are `for _ in 0..5` / `from_secs(1)`; the leading
# indentation is what pins each replacement to the intended one.
#
# The third replacement hits the other loop, in retry_playback_state: it
# re-reads the playback state five times after *every* playback-changing
# action, so one flick of the volume wheel costs a write plus five reads
# per notch. The state is updated optimistically client-side anyway, so
# two confirmations are plenty.
postPatch = (old.postPatch or "") + ''
substituteInPlace spotify_player/src/client/mod.rs \
--replace-fail " for _ in 0..5 {" \
" for _ in 0..12 {" \
--replace-fail " let delay = std::time::Duration::from_secs(1);" \
" let delay = std::time::Duration::from_secs(5);" \
--replace-fail " for _ in 0..5 {" \
" for _ in 0..2 {"
'';
});
settings = {
enable_audio_visualization = true;
# Our own Spotify app rather than the bundled default (ncspot's client ID,
# shared by every spotify-player/ncspot install). Upstream recommends the
# default because it has extended quota mode, but its rate-limit bucket is
# shared across every user of it, and we hit 429s on it just adjusting the
# volume. Our own app gets a private bucket.
#
# The cost of a post-2024-11-27 app is restricted quota: Spotify returns
# its own editorial/algorithmic playlists as null, so Discover Weekly,
# Made-For-You and browse categories are missing. That used to break
# *search* outright (upstream #617/#621) because one null sank the whole
# deserialization, but rspotify 0.15.3 — pinned by 0.24.1 — drops null
# page items instead (`vec_without_nulls`), so search works again.
# Public PKCE client, no secret involved, so it's fine in the repo.
# Redirect URI registered: http://127.0.0.1:8989/login
client_id = "265506bc7bbd4032bf0fee708b321d3e";
# Each notch fires a volume write plus a burst of confirmation polls, so
# halve the notches needed to cross the range.
volume_scroll_step = 10;
};
# The home-manager module only writes keymap.toml when `keymaps` is
# non-empty, so `actions` alone would silently emit nothing. Restating a
# default binding is the cheapest way to make the file exist.
keymaps = [
{
command = "ShowActionsOnCurrentTrack";
key_sequence = "a";
}
];
# Actions default to the *selected* item, which is useless when listening
# along to someone else's playback — the track was never selected, so
# liking it means finding it by hand. target = "PlayingTrack" acts on
# whatever is actually playing.
actions = [
{
action = "ToggleLiked";
key_sequence = "L";
target = "PlayingTrack";
}
{
action = "GoToArtist";
key_sequence = "g P";
target = "PlayingTrack";
}
{
action = "GoToAlbum";
key_sequence = "g B";
target = "PlayingTrack";
}
];
};
# Upstream ships no .desktop file (TUI only), so the launcher can't find it.
xdg.desktopEntries.spotify-player = lib.mkIf isDesktop {
name = "Spotify Player";
genericName = "Music Player";
exec = "ghostty --title=spotify-player -e spotify_player";
icon = "spotify";
terminal = false;
categories = [ "Audio" "Music" "Player" "AudioVideo" ];
};
# Warm Ghostty instance — new windows attach via gtk-single-instance.
systemd.user.services.ghostty-daemon = lib.mkIf isDesktop {
Unit = {
Description = "Ghostty warm instance (no window)";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
Type = "simple";
ExecStart = "${pkgs.ghostty}/bin/ghostty --initial-window=false";
Restart = "always";
};
Install.WantedBy = [ "graphical-session.target" ];
};
}