Initial commit

This commit is contained in:
Beda Schmid
2025-10-06 18:22:09 +00:00
commit 323b2e1bb1
28 changed files with 1912 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Environment variables
.env
# Config files
config/
# Python cache
__pycache__/
*.pyc
# Docker crap
*.log

7
.sample-env Normal file
View File

@@ -0,0 +1,7 @@
last_fm_api_key=
last_fm_api_secret=
lidarr_address=http://192.168.1.1:8686
quality_profile_id=1
lidarr_api_key=
youtube_api_key=
similar_artist_batch_size=10

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.12-alpine
ARG RELEASE_VERSION
ENV RELEASE_VERSION=${RELEASE_VERSION}
RUN apk update && apk add --no-cache su-exec
# Copy only requirements first
COPY requirements.txt /sonobarr/
WORKDIR /sonobarr
# Install requirements
RUN pip install --no-cache-dir -r requirements.txt
# Now copy the rest of your code
COPY src/ /sonobarr/src/
COPY gunicorn_config.py /sonobarr/
COPY init.sh /sonobarr/
RUN chmod +x init.sh
ENTRYPOINT ["./init.sh"]

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2024 TheWicklowWolf
Copyright (c) 2025 Dodelidoo Labs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# Sonobarr
<p align="center">
<img src="/src/static/sonobarr.png" alt="Sonobarr Logo">
</p>
**Sonobarr** is a music discovery tool that integrates with **Lidarr** and provides recommendations using **Last.fm**.
---
## Features
The app can:
- Fetch artists from Lidarr
- Let you select one or more artists to find similar artists (from Last.fm only)
- Display artist biographies
- Play YouTube videos to listen to artists directly
- Load additional artists dynamically
## Planned Features
- Sorting options
- AI-powered recommendations
- Manual artist search
- Pre-built Docker image on GitHub Container Registry (GHCR) and/or Docker Hub mirror
- …and more to come
---
## Screenshots
<p align="center">
<img src="/src/static/fetch-from-lidarr.png" alt="Fetch from Lidarr" width="100%">
<img src="/src/static/bio-detail.png" alt="Bio Detail" width="50%">
<img src="/src/static/card-detail.png" alt="Card Detail" width="50%">
<img src="/src/static/prehear-detail.png" alt="Prehear Detail" width="50%">
<img src="/src/static/settings-detail.png" alt="Settings Detail" width="50%">
</p>
---
## Run using Docker Compose
_This setup assumes you already use Docker and have a network (for example, with NGINX Proxy Manager). Check the comments in the `docker-compose.yml` file if you prefer a simpler setup with exposed ports._
1. Clone this repository
2. Edit the `docker-compose.yml` file to match your environment
3. Rename `.sample-env` to `.env` and adjust the values as needed
4. Run <code>sudo docker compose up -d</code>
- This will **build the image locally** on first run, and then reuse the local image afterwards
---
## Configuration via Environment Variables
- **PUID** User ID (default: `1000`)
- **PGID** Group ID (default: `1000`)
- **lidarr_address** Lidarr URL (default: `http://192.168.1.1:8686`)
- **lidarr_api_key** API key for Lidarr
- **root_folder_path** Music root folder path (default: `/data/media/music/`)
- **fallback_to_top_result** Use top result if no match is found (default: `False`)
- **lidarr_api_timeout** API timeout in seconds (default: `120`)
- **quality_profile_id** Quality profile ID (default: `1`). See [here]() how to find it.
- **metadata_profile_id** Metadata profile ID (default: `1`)
- **search_for_missing_albums** Start searching when adding artists (default: `False`)
- **dry_run_adding_to_lidarr** Run without adding artists (default: `False`)
- **app_name** Application name (default: `Sonobarr`)
- **app_rev** Application revision (default: `0.01`, Version string sent to MusicBrainz as part of the HTTP User-Agent)
- **app_url** Application URL (default: `Random URL`, Contact/project URL sent to MusicBrainz as part of the HTTP User-Agent)
- **last_fm_api_key** API key for Last.fm
- **last_fm_api_secret** API secret for Last.fm
- **youtube_api_key** API key for YouTube
- **similar_artist_batch_size** Batch size for similar artists (default: `10`)
- **auto_start** Run automatically at startup (default: `False`)
- **auto_start_delay** Delay in seconds for auto start (default: `60`)
---
## License
This project is licensed under the MIT License.
Original work © 2024 TheWicklowWolf.
Forked and modified by Dodelidoo Labs, © 2025.

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
services:
sonobarr:
build:
context: .
dockerfile: Dockerfile
image: sonobarr-local
container_name: sonobarr
volumes:
- ./config:/sonobarr/config
- /etc/localtime:/etc/localtime:ro
- ./src:/sonobarr/src # only needed if you want to modify the source code
#ports: # unless you want to expose the app directly, otherwise use a reverse proxy
#- 5000:5000
restart: unless-stopped
environment:
- last_fm_api_key=${last_fm_api_key}
- last_fm_api_secret=${last_fm_api_secret}
- lidarr_address=${lidarr_address}
- quality_profile_id=${quality_profile_id}
- lidarr_api_key=${lidarr_api_key}
- youtube_api_key=${youtube_api_key}
networks: # unless you want to expose the app directly, in which case you will open ports
npm_proxy:
ipv4_address: 192.168.97.23
networks:
npm_proxy:
external: true

5
gunicorn_config.py Normal file
View File

@@ -0,0 +1,5 @@
bind = "0.0.0.0:5000"
workers = 1
threads = 4
timeout = 120
worker_class = "geventwebsocket.gunicorn.workers.GeventWebSocketWorker"

31
init.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
echo -e "\033[1;34mSonobarr\033[0m"
echo "Initializing app..."
cat << 'EOF'
██ _______. ______ .__ __. ______ .______ ___ .______ .______
██ / | / __ \ | \ | | / __ \ | _ \ / \ | _ \ | _ \
██ ██ ▄▄ | (----`| | | | | \| | | | | | | |_) | / ^ \ | |_) | | |_) |
██ ██ ██ ██ \ \ | | | | | . ` | | | | | | _ < / /_\ \ | / | /
██ ██ ██ ██ ██ .----) | | `--' | | |\ | | `--' | | |_) | / _____ \ | |\ \----. | |\ \----.
██ ██ ██ ██ ██ |_______/ \______/ |__| \__| \______/ |______/ /__/ \__\ | _| `._____| | _| `._____|
EOF
PUID=${PUID:-1000}
PGID=${PGID:-1000}
echo "-----------------"
echo -e "\033[1mRunning with:\033[0m"
echo "PUID=${PUID}"
echo "PGID=${PGID}"
echo "-----------------"
# Create the required directories with the correct permissions
echo "Setting up directories.."
mkdir -p /sonobarr/config
chown -R ${PUID}:${PGID} /sonobarr
# Start the application with the specified user permissions
echo "Running Sonobarr..."
exec su-exec ${PUID}:${PGID} gunicorn src.Sonobarr:app -c gunicorn_config.py

10
requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
gunicorn
gevent
gevent-websocket
flask
flask_socketio
requests
musicbrainzngs
thefuzz
Unidecode
pylast

649
src/Sonobarr.py Normal file
View File

