Skip to content

Commit

Permalink
fetch liked songs and metadata on init
Browse files Browse the repository at this point in the history
* extracts out the functions for fetching all liked songs and their
  metadata into a common file, so that it can be used in both
  `export` and `init`

* updates README to remove the note asking users to run an export
  before `show-genres`. Liked songs and their metadata are fetched on
  init itself, so `show-genres` can be run after an init without
  needing an export first.
  • Loading branch information
Ak5cel committed Jan 15, 2024
1 parent f6b7a28 commit 7a5e878
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 107 deletions.
46 changes: 19 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
# heartify-cli

A CLI tool to export and filter out your Spotify liked songs into playlists.
A CLI tool to export and filter out your Spotify liked songs into playlists.
With support for filters like ranges of date added, release date, genres, audio features, and more.


## What does it solve?

Around 2500 Liked songs in, I needed a way to organise the chaos without having to move away from 'liking' any more songs.
Hopefully, it's useful to more people out there with little time and a lot of liked songs :)

### What you can do

✔️ Export them to a playlist - you can now share your liked songs!
✔️ Filter by the year you liked them - make a 'My Top Songs 2023' playlist, for example
✔️ Filter by genre(s) to make genre-mixes out of your liked songs
✔️ Make monthly playlists - no more adding songs manually to monthly playlists, simply filter by ranges of dates added
✔️ Decade mixes - filter by release date
✔️ Filter by audio features - make a workout playlist of songs in a certain bpm range, for example
✔️ Export them to a playlist - you can now share your liked songs!

✔️ Filter by the year you liked them - make a 'My Top Songs 2023' playlist, for example

✔️ Filter by genre(s) to make genre-mixes out of your liked songs

✔️ Make monthly playlists - no more adding songs manually to monthly playlists, simply filter by ranges of dates added

✔️ Decade mixes - filter by release date

✔️ Filter by audio features - make a workout playlist of songs in a certain bpm range, for example

### No library size limits

There's no (known) limit to the number of songs Heartify can fetch, so bring along your massive library of 7000 liked songs (or more?)!
Just be prepared for it to take a bit longer with the measures in place to account for Spotify's rate limits.


## Getting Started

### Installation
Expand All @@ -41,16 +39,16 @@ npm install -g heartify-cli

### Authorisation

Run the following command from any directory, all data is stored locally where Heartify is installed.
Run the following command from any directory, all data is stored locally where Heartify is installed.
Then follow the instructions to authorise access to your Spotify library.

```sh
heartify init

```

This command needs to be run just once, and you're logged in until you revoke permissions from your account page,
logout, or until Spotify automatically revokes permissions from time-to-time (in which case, run `heartify init` again).
This command needs to be run just once, and you're logged in until you revoke permissions from your account page,
logout, or until Spotify automatically revokes permissions from time-to-time (in which case, run `heartify init` again).
Heartify uses the OAuth 2.0 Authorization Code with PKCE flow, and refreshes the access token automatically until it's revoked.

### Basic Examples
Expand Down Expand Up @@ -85,11 +83,6 @@ Heartify uses the OAuth 2.0 Authorization Code with PKCE flow, and refreshes the

```

> Note:
> As of now, you need to have run at least one `export` command before running `show-genres` as it needs to fetch the
> liked songs first while exporting to identify the genres. This will be fixed in a future update.

Then pick a genre and filter (replace '\<genre name\>'):

```sh
Expand Down Expand Up @@ -160,12 +153,11 @@ Heartify uses the OAuth 2.0 Authorization Code with PKCE flow, and refreshes the

```


## Detailed Docs (TODO)

### `--filter`

Filters are of the form `field=value` and accept any field from the list of supported fields below.
Filters are of the form `field=value` and accept any field from the list of supported fields below.

The value can be either individual values like in `genre=disco` and `time_signature=4`, or ranges like
in `tempo=[100,120]` (only DateTime and Number fields accept ranges at the time of writing)
Expand Down Expand Up @@ -209,7 +201,7 @@ Filters for different fields are joined by AND

```

Filters do not need to be wrapped in quotes as long as they do not contain ANY spaces.
Filters do not need to be wrapped in quotes as long as they do not contain ANY spaces.
Wrap them in single/double quotes when they contain whitespace.

```sh
Expand All @@ -231,7 +223,7 @@ Wrap them in single/double quotes when they contain whitespace.
```

### String fields

- `artist`
- `genre`

Expand All @@ -249,7 +241,7 @@ These fields correspond to those returned by the Spotify Web API. The descriptio
which can be found in the [Spotify Web Api docs for Audio Features](https://developer.spotify.com/documentation/web-api/reference/get-audio-features).

| field | description | range |
|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- |
| `danceability` | How suitable a track is for dancing based on <br>a combination of musical elements including tempo, <br>rhythm stability, beat strength,and overall regularity | 0.0 to 1.0<br>(float) |
| `energy` | Represents a perceptual measure of intensity <br>and activity | 0.0 to 1.0<br>(float) |
| `key` | The key the track is in. <br>Integers map to pitches using standard Pitch Class <br>notation. E.g. 0 = C, 1 = C♯/D♭, 2 = D, and so on. <br>If no key was detected, the value is -1. | -1 to 11<br>(integer) |
Expand Down
81 changes: 81 additions & 0 deletions commands/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const {
fetchLikedSongs,
fetchArtists,
fetchAudioFeatures,
} = require("../libs/api");
const {
saveTrack,
getFetchedArtists,
batchSaveGenres,
getFetchedTracks,
batchSaveAudioFeatures,
} = require("../libs/db");
const pc = require("picocolors");

exports.fetchAllLikedSongs = async () => {
const header = "Fetching Liked songs...";
process.stdout.write(header);

let count = 0;
for await (let [savedTrackObj, total] of fetchLikedSongs()) {
saveTrack(savedTrackObj);

count++;

process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.dim(`[${count}/${total}]`));
}

process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
console.log(pc.green("Done."));
};

