# Navidrome Weekly Playlist

Tags: Homelab Navidrome Selfhosting

Reading time: 4 minutes

Description: Badly written (but (somewhat) working!) implementation of a weekly recommendation playlist bot for navidrome






Gitlab repo: https://gitlab.kaltes.beer/justarandomname/navidrome-weekly


wanna see the bode? xkcb


# What?

This is a simple weekly-recommendation-playlist-updater-bot, or something along those lines.


# Configuration

Some information is required for this script to function (mostly credentials for API’s):


config.ini

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[listenbrainz]
user = <listenbrainz username>

[subsonic]
url = <subsonic server url>
user = <subsonic username>
password = <subsonic password>
playlist_id = <subsonic playlist id>

[deemix]
arl = <your deezer arl>
server_url = <url of your deemix instance>

# Inner workings

Nothing fancy, just a simple python script which uses different API’s (listenbrainz, deezer, subsonic)


## 1. Fetching the recommendations from listenbrainz

The first step is to actually acquire the “createdfor” playlists from listenbrainz recommendation system, this is done with a simple GET request to https://api.listenbrainz.org/1/user/<username>/playlists/createdfor


source

1
2
3
4
5
6
7
8
9
def get_createdfor_playlists(username: str) -> Sequence[Playlist]:
    url = urljoin(API, "/1/user/", username, "/playlists/createdfor")
    response = requests.get(url)
    if response.status_code != 200:
        raise ValueError(f"Error fetching playlists for user, api returned status code not 200:\n{response.text}")
        return
    
    playlists = response.json()["playlists"]
    return map(lambda json_playlist: Playlist(json_playlist["playlist"]), playlists)

## 2. Processing the recommendations

Now we can extract everything under the playlists key, giving us a list of all the recommendations playlists, from which we only really need the identifier.

Next step is the extraction of the mbid (musicbrainz ID) from the identifier url, which is easy, we only need to cut the url string at every / and return the last element.


source

1
2
def id_from_url(url: str) -> str:
    return url.split("/")[-1]

With the mbid we can fetch the actual playlist, wich is a simple GET request to https://api.listenbrainz.org/1/playlists/<mbid>


source

1
2
3
4
5
6
7
8
9
def get_playlist(url: str) -> Playlist:
    mbid = id_from_url(url)
    url = urljoin(API, "/1/playlist", mbid)
    response = requests.get(url)
    if response.status_code != 200:
        raise ValueError(f"Error fetching playlists for user, api returned status code not 200:\n{response.text}")
        return
    
    return Playlist(response.json()["playlist"])

## 3. Turning this information in some usable data

Now we just need to get some more information since we only have the song titles and author names. I’m using the deemix api for this because im lazy but you could also directly use deezer (which is a bit more work since you need to get an API key first, just open deezer an monitor the network traffic while searching).

To execute a search with deemix, send a get request to your-deemix-url/api/search?term=<query>


source

1
2
3
4
5
6
def __search(self, query) -> Sequence[SearchResult]:
    url = urljoin(self.server_url, "/api/search")
    params = { "term": query }
    response = self.client.get(url, params=params)
    results = response.json().get("data", None)
    return list(map(lambda result: SearchResult(result), results))

## 4. The rest of the fucking owl

the rest of the fucking owl meme


Almost done, by now we have all the song information we need. We just need to truncate the playlist on your subsonic server (or not, your choice) and insert the tracks.


source

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def __truncate_playlist(self, playlist_id: str) -> bool:
    url = urljoin(self.server_url, "/rest/getPlaylist")
    params = { "id": playlist_id }
    response = self.client.get(url, params=params)

    song_count = response.json().get("subsonic-response", {}).get("playlist", {}).get("songCount", 0)

    if song_count == 0:
        return True

    url = urljoin(self.server_url, "/rest/updatePlaylist")
    params = {
        "playlistId": playlist_id,
        "songIndexToRemove": range(0, song_count)
    }

    print(f"Truncating {playlist_id} with {song_count} songs...")

    response = self.client.get(url, params=params)
    return response.json().get("subsonic-response", {}).get("status", "error") == "ok"

Last but not least we need to insert the songs into the playlist…


source

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# ...
subsonic_tracks = []
for track in tracks:
    subsonic_track = self.__mbid_to_subsonic_track(track)
    if subsonic_track is None:
        continue
    subsonic_tracks.append(subsonic_track)

songs = list(map(lambda track: track.id, subsonic_tracks))

url = urljoin(self.server_url, "/rest/updatePlaylist")
params = {
    "playlistId": playlist_id,
    "songIdToAdd": songs
}

print(f"Adding {len(songs)} songs to {playlist_id}...")

response = self.client.get(url, params=params)
return response.json().get("subsonic-response", {}).get("status", "error") == "ok"
# ...

…and initiate a library scan


source

1
2
3
def start_scan(self):
    url = urljoin(self.server_url, "/rest/startScan")
    response = self.client.get(url)

Done :thumbsup:




# Possible improvements