@@ -0,0 +1,649 @@
import json
import time
import logging
import os
import random
import string
import threading
import urllib.parse
from flask import Flask, render_template, request
from flask_socketio import SocketIO
import requests
import musicbrainzngs
from thefuzz import fuzz
from unidecode import unidecode
import pylast
class DataHandler:
def __init__(self):
logging.basicConfig(level=logging.INFO, format="%(message)s")
self.sonobarr_logger = logging.getLogger()
self.musicbrainzngs_logger = logging.getLogger("musicbrainzngs")
self.musicbrainzngs_logger.setLevel("WARNING")
self.pylast_logger = logging.getLogger("pylast")
self.pylast_logger.setLevel("WARNING")
app_name_text = os.path.basename(__file__).replace(".py", "")
release_version = os.environ.get("RELEASE_VERSION", "unknown")
self.sonobarr_logger.warning(f"{'*' * 50}\n")
self.sonobarr_logger.warning(f"{app_name_text} Version: {release_version}\n")
self.sonobarr_logger.warning(f"{'*' * 50}")
self.search_in_progress_flag = False
self.new_found_artists_counter = 0
self.clients_connected_counter = 0
self.config_folder = "config"
self.recommended_artists = []
self.lidarr_items = []
self.cleaned_lidarr_items = []
self.similar_artist_batch_size = ""
self.stop_event = threading.Event()
self.stop_event.set()
if not os.path.exists(self.config_folder):
os.makedirs(self.config_folder)
self.load_environ_or_config_settings()
if self.auto_start:
try:
auto_start_thread = threading.Timer(self.auto_start_delay, self.automated_startup)
auto_start_thread.daemon = True
auto_start_thread.start()
except Exception as e:
self.sonobarr_logger.error(f"Auto Start Error: {str(e)}")
self.similar_artist_batch_pointer = 0
self.similar_artist_candidates = []
self.initial_batch_sent = False
def load_environ_or_config_settings(self):
# Defaults
default_settings = {
"lidarr_address": "http://192.168.1.1:8686",
"lidarr_api_key": "",
"root_folder_path": "/data/media/music/",
"fallback_to_top_result": False,
"lidarr_api_timeout": 120.0,
"quality_profile_id": 1,
"metadata_profile_id": 1,
"search_for_missing_albums": False,
"dry_run_adding_to_lidarr": False,
"app_name": "Sonobarr",
"app_rev": "0.10",
"app_url": "http://" + "".join(random.choices(string.ascii_lowercase, k=10)) + ".com",
"last_fm_api_key": "",
"last_fm_api_secret": "",
"auto_start": False,
"auto_start_delay": 60,
"youtube_api_key": "",
"similar_artist_batch_size": 10,
}
# Load settings from environmental variables (which take precedence) over the configuration file.
self.lidarr_address = os.environ.get("lidarr_address", "")
self.lidarr_api_key = os.environ.get("lidarr_api_key", "")
self.youtube_api_key = os.environ.get("youtube_api_key", "")
self.root_folder_path = os.environ.get("root_folder_path", "")
fallback_to_top_result = os.environ.get("fallback_to_top_result", "")
self.fallback_to_top_result = fallback_to_top_result.lower() == "true" if fallback_to_top_result != "" else ""
lidarr_api_timeout = os.environ.get("lidarr_api_timeout", "")
self.lidarr_api_timeout = float(lidarr_api_timeout) if lidarr_api_timeout else ""
quality_profile_id = os.environ.get("quality_profile_id", "")
self.quality_profile_id = int(quality_profile_id) if quality_profile_id else ""
metadata_profile_id = os.environ.get("metadata_profile_id", "")
self.metadata_profile_id = int(metadata_profile_id) if metadata_profile_id else ""
search_for_missing_albums = os.environ.get("search_for_missing_albums", "")
self.search_for_missing_albums = search_for_missing_albums.lower() == "true" if search_for_missing_albums != "" else ""
dry_run_adding_to_lidarr = os.environ.get("dry_run_adding_to_lidarr", "")
self.dry_run_adding_to_lidarr = dry_run_adding_to_lidarr.lower() == "true" if dry_run_adding_to_lidarr != "" else ""
self.app_name = os.environ.get("app_name", "")
self.app_rev = os.environ.get("app_rev", "")
self.app_url = os.environ.get("app_url", "")
self.last_fm_api_key = os.environ.get("last_fm_api_key", "")
self.last_fm_api_secret = os.environ.get("last_fm_api_secret", "")
auto_start = os.environ.get("auto_start", "")
self.auto_start = auto_start.lower() == "true" if auto_start != "" else ""
auto_start_delay = os.environ.get("auto_start_delay", "")
self.auto_start_delay = float(auto_start_delay) if auto_start_delay else ""
# Load variables from the configuration file if not set by environmental variables.
try:
self.settings_config_file = os.path.join(self.config_folder, "settings_config.json")
if os.path.exists(self.settings_config_file):
self.sonobarr_logger.info(f"Loading Config via file")
with open(self.settings_config_file, "r") as json_file:
ret = json.load(json_file)
for key in ret:
if getattr(self, key, "") == "":
setattr(self, key, ret[key])
except Exception as e:
self.sonobarr_logger.error(f"Error Loading Config: {str(e)}")
# Load defaults if not set by an environmental variable or configuration file.
for key, value in default_settings.items():
if getattr(self, key) == "":
setattr(self, key, value)
# Validate and apply similar_artist_batch_size
try:
self.similar_artist_batch_size = int(self.similar_artist_batch_size)
except (TypeError, ValueError):
self.similar_artist_batch_size = default_settings["similar_artist_batch_size"]
if self.similar_artist_batch_size <= 0:
self.sonobarr_logger.warning("similar_artist_batch_size must be greater than zero; using default.")
self.similar_artist_batch_size = default_settings["similar_artist_batch_size"]
# Save config.
self.save_config_to_file()
def automated_startup(self):
self.get_artists_from_lidarr(checked=True)
artists = [x["name"] for x in self.lidarr_items]
self.start(artists)
def connection(self):
if self.recommended_artists:
socketio.emit("more_artists_loaded", self.recommended_artists)
self.clients_connected_counter += 1
def disconnection(self):
self.clients_connected_counter = max(0, self.clients_connected_counter - 1)
def start(self, data):
try:
socketio.emit("clear")
self.new_found_artists_counter = 1
self.artists_to_use_in_search = []
self.recommended_artists = []
self.similar_artist_batch_pointer = 0
self.similar_artist_candidates = []
self.initial_batch_sent = False
for item in self.lidarr_items:
item_name = item["name"]
if item_name in data:
item["checked"] = True
self.artists_to_use_in_search.append(item_name)
else:
item["checked"] = False
if self.artists_to_use_in_search:
self.stop_event.clear()
else:
self.stop_event.set()
raise Exception("No Lidarr Artists Selected")
except Exception as e:
self.sonobarr_logger.error(f"Statup Error: {str(e)}")
self.stop_event.set()
ret = {"Status": "Error", "Code": str(e), "Data": self.lidarr_items, "Running": not self.stop_event.is_set()}
socketio.emit("lidarr_sidebar_update", ret)
else:
self.prepare_similar_artist_candidates()
self.load_similar_artist_batch()
def prepare_similar_artist_candidates(self):
# Only LastFM supported
self.similar_artist_candidates = []
lfm = pylast.LastFMNetwork(api_key=self.last_fm_api_key, api_secret=self.last_fm_api_secret)
seen_candidates = set()
for artist_name in self.artists_to_use_in_search:
try:
chosen_artist = lfm.get_artist(artist_name)
related_artists = chosen_artist.get_similar()
for related_artist in related_artists:
cleaned_artist = unidecode(related_artist.item.name).lower()
if cleaned_artist in self.cleaned_lidarr_items or cleaned_artist in seen_candidates:
continue
seen_candidates.add(cleaned_artist)
raw_match = getattr(related_artist, "match", None)
try:
match_score = float(raw_match) if raw_match is not None else None
except (TypeError, ValueError):
match_score = None
self.similar_artist_candidates.append({
"artist": related_artist,
"match": match_score,
})
except Exception:
continue
if len(self.similar_artist_candidates) >= 500:
break
def sort_key(item):
match_value = item["match"] if item["match"] is not None else -1.0
return (-match_value, unidecode(item["artist"].item.name).lower())
self.similar_artist_candidates.sort(key=sort_key)
def load_similar_artist_batch(self):
if self.stop_event.is_set():
return
batch_start = self.similar_artist_batch_pointer
batch_size = max(1, int(self.similar_artist_batch_size))
batch_end = batch_start + batch_size
batch = self.similar_artist_candidates[batch_start:batch_end]
lfm_network = pylast.LastFMNetwork(
api_key=self.last_fm_api_key,
api_secret=self.last_fm_api_secret
)
for candidate in batch:
related_artist = candidate["artist"]
similarity_score = candidate.get("match")
try:
artist_obj = lfm_network.get_artist(related_artist.item.name)
genres = ", ".join([tag.item.get_name().title() for tag in artist_obj.get_top_tags()[:5]]) or "Unknown Genre"
try:
listeners = artist_obj.get_listener_count() or 0
except Exception:
listeners = 0
try:
play_count = artist_obj.get_playcount() or 0
except Exception:
play_count = 0
# Fetch image (deezer)
img_link = None
try:
endpoint = "https://api.deezer.com/search/artist"
params = {"q": related_artist.item.name}
response = requests.get(endpoint, params=params)
data = response.json()
if "data" in data and data["data"]:
artist_info = data["data"][0]
img_link = artist_info.get(
"picture_xl",
artist_info.get("picture_large",
artist_info.get("picture_medium",
artist_info.get("picture", ""))))
except Exception:
img_link = None
if similarity_score is not None:
clamped_similarity = max(0.0, min(1.0, similarity_score))
similarity_label = f"Similarity: {clamped_similarity * 100:.1f}%"
else:
clamped_similarity = None
similarity_label = None
exclusive_artist = {
"Name": related_artist.item.name,
"Genre": genres,
"Status": "",
"Img_Link": img_link if img_link else "https://placehold.co/300x200",
"Popularity": f"Play Count: {self.format_numbers(play_count)}",
"Followers": f"Listeners: {self.format_numbers(listeners)}",
"SimilarityScore": clamped_similarity,
"Similarity": similarity_label,
}
# add to list + send immediately
self.recommended_artists.append(exclusive_artist)
socketio.emit("more_artists_loaded", [exclusive_artist])
except Exception as e:
self.sonobarr_logger.error(f"Error loading artist {related_artist.item.name}: {str(e)}")
self.similar_artist_batch_pointer += len(batch)
has_more = self.similar_artist_batch_pointer < len(self.similar_artist_candidates)
if not self.initial_batch_sent:
socketio.emit("initial_load_complete", {"hasMore": has_more})
self.initial_batch_sent = True
else:
socketio.emit("load_more_complete", {"hasMore": has_more})
def find_similar_artists(self):
# Only batch loading for LastFM
if self.stop_event.is_set() or self.search_in_progress_flag:
return
if self.similar_artist_batch_pointer < len(self.similar_artist_candidates):
self.load_similar_artist_batch()
else:
socketio.emit("new_toast_msg", {"title": "No More Artists", "message": "No more similar artists to load."})
def get_artists_from_lidarr(self, checked=False):
try:
self.sonobarr_logger.info(f"Getting Artists from Lidarr")
self.lidarr_items = []
endpoint = f"{self.lidarr_address}/api/v1/artist"
headers = {"X-Api-Key": self.lidarr_api_key}
response = requests.get(endpoint, headers=headers, timeout=self.lidarr_api_timeout)
if response.status_code == 200:
self.full_lidarr_artist_list = response.json()
self.lidarr_items = [{"name": unidecode(artist["artistName"], replace_str=" "), "checked": checked} for artist in self.full_lidarr_artist_list]
self.lidarr_items.sort(key=lambda x: x["name"].lower())
self.cleaned_lidarr_items = [item["name"].lower() for item in self.lidarr_items]
status = "Success"
data = self.lidarr_items
else:
status = "Error"
data = response.text
ret = {"Status": status, "Code": response.status_code if status == "Error" else None, "Data": data, "Running": not self.stop_event.is_set()}
except Exception as e:
self.sonobarr_logger.error(f"Getting Artist Error: {str(e)}")
ret = {"Status": "Error", "Code": 500, "Data": str(e), "Running": not self.stop_event.is_set()}
finally:
socketio.emit("lidarr_sidebar_update", ret)
def add_artists(self, raw_artist_name):
try:
artist_name = urllib.parse.unquote(raw_artist_name)
artist_folder = artist_name.replace("/", " ")
musicbrainzngs.set_useragent(self.app_name, self.app_rev, self.app_url)
mbid = self.get_mbid_from_musicbrainz(artist_name)
if mbid:
lidarr_url = f"{self.lidarr_address}/api/v1/artist"
headers = {"X-Api-Key": self.lidarr_api_key}
payload = {
"ArtistName": artist_name,
"qualityProfileId": self.quality_profile_id,
"metadataProfileId": self.metadata_profile_id,
"path": os.path.join(self.root_folder_path, artist_folder, ""),
"rootFolderPath": self.root_folder_path,
"foreignArtistId": mbid,
"monitored": True,
"addOptions": {"searchForMissingAlbums": self.search_for_missing_albums},
}
if self.dry_run_adding_to_lidarr:
response = requests.Response()
response.status_code = 201
else:
response = requests.post(lidarr_url, headers=headers, json=payload)
if response.status_code == 201:
self.sonobarr_logger.info(f"Artist '{artist_name}' added successfully to Lidarr.")
status = "Added"
self.lidarr_items.append({"name": artist_name, "checked": False})
self.cleaned_lidarr_items.append(unidecode(artist_name).lower())
else:
self.sonobarr_logger.error(f"Failed to add artist '{artist_name}' to Lidarr.")
error_data = json.loads(response.content)
error_message = error_data[0].get("errorMessage", "No Error Message Returned") if error_data else "Error Unknown"
self.sonobarr_logger.error(error_message)
if "already been added" in error_message:
status = "Already in Lidarr"
self.sonobarr_logger.info(f"Artist '{artist_name}' is already in Lidarr.")
elif "configured for an existing artist" in error_message:
status = "Already in Lidarr"
self.sonobarr_logger.info(f"'{artist_folder}' folder already configured for an existing artist.")
elif "Invalid Path" in error_message:
status = "Invalid Path"
self.sonobarr_logger.info(f"Path: {os.path.join(self.root_folder_path, artist_folder, '')} not valid.")
else:
status = "Failed to Add"
else:
status = "Failed to Add"
self.sonobarr_logger.info(f"No Matching Artist for: '{artist_name}' in MusicBrainz.")
socketio.emit("new_toast_msg", {"title": "Failed to add Artist", "message": f"No Matching Artist for: '{artist_name}' in MusicBrainz."})
for item in self.recommended_artists:
if item["Name"] == artist_name:
item["Status"] = status
socketio.emit("refresh_artist", item)
break
except Exception as e:
self.sonobarr_logger.error(f"Adding Artist Error: {str(e)}")
def get_mbid_from_musicbrainz(self, artist_name):
result = musicbrainzngs.search_artists(artist=artist_name)
mbid = None
if "artist-list" in result:
artists = result["artist-list"]
for artist in artists:
match_ratio = fuzz.ratio(artist_name.lower(), artist["name"].lower())
decoded_match_ratio = fuzz.ratio(unidecode(artist_name.lower()), unidecode(artist["name"].lower()))
if match_ratio > 90 or decoded_match_ratio > 90:
mbid = artist["id"]
self.sonobarr_logger.info(f"Artist '{artist_name}' matched '{artist['name']}' with MBID: {mbid} Match Ratio: {max(match_ratio, decoded_match_ratio)}")
break
else:
if self.fallback_to_top_result and artists:
mbid = artists[0]["id"]
self.sonobarr_logger.info(f"Artist '{artist_name}' matched '{artists[0]['name']}' with MBID: {mbid} Match Ratio: {max(match_ratio, decoded_match_ratio)}")
return mbid
def load_settings(self):
try:
data = {
"lidarr_address": self.lidarr_address,
"lidarr_api_key": self.lidarr_api_key,
"root_folder_path": self.root_folder_path,
"youtube_api_key": self.youtube_api_key,
"similar_artist_batch_size": self.similar_artist_batch_size,
}
socketio.emit("settingsLoaded", data)
except Exception as e:
self.sonobarr_logger.error(f"Failed to load settings: {str(e)}")
def update_settings(self, data):
try:
self.lidarr_address = data["lidarr_address"]
self.lidarr_api_key = data["lidarr_api_key"]
self.root_folder_path = data["root_folder_path"]
self.youtube_api_key = data.get("youtube_api_key", "")
batch_size = data.get("similar_artist_batch_size")
if batch_size is not None:
try:
batch_size = int(batch_size)
except (TypeError, ValueError):
batch_size = self.similar_artist_batch_size
if batch_size > 0:
self.similar_artist_batch_size = batch_size
except Exception as e:
self.sonobarr_logger.error(f"Failed to update settings: {str(e)}")
def format_numbers(self, count):
if count >= 1000000:
return f"{count / 1000000:.1f}M"
elif count >= 1000:
return f"{count / 1000:.1f}K"
else:
return count
def save_config_to_file(self):
try:
with open(self.settings_config_file, "w") as json_file:
json.dump(
{
"lidarr_address": self.lidarr_address,
"lidarr_api_key": self.lidarr_api_key,
"root_folder_path": self.root_folder_path,
"fallback_to_top_result": self.fallback_to_top_result,
"lidarr_api_timeout": float(self.lidarr_api_timeout),
"quality_profile_id": self.quality_profile_id,
"metadata_profile_id": self.metadata_profile_id,
"search_for_missing_albums": self.search_for_missing_albums,
"dry_run_adding_to_lidarr": self.dry_run_adding_to_lidarr,
"app_name": self.app_name,
"app_rev": self.app_rev,
"app_url": self.app_url,
"last_fm_api_key": self.last_fm_api_key,
"last_fm_api_secret": self.last_fm_api_secret,
"auto_start": self.auto_start,
"auto_start_delay": self.auto_start_delay,
"youtube_api_key": self.youtube_api_key,
"similar_artist_batch_size": self.similar_artist_batch_size,
},
json_file,
indent=4,
)
except Exception as e:
self.sonobarr_logger.error(f"Error Saving Config: {str(e)}")
def preview(self, raw_artist_name):
artist_name = urllib.parse.unquote(raw_artist_name)
# Only LastFM supported
try:
preview_info = {}
biography = None
lfm = pylast.LastFMNetwork(api_key=self.last_fm_api_key, api_secret=self.last_fm_api_secret)
search_results = lfm.search_for_artist(artist_name)
artists = search_results.get_next_page()
cleaned_artist_name = unidecode(artist_name).lower()
for artist_obj in artists:
match_ratio = fuzz.ratio(cleaned_artist_name, artist_obj.name.lower())
decoded_match_ratio = fuzz.ratio(unidecode(cleaned_artist_name), unidecode(artist_obj.name.lower()))
if match_ratio > 90 or decoded_match_ratio > 90:
biography = artist_obj.get_bio_content()
preview_info["artist_name"] = artist_obj.name
preview_info["biography"] = biography
break
else:
preview_info = f"No Artist match for: {artist_name}"
self.sonobarr_logger.error(preview_info)
if biography is None:
preview_info = f"No Biography available for: {artist_name}"
self.sonobarr_logger.error(preview_info)
except Exception as e:
preview_info = {"error": f"Error retrieving artist bio: {str(e)}"}
self.sonobarr_logger.error(preview_info)
finally:
socketio.emit("lastfm_preview", preview_info, room=request.sid)
def prehear(self, raw_artist_name, sid):
import pylast
import requests
import time
artist_name = urllib.parse.unquote(raw_artist_name)
lfm = pylast.LastFMNetwork(api_key=self.last_fm_api_key, api_secret=self.last_fm_api_secret)
yt_key = self.youtube_api_key
if not yt_key:
result = {"error": "YouTube API key missing"}
socketio.emit("prehear_result", result, room=sid)
return
result = {"error": "No sample found"}
try:
top_tracks = []
try:
artist = lfm.get_artist(artist_name)
top_tracks = artist.get_top_tracks(limit=10)
except Exception as e:
self.sonobarr_logger.error(f"LastFM error: {str(e)}")
for track in top_tracks:
track_name = track.item.title
query = f"{artist_name} {track_name}"
yt_url = (
f"https://www.googleapis.com/youtube/v3/search?part=snippet&q={requests.utils.quote(query)}"
f"&key={yt_key}&type=video&maxResults=1"
)
yt_resp = requests.get(yt_url)
yt_items = yt_resp.json().get("items", [])
if yt_items:
video_id = yt_items[0]["id"]["videoId"]
result = {"videoId": video_id, "track": track_name, "artist": artist_name}
break
time.sleep(0.2)
except Exception as e:
result = {"error": str(e)}
self.sonobarr_logger.error(f"Prehear error: {str(e)}")
socketio.emit("prehear_result", result, room=sid)
app = Flask(__name__)
app.secret_key = "secret_key"
socketio = SocketIO(app)
data_handler = DataHandler()
@app.route("/")
def home():
return render_template("base.html")
@socketio.on("side_bar_opened")
def side_bar_opened():
if data_handler.lidarr_items:
ret = {"Status": "Success", "Data": data_handler.lidarr_items, "Running": not data_handler.stop_event.is_set()}
socketio.emit("lidarr_sidebar_update", ret)
@socketio.on("get_lidarr_artists")
def get_lidarr_artists():
thread = threading.Thread(target=data_handler.get_artists_from_lidarr, name="Lidarr_Thread")
thread.daemon = True
thread.start()
@socketio.on("finder")
def find_similar_artists(data):
thread = threading.Thread(target=data_handler.find_similar_artists, args=(data,), name="Find_Similar_Thread")
thread.daemon = True
thread.start()
@socketio.on("adder")
def add_artists(data):
thread = threading.Thread(target=data_handler.add_artists, args=(data,), name="Add_Artists_Thread")
thread.daemon = True
thread.start()
@socketio.on("connect")
def connection():
data_handler.connection()
@socketio.on("disconnect")
def disconnection():
data_handler.disconnection()
@socketio.on("load_settings")
def load_settings():
data_handler.load_settings()
@socketio.on("update_settings")
def update_settings(data):
data_handler.update_settings(data)
data_handler.save_config_to_file()
@socketio.on("start_req")
def starter(data):
data_handler.start(data)
@socketio.on("stop_req")
def stopper():
data_handler.stop_event.set()
@socketio.on("load_more_artists")
def load_more_artists():
thread = threading.Thread(target=data_handler.find_similar_artists, name="FindSimilar")
thread.daemon = True
thread.start()
@socketio.on("preview_req")
def preview(artist):
data_handler.preview(artist)
@socketio.on("prehear_req")
def prehear_req(artist_name):
thread = threading.Thread(target=data_handler.prehear, args=(artist_name, request.sid), name="PrehearThread")
thread.daemon = True
thread.start()
if __name__ == "__main__":
socketio.run(app, host="0.0.0.0", port=5000)