exports.fetchGenres = async () => {
const header = "Fetching genre data...";
process.stdout.write(header);

let count = 0;
for await (let artistIDs of getFetchedArtists()) {
const artists = await fetchArtists(artistIDs);
const genreData = artists.map((artist) => {
return { artistID: artist.id, genres: artist.genres };
});

batchSaveGenres(genreData);

count += artistIDs.length;
process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.dim(`[${count}] artists`));
}

process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.green(`Done.\n`));
};

exports.fetchAllAudioFeatures = async () => {
const header = "Fetching audio features...";
process.stdout.write(header);

let count = 0;
for await (let trackIDs of getFetchedTracks({}, 100)) {
const audioFeatures = await fetchAudioFeatures(trackIDs);
const trackFeatures = audioFeatures.map((featuresObj) => {
return { trackID: featuresObj.id, audioFeatures: featuresObj };
});

batchSaveAudioFeatures(trackFeatures);

count += trackIDs.length;
process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.dim(`[${count}] songs`));
}

process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.green(`Done.\n`));
};
87 changes: 7 additions & 80 deletions commands/export-tracks.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
const { createPlaylist, addTracksToPlaylist } = require("../libs/api");
const {
fetchLikedSongs,
createPlaylist,
addTracksToPlaylist,
fetchArtists,
fetchAudioFeatures,
} = require("../libs/api");
const {
saveTrack,
clearRecords,
checkIsDBUpToDate,
getFetchedTracks,
batchSaveGenres,
getFetchedArtists,
batchSaveAudioFeatures,
checkTablesExist,
} = require("../libs/db");
const {
fetchAllLikedSongs,
fetchGenres,
fetchAllAudioFeatures,
} = require("./common");
const pc = require("picocolors");

exports.exportTracks = async (playlistName, options) => {
Expand All @@ -32,7 +27,7 @@ exports.exportTracks = async (playlistName, options) => {
} else {
console.log("Changes detected. Updating data.\n");
clearRecords();
await reFetchLikedSongs();
await fetchAllLikedSongs();
await fetchGenres();
await fetchAllAudioFeatures();
}
Expand Down Expand Up @@ -80,71 +75,3 @@ exports.exportTracks = async (playlistName, options) => {
console.log(`\n\n\t${playlistURI}\n\n`);
console.log("in the Spotify desktop client.");
};

const reFetchLikedSongs = async () => {
const header = "Fetching Liked songs...";
process.stdout.write(header);

let count = 0;
for await (let [savedTrackObj, total] of fetchLikedSongs()) {
saveTrack(savedTrackObj);

count++;

process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.dim(`[${count}/${total}]`));
}

process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
console.log(pc.green("Done."));
};

const fetchGenres = async () => {
const header = "Fetching genre data...";
process.stdout.write(header);

let count = 0;
for await (let artistIDs of getFetchedArtists()) {
const artists = await fetchArtists(artistIDs);
const genreData = artists.map((artist) => {
return { artistID: artist.id, genres: artist.genres };
});

batchSaveGenres(genreData);

count += artistIDs.length;
process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.dim(`[${count}] artists`));
}

process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.green(`Done.\n`));
};

const fetchAllAudioFeatures = async () => {
const header = "Fetching audio features...";
process.stdout.write(header);

let count = 0;
for await (let trackIDs of getFetchedTracks({}, 100)) {
const audioFeatures = await fetchAudioFeatures(trackIDs);
const trackFeatures = audioFeatures.map((featuresObj) => {
return { trackID: featuresObj.id, audioFeatures: featuresObj };
});

batchSaveAudioFeatures(trackFeatures);

count += trackIDs.length;
process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.dim(`[${count}] songs`));
}

process.stdout.cursorTo(header.length);
process.stdout.clearLine(+1);
process.stdout.write(pc.green(`Done.\n`));
};
9 changes: 9 additions & 0 deletions commands/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const {
createUserWithTokens,
dropAllTables,
} = require("../libs/db");
const {
fetchAllLikedSongs,
fetchGenres,
fetchAllAudioFeatures,
} = require("./common");

exports.init = async () => {
const version = require("../package.json").version;
Expand Down Expand Up @@ -73,6 +78,10 @@ exports.init = async () => {

console.log(pc.green("Done.\n"));

await fetchAllLikedSongs();
await fetchGenres();
await fetchAllAudioFeatures();

console.log(pc.green("Init complete.\n"));
console.log(
`Run \`heartify export --help\` to see how to make your first playlist!`
Expand Down

0 comments on commit 7a5e878

Please sign in to comment.