forked from CopyBot/sptnr
init
This commit is contained in:
@@ -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 |
@@ -0,0 +1,3 @@
|
|||||||
|
logs/*.log
|
||||||
|
.env
|
||||||
|
docker-compose.yml
|
||||||
+14
@@ -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"]
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
requests==2.31.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
colorama==0.4.6
|
||||||
|
tqdm==4.66.1
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user