Initial commit
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Config files
|
||||
config/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Docker crap
|
||||
*.log
|
||||
7
.sample-env
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
||||
gunicorn
|
||||
gevent
|
||||
gevent-websocket
|
||||
flask
|
||||
flask_socketio
|
||||
requests
|
||||
musicbrainzngs
|
||||
thefuzz
|
||||
Unidecode
|
||||
pylast
|
||||
649
src/Sonobarr.py
Normal 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
|
After Width: | Height: | Size: 3.1 MiB |
BIN
src/static/card-detail.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
src/static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 619 B |
BIN
src/static/favicon-180x180.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src/static/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/static/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/static/favicon-512x512.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
src/static/favicon-64x64.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
src/static/fetch-from-lidarr.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
25
src/static/lidarr.svg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/static/prehear-detail.png
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
644
src/static/script.js
Normal 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);
|
||||
}
|
||||
});
|
||||
BIN
src/static/settings-detail.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
src/static/sonobarr.png
Normal file
|
After Width: | Height: | Size: 448 KiB |
123
src/static/style.css
Normal 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
@@ -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>
|
||||