This commit is contained in:
Kevin Restaino
2023-09-01 00:48:50 -07:00
commit 464d6a8ef3
8 changed files with 633 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
NAV_BASE_URL=your_navidrome_server_url
NAV_USER=your_navidrome_username
NAV_PASS=your_navidrome_password
SPOTIFY_CLIENT_ID=your_spotify_client_id
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

+3
View File
@@ -0,0 +1,3 @@
logs/*.log
.env
docker-compose.yml
+14
View File
@@ -0,0 +1,14 @@
# Use an official Python runtime as a parent image
FROM python:3.9-slim
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy the current directory contents into the container at /usr/src/app
COPY . .
# Install any needed packages specified in requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Use an entrypoint script
ENTRYPOINT ["python", "./sptnr.py"]
+209
View File
@@ -0,0 +1,209 @@
# Spotify Popularity to Navidrome Ratings (sptnr)
This script was developed as a solution to repurpose the star ratings in Navidrome, aligning them with Spotify's track popularity. As a Navidrome user who utilizes the 'favorite' feature instead of star ratings, I wanted to give new life and utility to the unused rating system. By syncing Spotify popularity data with Navidrome's ratings, the script provides a quick way to identify popular tracks. This becomes particularly useful when frequently adding new albums and artists to the Navidrome server, especially those you're not familiar with.
![Screenshot of script and phone](.github/screenshot.png)
## Features
- **Spotify Integration**: Connects to Spotify's API to fetch popularity data for tracks.
- **Navidrome Integration**: Updates track ratings in Navidrome based on Spotify popularity.
- **Flexible Processing**: Process specific artists, albums, or a range of artists or albums.
- **Preview Mode**: Run the script in preview mode to see changes without making any actual updates.
- **Logging**: Detailed logging of the process, both in the console and to a file.
- **Docker Support**: Run the script in a Docker container for consistent environments and ease of use.
## Requirements
- Python 3.x or Docker
- A Spotify Developer account with an API key ([Get your Spotify API key here](https://developer.spotify.com/dashboard/create))
- Access to a Navidrome server
## Quick Start
You can easily run the script using a pre-built public Docker image. This method is straightforward and does not require building the Docker image locally. You can use the following Docker command and replace the environment variable values with your own:
```console
docker run \
-e NAV_BASE_URL=your_navidrome_server_url \
-e NAV_USER=your_navidrome_username \
-e NAV_PASS=your_navidrome_password \
-e SPOTIFY_CLIENT_ID=your_spotify_client_id \
-e SPOTIFY_CLIENT_SECRET=your_spotify_client_secret \
krestaino/sptnr:latest
```
### Using Docker Compose
1. **Create `docker-compose.yml` File**: Set up your `docker-compose.yml` file with the following content, replacing the environment variables with your own details:
```yaml
version: "3.8"
services:
sptnr:
container_name: sptnr
image: krestaino/sptnr:latest
environment:
- NAV_BASE_URL=your_navidrome_server_url
- NAV_USER=your_navidrome_username
- NAV_PASS=your_navidrome_password
- SPOTIFY_CLIENT_ID=your_spotify_client_id
- SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
volumes:
- ./logs:/usr/src/app/logs
```
2. **Run the Script**: Execute the Docker Compose command to run the script:
```console
docker-compose run sptnr
```
## Running Natively or Building Locally
For those who prefer running the script natively using Python or building the Docker image locally, the following steps apply:
### Running Natively (Without Docker)
1. **Clone the Repository**: Clone the repository or download the necessary files (`sptnr.py`, `requirements.txt`, `.env.example`) to your local machine.
2. **Install Python Packages**: Use the `requirements.txt` file to install dependencies:
```console
pip install -r requirements.txt
```
3. **Configure Environment Variables**: Rename `.env.example` to `.env` and fill in your details:
```console
mv .env.example .env
# Edit the .env file with your details
```
4. **Run the Script**: Execute the script with Python:
```console
python sptnr.py [options]
```
### Building and Running with Docker Locally
1. **Clone the Repository**: Clone the repository or download the necessary files (`sptnr.py`, `requirements.txt`, `Dockerfile`, `docker-compose.yml.example`) to your local machine.
2. **Configure Docker Compose**: Rename and edit your `docker-compose.yml`:
```console
mv docker-compose.yml.example docker-compose.yml
# Edit the docker-compose.yml file
```
3. **Set the Docker Image Source**: Uncomment the line `build: .` in the `docker-compose.yml` file to build a local Docker image.
4. **Build and Run**: Build the Docker image and run the script:
```console
docker-compose build
docker-compose run sptnr [options]
```
## Usage
The script supports various options for flexible usage. Below are examples of how to run the script with different options, using Python, Docker Compose, and Docker Run methods. Replace `[options]` with any of the specified options based on your needs.
### Options
- `-p, --preview`: Execute the script in preview mode (no changes made).
- `-a, --artist ARTIST_ID`: Process a specific artist. Multiple artists can be specified.
- `-b, --album ALBUM_ID`: Process a specific album. Multiple albums can be specified.
- `-s, --start START_INDEX`: Start processing from the artist at the specified index (0-based).
- `-l, --limit LIMIT`: Limit the processing to a specific number of artists from the start index.
### Command Formats
1. **Running Natively (Python)**:
```console
python sptnr.py [options]
```
2. **Using Docker Compose**:
```console
docker-compose run sptnr [options]
```
3. **Using Docker Run**:
```console
docker run [environment variables] krestaino/sptnr:latest [options]
```
## Examples
- **Preview Mode**:
Run the script in preview mode to see changes without making any actual updates.
- Python: `python sptnr.py -p`
- Docker Compose: `docker-compose run sptnr -p`
- Docker Run: `docker run [env vars] krestaino/sptnr:latest -p`
- **Process Specific Artist**:
Process only one artist by specifying their ID.
- Python: `python sptnr.py -a artist_id`
- Docker Compose: `docker-compose run sptnr -a artist_id`
- Docker Run: `docker run [env vars] krestaino/sptnr:latest -a artist_id`
- **Process Specific Albums**:
Process multiple specific albums by specifying their IDs.
- Python: `python sptnr.py -b album_id1 -b album_id2`
- Docker Compose: `docker-compose run sptnr -b album_id1 -b album_id2`
- Docker Run: `docker run [env vars] krestaino/sptnr:latest -b album_id1 -b album_id2`
- **Process Range of Artists**:
Process artists starting from a certain index with a limit.
- Python: `python sptnr.py -s 10 -l 5`
- Docker Compose: `docker-compose run sptnr -s 10 -l 5`
- Docker Run: `docker run [env vars] krestaino/sptnr:latest -s 10 -l 5`
## Resuming Interrupted Sessions
In cases where your session gets interrupted - for instance, if your machine goes to sleep, you encounter rate limits from Spotify, or for any other reason that causes the script to not complete - you have the option to resume from where you left off.
To determine the point of interruption, check the log file. The log entry will contain details of the artist it failed on, along with the index in a format similar to: `Artist: ARTIST_NAME (ARTIST_NAVIDROME_ID)[INDEX]`. Here, the index is enclosed in brackets.
When you restart the script, use the `-s INDEX` option, where `INDEX` is the index number from the log. This tells the script to start processing from that specific artist, skipping all previously processed entries.
Example command to continue from a specific point:
- Python: `python sptnr.py -s INDEX`
- Docker Compose: `docker-compose run sptnr -s INDEX`
- Docker Run: `docker run [env vars] krestaino/sptnr:latest -s INDEX`
_Note: Replace `[env vars]` with the required environment variable arguments and `INDEX` with the specific index number from your log file._
## Note on Using `docker-compose run`
In this project, `docker-compose run` is used instead of `docker-compose up`. This choice allows for greater flexibility in passing command-line options directly to the script, essential for its varied operational modes.
It's important to understand that `docker-compose run` creates a new container each time it's executed. If you're frequently running the script, you might accumulate a number of these containers. To manage this, you can periodically clean up these containers using the following Docker command. This command is tailored to remove only the containers created by this project:
```console
docker container prune --filter "label=com.docker.compose.project=sptnr"
```
## Mapping Spotify Popularity to Navidrome Ratings
The script translates Spotify's popularity metric, which ranges from 0 to 100, into Navidrome's 5-star rating system. This conversion allows you to quickly gauge a track's popularity on Spotify directly within Navidrome. The mapping is as follows:
- **0 to 16**: Mapped to 0 stars in Navidrome (Not popular)
- **17 to 33**: Mapped to 1 star (Low popularity)
- **34 to 50**: Mapped to 2 stars (Moderately popular)
- **51 to 66**: Mapped to 3 stars (Popular)
- **67 to 83**: Mapped to 4 stars (Very popular)
- **84 to 100**: Mapped to 5 stars (Extremely popular)
## Logs
Logs are stored in the `logs` directory. Each execution creates a new log file with a timestamp.
+19
View File
@@ -0,0 +1,19 @@
version: '3.8'
services:
sptnr:
container_name: sptnr
# Uncomment the next line to use the public Docker image
# image: krestaino/sptnr:latest
# Uncomment the next line to build the Docker image locally
# build: .
environment:
- NAV_BASE_URL=your_navidrome_server_url
- NAV_USER=your_navidrome_username
- NAV_PASS=your_navidrome_password
- SPOTIFY_CLIENT_ID=your_spotify_client_id
- SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
volumes:
- ./logs:/usr/src/app/logs
+4
View File
@@ -0,0 +1,4 @@
requests==2.31.0
python-dotenv==1.0.0
colorama==0.4.6
tqdm==4.66.1
+379
View File
@@ -0,0 +1,379 @@
import argparse
import base64
import json
import logging
import os
import re
import sys
import time
import urllib.parse
from dotenv import load_dotenv
import requests
from colorama import init, Fore, Style
from tqdm import tqdm
# Load environment variables from .env file if it exists
if os.path.exists(".env"):
load_dotenv()
# Record the start time
start_time = time.time()
# Config
NAV_BASE_URL = os.getenv("NAV_BASE_URL")
NAV_USER = os.getenv("NAV_USER")
NAV_PASS = os.getenv("NAV_PASS")
SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
# Setup logs
LOG_DIR = "logs"
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
LOGFILE = os.path.join(LOG_DIR, f"spotify-popularity_{int(time.time())}.log")
# Auth
HEX_ENCODED_PASS = NAV_PASS.encode().hex()
TOKEN_AUTH = base64.b64encode(
f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_CLIENT_SECRET}".encode()
).decode()
TOKEN_URL = "https://accounts.spotify.com/api/token"
response = requests.post(
TOKEN_URL,
headers={"Authorization": f"Basic {TOKEN_AUTH}"},
data={"grant_type": "client_credentials"},
)
SPOTIFY_TOKEN = json.loads(response.text)["access_token"]
init(autoreset=True)
LIGHT_PURPLE = Fore.MAGENTA + Style.BRIGHT
LIGHT_GREEN = Fore.GREEN + Style.BRIGHT
LIGHT_RED = Fore.RED + Style.BRIGHT
LIGHT_BLUE = Fore.BLUE + Style.BRIGHT
LIGHT_CYAN = Fore.CYAN + Style.BRIGHT
LIGHT_YELLOW = Fore.YELLOW + Style.BRIGHT
BOLD = Style.BRIGHT
RESET = Style.RESET_ALL
# Default flags
PREVIEW = 0
START = 0
LIMIT = 0
ARTIST_IDs = []
ALBUM_IDs = []
# Variables
ARTISTS_PROCESSED = 0
TOTAL_TRACKS = 0
FOUND_AND_UPDATED = 0
NOT_FOUND = 0
UNMATCHED_TRACKS = []
# Setup logging
class NoColorFormatter(logging.Formatter):
ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
def format(self, record):
record.msg = self.ansi_escape.sub("", record.msg)
return super(NoColorFormatter, self).format(record)
# Set up the stream handler (console logging) without timestamp
logging.basicConfig(
level=logging.INFO, format="%(message)s", handlers=[logging.StreamHandler()]
)
# Set up the file handler (file logging) with timestamp
file_handler = logging.FileHandler(LOGFILE, "a")
file_handler.setFormatter(NoColorFormatter("[%(asctime)s] %(message)s"))
logging.getLogger().addHandler(file_handler)
# Parse arguments
description_text = "process command-line flags for sync"
parser = argparse.ArgumentParser()
parser.add_argument(
"-p",
"--preview",
action="store_true",
help="execute script in preview mode (no changes made)",
)
parser.add_argument(
"-a",
"--artist",
action="append",
help="process the artist using the Navidrome artist ID (ignores START and LIMIT)",
type=str,
)
parser.add_argument(
"-b",
"--album",
action="append",
help="process the album using the Navidrome album ID (ignores START and LIMIT)",
type=str,
)
parser.add_argument(
"-s",
"--start",
default=0,
type=int,
help="start processing from artist at index [NUM] (0-based index, so 0 is the first artist)",
)
parser.add_argument(
"-l",
"--limit",
default=0,
type=int,
help="limit to processing [NUM] artists from the start index",
)
args = parser.parse_args()
ARTIST_IDs = args.artist if args.artist else []
ALBUM_IDs = args.album if args.album else []
START = args.start
LIMIT = args.limit
if args.preview:
logging.info(f"{LIGHT_YELLOW}Preview mode, no changes will be made.{RESET}")
PREVIEW = 1
# Check if both ARTIST_ID and START/LIMIT are provided
if ARTIST_IDs and (START != 0 or LIMIT != 0):
START = 0
LIMIT = 0
logging.info(
f"{LIGHT_YELLOW}Warning: The --artist flag overrides --start and --limit. Ignoring these settings.{RESET}"
)
if not args.preview:
logging.info(
f"{BOLD}Syncing Spotify {LIGHT_CYAN}popularity{RESET}{BOLD} with Navidrome {LIGHT_BLUE}rating{RESET}...{RESET}"
)
def url_encode(string):
return urllib.parse.quote_plus(string)
def get_rating_from_popularity(popularity):
popularity = float(popularity)
if popularity < 16.66:
return 0
elif popularity < 33.33:
return 1
elif popularity < 50:
return 2
elif popularity < 66.66:
return 3
elif popularity < 83.33:
return 4
else:
return 5
def process_track(track_id, artist_name, album, track_name):
def search_spotify(query):
spotify_url = f"https://api.spotify.com/v1/search?q={query}&type=track&limit=1"
headers = {"Authorization": f"Bearer {SPOTIFY_TOKEN}"}
try:
response = requests.get(spotify_url, headers=headers)
except requests.exceptions.ConnectionError:
logging.error(f"{LIGHT_RED}Error: Unable to reach server.{RESET}")
sys.exit(1)
if response.status_code != 200:
if response.status_code == 429:
logging.error(
f"{LIGHT_RED}Error {response.status_code}: Retry after {BOLD}{response.headers.get('Retry-After', 'some time')}s{RESET}"
)
else:
logging.error(
f"{LIGHT_RED}Error {response.status_code}: {response.text}{RESET}"
)
sys.exit(1)
try:
return response.json()
except ValueError as e:
logging.error(
f"{LIGHT_RED}Error decoding JSON from Spotify API: {e}{RESET}"
)
sys.exit(1)
def remove_parentheses_content(s):
"""Remove content inside parentheses from a string."""
return re.sub(r"\s*\(.*?\)\s*", " ", s).strip()
encoded_track_name = url_encode(track_name)
encoded_artist_name = url_encode(artist_name)
encoded_album = url_encode(album)
# Primary Search (with album)
spotify_data = search_spotify(
f"{encoded_track_name}%20artist:{encoded_artist_name}%20album:{encoded_album}"
)
found_track = len(spotify_data.get("tracks", {}).get("items", [])) > 0
if not found_track:
# Secondary Search (without album and parentheses content)
sanitized_track_name = url_encode(remove_parentheses_content(track_name))
spotify_data = search_spotify(
f"{sanitized_track_name}%20artist:{encoded_artist_name}"
)
found_track = len(spotify_data.get("tracks", {}).get("items", [])) > 0
if found_track:
popularity = spotify_data["tracks"]["items"][0].get("popularity", 0)
rating = get_rating_from_popularity(popularity)
popularity_str = f"{popularity} " if 0 <= popularity <= 9 else str(popularity)
message = f" p:{LIGHT_CYAN}{popularity_str}{RESET} → r:{LIGHT_BLUE}{rating}{RESET} | {LIGHT_GREEN}{track_name}{RESET}"
logging.info(message)
if PREVIEW != 1:
nav_url = f"{NAV_BASE_URL}/rest/setRating?u={NAV_USER}&p=enc:{HEX_ENCODED_PASS}&v=1.12.0&c=myapp&id={track_id}&rating={rating}"
requests.get(nav_url)
global FOUND_AND_UPDATED
FOUND_AND_UPDATED += 1
else:
logging.info(
f" p:{LIGHT_RED}??{RESET} → r:{LIGHT_BLUE}0{RESET} | {LIGHT_RED}(not found) {track_name}{RESET}"
)
global UNMATCHED_TRACKS
UNMATCHED_TRACKS.append(f"{artist_name} - {album} - {track_name}")
global NOT_FOUND
NOT_FOUND += 1
global TOTAL_TRACKS
TOTAL_TRACKS += 1
def process_album(album_id):
nav_url = f"{NAV_BASE_URL}/rest/getAlbum?id={album_id}&u={NAV_USER}&p=enc:{HEX_ENCODED_PASS}&v=1.12.0&c=spotify_sync&f=json"
response = requests.get(nav_url).json()
album_info = response["subsonic-response"]["album"]
album_artist = album_info["artist"]
tracks = [
(song["id"], album_artist, song["album"], song["title"])
for song in album_info.get("song", [])
]
for track in tracks:
process_track(*track)
def process_artist(artist_id):
nav_url = f"{NAV_BASE_URL}/rest/getArtist?id={artist_id}&u={NAV_USER}&p=enc:{HEX_ENCODED_PASS}&v=1.12.0&c=spotify_sync&f=json"
response = requests.get(nav_url).json()
albums = [
(album["id"], album["name"])
for album in response["subsonic-response"]["artist"].get("album", [])
]
for album_id, album_name in albums:
logging.info(f" Album: {LIGHT_YELLOW}{album_name}{RESET} ({album_id})")
process_album(album_id)
def fetch_data(url):
response = requests.get(url)
return json.loads(response.text)["subsonic-response"]
if ARTIST_IDs:
for ARTIST_ID in ARTIST_IDs:
url = f"{NAV_BASE_URL}/rest/getArtist?id={ARTIST_ID}&u={NAV_USER}&p=enc:{HEX_ENCODED_PASS}&v=1.12.0&c=spotify_sync&f=json"
data = fetch_data(url)
ARTIST_NAME = data["artist"]["name"]
logging.info("")
logging.info(f"Artist: {LIGHT_PURPLE}{ARTIST_NAME}{RESET} ({ARTIST_ID})")
process_artist(ARTIST_ID)
elif ALBUM_IDs:
for ALBUM_ID in ALBUM_IDs:
url = f"{NAV_BASE_URL}/rest/getAlbum?id={ALBUM_ID}&u={NAV_USER}&p=enc:{HEX_ENCODED_PASS}&v=1.12.0&c=spotify_sync&f=json"
data = fetch_data(url)
ARTIST_NAME = data["album"]["artist"]
ARTIST_ID = data["album"]["artistId"]
ALBUM_NAME = data["album"]["name"]
logging.info("")
logging.info(f"Artist: {LIGHT_PURPLE}{ARTIST_NAME}{RESET} ({ARTIST_ID})")
logging.info(f" Album: {LIGHT_YELLOW}{ALBUM_NAME}{RESET} ({ALBUM_ID})")
process_album(ALBUM_ID)
else:
url = f"{NAV_BASE_URL}/rest/getArtists?u={NAV_USER}&p=enc:{HEX_ENCODED_PASS}&v=1.12.0&c=spotify_sync&f=json"
data = fetch_data(url)
ARTIST_DATA = [
(artist["id"], artist["name"])
for index_entry in data["artists"]["index"]
for artist in index_entry["artist"]
]
if START == 0 and LIMIT == 0:
data_slice = ARTIST_DATA
total_count = len(ARTIST_DATA)
else:
if LIMIT == 0:
data_slice = ARTIST_DATA[START:]
else:
data_slice = ARTIST_DATA[START : START + LIMIT]
total_count = len(data_slice)
logging.info(f"Total artists to process: {LIGHT_GREEN}{total_count}{RESET}")
for index, ARTIST_ENTRY in tqdm(
enumerate(data_slice), total=total_count, leave=False
):
ARTIST_ID, ARTIST_NAME = ARTIST_ENTRY
logging.info("")
logging.info(
f"Artist: {LIGHT_PURPLE}{ARTIST_NAME}{RESET} ({ARTIST_ID})[{index}]"
)
process_artist(ARTIST_ID)
ARTISTS_PROCESSED += 1
# Display the results
logging.info("")
MATCH_PERCENTAGE = (FOUND_AND_UPDATED / TOTAL_TRACKS) * 100 if TOTAL_TRACKS != 0 else 0
FORMATTED_MATCH_PERCENTAGE = round(MATCH_PERCENTAGE, 2) # Rounding to 2 decimal places
TOTAL_BLOCKS = 20
color_found = LIGHT_GREEN if FOUND_AND_UPDATED == TOTAL_TRACKS else LIGHT_YELLOW
color_found_white = LIGHT_GREEN if FOUND_AND_UPDATED == TOTAL_TRACKS else BOLD
color_not_found = LIGHT_GREEN if NOT_FOUND == 0 else LIGHT_RED
blocks_found = "" * round(FOUND_AND_UPDATED * TOTAL_BLOCKS / TOTAL_TRACKS)
blocks_not_found = "" * (TOTAL_BLOCKS - len(blocks_found))
full_blocks_found = f"{color_found_white}{blocks_found}{RESET}"
full_blocks_not_found = f"{color_not_found}{blocks_not_found}{RESET}"
# Calculate elapsed time
elapsed_time = time.time() - start_time
hours, remainder = divmod(elapsed_time, 3600)
minutes, seconds = divmod(remainder, 60)
parts = []
if hours:
parts.append(f"{int(hours)}h")
if minutes:
parts.append(f"{int(minutes)}m")
if seconds or not parts: # Show seconds if it's the only value, even if it's 0
parts.append(f"{int(seconds)}s")
formatted_elapsed_time = " ".join(parts)
# logging.info(f"Processing completed in {int(hours):02}:{int(minutes):02}:{int(seconds):02}")
logging.info(
f"Tracks: {LIGHT_PURPLE}{TOTAL_TRACKS}{RESET} | Found: {color_found}{FOUND_AND_UPDATED}{RESET} |{full_blocks_found}{full_blocks_not_found}| Not Found: {color_not_found}{NOT_FOUND}{RESET} | Match: {color_found}{FORMATTED_MATCH_PERCENTAGE}%{RESET} | Time: {LIGHT_PURPLE}{formatted_elapsed_time}{RESET}"
)