TL;DR:
Rien à voir avec la cybersécurité. Cet article explique comment récupérer automatiquement toutes vos playlists Spotify en mp3 dans votre ordinateur afin d'en avoir une copie de sauvegarde.
Outils:
Linux Ubuntu (24.04), python, package Spotify (via snap), sox, ffmpeg.
On savait déjà comment récupérer des mp3 à partir de vidéos Youtube en utilisant des convertisseurs en ligne, mais la qualité n'est pas toujours bonne, et il faut convertir chaque morceau l'un après l'autre. L'objectif ici est donc de convertir automatiquement des playlists entières depuis Spotify et de récupérer localement les morceaux au format mp3.
Contexte et pré-requis:
Ce tuto fonctionne sous Ubuntu (les tests ont été faits avec la version 24.04).
1. Le principe général est le suivant: on utilise l'app Spotify pour jouer les morceaux en temps réel, et en parallèle on enregistre le son. C'est parfaitement automatisé (séquencement des pistes, nommage des MP3, etc) et il n'y a qu'une commande à passer pour dumper une playlist, mais ça durera le temps de lecture total.
2. Disclaimer: Cette méthode contourne les protections de Spotify et va à l'encontre de ses conditions d'utilisation (le stream-ripping y est explicitement interdit). Il y a un risque de suspension de compte.
Sommaire:
Installer les pré-requis:
$ snap install spotify
$ python3 -m pip install spotipy --break-system-packages # ou bien utiliser un venv
$ sudo apt install jq ffmpeg sox libsox-fmt-mp3 python3-pydub python3-requests
Générer un token API sur Spotify:
- Aller sur https://developer.spotify.com/dashboard dans un browser, se loguer et aller sur Create app.
- Donner un nom quelconque à l'app.
- Mettre en Redirect URI : http://127.0.0.1:8888/callback/
- Mettre en Which API/SDK seulement Web API.
- Noter le Client ID et le Client secret pour plus tard.
Ouvrir le site web de Spotify dans un browser, se loguer et ouvrir la playlist à enregistrer ; relever l'URL. Par exemple:
Récupérer l'identifiant de la playlist. Dans cet exemple:
5Hzt5H5llXZIe9oZHpUofe
C'est l'UID Spotify de la playlist ouverte dans le browser, on va ensuite l'utiliser pour récupérer les UID des morceaux qu'elle contient.
Créer une playlist vide nommée "enregistrement" et noter également son UID. Par exemple:
6bQ25jWzoAN5lLg8OGLoxm
Ajouter un mp3 vide de 5 secondes au début de la playlist vide enregistrement (on peut le générer en tapant ffmpeg -f lavfi -i anullsrc=r=44100:cl=stereo -t 5 -q:a 9 -acodec libmp3lame silence.mp3) puis l'importer et l'ajouter au début de la playlist.
Le début de la playlist ressemble à ça avec le morceau contenant les 5 secondes de silence au début:
Cela permettra à la lecture du premier morceau de ne pas débuter avant que l'enregistrement ne commence.
Créer un fichier ".env" contenant toutes ces infos:
PLAYLIST_ID=5Hzt5H5llXZIe9oZHpUofe
RECORD_PLAYLIST_ID=6bQ25jWzoAN5lLg8OGLoxm
CLIENT_ID=<Le Client_ID du token API>
CLIENT_SECRET=<Le Secret du token API>
Et remplacer CLIENT_ID et CLIENT_SECRET par les valeurs du token API récupéré plus tôt.
Finalement, empêcher l'ordinateur d'entrer en veille pendant la durée de l'enregistrement (via les réglages du window-manager, ou comme dans mon cas sous Xfce en tapant les commandes suivantes):
$ xset -dpms
$ xset s off
Script python: tracks_list.py
Ce script prend en entrée un UID de playlist Spotify et produit en sortie les UID Spotify de tous les morceaux contenus dans ladite playlist.
#!/usr/bin/python3
import requests
import subprocess
# Infos token + ID de la playlist Spotify à enregistrer
client_id = None
client_secret = None
PLAYLIST_ID = None
with open(".env") as f:
for line in f:
line = line.strip()
if line.startswith("CLIENT_ID="):
client_id = line.split("=", 1)[1]
if line.startswith("CLIENT_SECRET="):
client_secret = line.split("=", 1)[1]
if line.startswith("PLAYLIST_ID="):
PLAYLIST_ID = line.split("=", 1)[1]
result = subprocess.run(f"/usr/bin/curl -X POST -H \"Authorization: Basic $(echo -n '{client_id}:{client_secret}' | base64 -w 0)\" -d grant_type=client_credentials https://accounts.spotify.com/api/token | jq -r '.access_token'", shell=True, capture_output=True, text=True)
ACCESS_TOKEN = result.stdout.strip()
BASE_URL = 'https://api.spotify.com/v1'
def get_track_ids(playlist_id):
url = f"{BASE_URL}/playlists/{playlist_id}/tracks"
headers = { "Authorization": f"Bearer {ACCESS_TOKEN}" }
response = requests.get(url, headers=headers)
respjson = response.json()
track_ids = [item['track']['id'] for item in respjson['items']]
return track_ids
track_ids = get_track_ids(PLAYLIST_ID)
for track_id in track_ids:
print(track_id)
Maintenant si je lance ce script, il va me donner les UID de toutes les tracks contenues dans la playlist:
thom@bureau:~/spotify$ ./tracks_list.py
7a6tkmsh9RXN0bQ9BSM7td
2ohinF8keJDshzetGAK0GX
1f7ivAOOpU2pizwbzYkhmL
2FtErelpxJIk3Z5yKwKf2e
0LoVuHRdOFBhT61crHWyIo
5Ox9Ofgl9OOXWfNAmzyLyv
Script shell: details.sh
Ce script récupère via l'API Spotify les métadonnées (artiste, titre, etc.) d'un morceau Spotify à partir de son UID. Cela va permettre de récupérer les infos pour remplir les tags ID3 et nommer les fichiers correctement.
#!/bin/bash
CLIENT_ID=$(cat .env |grep ^CLIENT_ID |tail -1 |awk -F= '{print $2}')
CLIENT_SECRET=$(cat .env |grep ^CLIENT_SECRET |tail -1 |awk -F= '{print $2}')
access_token=$(/usr/bin/curl -X POST -H "Authorization: Basic $(echo -n "$CLIENT_ID:$CLIENT_SECRET" | base64 -w 0)" -d grant_type=client_credentials https://accounts.spotify.com/api/token 2>/dev/null | jq -r '.access_token')
track_id="$1"
response=$(curl -s -X GET "https://api.spotify.com/v1/tracks/$track_id" -H "Authorization: Bearer $access_token")
if echo "$response" | grep -q "error"; then
echo "Error."
exit 1
fi
title=$(echo "$response" | jq -r '.name')
artist=$(echo "$response" | jq -r '.artists[0].name')
duration_ms=$(echo "$response" | jq -r '.duration_ms')
duration_sec=$((duration_ms / 1000))
echo "Titre : $title"
echo "Artiste : $artist"
echo "Durée : ${duration_sec}s"
(toujours bien penser à remplacer le token API)
Exemple pour un morceau de ma playlist:
thom@bureau:~/spotify$ ./details.sh 7a6tkmsh9RXN0bQ9BSM7td
Titre : Unconditional (feat. Rationale)
Artiste : Quantic
Durée : 195s
Script python: unitary_add.py
Ce script ajoute un morceau dans une playlist Spotify.
#!/usr/bin/python3
import spotipy
from spotipy.oauth2 import SpotifyOAuth
import argparse
# track ID passé en paramètre
parser = argparse.ArgumentParser(description="parser")
parser.add_argument("id", type=str, help="track_id")
args = parser.parse_args()
track_id = args.id
# Informations d'identification
client_id = None
client_secret = None
with open(".env") as f:
for line in f:
line = line.strip()
if line.startswith("CLIENT_ID="):
client_id = line.split("=", 1)[1]
if line.startswith("CLIENT_SECRET="):
client_secret = line.split("=", 1)[1]
redirect_uri = 'http://127.0.0.1:8888/callback/'
scope = 'playlist-modify-public'
# Initialisation de l'authentification
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope=scope))
# Identifiant de la playlist 'enregistrement'
playlist_id = None
with open(".env") as f:
for line in f:
line = line.strip()
if line.startswith("RECORD_PLAYLIST_ID="):
playlist_id = line.split("=", 1)[1]
# Ajout de la piste à la playlist
sp.playlist_add_items(playlist_id, [track_id])
Test:
thom@bureau:~/spotify$ ./unitary_add.py 7a6tkmsh9RXN0bQ9BSM7td
Résultat: un morceau est ajouté dans la playlist:
Script python: unitary_remove.py
Ce script enlève un morceau d'une playlist Spotify.
#!/usr/bin/python3
import spotipy
from spotipy.oauth2 import SpotifyOAuth
import argparse
# track ID passé en paramètre
parser = argparse.ArgumentParser(description="parser")
parser.add_argument("id", type=str, help="track_id")
args = parser.parse_args()
track_id = args.id
# Informations d'identification
client_id = None
client_secret = None
with open(".env") as f:
for line in f:
line = line.strip()
if line.startswith("CLIENT_ID="):
client_id = line.split("=", 1)[1]
if line.startswith("CLIENT_SECRET="):
client_secret = line.split("=", 1)[1]
redirect_uri = 'http://127.0.0.1:8888/callback/'
scope = 'playlist-modify-public'
# Initialisation de l'authentification
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope=scope))
# Identifiant de la playlist 'enregistrement'
playlist_id = None
with open(".env") as f:
for line in f:
line = line.strip()
if line.startswith("RECORD_PLAYLIST_ID="):
playlist_id = line.split("=", 1)[1]
# Suppression de la piste de la playlist
sp.playlist_remove_all_occurrences_of_items(playlist_id, [track_id])
Test:
thom@bureau:~/spotify$ ./unitary_remove.py 7a6tkmsh9RXN0bQ9BSM7td
Résultat: le morceau est supprimé de la playlist (et dans cet exemple elle redevient vide):
Script bash: record_playlist.sh:
Ce script ouvre le client lourd Spotify, lance la lecture d'une playlist, enregistre la sortie dans un fichier mp3, puis ferme le client Spotify au terme d'une durée spécifiée en paramètre.
#!/bin/bash
PLAYLIST_ID=$1
DURATION=$2
ffmpeg -y -f pulse -ac 2 -i default -acodec libmp3lame -b:a 192k "output.mp3" >/dev/null 2>&1 &
FFMPEG_PID=$!
spotify 2>/dev/null &
SPOTIFY_PID=$!
sleep 5
dbus-send --print-reply --dest=org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.OpenUri string:"spotify:playlist:$PLAYLIST_ID"
echo FFMPEG_PID=$FFMPEG_PID
echo SPOTIFY_PID=$SPOTIFY_PID
sleep $DURATION
sleep 10
kill -SIGINT $FFMPEG_PID
kill -SIGKILL $SPOTIFY_PID
La qualité d'enregistrement est fixée ici à 192k, à ajuster selon le besoin. En tout cas le flux est décodé puis réencodé, il y a donc une (très) légère perte de qualité ; il ne faut pas s'attendre à retrouver du lossless, sauf à utiliser un format autre que le mp3 dans la commande ffmpeg, avec par exemple l'option "-acodec flac".
Script: remove_silence.py
Ce script détecte puis supprime les silences au début et à la fin d'un fichier mp3.
#!/usr/bin/python3
import argparse
from pydub import AudioSegment
from pydub.silence import detect_silence
# Charger le fichier
def load_audio(file_path):
return AudioSegment.from_file(file_path)
# Détecter les silences au début et à la fin
def detect_silences(audio, silence_thresh=-50, min_silence_len=500):
silences = detect_silence(audio, min_silence_len=min_silence_len, silence_thresh=silence_thresh)
return silences
# Supprimer les silences au début et à la fin
def trim_silences(audio, silences):
start_trim = 0
end_trim = len(audio)
# début du fichier
if silences and silences[0][0] == 0:
start_trim = silences[0][1] # Fin du premier silence
# fin du fichier
if silences and silences[-1][1] == len(audio):
end_trim = silences[-1][0] # Début du dernier silence
trimmed_audio = audio[start_trim:end_trim]
return trimmed_audio
# Enregistrer le nouveau mp3
def save_audio(audio, output_path, bitrate="192k"):
audio.export(output_path, format="mp3", bitrate=bitrate)
def main(input_file, output_file):
audio = load_audio(input_file)
silences = detect_silences(audio)
trimmed_audio = trim_silences(audio, silences)
save_audio(trimmed_audio, output_file)
print(f"OK: {output_file}")
if __name__ == "__main__":
# Gestion des arguments de ligne de commande
parser = argparse.ArgumentParser(description="Supprime les silences au début et à la fin d'un fichier MP3.")
parser.add_argument("input_file", type=str, help="Chemin du fichier MP3 d'entrée.")
parser.add_argument("output_file", type=str, help="Chemin du fichier MP3 de sortie.")
# Parsing des arguments
args = parser.parse_args()
# Exécution du script avec les arguments fournis
main(args.input_file, args.output_file)
La ligne silence_thresh=-50, min_silence_len=500 paramètre la recherche de blanc. Ici on indique un seuil à -50dB et une durée minimale de 500ms pour considérer un passage comme un "blanc". Je ne l'ai pas testé sur le décrescendo final de la 4e de Chostakovitch mais ça fonctionne sur tous les morceaux "normaux".
Et voilà, y'a plus qu'à mettre tout ça ensemble:
Script: dump.sh
#!/bin/bash
RECORD_PLAYLIST_ID=$(cat .env |grep ^RECORD_PLAYLIST_ID |tail -1 |awk -F= '{print $2}')
CLIENT_ID=$(cat .env |grep ^CLIENT_ID |tail -1 |awk -F= '{print $2}')
CLIENT_SECRET=$(cat .env |grep ^CLIENT_SECRET |tail -1 |awk -F= '{print $2}')
~/spotify/tracks_list.py > ~/spotify/tracks_list.txt
cat ~/spotify/tracks_list.txt | while read TRACK_ID
do
access_token=$(/usr/bin/curl -X POST -H "Authorization: Basic $(echo -n "$CLIENT_ID:$CLIENT_SECRET" | base64 -w 0)" -d grant_type=client_credentials https://accounts.spotify.com/api/token 2>/dev/null | jq -r '.access_token')
response=$(curl -s -X GET "https://api.spotify.com/v1/tracks/$TRACK_ID" -H "Authorization: Bearer $access_token")
if echo "$response" | grep -q "error"; then
echo "Erreur lors de la récupération des informations du morceau $TRACK_ID."
exit 1
fi
title=$(echo "$response" | jq -r '.name')
artist=$(echo "$response" | jq -r '.artists[0].name')
duration_ms=$(echo "$response" | jq -r '.duration_ms')
DURATION=$((duration_ms / 1000))
filename="$artist - $title"
echo "$filename ($DURATION seconds)"
OUTPUT_FILE="output/$filename.mp3"
./unitary_add.py $TRACK_ID
./record_playlist.sh $RECORD_PLAYLIST_ID $DURATION
sleep 1
mv output.mp3 "output/$filename.mp3"
./unitary_remove.py $TRACK_ID
done
cd output
ls -1 *.mp3 |while read a ;do echo ../remove_silence.py \"$a\" \"../output2/$a\" ;done > script
sh ./script
rm script
Penser à créer les sous-répertoires output et output2 pour que ça fonctionne.
Et voilà le résultat (exemple sur une petite playlist de 7 tracks):
thom@bureau:~/spotify$ ./dump.sh
Quantic - Unconditional (feat. Rationale) (195 seconds)
method return time=1786809708.171806 sender=:1.2027 -> destination=:1.2028 serial=44 reply_serial=2
FFMPEG_PID=512124
SPOTIFY_PID=512125
Bosq - Found Your Love (349 seconds)
method return time=1786809920.059323 sender=:1.2032 -> destination=:1.2033 serial=44 reply_serial=2
FFMPEG_PID=512956
SPOTIFY_PID=512957
Maajo - Musa Paradisa (284 seconds)
method return time=1786810286.044292 sender=:1.2037 -> destination=:1.2038 serial=44 reply_serial=2
FFMPEG_PID=513918
SPOTIFY_PID=513919
Abel Lima - Corre Riba, Corre Baxo (188 seconds)
method return time=1786810587.056015 sender=:1.2042 -> destination=:1.2043 serial=44 reply_serial=2
FFMPEG_PID=514665
SPOTIFY_PID=514666
David Walters - Mama - Déni-Shain & Mister Francky Remix (304 seconds)
method return time=1786810791.861445 sender=:1.2047 -> destination=:1.2048 serial=44 reply_serial=2
FFMPEG_PID=515319
SPOTIFY_PID=515320
Gizmo Varillas - Give a Little Love (148 seconds)
method return time=1786811112.804064 sender=:1.2053 -> destination=:1.2054 serial=44 reply_serial=2
FFMPEG_PID=516081
SPOTIFY_PID=516082
International Soleil Band - Ta Lassa - Hide & Smile Edit (376 seconds)
method return time=1786811277.697277 sender=:1.2058 -> destination=:1.2059 serial=44 reply_serial=2
FFMPEG_PID=516629
SPOTIFY_PID=516630
OK: ../output2/Abel Lima - Corre Riba, Corre Baxo.mp3
OK: ../output2/Bosq - Found Your Love.mp3
OK: ../output2/David Walters - Mama - Déni-Shain & Mister Francky Remix.mp3
OK: ../output2/Gizmo Varillas - Give a Little Love.mp3
OK: ../output2/International Soleil Band - Ta Lassa - Hide & Smile Edit.mp3
OK: ../output2/Maajo - Musa Paradisa.mp3
OK: ../output2/Quantic - Unconditional (feat. Rationale).mp3
thom@bureau:~/spotify$ ls -l output2
total 42776
-rw-rw-r-- 1 thom thom 4383405 août 15 18:34 'Abel Lima - Corre Riba, Corre Baxo.mp3'
-rw-rw-r-- 1 thom thom 8286381 août 15 18:35 'Bosq - Found Your Love.mp3'
-rw-rw-r-- 1 thom thom 7118829 août 15 18:35 'David Walters - Mama - Déni-Shain & Mister Francky Remix.mp3'
-rw-rw-r-- 1 thom thom 3492333 août 15 18:35 'Gizmo Varillas - Give a Little Love.mp3'
-rw-rw-r-- 1 thom thom 9006957 août 15 18:35 'International Soleil Band - Ta Lassa - Hide & Smile Edit.mp3'
-rw-rw-r-- 1 thom thom 6786477 août 15 18:36 'Maajo - Musa Paradisa.mp3'
-rw-rw-r-- 1 thom thom 4650093 août 15 18:36 'Quantic - Unconditional (feat. Rationale).mp3'
l'API retourne un maximum de 100 tracks par playlist (avec le script tracks_list.py) donc si la playlist contient plus de 100 morceaux il faut adapter le script pour faire du paging.
--
TG - 08/2025 update 08/2026