8 Commits

Author SHA1 Message Date
Kevin Restaino 6294427aeb feat: better logs view 2024-01-10 18:18:10 -05:00
Kevin Restaino 6a2f1a59cf Merge branch 'main' of github.com:krestaino/sptnr into develop 2024-01-09 14:32:52 -05:00
Kevin Restaino ddaf54fac8 refactor: tqdm unit 2024-01-09 13:30:19 -05:00
Kevin Restaino 147e9fb4b8 chore: alpha release 2024-01-09 00:14:59 -05:00
Kevin Restaino c5d08d6ab9 chore: update example 2024-01-08 23:49:40 -05:00
Kevin Restaino 651ed3f516 refactor: server.py 2024-01-08 23:48:46 -05:00
Kevin Restaino d703c6bf05 refactor: web api key 2024-01-08 23:31:35 -05:00
Kevin Restaino 46ab3e5be3 feat: web server 2024-01-08 22:24:21 -05:00
8 changed files with 203 additions and 14 deletions
+3 -1
View File
@@ -2,4 +2,6 @@ NAV_BASE_URL=your_navidrome_server_url
NAV_USER=your_navidrome_username NAV_USER=your_navidrome_username
NAV_PASS=your_navidrome_password NAV_PASS=your_navidrome_password
SPOTIFY_CLIENT_ID=your_spotify_client_id SPOTIFY_CLIENT_ID=your_spotify_client_id
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
WEB_API_KEY=changeme
ENABLE_WEB_API_KEY=True
+2 -1
View File
@@ -1,3 +1,4 @@
logs/*.log data/
.env .env
docker-compose.yml docker-compose.yml
__pycache__
+5 -2
View File
@@ -10,5 +10,8 @@ COPY . .
# Install any needed packages specified in requirements.txt # Install any needed packages specified in requirements.txt
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Use an entrypoint script # Expose port 5000 for the Flask app
ENTRYPOINT ["python", "./sptnr.py"] EXPOSE 5000
# Set the entrypoint to Python
ENTRYPOINT ["python", "sptnr.py"]
+1 -1
View File
@@ -1 +1 @@
1.3.0 2.0.0-alpha
+11 -1
View File
@@ -9,11 +9,21 @@ services:
# Uncomment the next line to build the Docker image locally # Uncomment the next line to build the Docker image locally
# build: . # build: .
# Uncomment the next line to run the script
# entrypoint: ["python", "sptnr.py"]
# Uncomment the next line to start the web server
# entrypoint: ["gunicorn", "-b", "0.0.0.0:5000", "server:sptnr"]
environment: environment:
- NAV_BASE_URL=your_navidrome_server_url - NAV_BASE_URL=your_navidrome_server_url
- NAV_USER=your_navidrome_username - NAV_USER=your_navidrome_username
- NAV_PASS=your_navidrome_password - NAV_PASS=your_navidrome_password
- SPOTIFY_CLIENT_ID=your_spotify_client_id - SPOTIFY_CLIENT_ID=your_spotify_client_id
- SPOTIFY_CLIENT_SECRET=your_spotify_client_secret - SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
- WEB_API_KEY=changeme
- ENABLE_WEB_API_KEY=False
volumes: volumes:
- ./logs:/usr/src/app/logs - ./data:/usr/src/app/data
ports:
- "3333:5000"
+4 -1
View File
@@ -1,4 +1,7 @@
requests==2.31.0 requests==2.31.0
python-dotenv==1.0.0 python-dotenv==1.0.0
colorama==0.4.6 colorama==0.4.6
tqdm==4.66.1 tqdm==4.66.1
gunicorn==20.1.0
Flask==2.1.2
Werkzeug==2.0.3
+133
View File
@@ -0,0 +1,133 @@
from flask import Flask, request, jsonify
import subprocess
import threading
import os
from dotenv import load_dotenv
import functools
import re
import datetime
load_dotenv()
sptnr = Flask(__name__)
WEB_API_KEY = os.getenv("WEB_API_KEY")
ENABLE_WEB_API_KEY = os.getenv("ENABLE_WEB_API_KEY", "True") == "True"
LOG_DIR = "data/logs"
def api_key_required(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
if ENABLE_WEB_API_KEY and request.args.get("api_key") != WEB_API_KEY:
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated_function
def run_script(cmd):
subprocess.run(cmd)
@sptnr.route("/process", methods=["GET", "POST"])
@api_key_required
def process_request():
cmd = ["python3", "sptnr.py"]
if request.args.get("preview", "") == "true":
cmd.append("--preview")
if request.args.get("force", "") == "true":
cmd.append("--force")
artist_ids = request.args.getlist("artist")
for artist_id in artist_ids:
cmd.extend(["--artist", artist_id])
album_ids = request.args.getlist("album")
for album_id in album_ids:
cmd.extend(["--album", album_id])
start = request.args.get("start")
if start:
cmd.extend(["--start", start])
limit = request.args.get("limit")
if limit:
cmd.extend(["--limit", limit])
thread = threading.Thread(target=run_script, args=(cmd,))
thread.start()
return jsonify({"message": "Processing started"})
@sptnr.route("/logs")
@api_key_required
def list_logs():
try:
logs = os.listdir(LOG_DIR)
logs = [log for log in logs if log.endswith(".log")] # Filter log files
full_paths = [os.path.join(LOG_DIR, log) for log in logs]
logs_sorted = sorted(full_paths, key=os.path.getmtime, reverse=True)
table_rows = []
for log_path in logs_sorted:
with open(log_path, "r") as file:
last_line = file.readlines()[-1]
tracks, found, not_found, match, time = parse_log_data(last_line)
log_name = os.path.basename(log_path)
timestamp = int(log_name.split("_")[1].split(".")[0])
log_datetime = datetime.datetime.fromtimestamp(timestamp)
formatted_datetime = log_datetime.strftime("%Y-%m-%d %H:%M:%S")
table_rows.append(
f"<tr><td><a href='/logs/{log_name}'>{log_name}</a></td><td>{formatted_datetime}</td><td>{tracks}</td><td>{found}</td><td>{not_found}</td><td>{match}</td><td>{time}</td></tr>"
)
table_html = f"<table border='1'><tr><th>Log File</th><th>Date & Time</th><th>Tracks</th><th>Found</th><th>Not Found</th><th>Match</th><th>Time</th></tr>{''.join(table_rows)}</table>"
return table_html
except Exception as e:
return f"An error occurred: {e}", 500
def parse_log_data(line):
# Regular expressions to extract the required data
tracks_pattern = r"Tracks: (\d+)"
found_pattern = r"Found: (\d+)"
not_found_pattern = r"Not Found: (\d+)"
match_pattern = r"Match: ([\d.]+%)"
time_pattern = r"Time: ([\ds]+)"
# Extracting data using regular expressions
tracks = re.search(tracks_pattern, line)
found = re.search(found_pattern, line)
not_found = re.search(not_found_pattern, line)
match = re.search(match_pattern, line)
time = re.search(time_pattern, line)
# Getting the values or "N/A" if not found
tracks = tracks.group(1) if tracks else "N/A"
found = found.group(1) if found else "N/A"
not_found = not_found.group(1) if not_found else "N/A"
match = match.group(1) if match else "N/A"
time = time.group(1) if time else "N/A"
return tracks, found, not_found, match, time
@sptnr.route("/logs/<filename>")
@api_key_required
def view_log(filename):
try:
full_path = os.path.join(LOG_DIR, filename)
if not os.path.exists(full_path) or not os.path.isfile(full_path):
return "File not found", 404
with open(full_path, "r") as file:
content = file.read()
# Convert content to HTML-friendly format
content = content.replace("\n", "<br>")
return f"<pre>{content}</pre>"
except Exception as e:
return f"An error occurred: {e}", 500
if __name__ == "__main__":
sptnr.run(debug=False, host="0.0.0.0", port=3333)
+44 -7
View File
@@ -41,11 +41,11 @@ BOLD = Style.BRIGHT
RESET = Style.RESET_ALL RESET = Style.RESET_ALL
# Setup logs # Setup logs
LOG_DIR = "logs" LOG_DIR = "data/logs"
if not os.path.exists(LOG_DIR): if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR) os.makedirs(LOG_DIR)
LOGFILE = os.path.join(LOG_DIR, f"spotify-popularity_{int(time.time())}.log") LOGFILE = os.path.join(LOG_DIR, f"sptnr_{int(time.time())}.log")
class NoColorFormatter(logging.Formatter): class NoColorFormatter(logging.Formatter):
@@ -103,6 +103,9 @@ TOTAL_TRACKS = 0
FOUND_AND_UPDATED = 0 FOUND_AND_UPDATED = 0
NOT_FOUND = 0 NOT_FOUND = 0
UNMATCHED_TRACKS = [] UNMATCHED_TRACKS = []
PROCESSED_ALBUMS_FILE = "data/processed_albums.txt"
processed_albums = set()
# Parse arguments # Parse arguments
description_text = "process command-line flags for sync" description_text = "process command-line flags for sync"
@@ -147,6 +150,13 @@ parser.add_argument(
"-v", "--version", action="version", version=f"%(prog)s {__version__}" "-v", "--version", action="version", version=f"%(prog)s {__version__}"
) )
parser.add_argument(
"-f",
"--force",
action="store_true",
help="force processing of all albums, even if they were processed previously",
)
args = parser.parse_args() args = parser.parse_args()
@@ -295,6 +305,13 @@ def process_track(track_id, artist_name, album, track_name):
def process_album(album_id): def process_album(album_id):
if not args.force:
global processed_albums
if album_id in processed_albums:
logging.info(f" {LIGHT_CYAN}Skipping already processed album{RESET}")
return
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" 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() response = requests.get(nav_url).json()
@@ -308,6 +325,10 @@ def process_album(album_id):
for track in tracks: for track in tracks:
process_track(*track) process_track(*track)
processed_albums.add(album_id)
with open(PROCESSED_ALBUMS_FILE, "a") as file:
file.write(f"{album_id}\n")
def process_artist(artist_id): 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" 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"
@@ -360,6 +381,11 @@ def fetch_data(url):
sys.exit(1) sys.exit(1)
# Load processed albums
if os.path.exists(PROCESSED_ALBUMS_FILE) and not args.force:
with open(PROCESSED_ALBUMS_FILE, "r") as file:
processed_albums = set(file.read().splitlines())
try: try:
validate_url(NAV_BASE_URL) validate_url(NAV_BASE_URL)
except ValueError as e: except ValueError as e:
@@ -411,7 +437,7 @@ else:
logging.info(f"Total artists to process: {LIGHT_GREEN}{total_count}{RESET}") logging.info(f"Total artists to process: {LIGHT_GREEN}{total_count}{RESET}")
for index, ARTIST_ENTRY in tqdm( for index, ARTIST_ENTRY in tqdm(
enumerate(data_slice), total=total_count, leave=False enumerate(data_slice), total=total_count, leave=False, unit="artist"
): ):
ARTIST_ID, ARTIST_NAME = ARTIST_ENTRY ARTIST_ID, ARTIST_NAME = ARTIST_ENTRY
@@ -426,14 +452,26 @@ else:
# Display the results # Display the results
logging.info("") 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 # Check if TOTAL_TRACKS is zero to avoid division by zero error
if TOTAL_TRACKS > 0:
MATCH_PERCENTAGE = (FOUND_AND_UPDATED / TOTAL_TRACKS) * 100
else:
MATCH_PERCENTAGE = 0
FORMATTED_MATCH_PERCENTAGE = round(MATCH_PERCENTAGE, 2)
TOTAL_BLOCKS = 20 TOTAL_BLOCKS = 20
color_found = LIGHT_GREEN if FOUND_AND_UPDATED == TOTAL_TRACKS else LIGHT_YELLOW 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_found_white = LIGHT_GREEN if FOUND_AND_UPDATED == TOTAL_TRACKS else BOLD
color_not_found = LIGHT_GREEN if NOT_FOUND == 0 else LIGHT_RED color_not_found = LIGHT_GREEN if NOT_FOUND == 0 else LIGHT_RED
blocks_found = "" * round(FOUND_AND_UPDATED * TOTAL_BLOCKS / TOTAL_TRACKS)
# Adjust the progress bar calculation
blocks_found = (
"" * round(FOUND_AND_UPDATED * TOTAL_BLOCKS / TOTAL_TRACKS)
if TOTAL_TRACKS > 0
else ""
)
blocks_not_found = "" * (TOTAL_BLOCKS - len(blocks_found)) blocks_not_found = "" * (TOTAL_BLOCKS - len(blocks_found))
full_blocks_found = f"{color_found_white}{blocks_found}{RESET}" full_blocks_found = f"{color_found_white}{blocks_found}{RESET}"
full_blocks_not_found = f"{color_not_found}{blocks_not_found}{RESET}" full_blocks_not_found = f"{color_not_found}{blocks_not_found}{RESET}"
@@ -453,7 +491,6 @@ if seconds or not parts: # Show seconds if it's the only value, even if it's 0
formatted_elapsed_time = " ".join(parts) formatted_elapsed_time = " ".join(parts)
# logging.info(f"Processing completed in {int(hours):02}:{int(minutes):02}:{int(seconds):02}")
logging.info( 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}" 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}"
) )