Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,14 @@ jobs:
for variant in spotify ytmusic full; do
archive="cloofy-$variant-$TARGET.tar.gz"
cp LICENSE README.md "dist/$variant/"
tar -czf "$archive" -C "dist/$variant" cloofy LICENSE README.md
files="cloofy LICENSE README.md"
# The app menu's entry and its icon, which the Linux packages install.
if [ "$RUNNER_OS" = Linux ]; then
cp integrations/linux/cloofy.desktop integrations/omarchy/cloofy.svg "dist/$variant/"
files="$files cloofy.desktop cloofy.svg"
fi
# shellcheck disable=SC2086
tar -czf "$archive" -C "dist/$variant" $files
shasum -a 256 "$archive" > "$archive.sha256"
done
# cloofy 0.4 and older update themselves from this name, and play Spotify.
Expand Down
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to cloofy. The format follows [Keep a Changelog](https://kee

## [Unreleased]

## [0.5.3] - 2026-11-12

What you asked for: every song at the same loudness, shelves you can hide, cloofy in the app menu on Linux, and a YouTube Music sign-in that lasts.

### Added

- **Normalize volume.** A new setting under Playback plays every song at about the same loudness, on Spotify with the loudness Spotify measured for each track, on YouTube Music with mpv's loudness filter, at the level Spotify and YouTube aim for. It applies at once, to the song that plays, and to `cloofy play` and `cloofy connect` ([#32](https://github.com/parda21/cloofy/issues/32)).
- **Hide a shelf on Home.** The menu of anything on a shelf (<kbd>.</kbd> or a right-click) has **Hide this shelf**, on both services; the shelf stays away after a restart. **Hidden shelves** on the Settings page shows them all again ([#31](https://github.com/parda21/cloofy/issues/31)).
- **cloofy in the app menu on Linux.** The installer and the Arch packages add a desktop entry and cloofy's icon, so GNOME, KDE Plasma, rofi or Walker find it; it opens in the desktop's terminal. The installer's entry starts cloofy by its path, so it works where the desktop doesn't look in `~/.local/bin`. `cloofy launcher install` and `cloofy launcher remove` do the same by hand, and `--uninstall` takes it out again. On Omarchy the launcher stays the one of `cloofy omarchy install` ([#30](https://github.com/parda21/cloofy/issues/30)).

### Fixed

- **Signed out of YouTube Music after a day or two.** A browser that goes on using its sign-in gets new cookies for it, and the ones cloofy copied stop working. When YouTube no longer takes them, cloofy now reads them from the same browser again, once per start, and keeps them only if they are still the same account's, told by its channel or handle; otherwise it asks you to sign in as before. Sign-ins made with 0.5.2 or older, and pasted ones, don't say which browser they came from: run `cloofy login ytmusic --force` once ([#26](https://github.com/parda21/cloofy/issues/26)).

## [0.5.2] - 2026-11-10

YouTube Music's Home looks and works as in its own apps, and cloofy asks once for a star.
Expand Down Expand Up @@ -166,7 +180,8 @@ The first release.
- **Session restore.** The page, the selection and the last track come back after a restart.
- **Installer.** It sets everything up step by step.

[Unreleased]: https://github.com/parda21/cloofy/compare/v0.5.2...HEAD
[Unreleased]: https://github.com/parda21/cloofy/compare/v0.5.3...HEAD
[0.5.3]: https://github.com/parda21/cloofy/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/parda21/cloofy/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/parda21/cloofy/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/parda21/cloofy/compare/v0.4.0...v0.5.0
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ members = ["crates/*"]
exclude = ["vendor"]

[workspace.package]
version = "0.5.2"
version = "0.5.3"
edition = "2024"
rust-version = "1.90"
license = "MIT"
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ The demo opens the full interface with a sample library: real albums and artists
curl -fsSL https://cloofy.app/install.sh | bash
```

The installer first asks what you listen to: Spotify, YouTube Music, or both. Each comes as a build of its own, so a YouTube Music install carries no Spotify code, and the other way around. Then it checks your system, puts the latest release in `~/.local/bin`, and walks you through the [setup](#setup). For Spotify that's your Spotify app, its client ID and signing in. For YouTube Music it's mpv, yt-dlp and deno, which it offers to install with your package manager, and an optional sign-in. On [Omarchy](https://omarchy.org) it also offers cloofy's [bar widget, theme, menu entries and launcher](#omarchy). Run it again to switch; cloofy offers new releases itself, see [updates](#updates).
The installer first asks what you listen to: Spotify, YouTube Music, or both. Each comes as a build of its own, so a YouTube Music install carries no Spotify code, and the other way around. Then it checks your system, puts the latest release in `~/.local/bin`, and walks you through the [setup](#setup). For Spotify that's your Spotify app, its client ID and signing in. For YouTube Music it's mpv, yt-dlp and deno, which it offers to install with your package manager, and an optional sign-in. On [Omarchy](https://omarchy.org) it also offers cloofy's [bar widget, theme, menu entries and launcher](#omarchy). On other Linux desktops it adds cloofy with its icon to the app menu. Run it again to switch; cloofy offers new releases itself, see [updates](#updates).

Package managers have the same three builds:

Expand Down Expand Up @@ -115,7 +115,7 @@ Homebrew's and Arch's YouTube Music packages bring mpv, yt-dlp and deno along; w

- `bash -s -- --service ytmusic` installs YouTube Music without asking about it: `spotify`, `ytmusic` or `both`.
- `bash -s -- --yes` installs without asking anything: the services you have, or Spotify the first time.
- `bash -s -- --uninstall` removes cloofy, and its Omarchy setup.
- `bash -s -- --uninstall` removes cloofy, its entry in the app menu and its Omarchy setup.

On platforms without a prebuilt binary, the installer builds cloofy with cargo, with only the services you picked. To do that yourself (Rust 1.90 or newer), name them as features; without any, you get both:

Expand Down Expand Up @@ -221,12 +221,14 @@ Installed with Homebrew, mise, a system package or Nix, cloofy leaves its update

## Settings

<kbd>,</kbd>, the command palette, or a click on your name in the top bar opens the Settings page: crossfade, cover art and how covers are drawn, the colors, the accent color, icons, power saving, song notifications and update checks. Changes apply at once and cloofy saves them in `~/.config/cloofy/config.toml`, so there's no need to edit the file. You still can; it's read at start:
<kbd>,</kbd>, the command palette, or a click on your name in the top bar opens the Settings page: crossfade, volume normalization, the shelves hidden from Home, cover art and how covers are drawn, the colors, the accent color, icons, power saving, song notifications and update checks. Changes apply at once and cloofy saves them in `~/.config/cloofy/config.toml`, so there's no need to edit the file. You still can; it's read at start:

```toml
client_id = "your client id"
services = ["ytmusic"] # "spotify", "ytmusic" or both, in the switch's order; every one built in when missing
crossfade = 6 # seconds tracks overlap, 0 to 12; off when missing
normalize_volume = true # every song at about the same loudness; off when missing
hidden_shelves = ["Covers and remixes"] # shelves Home leaves out, by title
covers = false # no cover art, for slower computers; on when missing
colors = "terminal" # "cloofy" or "terminal"; Omarchy's theme there, cloofy's own elsewhere, when missing
accent = "violet" # "theme", "green", "violet", "pink", "blue" or "orange"; follows the cover when missing
Expand Down Expand Up @@ -301,7 +303,7 @@ YouTube protects its audio streams with challenges that only a JavaScript runtim
<details>
<summary><b>Is signing in to YouTube Music with my browser's cookies safe?</b></summary>

`cloofy login ytmusic` has yt-dlp export the browser's cookies into a private folder, keeps only youtube.com's, and deletes the export right away. They stay in `~/.local/share/cloofy/ytmusic/cookies.txt`, which only you can read, or in the macOS Keychain with `credentials = "keychain"`. cloofy sends them only to music.youtube.com, with its own requests there; streams, covers and lyrics from LRCLIB load without them. They are a signed-in session like the browser's, so treat the file like a password. `cloofy logout ytmusic` deletes it.
`cloofy login ytmusic` has yt-dlp export the browser's cookies into a private folder, keeps only youtube.com's, and deletes the export right away. They stay in `~/.local/share/cloofy/ytmusic/cookies.txt`, which only you can read, or in the macOS Keychain with `credentials = "keychain"`. cloofy sends them only to music.youtube.com, with its own requests there; streams, covers and lyrics from LRCLIB load without them. They are a signed-in session like the browser's, so treat the file like a password. `cloofy logout ytmusic` deletes it. A browser that goes on using its sign-in gets new cookies for it, so after a day or two YouTube may stop taking cloofy's copy: cloofy then reads them from the same browser again, the same way, and keeps them only when they still sign in the same account. A sign-in made before cloofy 0.5.3, or from a pasted Cookie header, doesn't say which browser it came from: run `cloofy login ytmusic --force` once to sign in with a browser.
</details>

<details>
Expand Down
4 changes: 4 additions & 0 deletions crates/cloofy-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ pub struct Config {
pub client_id: Option<String>,
/// Seconds consecutive tracks overlap (0–12); off when missing.
pub crossfade: Option<u64>,
/// Plays every song at about the same loudness; off when missing.
pub normalize_volume: Option<bool>,
/// Titles of the shelves Home leaves out, such as `["Covers and remixes"]`.
pub hidden_shelves: Option<Vec<String>>,
/// `"nerd"` or `"text"`; detected from the terminal when missing.
pub icons: Option<String>,
/// `"keychain"` keeps sign-ins in the system's keychain; files when missing.
Expand Down
26 changes: 26 additions & 0 deletions crates/cloofy-core/src/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ pub struct Home {
}

impl Home {
/// This Home without the shelves titled as one of `hidden`, whatever the case. The
/// shortcuts at the top have no title to hide them by.
pub fn without_shelves(mut self, hidden: &[String]) -> Self {
let hidden: Vec<String> = hidden.iter().map(|title| title.trim().to_lowercase()).collect();
self.shelves.retain(|shelf| shelf.shortcuts || !hidden.contains(&shelf.title.trim().to_lowercase()));
self
}

/// The chip Home is narrowed to, if any.
pub fn chip(&self) -> Option<&Chip> {
self.chips.iter().find(|chip| chip.selected)
Expand Down Expand Up @@ -79,3 +87,21 @@ impl HomeItem {
self.kind == ItemKind::Playlist && self.context_uri != self.uri
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn hidden_shelves_go_by_title_and_the_shortcuts_stay() {
let shelf = |title: &str, shortcuts| Shelf { title: title.into(), shortcuts, ..Shelf::default() };
let home = Home {
shelves: vec![shelf("", true), shelf("Covers and remixes", false), shelf("Quick picks", false)],
..Home::default()
};
let shown = home.clone().without_shelves(&["covers and Remixes ".into(), String::new()]);
let titles: Vec<&str> = shown.shelves.iter().map(|shelf| shelf.title.as_str()).collect();
assert_eq!(titles, ["", "Quick picks"]);
assert_eq!(home.without_shelves(&[]).shelves.len(), 3);
}
}
10 changes: 8 additions & 2 deletions crates/cloofy-player/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,23 @@ pub struct Engine {

impl Engine {
/// `crossfade` overlaps consecutive tracks by that long; zero plays them back to back.
/// `normalize` plays every track at about the same loudness, as Spotify's apps do.
///
/// The receiver gets the name of each audio output that goes away during playback, such as
/// headphones being unplugged; playback should pause then.
pub fn new(session: &Session, crossfade: Duration) -> Result<(Self, UnboundedReceiver<String>)> {
pub fn new(session: &Session, crossfade: Duration, normalize: bool) -> Result<(Self, UnboundedReceiver<String>)> {
let backend = PREFERRED_BACKENDS
.iter()
.find_map(|name| audio_backend::find(Some((*name).to_owned())))
.or_else(|| audio_backend::find(None))
.context("No audio backend available")?;
let mixer = (mixer::find(None).context("No mixer available")?)(MixerConfig::default())?;
let config = PlayerConfig { bitrate: Bitrate::Bitrate320, crossfade, ..PlayerConfig::default() };
let config = PlayerConfig {
bitrate: Bitrate::Bitrate320,
crossfade,
normalisation: normalize,
..PlayerConfig::default()
};
let (lost_tx, lost_rx) = mpsc::unbounded_channel();
let player =
Player::new(config, session.clone(), mixer.get_soft_volume(), move || open_output(backend, lost_tx));
Expand Down
13 changes: 13 additions & 0 deletions crates/cloofy-tui/src/app/browsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ impl Browsing {
std::iter::once(&mut self.view).chain(self.history.iter_mut())
}

/// Shows this service's Home as it was loaded, on its pages that show the whole Home: for
/// when no shelf is hidden any more.
pub(super) fn show_whole_home(&mut self) {
let Some(home) = self.home_cache.clone() else { return };
for view in std::iter::once(&mut self.view).chain(self.history.iter_mut()) {
if let View::Home(shown) = view
&& shown.is_whole()
{
shown.set_home(home.clone());
}
}
}

/// Where its pages were left, for the next start.
fn page(&self) -> Page {
super::persist::page(&self.view, &self.history, self.focus, &self.nav, self.library_filter)
Expand Down
13 changes: 12 additions & 1 deletion crates/cloofy-tui/src/app/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ impl App {
SearchEntry::Artist(a) => Some(target(&a.uri, &a.name, TargetKind::Artist, a.image_url.clone())),
SearchEntry::Header(_) => None,
},
View::Home(v) => v.selected().map(|item| self.home_target(item)),
View::Home(v) => v
.selected()
.map(|item| ContextTarget { shelf: v.shelf_title().map(str::to_owned), ..self.home_target(item) }),
View::Artist(v) => match v.selected()? {
ArtistSelection::Track(t) => Some(track_target(&t.track, Some(v.uri.clone()))),
ArtistSelection::Item(item) => Some(self.home_target(item)),
Expand Down Expand Up @@ -197,6 +199,11 @@ impl App {
copy_to_clipboard(&backend::web_link(&target.uri));
self.notice("Link copied".into());
}
MenuAction::HideShelf => {
if let Some(title) = target.shelf {
self.hide_shelf(title);
}
}
}
}

Expand Down Expand Up @@ -294,6 +301,7 @@ fn target(uri: &str, name: &str, kind: TargetKind, image_url: Option<String>) ->
owned: false,
liked_songs: false,
upcoming: None,
shelf: None,
}
}

Expand Down Expand Up @@ -383,6 +391,9 @@ fn menu_items(target: &ContextTarget, saved: bool) -> Vec<(MenuAction, String)>
if !target.liked_songs {
add(MenuAction::CopyLink, i.link, "Copy link");
}
if target.shelf.is_some() {
add(MenuAction::HideShelf, i.remove, "Hide this shelf");
}
items
}

Expand Down
8 changes: 6 additions & 2 deletions crates/cloofy-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,8 @@ impl App {
let service = player.service();
self.player_errors.remove(&service);
player.set_crossfade(Duration::from_secs(self.prefs.crossfade));
// As the setting is now: it may have changed since the player was started with it.
player.set_normalize(self.prefs.normalize);
self.players.insert(service, player);
// Until something plays, the player of the service shown is the one that would.
if self.active.is_none() || (self.now.track.is_none() && self.loader.service() == Some(service)) {
Expand Down Expand Up @@ -1475,12 +1477,13 @@ impl App {
self.home_fetched_at = Some(Instant::now());
self.data_dirty = true;
}
let hidden = self.prefs.hidden_shelves.clone();
let Some(View::Home(v)) = self.view_mut(view) else { return false };
if !v.wants(chip.as_deref()) {
return false;
}
match result {
Ok(home) => v.set_home(home),
Ok(home) => v.set_home(home.without_shelves(&hidden)),
// The Home before the chip stays, so the failure is said at the bottom.
Err(err) => match v.chip_failed() {
Some(failed) => self.error(format!("Couldn't load {failed}: {err}")),
Expand Down Expand Up @@ -3245,7 +3248,8 @@ impl App {

fn open_home(&mut self) {
let id = self.next_id();
let mut view = HomeView::new(id, self.home_cache.clone());
let cached = self.home_cache.clone().map(|home| home.without_shelves(&self.prefs.hidden_shelves));
let mut view = HomeView::new(id, cached);
if self.home_fetched_at.is_some_and(|at| at.elapsed() < HOME_FRESH) {
view.loading = false;
} else {
Expand Down
Loading
Loading