BIN
src/static/bio-detail.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
src/static/card-detail.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
src/static/favicon.ico Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

25
src/static/lidarr.svg Normal file
View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

644
src/static/script.js Normal file
View File

@@ -0,0 +1,644 @@
var return_to_top = document.getElementById("return-to-top");
var lidarr_get_artists_button = document.getElementById('lidarr-get-artists-button');
var start_stop_button = document.getElementById('start-stop-button');
var lidarr_status = document.getElementById('lidarr-status');
var lidarr_spinner = document.getElementById('lidarr-spinner');
var load_more_button = document.getElementById('load-more-btn');
var header_spinner = document.getElementById('artists-loading-spinner');
var lidarr_item_list = document.getElementById("lidarr-item-list");
var lidarr_select_all_checkbox = document.getElementById("lidarr-select-all");
var lidarr_select_all_container = document.getElementById("lidarr-select-all-container");
var config_modal = document.getElementById('config-modal');
var lidarr_sidebar = document.getElementById('lidarr-sidebar');
var save_message = document.getElementById("save-message");
var save_changes_button = document.getElementById("save-changes-button");
const lidarr_address = document.getElementById("lidarr-address");
const lidarr_api_key = document.getElementById("lidarr-api-key");
const root_folder_path = document.getElementById("root-folder-path");
const youtube_api_key = document.getElementById("youtube-api-key");
var lidarr_items = [];
var socket = io();
// Initial load flow control
let initialLoadComplete = false;
let initialLoadHasMore = false;
let loadMorePending = false;
function show_header_spinner() {
if (header_spinner) {
header_spinner.classList.remove('d-none');
}
}
function hide_header_spinner() {
if (header_spinner) {
header_spinner.classList.add('d-none');
}
}
function escape_html(text) {
if (text === null || text === undefined) {
return '';
}
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function render_loading_spinner(message) {
return `
<div class="d-flex justify-content-center align-items-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">${message}</span>
</div>
</div>
`;
}
function show_modal_with_lock(modalId, onHidden) {
var modalEl = document.getElementById(modalId);
if (!modalEl) {
return null;
}
var scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;
var modalInstance = bootstrap.Modal.getOrCreateInstance(modalEl);
var hiddenHandler = function () {
document.body.style.overflow = 'auto';
document.body.style.paddingRight = '0';
modalEl.removeEventListener('hidden.bs.modal', hiddenHandler);
if (typeof onHidden === 'function') {
onHidden();
}
};
modalEl.addEventListener('hidden.bs.modal', hiddenHandler, { once: true });
modalInstance.show();
return modalInstance;
}
function ensure_audio_modal_visible() {
var modalEl = document.getElementById('audio-player-modal');
if (!modalEl) {
return;
}
if (!modalEl.classList.contains('show')) {
show_modal_with_lock('audio-player-modal', function () {
var container = document.getElementById('audio-player-modal-body');
if (container) {
container.innerHTML = '';
}
});
}
}
function show_audio_modal_loading(artistName) {
var bodyEl = document.getElementById('audio-player-modal-body');
var titleEl = document.getElementById('audio-player-modal-label');
if (titleEl) {
titleEl.textContent = `Fetching sample for ${artistName}`;
}
if (bodyEl) {
bodyEl.innerHTML = render_loading_spinner('Loading sample...');
}
ensure_audio_modal_visible();
}
function update_audio_modal_content(artist, track, videoId) {
var bodyEl = document.getElementById('audio-player-modal-body');
var titleEl = document.getElementById('audio-player-modal-label');
var safeArtist = escape_html(artist);
var safeTrack = escape_html(track);
var safeVideoId = encodeURIComponent(videoId);
if (titleEl) {
titleEl.textContent = `${artist} ${track}`;
}
if (bodyEl) {
bodyEl.innerHTML = `
<div class="ratio ratio-16x9">
<iframe src="https://www.youtube.com/embed/${safeVideoId}?autoplay=1" title="${safeArtist} ${safeTrack}"
allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div>
`;
}
ensure_audio_modal_visible();
}
function show_audio_modal_error(message) {
var bodyEl = document.getElementById('audio-player-modal-body');
var titleEl = document.getElementById('audio-player-modal-label');
if (titleEl) {
titleEl.textContent = 'Sample unavailable';
}
if (bodyEl) {
var safeMessage = escape_html(message);
bodyEl.innerHTML = `<div class="alert alert-warning mb-0">${safeMessage}</div>`;
}
ensure_audio_modal_visible();
}
function show_bio_modal_loading(artistName) {
var titleEl = document.getElementById('bio-modal-title');
var bodyEl = document.getElementById('modal-body');
if (titleEl) {
titleEl.textContent = artistName;
}
if (bodyEl) {
bodyEl.innerHTML = render_loading_spinner('Loading biography...');
}
show_modal_with_lock('bio-modal-modal');
}
function check_if_all_selected() {
var checkboxes = document.querySelectorAll('input[name="lidarr-item"]');
var all_checked = true;
for (var i = 0; i < checkboxes.length; i++) {
if (!checkboxes[i].checked) {
all_checked = false;
break;
}
}
lidarr_select_all_checkbox.checked = all_checked;
}
function load_lidarr_data(response) {
var every_check_box = document.querySelectorAll('input[name="lidarr-item"]');
if (response.Running) {
start_stop_button.classList.remove('btn-success');
start_stop_button.classList.add('btn-warning');
start_stop_button.textContent = "Stop";
every_check_box.forEach(item => {
item.disabled = true;
});
lidarr_select_all_checkbox.disabled = true;
lidarr_get_artists_button.disabled = true;
} else {
start_stop_button.classList.add('btn-success');
start_stop_button.classList.remove('btn-warning');
start_stop_button.textContent = "Start";
every_check_box.forEach(item => {
item.disabled = false;
});
lidarr_select_all_checkbox.disabled = false;
lidarr_get_artists_button.disabled = false;
}
check_if_all_selected();
}
function create_load_more_button() {
if (!load_more_button) return;
if (!initialLoadComplete || !initialLoadHasMore) {
load_more_button.classList.add('d-none');
load_more_button.disabled = false;
return;
}
load_more_button.classList.remove('d-none');
load_more_button.disabled = loadMorePending;
}
function remove_load_more_button() {
if (!load_more_button) return;
load_more_button.classList.add('d-none');
load_more_button.disabled = false;
}
function append_artists(artists) {
var artist_row = document.getElementById('artist-row');
var template = document.getElementById('artist-template');
if (!initialLoadComplete) {
remove_load_more_button();
}
artists.forEach(function (artist) {
var clone = document.importNode(template.content, true);
var artist_col = clone.querySelector('#artist-column');
artist_col.querySelector('.card-title').textContent = artist.Name;
var similarityEl = artist_col.querySelector('.similarity');
if (similarityEl) {
const hasScore = typeof artist.SimilarityScore === 'number' && !Number.isNaN(artist.SimilarityScore);
if (hasScore || (typeof artist.Similarity === 'string' && artist.Similarity.trim().length > 0)) {
const label = typeof artist.Similarity === 'string' && artist.Similarity.trim().length > 0
? artist.Similarity
: `Similarity: ${(artist.SimilarityScore * 100).toFixed(1)}%`;
similarityEl.textContent = label;
similarityEl.classList.remove('d-none');
} else {
similarityEl.textContent = '';
similarityEl.classList.add('d-none');
}
}
artist_col.querySelector('.genre').textContent = artist.Genre;
if (artist.Img_Link) {
artist_col.querySelector('.card-img-top').src = artist.Img_Link;
artist_col.querySelector('.card-img-top').alt = artist.Name;
} else {
artist_col.querySelector('.artist-img-container').removeChild(artist_col.querySelector('.card-img-top'));
}
var add_button = artist_col.querySelector('.add-to-lidarr-btn');
add_button.dataset.defaultText = add_button.dataset.defaultText || add_button.textContent;
add_button.addEventListener('click', function () {
add_to_lidarr(artist.Name, add_button);
});
artist_col.querySelector('.get-preview-btn').addEventListener('click', function () {
preview_req(artist.Name);
});
// Listen to Sample button logic
artist_col.querySelector('.listen-sample-btn').addEventListener('click', function () {
listenSampleReq(artist.Name);
});
artist_col.querySelector('.followers').textContent = artist.Followers;
artist_col.querySelector('.popularity').textContent = artist.Popularity;
if (artist.Status === "Added" || artist.Status === "Already in Lidarr") {
artist_col.querySelector('.card-body').classList.add('status-green');
add_button.classList.remove('btn-primary');
add_button.classList.add('btn-secondary');
add_button.disabled = true;
add_button.textContent = artist.Status;
} else if (artist.Status === "Failed to Add" || artist.Status === "Invalid Path") {
artist_col.querySelector('.card-body').classList.add('status-red');
add_button.classList.remove('btn-primary');
add_button.classList.add('btn-danger');
add_button.disabled = true;
add_button.textContent = artist.Status;
} else {
artist_col.querySelector('.card-body').classList.add('status-blue');
}
artist_row.appendChild(clone);
});
if (initialLoadComplete) {
create_load_more_button();
}
}
// Remove infinite scroll triggers
window.removeEventListener('scroll', function () {});
window.removeEventListener('touchmove', function () {});
window.removeEventListener('touchend', function () {});
function add_to_lidarr(artist_name, buttonEl) {
if (socket.connected) {
if (buttonEl) {
buttonEl.disabled = true;
buttonEl.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Adding...';
buttonEl.classList.remove('btn-primary', 'btn-danger');
if (!buttonEl.classList.contains('btn-secondary')) {
buttonEl.classList.add('btn-secondary');
}
buttonEl.dataset.loading = 'true';
}
socket.emit('adder', encodeURIComponent(artist_name));
}
else {
show_toast("Connection Lost", "Please reload to continue.");
}
}
function show_toast(header, message) {
var toast_container = document.querySelector('.toast-container');
var toast_template = document.getElementById('toast-template').cloneNode(true);
toast_template.classList.remove('d-none');
toast_template.querySelector('.toast-header strong').textContent = header;
toast_template.querySelector('.toast-body').textContent = message;
toast_template.querySelector('.text-muted').textContent = new Date().toLocaleString();
toast_container.appendChild(toast_template);
var toast = new bootstrap.Toast(toast_template);
toast.show();
toast_template.addEventListener('hidden.bs.toast', function () {
toast_template.remove();
});
}
return_to_top.addEventListener("click", function () {
window.scrollTo({ top: 0, behavior: "smooth" });
});
lidarr_select_all_checkbox.addEventListener("change", function () {
var is_checked = this.checked;
var checkboxes = document.querySelectorAll('input[name="lidarr-item"]');
checkboxes.forEach(function (checkbox) {
checkbox.checked = is_checked;
});
});
lidarr_get_artists_button.addEventListener('click', function () {
lidarr_get_artists_button.disabled = true;
lidarr_spinner.classList.remove('d-none');
lidarr_status.textContent = "Accessing Lidarr API";
lidarr_item_list.innerHTML = '';
socket.emit("get_lidarr_artists");
});
start_stop_button.addEventListener('click', function () {
var running_state = start_stop_button.textContent.trim() === "Start" ? true : false;
if (running_state) {
// Reset initial load state and show overlay until first results arrive
initialLoadComplete = false;
initialLoadHasMore = false;
loadMorePending = false;
show_header_spinner();
remove_load_more_button();
start_stop_button.classList.remove('btn-success');
start_stop_button.classList.add('btn-warning');
start_stop_button.textContent = "Stop";
var checked_items = Array.from(document.querySelectorAll('input[name="lidarr-item"]:checked'))
.map(item => item.value);
document.querySelectorAll('input[name="lidarr-item"]').forEach(item => {
item.disabled = true;
});
lidarr_get_artists_button.disabled = true;
lidarr_select_all_checkbox.disabled = true;
socket.emit("start_req", checked_items);
}
else {
hide_header_spinner();
start_stop_button.classList.add('btn-success');
start_stop_button.classList.remove('btn-warning');
start_stop_button.textContent = "Start";
document.querySelectorAll('input[name="lidarr-item"]').forEach(item => {
item.disabled = false;
});
lidarr_get_artists_button.disabled = false;
lidarr_select_all_checkbox.disabled = false;
socket.emit("stop_req");
}
});
if (load_more_button) {
load_more_button.addEventListener('click', function () {
if (loadMorePending || load_more_button.disabled) {
return;
}
loadMorePending = true;
load_more_button.disabled = true;
show_header_spinner();
socket.emit('load_more_artists');
});
}
save_changes_button.addEventListener("click", () => {
socket.emit("update_settings", {
"lidarr_address": lidarr_address.value,
"lidarr_api_key": lidarr_api_key.value,
"root_folder_path": root_folder_path.value,
"youtube_api_key": youtube_api_key.value,
});
save_message.style.display = "block";
setTimeout(function () {
save_message.style.display = "none";
}, 1000);
});
config_modal.addEventListener('show.bs.modal', function (event) {
socket.emit("load_settings");
function handle_settings_loaded(settings) {
lidarr_address.value = settings.lidarr_address;
lidarr_api_key.value = settings.lidarr_api_key;
root_folder_path.value = settings.root_folder_path;
youtube_api_key.value = settings.youtube_api_key;
socket.off("settingsLoaded", handle_settings_loaded);
}
socket.on("settingsLoaded", handle_settings_loaded);
});
lidarr_sidebar.addEventListener('show.bs.offcanvas', function (event) {
socket.emit("side_bar_opened");
});
socket.on("lidarr_sidebar_update", (response) => {
if (response.Status == "Success") {
lidarr_status.textContent = "Lidarr List Retrieved";
lidarr_items = response.Data;
lidarr_item_list.innerHTML = '';
lidarr_select_all_container.classList.remove('d-none');
for (var i = 0; i < lidarr_items.length; i++) {
var item = lidarr_items[i];
var div = document.createElement("div");
div.className = "form-check";
var input = document.createElement("input");
input.type = "checkbox";
input.className = "form-check-input";
input.id = "lidarr-" + i;
input.name = "lidarr-item";
input.value = item.name;
if (item.checked) {
input.checked = true;
}
var label = document.createElement("label");
label.className = "form-check-label";
label.htmlFor = "lidarr-" + i;
label.textContent = item.name;
input.addEventListener("change", function () {
check_if_all_selected();
});
div.appendChild(input);
div.appendChild(label);
lidarr_item_list.appendChild(div);
}
}
else {
lidarr_status.textContent = response.Code;
}
lidarr_get_artists_button.disabled = false;
lidarr_spinner.classList.add('d-none');
load_lidarr_data(response);
if (!response.Running) {
hide_header_spinner();
}
});
socket.on("refresh_artist", (artist) => {
var artist_cards = document.querySelectorAll('#artist-column');
artist_cards.forEach(function (card) {
var card_body = card.querySelector('.card-body');
var card_artist_name = card_body.querySelector('.card-title').textContent.trim();
if (card_artist_name === artist.Name) {
card_body.classList.remove('status-green', 'status-red', 'status-blue');
var add_button = card_body.querySelector('.add-to-lidarr-btn');
if (artist.Status === "Added" || artist.Status === "Already in Lidarr") {
card_body.classList.add('status-green');
add_button.classList.remove('btn-primary');
add_button.classList.add('btn-secondary');
add_button.disabled = true;
add_button.innerHTML = artist.Status;
add_button.dataset.loading = '';
} else if (artist.Status === "Failed to Add" || artist.Status === "Invalid Path") {
card_body.classList.add('status-red');
add_button.classList.remove('btn-primary');
add_button.classList.add('btn-danger');
add_button.disabled = true;
add_button.innerHTML = artist.Status;
add_button.dataset.loading = '';
} else {
card_body.classList.add('status-blue');
add_button.disabled = false;
add_button.classList.remove('btn-danger', 'btn-secondary');
if (!add_button.classList.contains('btn-primary')) {
add_button.classList.add('btn-primary');
}
add_button.innerHTML = add_button.dataset.defaultText || 'Add to Lidarr';
add_button.dataset.loading = '';
}
return;
}
});
});
socket.on('more_artists_loaded', function (data) {
append_artists(data);
});
// Server signals that initial batches are complete: show the Load More button now
socket.on('initial_load_complete', function (payload) {
initialLoadComplete = true;
initialLoadHasMore = !!(payload && payload.hasMore);
loadMorePending = false;
hide_header_spinner();
if (initialLoadHasMore) {
create_load_more_button();
} else {
remove_load_more_button();
}
});
socket.on('load_more_complete', function (payload) {
loadMorePending = false;
initialLoadHasMore = !!(payload && payload.hasMore);
hide_header_spinner();
if (initialLoadHasMore) {
create_load_more_button();
} else {
remove_load_more_button();
}
});
socket.on('clear', function () {
clear_all();
});
socket.on("new_toast_msg", function (data) {
show_toast(data.title, data.message);
});
socket.on("disconnect", function () {
show_toast("Connection Lost", "Please reconnect to continue.");
hide_header_spinner();
clear_all();
});
function clear_all() {
var artist_row = document.getElementById('artist-row');
var artist_cards = artist_row.querySelectorAll('#artist-column');
artist_cards.forEach(function (card) {
card.remove();
});
remove_load_more_button();
initialLoadComplete = false;
initialLoadHasMore = false;
loadMorePending = false;
// spinner state is controlled by the caller
}
var preview_request_flag = false;
function preview_req(artist_name) {
if (!preview_request_flag) {
preview_request_flag = true;
show_bio_modal_loading(artist_name);
socket.emit("preview_req", encodeURIComponent(artist_name));
setTimeout(() => {
preview_request_flag = false;
}, 1500);
}
}
socket.on("lastfm_preview", function (preview_info) {
var modal_body = document.getElementById('modal-body');
var modal_title = document.getElementById('bio-modal-title');
var modalEl = document.getElementById('bio-modal-modal');
if (typeof preview_info === 'string') {
if (modal_body) {
var safeMessage = escape_html(preview_info);
modal_body.innerHTML = `<div class="alert alert-warning mb-0">${safeMessage}</div>`;
}
show_toast("Error Retrieving Bio", preview_info);
if (modalEl && !modalEl.classList.contains('show')) {
show_modal_with_lock('bio-modal-modal');
}
return;
}
var artist_name = preview_info.artist_name;
var biography = preview_info.biography;
if (modal_title) {
modal_title.textContent = artist_name;
}
if (modal_body) {
modal_body.innerHTML = DOMPurify.sanitize(biography);
}
if (modalEl && !modalEl.classList.contains('show')) {
show_modal_with_lock('bio-modal-modal');
}
});
const theme_switch = document.getElementById('theme-switch');
const saved_theme = localStorage.getItem('theme');
const saved_switch_position = localStorage.getItem('switch-position');
if (saved_switch_position) {
theme_switch.checked = saved_switch_position === 'true';
}
if (saved_theme) {
document.documentElement.setAttribute('data-bs-theme', saved_theme);
}
theme_switch.addEventListener('click', () => {
if (document.documentElement.getAttribute('data-bs-theme') === 'dark') {
document.documentElement.setAttribute('data-bs-theme', 'light');
} else {
document.documentElement.setAttribute('data-bs-theme', 'dark');
}
localStorage.setItem('theme', document.documentElement.getAttribute('data-bs-theme'));
localStorage.setItem('switch_position', theme_switch.checked);
});
// Listen Sample button event
function listenSampleReq(artist_name) {
show_audio_modal_loading(artist_name);
socket.emit("prehear_req", encodeURIComponent(artist_name));
}
socket.on("prehear_result", function (data) {
if (data.videoId) {
update_audio_modal_content(data.artist, data.track, data.videoId);
} else {
var message = data.error || "No YouTube video found for this artist.";
show_audio_modal_error(message);
show_toast("No sample found", message);
}
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
src/static/sonobarr.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

123
src/static/style.css Normal file
View File

@@ -0,0 +1,123 @@
body {
margin: 0;
padding: 0;
}
.logo {
width: 60px;
margin-right: 0px;
}
.scrollable-content {
flex: 1;
overflow: auto;
max-width: 100%;
}
.form-group {
margin-bottom: 0rem !important;
}
.logo-and-title {
display: flex;
}
.artist-img-container {
position: relative;
overflow: hidden;
}
.artist-img-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
opacity: 0;
transition: opacity 0.3s ease;
}
.artist-img-container:hover .artist-img-overlay {
opacity: 1;
}
.button-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
display: flex;
flex-direction: column;
}
.add-to-lidarr-btn,
.get-preview-btn,
.listen-sample-btn {
margin: 2px 0px;
z-index: 1;
opacity: 0;
}
.artist-img-container:hover .add-to-lidarr-btn,
.artist-img-container:hover .get-preview-btn,
.artist-img-container:hover .listen-sample-btn {
transition: opacity 0.6s ease;
opacity: 1;
}
.status-indicator {
position: absolute;
top: 0;
right: 0;
width: 20px;
height: 20px;
}
.led {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid;
position: absolute;
top: 4px;
right: 4px;
}
.status-green .led {
background-color: #28a745;
border-color: #28a745;
background-color: var(--bs-success);
border-color: var(--bs-success);
}
.status-red .led {
background-color: #dc3545;
border-color: #dc3545;
background-color: var(--bs-danger);
border-color: var(--bs-danger);
}
.status-blue .led {
background-color: #007bff;
border-color: #007bff;
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
@media screen and (max-width: 600px) {
h1{
margin-bottom: 0rem!important;
}
.logo{
height: 40px;
width: 40px;
}
.container {
width: 98%;
}
.custom-spacing .form-group-modal {
margin-bottom: 5px;
}
}

250
src/templates/base.html Normal file
View File

@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" sizes="16x16" href="{{url_for('static', filename='favicon-16x16.png')}}">
<link rel="icon" type="image/png" sizes="32x32" href="{{url_for('static', filename='favicon-32x32.png')}}">
<link rel="icon" type="image/png" sizes="48x48" href="{{url_for('static', filename='favicon-48x48.png')}}">
<link rel="icon" type="image/png" sizes="64x64" href="{{url_for('static', filename='favicon-64x64.png')}}">
<link rel="icon" type="image/png" sizes="180x180" href="{{url_for('static', filename='favicon-180x180.png')}}">
<link rel="icon" type="image/png" sizes="192x192" href="{{url_for('static', filename='favicon-192x192.png')}}">
<link rel="icon" type="image/png" sizes="512x512" href="{{url_for('static', filename='favicon-512x512.png')}}">
<link rel="apple-touch-icon" sizes="180x180" href="{{url_for('static', filename='favicon-180x180.png')}}">
<link rel="apple-touch-icon" sizes="192x192" href="{{url_for('static', filename='favicon-192x192.png')}}">
<link rel="apple-touch-icon" sizes="512x512" href="{{url_for('static', filename='favicon-512x512.png')}}">
<link rel="stylesheet" href="{{url_for('static', filename='style.css')}}">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- Bootstrap JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"
integrity="sha512-ykZ1QQr0Jy/4ZkvKuqWn4iF3lqPZyij9iRv6sGqLRdTPkY69YX6+7wvVGmsdBbiIfN/8OdsI7HABjvEok6ZopQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer">
<!-- Socket IO -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.4/socket.io.js"
integrity="sha512-tE1z+95+lMCGwy+9PnKgUSIeHhvioC9lMlI7rLWU0Ps3XTdjRygLcy4mLuL0JAoK4TLdQEyP0yOl/9dMOqpH/Q=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<!-- DOM Purify -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js"
integrity="sha512-Ll+TuDvrWDNNRnFFIM8dOiw7Go7dsHyxRp4RutiIFW/wm3DgDmCnRZow6AqbXnCbpWu93yM1O34q+4ggzGeXVA=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<title>Sonobarr</title>
</head>
<body class="bg-body-secondary">
<!-- Top Bar -->
<div class="sticky-top">
<div class="container-fluid bg-dark">
<div class="top-bar d-flex justify-content-between align-items-center">
<button class="btn btn-link text-light" type="button" data-bs-toggle="offcanvas"
data-bs-target="#lidarr-sidebar" aria-controls="lidarr-sidebar">
<i class="fa fa-bars fa-2x"></i>
</button>
<h1 class="title text-center text-light flex-grow-1" id="return-to-top">Sonobarr</h1>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-light btn-sm d-none" id="load-more-btn" type="button">
Load More
</button>
<div class="spinner-border spinner-border-sm text-light d-none" id="artists-loading-spinner" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<button class="btn btn-link text-light" id="settings-button" data-bs-toggle="modal"
data-bs-target="#config-modal">
<i class="fa fa-gear fa-2x"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal fade" id="config-modal" tabindex="-1" role="dialog" aria-labelledby="settings-modal"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="settings-modal">Configuration</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="save-message" style="display: none;" class="alert alert-success mt-3">
Settings saved successfully.
</div>
<div class="form-group-modal">
<label for="lidarr-address">Lidarr Address:</label>
<input type="text" class="form-control" id="lidarr-address" placeholder="Enter Lidarr Address">
</div>
<div class="form-group-modal my-3">
<label for="lidarr-api-key">Lidarr API Key:</label>
<input type="text" class="form-control" id="lidarr-api-key" placeholder="Enter Lidarr API Key">
</div>
<div class="form-group-modal my-3">
<label for="root-folder-path">Lidarr Root Folder Path:</label>
<input type="text" class="form-control" id="root-folder-path" placeholder="Enter Lidarr Root Folder Path">
</div>
<div class="form-group-modal my-3">
<label for="youtube-api-key">YouTube API Key:</label>
<input type="text" class="form-control" id="youtube-api-key" placeholder="Enter YouTUbe API Key">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" id="save-changes-button" class="btn btn-primary">Save changes</button>
<i class="fa fa-sun"></i>
<div class="form-check form-switch">
<input class="form-check-input rounded" type="checkbox" id="theme-switch">
</div>
<i class="fa fa-moon"></i>
</div>
</div>
</div>
</div>
<!-- SideBar Modal -->
<div class="offcanvas offcanvas-start" tabindex="-1" id="lidarr-sidebar" aria-labelledby="lidarr-sidebar">
<div class="offcanvas-header">
<div class="logo-and-title m-1">
<img src="{{url_for('static', filename='lidarr.svg')}}" alt="Logo" class="logo">
<div class="d-flex align-items-center">
<h2 class="panel-title ps-2 mb-0">Lidarr</h2>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="container">
<div class="control-box m-1 w-100">
<div class="row w-100">
<div class="col p-1">
<button class="btn btn-primary w-100" id="lidarr-get-artists-button" type="button">
<span class="spinner-border spinner-border-sm d-none" id="lidarr-spinner" role="status"
aria-hidden="true"></span>
Get Lidarr Artists
</button>
</div>
<div class="col p-1">
<button class="btn btn-success w-100 ms-2" id="start-stop-button">Start</button>
</div>
</div>
<div class="row w-100">
<div class="col">
<div class="status-only">
<span id="lidarr-status"></span>
</div>
</div>
</div>
</div>
<div class="row w-100">
<div class="col">
<div class="p-2 pt-1 d-none" id="lidarr-select-all-container">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="lidarr-select-all">
<label class="form-check-label" for="lidarr-select-all">Select All</label>
</div>
</div>
</div>
</div>
</div>
<div class="offcanvas-body">
<div id="lidarr-item-list" class="scrollable-content p-1 bg-light-subtle">
</div>
</div>
</div>
<!-- Artits Cards -->
<div class="container-fluid py-4 px-4" id="artist-container">
<div class="row g-4 row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xxl-5" id="artist-row">
<template id="artist-template">
<div class="col" id="artist-column">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="status-indicator">
<div class="led"></div>
</div>
<h5 class="card-title"></h5>
<p class="card-text similarity d-none"></p>
<p class="card-text genre"></p>
<div class="artist-img-container">
<img src="" class="card-img-top" alt="">
<div class="artist-img-overlay"></div>
<div class="button-container">
<button class="btn btn-primary add-to-lidarr-btn">Add to Lidarr</button>
<button class="btn btn-success get-preview-btn">Bio</button>
<!-- Listen to Sample button -->
<button class="btn btn-info listen-sample-btn">Listen to Sample</button>
</div>
</div>
<div class="row mt-auto pt-2">
<div class="col">
<p class="card-text followers"></p>
</div>
<div class="col">
<p class="card-text text-end popularity"></p>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Audio Modal -->
<div class="modal fade" id="audio-player-modal" tabindex="-1" aria-labelledby="audio-player-modal-label"
aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="audio-player-modal-label">Preview Player</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="audio-player-modal-body">
</div>
</div>
</div>
</div>
<!-- Bio Modal -->
<div class="modal fade" id="bio-modal-modal" tabindex="-1" aria-labelledby="bio-modal-title" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bio-modal-title">Modal title</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="toast-template" class="toast d-none" role="alert" aria-live="assertive" aria-atomic="true"
data-bs-delay="5000">
<div class="toast-header">
<strong class="me-auto"></strong>
<small class="text-muted"></small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<script src="{{url_for('static',filename='script.js')}}"></script>
</body>
</html>