├── src ├── __init__.py ├── sonobarr_app │ ├── services │ │ ├── __init__.py │ │ ├── integrations │ │ │ ├── __init__.py │ │ │ ├── lastfm_user.py │ │ │ └── listenbrainz_user.py │ │ ├── releases.py │ │ └── openai_client.py │ ├── web │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── main.py │ │ ├── admin.py │ │ └── api.py │ ├── extensions.py │ ├── bootstrap.py │ ├── models.py │ ├── config.py │ ├── sockets │ │ └── __init__.py │ └── __init__.py ├── static │ ├── favicon.ico │ ├── sonobarr.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-48x48.png │ ├── favicon-64x64.png │ ├── favicon-180x180.png │ ├── favicon-192x192.png │ ├── favicon-512x512.png │ ├── style.css │ └── lidarr.svg ├── models.py ├── Sonobarr.py └── templates │ ├── login.html │ ├── admin_artist_requests.html │ ├── profile.html │ ├── admin_users.html │ ├── layout.html │ └── base.html ├── sonar-project.properties ├── migrations ├── README ├── script.py.mako ├── versions │ ├── 20251009_01_user_listening_profiles.py │ ├── 20251013_01_listenbrainz_usernames.py │ └── 20251011_01_artist_requests.py └── env.py ├── gunicorn_config.py ├── .gitignore ├── requirements.txt ├── .dockerignore ├── docker-compose.yml ├── .sample-env ├── Dockerfile ├── .github └── workflows │ ├── build.yml │ └── release-docker.yml ├── LICENSE ├── CONTRIBUTING.md ├── CHANGELOG.md ├── init.sh └── README.md /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/sonobarr_app/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/sonobarr_app/services/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=Sonobarr 2 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration for Alembic. 2 | -------------------------------------------------------------------------------- /src/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/favicon.ico -------------------------------------------------------------------------------- /src/static/sonobarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/sonobarr.png -------------------------------------------------------------------------------- /src/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/favicon-16x16.png -------------------------------------------------------------------------------- /src/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/favicon-32x32.png -------------------------------------------------------------------------------- /src/static/favicon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/favicon-48x48.png -------------------------------------------------------------------------------- /src/static/favicon-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/favicon-64x64.png -------------------------------------------------------------------------------- /src/static/favicon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/favicon-180x180.png -------------------------------------------------------------------------------- /src/static/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/favicon-192x192.png -------------------------------------------------------------------------------- /src/static/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/HEAD/src/static/favicon-512x512.png -------------------------------------------------------------------------------- /gunicorn_config.py: -------------------------------------------------------------------------------- 1 | bind = "0.0.0.0:5000" 2 | workers = 1 3 | threads = 4 4 | timeout = 120 5 | worker_class = "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" 6 | -------------------------------------------------------------------------------- /src/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sonobarr_app.extensions import db 4 | from sonobarr_app.models import User 5 | 6 | 7 | __all__ = ["db", "User"] 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env 3 | 4 | # Config files 5 | config/ 6 | 7 | # Python cache 8 | __pycache__/ 9 | *.pyc 10 | 11 | # Docker crap 12 | *.log 13 | docker-compose.override.yml 14 | 15 | VSCode 16 | .vscode/ 17 | -------------------------------------------------------------------------------- /src/Sonobarr.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sonobarr_app import create_app, socketio 4 | 5 | 6 | app = create_app() 7 | 8 | 9 | if __name__ == "__main__": 10 | socketio.run(app, host="0.0.0.0", port=5000) 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gunicorn 2 | gevent 3 | gevent-websocket 4 | flask 5 | flask_socketio 6 | flask_login 7 | flask_sqlalchemy 8 | Flask-Migrate 9 | Flask-WTF 10 | requests 11 | musicbrainzngs 12 | thefuzz 13 | Unidecode 14 | pylast 15 | openai 16 | flasgger 17 | -------------------------------------------------------------------------------- /src/sonobarr_app/web/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .auth import bp as auth_bp 4 | from .main import bp as main_bp 5 | from .admin import bp as admin_bp 6 | from .api import bp as api_bp 7 | 8 | __all__ = ["auth_bp", "main_bp", "admin_bp", "api_bp"] 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Exclude unnecessary files from Docker build context 2 | .git 3 | .github 4 | .gitignore 5 | .DS_Store 6 | README.md 7 | LICENSE 8 | *.md 9 | docker-compose.yml 10 | config 11 | *.log 12 | __pycache__ 13 | *.pyc 14 | *.pyo 15 | *.pyd 16 | .pytest_cache 17 | venv 18 | env 19 | .env 20 | .sample-env 21 | tests 22 | tmp 23 | node_modules 24 | -------------------------------------------------------------------------------- /src/sonobarr_app/extensions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask_login import LoginManager 4 | from flask_migrate import Migrate 5 | from flask_socketio import SocketIO 6 | from flask_sqlalchemy import SQLAlchemy 7 | from flask_wtf import CSRFProtect 8 | 9 | 10 | db = SQLAlchemy() 11 | login_manager = LoginManager() 12 | socketio = SocketIO() 13 | csrf = CSRFProtect() 14 | migrate = Migrate() 15 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message}""" 2 | 3 | revision = ${repr(revision)} 4 | down_revision = ${repr(down_revision)} 5 | branch_labels = ${repr(branch_labels)} 6 | depends_on = ${repr(depends_on)} 7 | 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | def upgrade(): 13 | ${upgrades if upgrades else "pass"} 14 | 15 | 16 | def downgrade(): 17 | ${downgrades if downgrades else "pass"} 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | sonobarr: 3 | image: ghcr.io/dodelidoo-labs/sonobarr:latest # pin to ghcr.io/dodelidoo-labs/sonobarr:0.7.0 for a specific release 4 | container_name: sonobarr 5 | env_file: 6 | - .env 7 | volumes: 8 | - ./config:/sonobarr/config 9 | - /etc/localtime:/etc/localtime:ro 10 | ports: 11 | - "5000:5000" 12 | restart: unless-stopped 13 | 14 | # Need to attach Sonobarr to an external reverse-proxy network instead of 15 | # publishing port 5000? Remove the `ports:` section above and add a `networks:` 16 | # stanza that matches your proxy. See the README for details. 17 | -------------------------------------------------------------------------------- /.sample-env: -------------------------------------------------------------------------------- 1 | secret_key= 2 | 3 | # Lidarr configuration 4 | lidarr_address=http://192.168.1.1:8686 5 | lidarr_api_key= 6 | root_folder_path=/data/media/music/ 7 | quality_profile_id=1 8 | metadata_profile_id=1 9 | lidarr_api_timeout=120 10 | fallback_to_top_result=false 11 | search_for_missing_albums=false 12 | dry_run_adding_to_lidarr=false 13 | 14 | # Discovery tuning 15 | similar_artist_batch_size=10 16 | auto_start=false 17 | auto_start_delay=60 18 | 19 | # External APIs 20 | last_fm_api_key= 21 | last_fm_api_secret= 22 | youtube_api_key= 23 | openai_api_key= 24 | openai_model=gpt-4o-mini 25 | openai_max_seed_artists=5 26 | 27 | # Super-admin bootstrap (used on first launch) 28 | sonobarr_superadmin_username=admin 29 | sonobarr_superadmin_password=change-me 30 | sonobarr_superadmin_display_name=Super Admin 31 | sonobarr_superadmin_reset=false 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine 2 | 3 | ARG RELEASE_VERSION 4 | ENV RELEASE_VERSION=${RELEASE_VERSION} 5 | ENV PYTHONPATH="/sonobarr/src" 6 | 7 | RUN apk update && apk add --no-cache su-exec \ 8 | && addgroup -S -g 1000 sonobarr \ 9 | && adduser -S -G sonobarr -u 1000 sonobarr 10 | 11 | # Copy only requirements first 12 | COPY requirements.txt /sonobarr/ 13 | WORKDIR /sonobarr 14 | 15 | # Install requirements 16 | RUN pip install --no-cache-dir -r requirements.txt 17 | 18 | 19 | # Now copy the rest of your code 20 | COPY src/ /sonobarr/src/ 21 | COPY migrations/ /sonobarr/migrations/ 22 | COPY gunicorn_config.py /sonobarr/ 23 | COPY init.sh /sonobarr/ 24 | 25 | RUN chmod 755 init.sh \ 26 | && mkdir -p /sonobarr/config \ 27 | && chown -R sonobarr:sonobarr /sonobarr/config 28 | 29 | USER sonobarr 30 | 31 | ENTRYPOINT ["./init.sh"] 32 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | 9 | jobs: 10 | build: 11 | name: Build and analyze 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 18 | - uses: SonarSource/sonarqube-scan-action@v6 19 | env: 20 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 21 | SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} 22 | # If you wish to fail your job when the Quality Gate is red, uncomment the 23 | # following lines. This would typically be used to fail a deployment. 24 | # - uses: SonarSource/sonarqube-quality-gate-action@v1 25 | # timeout-minutes: 5 26 | # env: 27 | # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 TheWicklowWolf 4 | Copyright (c) 2025 Dodelidoo Labs 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /migrations/versions/20251009_01_user_listening_profiles.py: -------------------------------------------------------------------------------- 1 | """add Last.fm username column 2 | 3 | Revision ID: 20251009_01 4 | Revises: 5 | Create Date: 2025-10-09 12:00:00 6 | """ 7 | 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy import inspect 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "20251009_01" 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | bind = op.get_bind() 22 | inspector = inspect(bind) 23 | existing_columns = {col["name"] for col in inspector.get_columns("users")} 24 | 25 | with op.batch_alter_table("users", schema=None) as batch_op: 26 | if "lastfm_username" not in existing_columns: 27 | batch_op.add_column( 28 | sa.Column("lastfm_username", sa.String(length=120), nullable=True) 29 | ) 30 | 31 | 32 | def downgrade(): 33 | bind = op.get_bind() 34 | inspector = inspect(bind) 35 | existing_columns = {col["name"] for col in inspector.get_columns("users")} 36 | 37 | with op.batch_alter_table("users", schema=None) as batch_op: 38 | if "lastfm_username" in existing_columns: 39 | batch_op.drop_column("lastfm_username") 40 | -------------------------------------------------------------------------------- /migrations/versions/20251013_01_listenbrainz_usernames.py: -------------------------------------------------------------------------------- 1 | """add ListenBrainz username column 2 | 3 | Revision ID: 20251013_01 4 | Revises: 20251011_01 5 | Create Date: 2025-10-13 00:00:00 6 | """ 7 | 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy import inspect 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "20251013_01" 15 | down_revision = "20251011_01" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | bind = op.get_bind() 22 | inspector = inspect(bind) 23 | existing_columns = {column["name"] for column in inspector.get_columns("users")} 24 | 25 | if "listenbrainz_username" not in existing_columns: 26 | with op.batch_alter_table("users", schema=None) as batch_op: 27 | batch_op.add_column(sa.Column("listenbrainz_username", sa.String(length=120), nullable=True)) 28 | 29 | 30 | def downgrade(): 31 | bind = op.get_bind() 32 | inspector = inspect(bind) 33 | existing_columns = {column["name"] for column in inspector.get_columns("users")} 34 | 35 | if "listenbrainz_username" in existing_columns: 36 | with op.batch_alter_table("users", schema=None) as batch_op: 37 | batch_op.drop_column("listenbrainz_username") 38 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Clone the repository. 4 | 2. Checkout the `develop` branch: `git checkout develop`. 5 | 3. **Create** a `docker-compose.override.yml` with the following contents: 6 | ```yaml 7 | services: 8 | sonobarr: 9 | build: 10 | context: . 11 | dockerfile: Dockerfile 12 | network: host 13 | image: sonobarr-local 14 | volumes: 15 | - ./config:/sonobarr/config 16 | - /etc/localtime:/etc/localtime:ro 17 | - ./src:/sonobarr/src 18 | #ports: 19 | # - "5000:5000" 20 | networks: 21 | npm_proxy: 22 | ipv4_address: 192.168.97.23 #change as you need 23 | 24 | networks: 25 | npm_proxy: 26 | external: true 27 | ``` 28 | 4. Build the image with `sudo docker compose up -d` - later this will re-use the local image. 29 | 5. Make code changes in `src/` or other required files. 30 | 6. Test the changes by restarting the docker image `sudo docker compose down && sudo docker compose up -d` and clearing cache in browser. 31 | 7. Once ready to commit, make sure the build still works as well `sudo docker compose down -v --remove-orphans && sudo docker system prune -a --volumes -f && sudo docker compose up -d`. 32 | 8. Commit your work to the `develop` branch. 33 | 34 | **Always test your changes with at least two accounts - admin and a common user - in the app, in at least two distinct browser builds (such as safari and chrome, for example).** 35 | **Remember that if you made changes affecting config (that is, database or configuration) you have to delete the `./config` folder before rebuilding or restarting the app. 36 | -------------------------------------------------------------------------------- /src/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}Sign in · Sonobarr{% endblock %} 4 | {% block body_classes %}bg-body-secondary{% endblock %} 5 | {% block topbar_title %}Sign in{% endblock %} 6 | {% block topbar_actions %}{% endblock %} 7 | {% block header %}{% endblock %} 8 | 9 | {% block main %} 10 |
11 |
12 |
13 |
14 |
15 |
16 | Sonobarr 18 |
19 |

Sign in to Sonobarr

20 |
21 | 22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 |
30 | 31 |
32 |

33 | Need help? Ask your Sonobarr administrator to create an account for you. 34 |

35 |
36 |
37 |
38 |
39 |
40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /src/sonobarr_app/bootstrap.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import secrets 4 | 5 | from sqlalchemy.exc import OperationalError, ProgrammingError 6 | 7 | from .extensions import db 8 | from .models import User 9 | 10 | 11 | def bootstrap_super_admin(logger, data_handler) -> None: 12 | try: 13 | admin_count = User.query.filter_by(is_admin=True).count() 14 | except (OperationalError, ProgrammingError) as exc: 15 | logger.warning("Database not ready; skipping super-admin bootstrap: %s", exc) 16 | db.session.rollback() 17 | return 18 | reset_flag = data_handler.superadmin_reset_flag 19 | if admin_count > 0 and not reset_flag: 20 | return 21 | 22 | username = data_handler.superadmin_username 23 | password = data_handler.superadmin_password 24 | display_name = data_handler.superadmin_display_name 25 | generated_password = False 26 | if not password: 27 | password = secrets.token_urlsafe(16) 28 | generated_password = True 29 | 30 | existing = User.query.filter_by(username=username).first() 31 | if existing: 32 | existing.is_admin = True 33 | if password: 34 | existing.set_password(password) 35 | if display_name: 36 | existing.display_name = display_name 37 | action = "updated" 38 | else: 39 | admin = User( 40 | username=username, 41 | display_name=display_name, 42 | is_admin=True, 43 | ) 44 | admin.set_password(password) 45 | db.session.add(admin) 46 | action = "created" 47 | 48 | try: 49 | db.session.commit() 50 | except (OperationalError, ProgrammingError) as exc: 51 | logger.warning("Failed to commit super-admin bootstrap changes: %s", exc) 52 | db.session.rollback() 53 | return 54 | 55 | if generated_password: 56 | logger.warning( 57 | "Generated super-admin credentials. Username: %s Password: %s", 58 | username, 59 | password, 60 | ) 61 | else: 62 | logger.info("Super-admin %s %s.", username, action) 63 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | from pathlib import Path 6 | 7 | from alembic import context 8 | from flask import current_app 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging when available. 15 | # Older runtime scaffolds may not include alembic.ini alongside env.py, 16 | # so fall back to basicConfig instead of crashing during upgrades. 17 | if config.config_file_name is not None and Path(config.config_file_name).exists(): 18 | fileConfig(config.config_file_name) 19 | else: 20 | logging.basicConfig(level=logging.INFO) 21 | 22 | logger = logging.getLogger("alembic.env") 23 | 24 | def get_engine(): 25 | try: 26 | return current_app.extensions["migrate"].db.get_engine() 27 | except TypeError: 28 | return current_app.extensions["migrate"].db.engine 29 | 30 | def get_metadata(): 31 | return current_app.extensions["migrate"].db.metadata 32 | 33 | def run_migrations_offline() -> None: 34 | """Run migrations in 'offline' mode.""" 35 | 36 | url = get_engine().url 37 | context.configure(url=str(url), target_metadata=get_metadata(), literal_binds=True) 38 | 39 | with context.begin_transaction(): 40 | context.run_migrations() 41 | 42 | def run_migrations_online() -> None: 43 | """Run migrations in 'online' mode.""" 44 | 45 | connectable = get_engine() 46 | 47 | with connectable.connect() as connection: 48 | context.configure(connection=connection, target_metadata=get_metadata()) 49 | 50 | with context.begin_transaction(): 51 | context.run_migrations() 52 | 53 | def run_migrations() -> None: 54 | config.set_main_option( 55 | "sqlalchemy.url", 56 | str(get_engine().url).replace("%", "%%"), 57 | ) 58 | if context.is_offline_mode(): 59 | run_migrations_offline() 60 | else: 61 | run_migrations_online() 62 | 63 | if Path("./migrations").exists(): 64 | run_migrations() 65 | -------------------------------------------------------------------------------- /src/sonobarr_app/web/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for 4 | from flask_login import current_user, login_required, login_user, logout_user 5 | from sqlalchemy.exc import OperationalError, ProgrammingError 6 | 7 | from ..models import User 8 | from ..extensions import db 9 | 10 | 11 | bp = Blueprint("auth", __name__) 12 | 13 | _HOME_ENDPOINT = "main.home" 14 | 15 | 16 | def _authenticate(username: str, password: str): 17 | if not username or not password: 18 | flash("Username and password are required.", "danger") 19 | return None 20 | 21 | try: 22 | user = User.query.filter_by(username=username).first() 23 | except (OperationalError, ProgrammingError) as exc: 24 | current_app.logger.warning( 25 | "Database schema not ready during login attempt for username %s: %s", 26 | username, 27 | exc, 28 | ) 29 | db.session.rollback() 30 | flash("Database upgrade in progress. Please try again in a moment.", "warning") 31 | return None 32 | 33 | if not user or not user.check_password(password): 34 | flash("Invalid username or password.", "danger") 35 | return None 36 | if not user.is_active: 37 | flash("Account is disabled.", "danger") 38 | return None 39 | 40 | login_user(user) 41 | flash("Welcome to Sonobarr!", "success") 42 | return redirect(url_for(_HOME_ENDPOINT)) 43 | 44 | 45 | @bp.get("/login") 46 | def login(): 47 | if current_user.is_authenticated: 48 | return redirect(url_for(_HOME_ENDPOINT)) 49 | return render_template("login.html") 50 | 51 | 52 | @bp.post("/login") 53 | def login_submit(): 54 | if current_user.is_authenticated: 55 | return redirect(url_for(_HOME_ENDPOINT)) 56 | 57 | username = (request.form.get("username") or "").strip() 58 | password = request.form.get("password") or "" 59 | response = _authenticate(username, password) 60 | if response is not None: 61 | return response 62 | return render_template("login.html") 63 | 64 | 65 | @bp.route("/logout") 66 | @login_required 67 | def logout(): 68 | logout_user() 69 | flash("You have been signed out.", "info") 70 | return redirect(url_for("auth.login")) 71 | -------------------------------------------------------------------------------- /.github/workflows/release-docker.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | inputs: 8 | release_tag: 9 | description: "Release tag to build" 10 | required: true 11 | 12 | permissions: 13 | contents: read 14 | packages: write 15 | 16 | jobs: 17 | build-and-push: 18 | name: Build and push image to GHCR 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Prepare image name (lowercase) 27 | id: prep 28 | run: | 29 | REPO_LC="${GITHUB_REPOSITORY,,}" 30 | echo "IMAGE=ghcr.io/${REPO_LC}" >> $GITHUB_ENV 31 | if [ "${{ github.event_name }}" = "release" ]; then 32 | echo "RELEASE_VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV 33 | else 34 | echo "RELEASE_VERSION=${{ github.event.inputs.release_tag }}" >> $GITHUB_ENV 35 | fi 36 | 37 | - name: Extract Docker metadata 38 | id: meta 39 | uses: docker/metadata-action@v5 40 | with: 41 | images: ${{ env.IMAGE }} 42 | tags: | 43 | type=raw,value=${{ env.RELEASE_VERSION }} 44 | 45 | - name: Add "latest" tag for any non-prerelease release 46 | run: echo "EXTRA_TAGS=${{ env.IMAGE }}:latest" >> $GITHUB_ENV 47 | 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v3 50 | 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@v3 53 | 54 | - name: Log in to GitHub Container Registry 55 | uses: docker/login-action@v3 56 | with: 57 | registry: ghcr.io 58 | username: ${{ github.actor }} 59 | password: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | - name: Build and push 62 | uses: docker/build-push-action@v6 63 | with: 64 | context: . 65 | file: Dockerfile 66 | push: true 67 | platforms: linux/amd64,linux/arm64 68 | tags: | 69 | ${{ steps.meta.outputs.tags }} 70 | ${{ env.EXTRA_TAGS }} 71 | labels: ${{ steps.meta.outputs.labels }} 72 | build-args: | 73 | RELEASE_VERSION=${{ env.RELEASE_VERSION }} 74 | -------------------------------------------------------------------------------- /src/sonobarr_app/services/releases.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import threading 4 | import time 5 | from typing import Any, Dict 6 | 7 | import requests 8 | 9 | 10 | class ReleaseClient: 11 | """Simple cached client for retrieving the latest GitHub release.""" 12 | 13 | def __init__(self, repo: str, user_agent: str, ttl_seconds: int, logger) -> None: 14 | self.repo = repo 15 | self.user_agent = user_agent or "sonobarr-app" 16 | self.ttl_seconds = max(ttl_seconds, 60) 17 | self.logger = logger 18 | self._lock = threading.Lock() 19 | self._cache: Dict[str, Any] = { 20 | "fetched_at": 0.0, 21 | "tag_name": None, 22 | "html_url": None, 23 | "error": None, 24 | } 25 | 26 | def fetch_latest(self, force: bool = False) -> Dict[str, Any]: 27 | now = time.time() 28 | with self._lock: 29 | age = now - self._cache["fetched_at"] 30 | if not force and age < self.ttl_seconds and ( 31 | self._cache["tag_name"] or self._cache["error"] 32 | ): 33 | return dict(self._cache) 34 | 35 | info: Dict[str, Any] = { 36 | "tag_name": None, 37 | "html_url": None, 38 | "error": None, 39 | "fetched_at": now, 40 | } 41 | 42 | releases_url = f"https://github.com/{self.repo}/releases" 43 | request_url = f"https://api.github.com/repos/{self.repo}/releases/latest" 44 | headers = { 45 | "Accept": "application/vnd.github+json", 46 | "User-Agent": self.user_agent, 47 | } 48 | 49 | try: 50 | response = requests.get(request_url, headers=headers, timeout=5) 51 | if response.status_code == 200: 52 | payload = response.json() 53 | tag_name = (payload.get("tag_name") or payload.get("name") or "").strip() or None 54 | info["tag_name"] = tag_name 55 | info["html_url"] = payload.get("html_url") or releases_url 56 | else: 57 | info["error"] = f"GitHub API returned status {response.status_code}" 58 | except Exception as exc: # pragma: no cover - network errors 59 | info["error"] = str(exc) 60 | 61 | if not info.get("html_url"): 62 | info["html_url"] = releases_url 63 | 64 | with self._lock: 65 | self._cache.update(info) 66 | 67 | return dict(info) 68 | -------------------------------------------------------------------------------- /src/templates/admin_artist_requests.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}Artist requests · Sonobarr{% endblock %} 4 | {% block body_classes %}bg-body-secondary{% endblock %} 5 | {% block topbar_title %}Artist requests{% endblock %} 6 | {% block topbar_actions %} 7 | Back to app 8 | {% endblock %} 9 | 10 | {% block main %} 11 |
12 |
13 |
14 |
15 |
16 |

Pending artist requests

17 | {% if requests %} 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% for req in requests %} 30 | 31 | 32 | 33 | 34 | 48 | 49 | {% endfor %} 50 | 51 |
ArtistRequested byRequested atActions
{{ req.artist_name }}{{ req.requested_by.name }}{{ req.created_at.strftime('%Y-%m-%d %H:%M') }} 35 |
36 | 37 | 38 | 39 | 40 |
41 |
42 | 43 | 44 | 45 | 46 |
47 |
52 |
53 | {% else %} 54 |

No pending requests.

55 | {% endif %} 56 |
57 |
58 |
59 |
60 |
61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /migrations/versions/20251011_01_artist_requests.py: -------------------------------------------------------------------------------- 1 | """add artist requests table 2 | 3 | Revision ID: 20251011_01 4 | Revises: 20251009_01 5 | Create Date: 2025-10-11 12:00:00 6 | """ 7 | 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy import inspect 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "20251011_01" 15 | down_revision = "20251009_01" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | bind = op.get_bind() 22 | inspector = inspect(bind) 23 | existing_tables = inspector.get_table_names() 24 | 25 | if "artist_requests" not in existing_tables: 26 | op.create_table( 27 | "artist_requests", 28 | sa.Column("id", sa.Integer(), nullable=False), 29 | sa.Column("artist_name", sa.String(length=255), nullable=False), 30 | sa.Column("requested_by_id", sa.Integer(), nullable=False), 31 | sa.Column("status", sa.String(length=20), nullable=False), 32 | sa.Column("created_at", sa.DateTime(), nullable=False), 33 | sa.Column("updated_at", sa.DateTime(), nullable=False), 34 | sa.Column("approved_by_id", sa.Integer(), nullable=True), 35 | sa.Column("approved_at", sa.DateTime(), nullable=True), 36 | sa.ForeignKeyConstraint( 37 | ["approved_by_id"], 38 | ["users.id"], 39 | ondelete="SET NULL", 40 | ), 41 | sa.ForeignKeyConstraint( 42 | ["requested_by_id"], 43 | ["users.id"], 44 | ondelete="CASCADE", 45 | ), 46 | sa.PrimaryKeyConstraint("id"), 47 | ) 48 | op.create_index(op.f("ix_artist_requests_artist_name"), "artist_requests", ["artist_name"], unique=False) 49 | else: 50 | # Table exists, check if index exists 51 | existing_indexes = [idx["name"] for idx in inspector.get_indexes("artist_requests") if idx["name"]] 52 | if "ix_artist_requests_artist_name" not in existing_indexes: 53 | op.create_index(op.f("ix_artist_requests_artist_name"), "artist_requests", ["artist_name"], unique=False) 54 | 55 | 56 | def downgrade(): 57 | bind = op.get_bind() 58 | inspector = inspect(bind) 59 | existing_tables = inspector.get_table_names() 60 | 61 | if "artist_requests" in existing_tables: 62 | # Check if index exists before dropping 63 | existing_indexes = [idx["name"] for idx in inspector.get_indexes("artist_requests") if idx["name"]] 64 | if "ix_artist_requests_artist_name" in existing_indexes: 65 | op.drop_index(op.f("ix_artist_requests_artist_name"), table_name="artist_requests") 66 | op.drop_table("artist_requests") 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | ### Fixed 9 | - GitHub 429 on images by loading the Screenshots from an external domain 10 | 11 | ## [0.9.0] - 2025-10-13 12 | ### Added 13 | - Add REST API with API key auth. 14 | - Add ListenBrainz discovery + Lidarr monitoring. 15 | - Deep Lidarr Integration 16 | 17 | ### Security 18 | - Harden startup and refactor web/API logic. 19 | 20 | ## [0.8.0] - 2025-10-10 21 | ### Added 22 | - "Request Artist" logic for non-admin users. Admins can approve/deny requests. 23 | 24 | ## [0.7.0] - 2025-10-10 25 | ### Added 26 | - LastFM integration (for each user) to get "My LastFM recommendations". 27 | 28 | ### Changed 29 | - Settings persistence now writes atomically to `settings_config.json` and forces `0600` permissions to keep API keys and admin credentials private inside the container. 30 | 31 | ## [0.6.0] - 2025-10-09 32 | ### Added 33 | - OpenAI-powered "AI Assist" modal that turns natural language prompts into fresh discovery sessions. 34 | - Settings modal now surfaces every persisted configuration option, grouped by integration. 35 | 36 | ### Changed 37 | - `.env` enumerates all available environment keys with sensible defaults. 38 | - Discovery sidebar, header, and card layout refreshed for a nicer experience. 39 | 40 | ### Fixed 41 | - Biography modal sanitisation now retains Last.fm paragraph breaks and inline links for improved readability. 42 | 43 | ## [0.5.0] - 2025-10-08 44 | ### Added 45 | - Application factory bootstrapping with modular blueprints, services, and Socket.IO handlers. 46 | - CSRF protection via Flask-WTF across all forms and API posts. 47 | - Flask-Migrate integration that automatically initializes and upgrades the database on container start. 48 | 49 | ### Changed 50 | - Docker entrypoint now exports `PYTHONPATH`, prepares the migrations directory under the mounted config volume, and runs migrations before Gunicorn boots. 51 | - Release version metadata is injected at build time and surfaced in the footer badge. 52 | 53 | ## [0.4.0] - 2025-10-08 54 | ### Added 55 | - Software version and update status in footer 56 | 57 | ### Fixed 58 | - Actually use .env file instead of environment docker variables 59 | 60 | ## [0.3.0] - 2025-10-08 61 | ### Added 62 | - Fallback to play iTunes previews when a YouTube API key is unavailable. 63 | 64 | ## [0.2.0] - 2025-10-07 65 | ### Added 66 | - Full user management and authentication workflow. 67 | - Super-admin bootstrap settings. 68 | 69 | ## [0.1.0] - 2025-10-06 70 | ### Added 71 | - Revamped user interface with progress spinners and a “Load more” button. 72 | - YouTube-based audio prehear support. 73 | 74 | ### Removed 75 | - Spotify integration. 76 | -------------------------------------------------------------------------------- /src/sonobarr_app/web/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for 4 | from flask_login import current_user, login_required 5 | 6 | from ..extensions import db 7 | 8 | 9 | bp = Blueprint("main", __name__) 10 | 11 | 12 | @bp.route("/") 13 | @login_required 14 | def home(): 15 | return render_template("base.html") 16 | 17 | 18 | def _update_user_profile(form_data, user): 19 | display_name = (form_data.get("display_name") or "").strip() 20 | avatar_url = (form_data.get("avatar_url") or "").strip() 21 | lastfm_username = (form_data.get("lastfm_username") or "").strip() 22 | listenbrainz_username = (form_data.get("listenbrainz_username") or "").strip() 23 | 24 | user.display_name = display_name or None 25 | user.avatar_url = avatar_url or None 26 | user.lastfm_username = lastfm_username or None 27 | user.listenbrainz_username = listenbrainz_username or None 28 | 29 | new_password = form_data.get("new_password", "") 30 | confirm_password = form_data.get("confirm_password", "") 31 | current_password = form_data.get("current_password", "") 32 | errors: list[str] = [] 33 | password_changed = False 34 | 35 | if not new_password: 36 | return errors, password_changed 37 | 38 | if new_password != confirm_password: 39 | errors.append("New password and confirmation do not match.") 40 | elif len(new_password) < 8: 41 | errors.append("New password must be at least 8 characters long.") 42 | elif not user.check_password(current_password): 43 | errors.append("Current password is incorrect.") 44 | else: 45 | user.set_password(new_password) 46 | password_changed = True 47 | 48 | return errors, password_changed 49 | 50 | 51 | def _refresh_personal_sources(user): 52 | data_handler = current_app.extensions.get("data_handler") 53 | if not data_handler or user.id is None: 54 | return 55 | 56 | try: 57 | data_handler.refresh_personal_sources_for_user(int(user.id)) 58 | except Exception as exc: # pragma: no cover - defensive logging 59 | current_app.logger.error("Failed to refresh personal discovery state: %s", exc) 60 | 61 | 62 | @bp.get("/profile") 63 | @login_required 64 | def profile(): 65 | return render_template("profile.html") 66 | 67 | 68 | @bp.post("/profile") 69 | @login_required 70 | def update_profile(): 71 | errors, password_changed = _update_user_profile(request.form, current_user) 72 | 73 | if errors: 74 | for message in errors: 75 | flash(message, "danger") 76 | db.session.rollback() 77 | else: 78 | db.session.commit() 79 | flash("Profile updated.", "success") 80 | if password_changed: 81 | flash("Password updated.", "success") 82 | _refresh_personal_sources(current_user) 83 | return redirect(url_for("main.profile")) 84 | -------------------------------------------------------------------------------- /src/sonobarr_app/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | 5 | from flask_login import UserMixin 6 | from werkzeug.security import check_password_hash, generate_password_hash 7 | 8 | from .extensions import db 9 | 10 | 11 | class User(UserMixin, db.Model): 12 | __tablename__ = "users" 13 | 14 | id = db.Column(db.Integer, primary_key=True) 15 | username = db.Column(db.String(80), unique=True, nullable=False, index=True) 16 | password_hash = db.Column(db.String(255), nullable=False) 17 | display_name = db.Column(db.String(120), nullable=True) 18 | avatar_url = db.Column(db.String(512), nullable=True) 19 | lastfm_username = db.Column(db.String(120), nullable=True) 20 | listenbrainz_username = db.Column(db.String(120), nullable=True) 21 | is_admin = db.Column(db.Boolean, default=False, nullable=False) 22 | is_active = db.Column(db.Boolean, default=True, nullable=False) 23 | created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) 24 | updated_at = db.Column( 25 | db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False 26 | ) 27 | 28 | def set_password(self, raw_password: str) -> None: 29 | self.password_hash = generate_password_hash(raw_password) 30 | 31 | def check_password(self, raw_password: str) -> bool: 32 | if not self.password_hash: 33 | return False 34 | return check_password_hash(self.password_hash, raw_password) 35 | 36 | @property 37 | def name(self) -> str: 38 | return self.display_name or self.username 39 | 40 | def __repr__(self) -> str: # pragma: no cover - representation helper 41 | return f"" 42 | 43 | 44 | class ArtistRequest(db.Model): 45 | __tablename__ = "artist_requests" 46 | 47 | id = db.Column(db.Integer, primary_key=True) 48 | artist_name = db.Column(db.String(255), nullable=False, index=True) 49 | requested_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False) 50 | status = db.Column(db.String(20), default="pending", nullable=False) # pending, approved, rejected 51 | created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) 52 | updated_at = db.Column( 53 | db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False 54 | ) 55 | approved_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) 56 | approved_at = db.Column(db.DateTime, nullable=True) 57 | 58 | # Relationships 59 | requested_by = db.relationship("User", foreign_keys=[requested_by_id], backref="requested_artists") 60 | approved_by = db.relationship("User", foreign_keys=[approved_by_id], backref="approved_requests") 61 | 62 | def __repr__(self) -> str: # pragma: no cover - representation helper 63 | return f"" 64 | -------------------------------------------------------------------------------- /src/sonobarr_app/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | PROJECT_ROOT = Path(__file__).resolve().parent.parent 8 | APP_ROOT = PROJECT_ROOT.parent 9 | TEMPLATE_DIR = PROJECT_ROOT / "templates" 10 | STATIC_DIR = PROJECT_ROOT / "static" 11 | 12 | _CONFIG_DIR_OVERRIDE = os.environ.get("sonobarr_config_dir") or os.environ.get("SONOBARR_CONFIG_DIR") 13 | CONFIG_DIR_PATH = Path(_CONFIG_DIR_OVERRIDE) if _CONFIG_DIR_OVERRIDE else APP_ROOT / "config" 14 | CONFIG_DIR_PATH.mkdir(parents=True, exist_ok=True) 15 | DB_PATH = CONFIG_DIR_PATH / "app.db" 16 | SETTINGS_FILE_PATH = CONFIG_DIR_PATH / "settings_config.json" 17 | 18 | 19 | def get_env_value(key: str, default: Optional[str] = None) -> Optional[str]: 20 | """Retrieve an environment variable preferring lowercase naming.""" 21 | candidates: list[str] = [] 22 | for candidate in (key, key.lower(), key.upper()): 23 | if candidate not in candidates: 24 | candidates.append(candidate) 25 | for candidate in candidates: 26 | value = os.environ.get(candidate) 27 | if value not in (None, ""): 28 | return value 29 | return default 30 | 31 | 32 | def _get_bool(key: str, default: bool) -> bool: 33 | raw_value = get_env_value(key) 34 | if raw_value is None: 35 | return default 36 | return raw_value.strip().lower() in {"1", "true", "yes", "on"} 37 | 38 | 39 | def _get_int(key: str, default: int) -> int: 40 | raw_value = get_env_value(key) 41 | if raw_value is None: 42 | return default 43 | try: 44 | return int(raw_value) 45 | except (TypeError, ValueError): 46 | return default 47 | 48 | 49 | class Config: 50 | SECRET_KEY = get_env_value("secret_key") 51 | if not SECRET_KEY: 52 | raise RuntimeError( 53 | "SECRET_KEY environment variable is required. Set 'secret_key' (preferred) or 'SECRET_KEY'." 54 | ) 55 | 56 | SQLALCHEMY_DATABASE_URI = f"sqlite:///{DB_PATH}" 57 | SQLALCHEMY_TRACK_MODIFICATIONS = False 58 | 59 | SESSION_COOKIE_SAMESITE = get_env_value("session_cookie_samesite", "Lax") 60 | SESSION_COOKIE_HTTPONLY = True 61 | SESSION_COOKIE_SECURE = _get_bool("session_cookie_secure", False) 62 | 63 | REMEMBER_COOKIE_HTTPONLY = True 64 | REMEMBER_COOKIE_SECURE = SESSION_COOKIE_SECURE 65 | 66 | WTF_CSRF_ENABLED = True 67 | WTF_CSRF_TIME_LIMIT = None 68 | 69 | APP_VERSION = get_env_value("release_version", "unknown") or "unknown" 70 | REPO_URL = get_env_value("repo_url", "https://github.com/Dodelidoo-Labs/sonobarr") 71 | GITHUB_REPO = get_env_value("github_repo", "Dodelidoo-Labs/sonobarr") 72 | GITHUB_USER_AGENT = get_env_value("github_user_agent", "sonobarr-app") 73 | RELEASE_CACHE_TTL_SECONDS = _get_int("release_cache_ttl_seconds", 60 * 60) 74 | LOG_LEVEL = (get_env_value("log_level", "INFO") or "INFO").upper() 75 | API_KEY = get_env_value("api_key") 76 | 77 | CONFIG_DIR = str(CONFIG_DIR_PATH) 78 | SETTINGS_FILE = str(SETTINGS_FILE_PATH) 79 | -------------------------------------------------------------------------------- /init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | echo -e "\033[1;34mSonobarr\033[0m" 5 | echo "Initializing app..." 6 | 7 | cat << 'EOF' 8 | ██ _______. ______ .__ __. ______ .______ ___ .______ .______ 9 | ██ / | / __ \ | \ | | / __ \ | _ \ / \ | _ \ | _ \ 10 | ██ ██ ▄▄ | (----`| | | | | \| | | | | | | |_) | / ^ \ | |_) | | |_) | 11 | ██ ██ ██ ██ \ \ | | | | | . ` | | | | | | _ < / /_\ \ | / | / 12 | ██ ██ ██ ██ ██ .----) | | `--' | | |\ | | `--' | | |_) | / _____ \ | |\ \----. | |\ \----. 13 | ██ ██ ██ ██ ██ |_______/ \______/ |__| \__| \______/ |______/ /__/ \__\ | _| `._____| | _| `._____| 14 | EOF 15 | 16 | CURRENT_UID="$(id -u)" 17 | CURRENT_GID="$(id -g)" 18 | 19 | PUID=${PUID:-} 20 | PGID=${PGID:-} 21 | APP_DIR=/sonobarr 22 | SRC_DIR="${APP_DIR}/src" 23 | CONFIG_DIR="${APP_DIR}/config" 24 | CONFIG_MIGRATIONS_DIR="${CONFIG_DIR}/migrations" 25 | MIGRATIONS_DIR=${MIGRATIONS_DIR:-${APP_DIR}/migrations} 26 | WRITABLE_PATHS="${CONFIG_DIR}" 27 | 28 | export PYTHONPATH=${PYTHONPATH:-${SRC_DIR}} 29 | export FLASK_APP=${FLASK_APP:-src.Sonobarr} 30 | export FLASK_ENV=${FLASK_ENV:-production} 31 | export FLASK_RUN_FROM_CLI=${FLASK_RUN_FROM_CLI:-true} 32 | 33 | # If we're not running as root, make sure UID/GID align with the current user 34 | if [ "${CURRENT_UID}" -eq 0 ]; then 35 | PUID=${PUID:-1000} 36 | PGID=${PGID:-1000} 37 | else 38 | PUID=${PUID:-${CURRENT_UID}} 39 | PGID=${PGID:-${CURRENT_GID}} 40 | fi 41 | 42 | echo "-----------------" 43 | echo -e "\033[1mRunning with:\033[0m" 44 | echo "PUID=${PUID}" 45 | echo "PGID=${PGID}" 46 | echo "-----------------" 47 | 48 | # Create the required directories with the correct permissions 49 | echo "Setting up directories.." 50 | mkdir -p "${CONFIG_DIR}" 51 | 52 | if [ "${CURRENT_UID}" -eq 0 ]; then 53 | for path in ${WRITABLE_PATHS}; do 54 | chown -R ${PUID}:${PGID} "${path}" 55 | done 56 | fi 57 | 58 | if [ -d "${CONFIG_MIGRATIONS_DIR}" ]; then 59 | echo "Removing legacy migrations directory at ${CONFIG_MIGRATIONS_DIR}..." 60 | rm -rf "${CONFIG_MIGRATIONS_DIR}" 61 | fi 62 | 63 | if [ ! -d "${MIGRATIONS_DIR}" ]; then 64 | echo "Error: bundled migrations directory ${MIGRATIONS_DIR} is missing." >&2 65 | exit 1 66 | fi 67 | 68 | if [ "${CURRENT_UID}" -eq 0 ]; then 69 | RUNNER="su-exec ${PUID}:${PGID}" 70 | else 71 | if [ "${PUID}" != "${CURRENT_UID}" ] || [ "${PGID}" != "${CURRENT_GID}" ]; then 72 | echo "Warning: running as UID ${CURRENT_UID} but PUID=${PUID}; ignoring PUID/PGID overrides because process is not root." >&2 73 | fi 74 | RUNNER="" 75 | fi 76 | 77 | echo "Applying database migrations..." 78 | if [ -n "${RUNNER}" ]; then 79 | SONOBARR_SKIP_PROFILE_BACKFILL=1 ${RUNNER} flask db upgrade --directory "${MIGRATIONS_DIR}" 80 | else 81 | SONOBARR_SKIP_PROFILE_BACKFILL=1 flask db upgrade --directory "${MIGRATIONS_DIR}" 82 | fi 83 | 84 | echo "Starting app..." 85 | if [ -n "${RUNNER}" ]; then 86 | exec ${RUNNER} gunicorn src.Sonobarr:app -c gunicorn_config.py 87 | else 88 | exec gunicorn src.Sonobarr:app -c gunicorn_config.py 89 | fi 90 | -------------------------------------------------------------------------------- /src/sonobarr_app/services/integrations/lastfm_user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import List, Optional 5 | 6 | import pylast 7 | 8 | 9 | @dataclass 10 | class LastFmUserArtist: 11 | name: str 12 | playcount: int 13 | match_score: Optional[float] = None 14 | 15 | 16 | class LastFmUserService: 17 | """Wrapper for fetching user-specific listening data from Last.fm. 18 | 19 | Note: Last.fm does not expose a public API for "personal recommendations" anymore. 20 | We approximate recommendations by aggregating similar artists to the user's top artists. 21 | This does not require user authentication (only a public username). 22 | """ 23 | 24 | def __init__(self, api_key: str, api_secret: str) -> None: 25 | self.api_key = api_key 26 | self.api_secret = api_secret 27 | 28 | def _client(self) -> pylast.LastFMNetwork: 29 | return pylast.LastFMNetwork(api_key=self.api_key, api_secret=self.api_secret) 30 | 31 | def get_top_artists(self, username: str, limit: int = 50) -> List[LastFmUserArtist]: 32 | if not username: 33 | return [] 34 | network = self._client() 35 | user = network.get_user(username) 36 | top_artists = user.get_top_artists(limit=limit) 37 | results: List[LastFmUserArtist] = [] 38 | for entry in top_artists: 39 | artist = entry.item 40 | playcount = int(entry.weight) if hasattr(entry, "weight") else 0 41 | results.append( 42 | LastFmUserArtist( 43 | name=getattr(artist, "name", "") or "", 44 | playcount=playcount, 45 | ) 46 | ) 47 | return results 48 | 49 | def get_recommended_artists(self, username: str, limit: int = 50) -> List[LastFmUserArtist]: 50 | """Approximate recommended artists by aggregating similar-to-top. 51 | 52 | Implementation: user.getTopArtists -> for each, artist.getSimilar, excluding the user's top artists. 53 | """ 54 | if not username: 55 | return [] 56 | try: 57 | network = self._client() 58 | user = network.get_user(username) 59 | # Approximate by similar-to-top aggregation 60 | top_entries = user.get_top_artists(limit=min(50, max(limit, 20))) 61 | top_names = [getattr(entry.item, "name", "") for entry in top_entries] 62 | top_set = {n for n in top_names if n} 63 | 64 | results: List[LastFmUserArtist] = [] 65 | seen: set[str] = set() 66 | for entry in top_entries: 67 | artist_obj = entry.item 68 | base_name = getattr(artist_obj, "name", "") 69 | if not base_name: 70 | continue 71 | try: 72 | similar = network.get_artist(base_name).get_similar() 73 | except Exception: 74 | continue 75 | for rel in similar: 76 | try: 77 | cand = getattr(rel.item, "name", "") 78 | match_val = getattr(rel, "match", None) 79 | match_score = float(match_val) if match_val is not None else None 80 | except Exception: 81 | cand, match_score = "", None 82 | if not cand or cand in top_set or cand in seen: 83 | continue 84 | seen.add(cand) 85 | results.append(LastFmUserArtist(name=cand, playcount=0, match_score=match_score)) 86 | if len(results) >= limit: 87 | return results 88 | return results 89 | except Exception: 90 | return [] 91 | -------------------------------------------------------------------------------- /src/templates/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}Profile · Sonobarr{% endblock %} 4 | {% block body_classes %}bg-body-secondary{% endblock %} 5 | {% block topbar_title %}Profile{% endblock %} 6 | {% block topbar_actions %} 7 | Back to app 8 | {% endblock %} 9 | 10 | {% block main %} 11 |
12 |
13 |
14 |
15 |
16 |

Profile

17 |
18 | 19 |
20 | 21 | 23 |
24 |
25 | 26 | 28 |
PNG/JPEG/SVG URLs work best. Leave empty to use the default icon.
29 |
30 |
31 |

Listening services

32 |
33 | 34 | 36 |
Optional. Used to pull personal recommendations while keeping the global API key.
37 |
We use your public Last.fm profile to approximate recommendations. Linking is not required.
38 |
39 |
40 | 41 | 43 |
Optional. Enables ListenBrainz weekly exploration picks in personal discovery.
44 |
We only read public playlist data; nothing is posted back to ListenBrainz.
45 |
46 |
47 |

Change password

48 |
49 | 50 | 52 |
53 |
54 | 55 | 57 |
Leave blank to keep your current password. Minimum 8 characters.
58 |
59 |
60 | 61 | 63 |
64 |
65 | Cancel 66 | 67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | {% endblock %} 75 | -------------------------------------------------------------------------------- /src/templates/admin_users.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}User management · Sonobarr{% endblock %} 4 | {% block body_classes %}bg-body-secondary{% endblock %} 5 | {% block topbar_title %}User management{% endblock %} 6 | {% block topbar_actions %} 7 | Back to app 8 | {% endblock %} 9 | 10 | {% block main %} 11 |
12 |
13 |
14 |
15 |
16 |

Create user

17 |
18 | 19 | 20 |
21 | 22 | 23 |
24 |
25 | 26 | 28 |
29 |
30 | 31 | 33 |
34 |
35 | 36 | 37 |
38 |
39 | 40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |

Existing users

57 | {% if users %} 58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | {% for user in users %} 70 | 71 | 72 | 73 | 80 | 92 | 93 | {% endfor %} 94 | 95 |
UsernameDisplay nameRoleActions
{{ user.username }}{{ user.display_name or '—' }} 74 | {% if user.is_admin %} 75 | Admin 76 | {% else %} 77 | User 78 | {% endif %} 79 | 81 | {% if user.id != current_user.id %} 82 |
83 | 84 | 85 | 86 | 87 |
88 | {% else %} 89 | This is you 90 | {% endif %} 91 |
96 |
97 | {% else %} 98 |

No users found.

99 | {% endif %} 100 |
101 |
102 |
103 |
104 |
105 | {% endblock %} 106 | -------------------------------------------------------------------------------- /src/sonobarr_app/services/integrations/listenbrainz_user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import urllib.parse 5 | from dataclasses import dataclass 6 | from typing import List, Sequence 7 | 8 | import requests 9 | 10 | LISTENBRAINZ_API_BASE = "https://api.listenbrainz.org/1" 11 | 12 | 13 | class ListenBrainzIntegrationError(Exception): 14 | """Raised when a ListenBrainz API call fails.""" 15 | 16 | 17 | @dataclass 18 | class ListenBrainzPlaylistArtists: 19 | artists: List[str] 20 | 21 | 22 | class ListenBrainzUserService: 23 | def __init__(self, *, timeout: float = 10.0, session: requests.Session | None = None) -> None: 24 | self._timeout = max(1.0, float(timeout)) 25 | self._session = session or requests.Session() 26 | 27 | def get_weekly_exploration_artists(self, username: str) -> ListenBrainzPlaylistArtists: 28 | username = (username or "").strip() 29 | if not username: 30 | return ListenBrainzPlaylistArtists(artists=[]) 31 | 32 | playlist_id = self._find_weekly_exploration_playlist(username) 33 | if not playlist_id: 34 | return ListenBrainzPlaylistArtists(artists=[]) 35 | 36 | artists = self._fetch_playlist_artists(playlist_id) 37 | return ListenBrainzPlaylistArtists(artists=artists) 38 | 39 | def _find_weekly_exploration_playlist(self, username: str) -> str | None: 40 | encoded_username = urllib.parse.quote(username) 41 | url = f"{LISTENBRAINZ_API_BASE}/user/{encoded_username}/playlists/createdfor" 42 | response = self._session.get(url, timeout=self._timeout) 43 | self._ensure_success(response) 44 | try: 45 | payload = response.json() 46 | except json.JSONDecodeError as exc: 47 | raise ListenBrainzIntegrationError("Invalid response from ListenBrainz when listing playlists.") from exc 48 | 49 | playlists = payload.get("playlists") or [] 50 | for playlist_entry in playlists: 51 | playlist = playlist_entry.get("playlist") or {} 52 | extension = playlist.get("extension") or {} 53 | playlist_ext = extension.get("https://musicbrainz.org/doc/jspf#playlist") or {} 54 | metadata = playlist_ext.get("additional_metadata") or {} 55 | algorithm_metadata = metadata.get("algorithm_metadata") or {} 56 | source_patch = (algorithm_metadata.get("source_patch") or "").strip().lower() 57 | if source_patch != "weekly-exploration": 58 | continue 59 | identifier = playlist.get("identifier") 60 | identifier_str = self._normalise_identifier(identifier) 61 | if identifier_str: 62 | return identifier_str 63 | return None 64 | 65 | def _fetch_playlist_artists(self, identifier: str) -> List[str]: 66 | url = f"{LISTENBRAINZ_API_BASE}/playlist/{identifier}" 67 | response = self._session.get(url, timeout=self._timeout) 68 | self._ensure_success(response) 69 | try: 70 | payload = response.json() 71 | except json.JSONDecodeError as exc: 72 | raise ListenBrainzIntegrationError("Invalid response from ListenBrainz when loading playlist.") from exc 73 | 74 | playlist = payload.get("playlist") or {} 75 | tracks = playlist.get("track") or [] 76 | artists: List[str] = [] 77 | for track in tracks: 78 | for name in self._extract_track_artists(track): 79 | if name not in artists: 80 | artists.append(name) 81 | return artists 82 | 83 | @staticmethod 84 | def _normalise_identifier(identifier: object) -> str: 85 | if isinstance(identifier, Sequence) and not isinstance(identifier, (str, bytes, bytearray)): 86 | if identifier: 87 | identifier = identifier[0] 88 | identifier_str = (str(identifier).strip() if identifier is not None else "") 89 | if not identifier_str: 90 | return "" 91 | identifier_str = identifier_str.rstrip("/") 92 | if "/" in identifier_str: 93 | identifier_str = identifier_str.rsplit("/", 1)[-1] 94 | return identifier_str 95 | 96 | @staticmethod 97 | def _extract_track_artists(track: dict) -> List[str]: 98 | names: List[str] = [] 99 | extension = track.get("extension") or {} 100 | track_ext = extension.get("https://musicbrainz.org/doc/jspf#track") or {} 101 | metadata = track_ext.get("additional_metadata") or {} 102 | artists_meta = metadata.get("artists") or [] 103 | for artist in artists_meta: 104 | name = (artist.get("artist_credit_name") or artist.get("name") or "").strip() 105 | if name: 106 | names.append(name) 107 | if not names: 108 | fallback = (track.get("creator") or track_ext.get("artist") or track.get("artist") or "").strip() 109 | if fallback: 110 | names.append(fallback) 111 | return names 112 | 113 | @staticmethod 114 | def _ensure_success(response: requests.Response) -> None: 115 | if response.status_code != 200: 116 | message = f"ListenBrainz API returned status {response.status_code}" 117 | raise ListenBrainzIntegrationError(message) 118 | -------------------------------------------------------------------------------- /src/sonobarr_app/sockets/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import threading 4 | from typing import Any 5 | 6 | from flask import request 7 | from flask_login import current_user 8 | from flask_socketio import SocketIO, disconnect 9 | 10 | 11 | def register_socketio_handlers(socketio: SocketIO, data_handler) -> None: 12 | @socketio.on("connect") 13 | def handle_connect(auth=None): 14 | if not current_user.is_authenticated: 15 | return False 16 | sid = request.sid 17 | try: 18 | identifier = current_user.get_id() 19 | user_id = int(identifier) if identifier is not None else None 20 | except (TypeError, ValueError): 21 | user_id = None 22 | data_handler.connection(sid, user_id, current_user.is_admin) 23 | 24 | @socketio.on("disconnect") 25 | def handle_disconnect(): 26 | data_handler.remove_session(request.sid) 27 | 28 | @socketio.on("side_bar_opened") 29 | def handle_side_bar_opened(): 30 | if not current_user.is_authenticated: 31 | disconnect() 32 | return 33 | data_handler.side_bar_opened(request.sid) 34 | 35 | @socketio.on("get_lidarr_artists") 36 | def handle_get_lidarr_artists(): 37 | if not current_user.is_authenticated: 38 | disconnect() 39 | return 40 | sid = request.sid 41 | 42 | socketio.start_background_task(data_handler.get_artists_from_lidarr, sid) 43 | 44 | @socketio.on("start_req") 45 | def handle_start_req(selected_artists: Any): 46 | if not current_user.is_authenticated: 47 | disconnect() 48 | return 49 | sid = request.sid 50 | selected = list(selected_artists or []) 51 | 52 | socketio.start_background_task(data_handler.start, sid, selected) 53 | 54 | @socketio.on("ai_prompt_req") 55 | def handle_ai_prompt(payload: Any): 56 | if not current_user.is_authenticated: 57 | disconnect() 58 | return 59 | sid = request.sid 60 | if isinstance(payload, dict): 61 | prompt = payload.get("prompt", "") 62 | else: 63 | prompt = str(payload or "") 64 | socketio.start_background_task(data_handler.ai_prompt, sid, prompt) 65 | 66 | @socketio.on("personal_sources_poll") 67 | def handle_personal_sources_poll(): 68 | if not current_user.is_authenticated: 69 | disconnect() 70 | return 71 | data_handler.emit_personal_sources_state(request.sid) 72 | 73 | @socketio.on("user_recs_req") 74 | def handle_user_recs(payload: Any): 75 | if not current_user.is_authenticated: 76 | disconnect() 77 | return 78 | sid = request.sid 79 | if isinstance(payload, dict): 80 | source = payload.get("source", "") 81 | else: 82 | source = str(payload or "") 83 | socketio.start_background_task(data_handler.personal_recommendations, sid, source) 84 | 85 | @socketio.on("stop_req") 86 | def handle_stop_req(): 87 | if not current_user.is_authenticated: 88 | disconnect() 89 | return 90 | data_handler.stop(request.sid) 91 | 92 | @socketio.on("load_more_artists") 93 | def handle_load_more(): 94 | if not current_user.is_authenticated: 95 | disconnect() 96 | return 97 | sid = request.sid 98 | socketio.start_background_task(data_handler.find_similar_artists, sid) 99 | 100 | @socketio.on("adder") 101 | def handle_add_artist(raw_artist_name: str): 102 | if not current_user.is_authenticated: 103 | disconnect() 104 | return 105 | sid = request.sid 106 | socketio.start_background_task(data_handler.add_artists, sid, raw_artist_name) 107 | 108 | @socketio.on("request_artist") 109 | def handle_request_artist(raw_artist_name: str): 110 | if not current_user.is_authenticated: 111 | disconnect() 112 | return 113 | sid = request.sid 114 | socketio.start_background_task(data_handler.request_artist, sid, raw_artist_name) 115 | 116 | @socketio.on("load_settings") 117 | def handle_load_settings(): 118 | if not current_user.is_authenticated: 119 | disconnect() 120 | return 121 | if not current_user.is_admin: 122 | socketio.emit( 123 | "new_toast_msg", 124 | { 125 | "title": "Unauthorized", 126 | "message": "Only administrators can view settings.", 127 | }, 128 | room=request.sid, 129 | ) 130 | return 131 | data_handler.load_settings(request.sid) 132 | 133 | @socketio.on("update_settings") 134 | def handle_update_settings(payload: dict): 135 | if not current_user.is_authenticated: 136 | disconnect() 137 | return 138 | if not current_user.is_admin: 139 | socketio.emit( 140 | "new_toast_msg", 141 | { 142 | "title": "Unauthorized", 143 | "message": "Only administrators can modify settings.", 144 | }, 145 | room=request.sid, 146 | ) 147 | return 148 | try: 149 | data_handler.update_settings(payload) 150 | data_handler.save_config_to_file() 151 | data_handler.load_settings(request.sid) 152 | socketio.emit( 153 | "settingsSaved", 154 | {"message": "Configuration updated successfully."}, 155 | room=request.sid, 156 | ) 157 | except Exception as exc: # pragma: no cover - runtime guard 158 | # Ensure exceptions are logged and surfaced to the UI without leaking sensitive details 159 | data_handler.logger.exception("Failed to persist settings: %s", exc) 160 | socketio.emit( 161 | "settingsSaveError", 162 | { 163 | "message": "Saving settings failed. Check the server logs for details.", 164 | }, 165 | room=request.sid, 166 | ) 167 | 168 | @socketio.on("preview_req") 169 | def handle_preview(raw_artist_name: str): 170 | if not current_user.is_authenticated: 171 | disconnect() 172 | return 173 | data_handler.preview(request.sid, raw_artist_name) 174 | 175 | @socketio.on("prehear_req") 176 | def handle_prehear(raw_artist_name: str): 177 | if not current_user.is_authenticated: 178 | disconnect() 179 | return 180 | sid = request.sid 181 | socketio.start_background_task(data_handler.prehear, sid, raw_artist_name) 182 | -------------------------------------------------------------------------------- /src/sonobarr_app/web/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timezone 4 | 5 | from functools import wraps 6 | 7 | from flask import Blueprint, abort, current_app, flash, redirect, render_template, request, url_for 8 | from flask_login import current_user, login_required 9 | 10 | from ..extensions import db 11 | from ..models import User, ArtistRequest 12 | 13 | 14 | bp = Blueprint("admin", __name__, url_prefix="/admin") 15 | 16 | 17 | def admin_required(view): 18 | @wraps(view) 19 | def wrapped(*args, **kwargs): 20 | if not current_user.is_authenticated or not current_user.is_admin: 21 | abort(403) 22 | return view(*args, **kwargs) 23 | 24 | return wrapped 25 | 26 | 27 | def _create_user_from_form(form): 28 | username = (form.get("username") or "").strip() 29 | password = form.get("password") or "" 30 | confirm_password = (form.get("confirm_password") or "").strip() 31 | display_name = (form.get("display_name") or "").strip() 32 | avatar_url = (form.get("avatar_url") or "").strip() 33 | is_admin = form.get("is_admin") == "on" 34 | 35 | if not username or not password: 36 | flash("Username and password are required.", "danger") 37 | return 38 | if password != confirm_password: 39 | flash("Password confirmation does not match.", "danger") 40 | return 41 | if User.query.filter_by(username=username).first(): 42 | flash("Username already exists.", "danger") 43 | return 44 | 45 | user = User( 46 | username=username, 47 | display_name=display_name or None, 48 | avatar_url=avatar_url or None, 49 | is_admin=is_admin, 50 | ) 51 | user.set_password(password) 52 | db.session.add(user) 53 | db.session.commit() 54 | flash(f"User '{username}' created.", "success") 55 | 56 | 57 | def _delete_user_from_form(form): 58 | try: 59 | user_id = int(form.get("user_id", "0")) 60 | except ValueError: 61 | flash("Invalid user id.", "danger") 62 | return 63 | 64 | user = User.query.get(user_id) 65 | if not user: 66 | flash("User not found.", "danger") 67 | return 68 | if user.id == current_user.id: 69 | flash("You cannot delete your own account.", "warning") 70 | return 71 | if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1: 72 | flash("At least one administrator must remain.", "warning") 73 | return 74 | 75 | # Delete associated artist requests first 76 | ArtistRequest.query.filter_by(requested_by_id=user_id).delete() 77 | ArtistRequest.query.filter_by(approved_by_id=user_id).delete() 78 | db.session.delete(user) 79 | db.session.commit() 80 | flash(f"User '{user.username}' deleted.", "success") 81 | 82 | 83 | def _resolve_artist_request(form): 84 | request_id = form.get("request_id") 85 | if not request_id: 86 | flash("Invalid request ID.", "danger") 87 | return None 88 | try: 89 | request_id_int = int(request_id) 90 | except ValueError: 91 | flash("Invalid request ID.", "danger") 92 | return None 93 | 94 | artist_request = ArtistRequest.query.get(request_id_int) 95 | if not artist_request: 96 | flash("Artist request not found.", "danger") 97 | return None 98 | if artist_request.status != "pending": 99 | flash("Request has already been processed.", "warning") 100 | return None 101 | return artist_request 102 | 103 | 104 | def _approve_artist_request(artist_request: ArtistRequest): 105 | data_handler = current_app.extensions.get("data_handler") 106 | if not data_handler: 107 | flash(f"Failed to add '{artist_request.artist_name}' to Lidarr. Request not approved.", "danger") 108 | return 109 | 110 | session_key = f"admin_{current_user.id}" 111 | data_handler.ensure_session(session_key, current_user.id, True) 112 | result_status = data_handler.add_artists(session_key, artist_request.artist_name) 113 | if result_status != "Added": 114 | flash(f"Failed to add '{artist_request.artist_name}' to Lidarr. Request not approved.", "danger") 115 | return 116 | 117 | artist_request.status = "approved" 118 | artist_request.approved_by_id = current_user.id 119 | artist_request.approved_at = datetime.now(timezone.utc) 120 | db.session.commit() 121 | 122 | approved_artist = {"Name": artist_request.artist_name, "Status": "Added"} 123 | data_handler.socketio.emit("refresh_artist", approved_artist) 124 | flash(f"Request for '{artist_request.artist_name}' approved and added to Lidarr.", "success") 125 | 126 | 127 | def _reject_artist_request(artist_request: ArtistRequest): 128 | artist_request.status = "rejected" 129 | artist_request.approved_by_id = current_user.id 130 | artist_request.approved_at = datetime.now(timezone.utc) 131 | db.session.commit() 132 | 133 | data_handler = current_app.extensions.get("data_handler") 134 | if data_handler: 135 | rejected_artist = {"Name": artist_request.artist_name, "Status": "Rejected"} 136 | data_handler.socketio.emit("refresh_artist", rejected_artist) 137 | flash(f"Request for '{artist_request.artist_name}' rejected.", "success") 138 | 139 | 140 | @bp.get("/users") 141 | @login_required 142 | @admin_required 143 | def users(): 144 | users_list = User.query.order_by(User.username.asc()).all() 145 | return render_template("admin_users.html", users=users_list) 146 | 147 | 148 | @bp.post("/users") 149 | @login_required 150 | @admin_required 151 | def modify_users(): 152 | action = request.form.get("action") 153 | if action == "create": 154 | _create_user_from_form(request.form) 155 | elif action == "delete": 156 | _delete_user_from_form(request.form) 157 | else: 158 | flash("Invalid action.", "danger") 159 | return redirect(url_for("admin.users")) 160 | 161 | 162 | @bp.get("/artist-requests") 163 | @login_required 164 | @admin_required 165 | def artist_requests(): 166 | pending_requests = ArtistRequest.query.filter_by(status="pending").order_by( 167 | ArtistRequest.created_at.desc() 168 | ).all() 169 | return render_template("admin_artist_requests.html", requests=pending_requests) 170 | 171 | 172 | @bp.post("/artist-requests") 173 | @login_required 174 | @admin_required 175 | def modify_artist_requests(): 176 | action = request.form.get("action") 177 | artist_request = _resolve_artist_request(request.form) 178 | if not artist_request: 179 | return redirect(url_for("admin.artist_requests")) 180 | 181 | if action == "approve": 182 | _approve_artist_request(artist_request) 183 | elif action == "reject": 184 | _reject_artist_request(artist_request) 185 | else: 186 | flash("Invalid action.", "danger") 187 | 188 | return redirect(url_for("admin.artist_requests")) 189 | -------------------------------------------------------------------------------- /src/static/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | .top-bar-wrapper { 12 | backdrop-filter: blur(12px); 13 | } 14 | 15 | .top-bar { 16 | min-height: 4.25rem; 17 | padding-inline: 1rem; 18 | } 19 | 20 | .topbar-leading, 21 | .topbar-actions { 22 | min-width: 3rem; 23 | } 24 | 25 | .btn-icon { 26 | width: 2.75rem; 27 | height: 2.75rem; 28 | border-radius: 999px; 29 | display: inline-flex; 30 | align-items: center; 31 | justify-content: center; 32 | padding: 0; 33 | line-height: 1; 34 | } 35 | 36 | .header-title-stack { 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | gap: .5rem; 41 | } 42 | 43 | .header-title-stack .top-bar-app-title { 44 | font-size: clamp(1.35rem, 3vw, 2rem); 45 | font-weight: 600; 46 | } 47 | 48 | .header-stream-controls { 49 | display: flex; 50 | align-items: center; 51 | gap: .75rem; 52 | } 53 | 54 | .header-stream-controls .spinner-border { 55 | width: 1.5rem; 56 | height: 1.5rem; 57 | } 58 | 59 | @media (min-width: 768px) { 60 | .header-title-stack { 61 | flex-direction: row; 62 | justify-content: center; 63 | } 64 | 65 | .header-stream-controls { 66 | margin-left: 1rem; 67 | } 68 | } 69 | 70 | .dropdown-theme-toggle { 71 | letter-spacing: .08em; 72 | } 73 | 74 | .app-footer { 75 | backdrop-filter: blur(6px); 76 | } 77 | 78 | .app-footer .footer-credit { 79 | color: rgba(255, 255, 255, 0.78); 80 | font-weight: 500; 81 | transition: color 0.2s ease; 82 | } 83 | 84 | [data-bs-theme="dark"] .app-footer .footer-credit { 85 | color: rgba(255, 255, 255, 0.68); 86 | } 87 | 88 | .artist-card { 89 | position: relative; 90 | border-radius: 1rem; 91 | background-color: var(--bs-body-bg); 92 | overflow: hidden; 93 | transition: transform 0.2s ease, box-shadow 0.2s ease; 94 | } 95 | 96 | .artist-card:hover { 97 | transform: translateY(-2px); 98 | box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12); 99 | } 100 | 101 | [data-bs-theme="dark"] .artist-card { 102 | background-color: rgba(255, 255, 255, 0.03); 103 | } 104 | 105 | .artist-img-container { 106 | position: relative; 107 | overflow: hidden; 108 | aspect-ratio: 1 / 1; 109 | background: linear-gradient(140deg, rgba(99, 102, 241, 0.15), rgba(168, 85, 247, 0.1)); 110 | display: flex; 111 | align-items: center; 112 | justify-content: center; 113 | } 114 | 115 | .artist-img-container img { 116 | width: 100%; 117 | height: 100%; 118 | object-fit: cover; 119 | display: block; 120 | border-radius: inherit; 121 | } 122 | 123 | .artist-img-container.artist-placeholder { 124 | background: linear-gradient(140deg, rgba(148, 163, 184, 0.3), rgba(99, 102, 241, 0.15)); 125 | } 126 | 127 | .artist-placeholder-letter { 128 | font-size: clamp(2rem, 6vw, 3rem); 129 | font-weight: 700; 130 | color: rgba(255, 255, 255, 0.85); 131 | } 132 | 133 | .artist-similarity { 134 | letter-spacing: .1em; 135 | } 136 | 137 | .artist-meta span { 138 | font-variant-numeric: tabular-nums; 139 | } 140 | 141 | .artist-img-container .status-indicator { 142 | position: absolute; 143 | top: .6rem; 144 | right: .6rem; 145 | display: inline-flex; 146 | align-items: center; 147 | justify-content: center; 148 | pointer-events: none; 149 | z-index: 2; 150 | } 151 | 152 | .avatar-thumb { 153 | width: 32px; 154 | height: 32px; 155 | object-fit: cover; 156 | border-radius: 50%; 157 | } 158 | 159 | .logo { 160 | width: 60px; 161 | margin-right: 0px; 162 | } 163 | 164 | .discovery-sidebar .sidebar-section { 165 | background-color: rgba(var(--bs-body-color-rgb, 33, 37, 41), .05); 166 | border: 1px solid rgba(var(--bs-body-color-rgb, 33, 37, 41), .08); 167 | border-radius: 1rem; 168 | padding: 1.25rem; 169 | } 170 | 171 | [data-bs-theme="dark"] .discovery-sidebar .sidebar-section { 172 | background-color: rgba(255, 255, 255, .04); 173 | border-color: rgba(255, 255, 255, .08); 174 | } 175 | 176 | .discovery-sidebar .sidebar-section-title { 177 | font-size: .75rem; 178 | font-weight: 700; 179 | letter-spacing: .12em; 180 | text-transform: uppercase; 181 | margin-bottom: .75rem; 182 | } 183 | 184 | .discovery-sidebar .sidebar-section-subtitle { 185 | line-height: 1.4; 186 | } 187 | 188 | .discovery-sidebar .sidebar-status { 189 | min-height: 1.5rem; 190 | } 191 | 192 | .discovery-sidebar .sidebar-queue { 193 | min-height: 12rem; 194 | } 195 | 196 | .discovery-sidebar #lidarr-item-list { 197 | border-radius: .75rem; 198 | border: 1px dashed rgba(var(--bs-body-color-rgb, 33, 37, 41), .15); 199 | background-color: rgba(var(--bs-body-bg-rgb, 248, 249, 250), .6); 200 | } 201 | 202 | [data-bs-theme="dark"] .discovery-sidebar #lidarr-item-list { 203 | border-color: rgba(255, 255, 255, .12); 204 | background-color: rgba(255, 255, 255, .04); 205 | } 206 | 207 | .scrollable-content { 208 | flex: 1; 209 | overflow: auto; 210 | max-width: 100%; 211 | } 212 | 213 | .form-group { 214 | margin-bottom: 0rem !important; 215 | } 216 | 217 | .logo-and-title { 218 | display: flex; 219 | } 220 | 221 | 222 | .artist-card .led { 223 | width: .75rem; 224 | height: .75rem; 225 | border-radius: 50%; 226 | border: 2px solid rgba(255, 255, 255, 0.85); 227 | box-shadow: 0 0 0 2px rgba(15, 23, 42, 0.12), 0 6px 18px rgba(15, 23, 42, 0.28); 228 | background-color: rgba(148, 163, 184, 0.65); 229 | transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; 230 | } 231 | 232 | [data-bs-theme="dark"] .artist-card .led { 233 | border-color: rgba(15, 23, 42, 0.85); 234 | box-shadow: 0 0 0 2px rgba(15, 23, 42, 0.5), 0 6px 18px rgba(15, 23, 42, 0.45); 235 | } 236 | 237 | .artist-card .led[data-status="info"] { 238 | background-color: var(--bs-primary); 239 | border-color: var(--bs-primary); 240 | } 241 | 242 | .artist-card .led[data-status="success"] { 243 | background-color: var(--bs-success); 244 | border-color: var(--bs-success); 245 | } 246 | 247 | .artist-card .led[data-status="danger"] { 248 | background-color: var(--bs-danger); 249 | border-color: var(--bs-danger); 250 | } 251 | 252 | .bio-modal-body { 253 | line-height: 1.6; 254 | } 255 | 256 | .bio-modal-body p { 257 | margin-bottom: 1rem; 258 | } 259 | 260 | .bio-modal-body a { 261 | color: var(--bs-primary); 262 | text-decoration: underline; 263 | } 264 | 265 | .bio-modal-body ul { 266 | padding-left: 1.25rem; 267 | margin-bottom: 1rem; 268 | } 269 | 270 | .bio-modal-body li { 271 | margin-bottom: .5rem; 272 | } 273 | 274 | @media screen and (max-width: 600px) { 275 | h1{ 276 | margin-bottom: 0rem!important; 277 | } 278 | .logo{ 279 | height: 40px; 280 | width: 40px; 281 | } 282 | .container { 283 | width: 98%; 284 | } 285 | .custom-spacing .form-group-modal { 286 | margin-bottom: 5px; 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/sonobarr_app/services/openai_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from typing import Any, List, Mapping, Optional, Sequence 4 | 5 | from openai import OpenAI 6 | from openai import OpenAIError 7 | 8 | 9 | DEFAULT_OPENAI_MODEL = "gpt-4o-mini" 10 | DEFAULT_MAX_SEED_ARTISTS = 5 11 | DEFAULT_OPENAI_TIMEOUT = 60.0 12 | 13 | _SYSTEM_PROMPT = ( 14 | "You are Sonobarr's music discovery assistant. " 15 | "Given a user request about music tastes, moods, genres, or artists, " 16 | "respond with a JSON array of up to {max_artists} artist names that best match the request. " 17 | "Only return the JSON array, with each artist as a string. " 18 | "The artists should be discoverable on major streaming services and ideally not already present in the provided library list." 19 | ) 20 | 21 | 22 | class OpenAIRecommender: 23 | def __init__( 24 | self, 25 | *, 26 | api_key: str | None, 27 | model: str | None = None, 28 | base_url: str | None = None, 29 | default_headers: Mapping[str, str] | None = None, 30 | max_seed_artists: int = DEFAULT_MAX_SEED_ARTISTS, 31 | timeout: float | None = DEFAULT_OPENAI_TIMEOUT, 32 | temperature: float | None = 0.7, 33 | ) -> None: 34 | self.timeout = timeout 35 | client_kwargs: dict[str, Any] = { 36 | "timeout": timeout, 37 | } 38 | if api_key is not None: 39 | client_kwargs["api_key"] = api_key or None 40 | elif base_url: 41 | # OpenAI Python SDK insists on an api_key; use a benign placeholder for keyless endpoints. 42 | client_kwargs["api_key"] = "not-provided" 43 | if base_url: 44 | client_kwargs["base_url"] = base_url 45 | if default_headers: 46 | client_kwargs["default_headers"] = dict(default_headers) 47 | self.client = OpenAI(**client_kwargs) 48 | self.model = model or DEFAULT_OPENAI_MODEL 49 | self.max_seed_artists = max_seed_artists 50 | self.temperature = temperature 51 | self.base_url = base_url 52 | self.default_headers = dict(default_headers) if default_headers else {} 53 | 54 | @staticmethod 55 | def _iter_fenced_code_blocks(text: str): 56 | start = 0 57 | text_length = len(text) 58 | while start < text_length: 59 | open_idx = text.find("```", start) 60 | if open_idx == -1: 61 | return 62 | label_start = open_idx + 3 63 | label_end = label_start 64 | while label_end < text_length and text[label_end] not in ("\n", "\r"): 65 | label_end += 1 66 | label = text[label_start:label_end].strip().lower() 67 | content_start = label_end 68 | if content_start < text_length and text[content_start] == "\r": 69 | content_start += 1 70 | if content_start < text_length and text[content_start] == "\n": 71 | content_start += 1 72 | close_idx = text.find("```", content_start) 73 | if close_idx == -1: 74 | return 75 | yield label, text[content_start:close_idx] 76 | start = close_idx + 3 77 | 78 | def _extract_from_fenced_blocks(self, content: str) -> Optional[str]: 79 | for label, block in self._iter_fenced_code_blocks(content): 80 | if label and label != "json": 81 | continue 82 | candidate = block.strip() 83 | if candidate.startswith("["): 84 | return candidate 85 | return None 86 | 87 | @staticmethod 88 | def _find_first_json_array(content: str) -> Optional[str]: 89 | content_stripped = content.strip() 90 | if content_stripped.startswith("["): 91 | return content_stripped 92 | 93 | decoder = json.JSONDecoder() 94 | text_length = len(content) 95 | idx = 0 96 | while idx < text_length: 97 | if content[idx] != "[": 98 | idx += 1 99 | continue 100 | try: 101 | parsed, end = decoder.raw_decode(content[idx:]) 102 | except json.JSONDecodeError: 103 | idx += 1 104 | continue 105 | if isinstance(parsed, list): 106 | return content[idx : idx + end] 107 | idx += 1 108 | return None 109 | 110 | def _extract_array_fragment(self, content: str) -> Optional[str]: 111 | if not content: 112 | return None 113 | 114 | candidate = self._extract_from_fenced_blocks(content) 115 | if candidate: 116 | return candidate 117 | 118 | return self._find_first_json_array(content) 119 | 120 | def _build_prompts(self, prompt: str, existing_artists: Sequence[str]) -> tuple[str, str]: 121 | system_prompt = _SYSTEM_PROMPT.format(max_artists=self.max_seed_artists) 122 | existing_preview = ", ".join(existing_artists[:50]) if existing_artists else "None provided." 123 | user_prompt = ( 124 | "User request:\n" 125 | f"{prompt.strip()}\n\n" 126 | "Artists already in the library:\n" 127 | f"{existing_preview}" 128 | ) 129 | return system_prompt, user_prompt 130 | 131 | def _prepare_request(self, system_prompt: str, user_prompt: str) -> dict: 132 | request_kwargs = { 133 | "model": self.model, 134 | "messages": [ 135 | {"role": "system", "content": system_prompt}, 136 | {"role": "user", "content": user_prompt}, 137 | ], 138 | } 139 | if self.temperature is not None: 140 | request_kwargs["temperature"] = self.temperature 141 | return request_kwargs 142 | 143 | def _execute_request(self, request_kwargs: dict): 144 | attempts = 2 145 | last_exc: Optional[Exception] = None 146 | for attempt in range(attempts): 147 | try: 148 | return self.client.chat.completions.create(**request_kwargs) 149 | except OpenAIError as exc: # pragma: no cover - network failure path 150 | message = str(exc) 151 | last_exc = exc 152 | if ( 153 | "temperature" in message.lower() 154 | and "unsupported" in message.lower() 155 | and request_kwargs.pop("temperature", None) is not None 156 | ): 157 | continue 158 | if "timed out" in message.lower() and attempt + 1 < attempts: 159 | continue 160 | raise RuntimeError(message) from exc 161 | if last_exc is not None: # pragma: no cover - defensive 162 | raise RuntimeError(str(last_exc)) from last_exc 163 | raise RuntimeError("LLM request failed without response") # pragma: no cover - defensive 164 | 165 | @staticmethod 166 | def _extract_response_content(response) -> str: 167 | try: 168 | content = response.choices[0].message.content 169 | except (AttributeError, IndexError, KeyError) as exc: 170 | raise RuntimeError("Unexpected response format from the LLM provider.") from exc 171 | return content or "" 172 | 173 | def _load_json_payload(self, array_fragment: str): 174 | try: 175 | return json.loads(array_fragment) 176 | except json.JSONDecodeError as exc: 177 | raise RuntimeError( 178 | "The LLM response was not valid JSON. " 179 | "Please try rephrasing your request." 180 | ) from exc 181 | 182 | @staticmethod 183 | def _coerce_artist_entries(raw_data): 184 | if isinstance(raw_data, list): 185 | return raw_data 186 | if isinstance(raw_data, dict): 187 | candidate_list = raw_data.get("artists") or raw_data.get("seeds") 188 | if isinstance(candidate_list, list): 189 | return candidate_list 190 | raise RuntimeError("The LLM response JSON was not a list of artists.") 191 | 192 | @staticmethod 193 | def _normalize_artist_entry(item) -> Optional[str]: 194 | if isinstance(item, str): 195 | candidate = item.strip() 196 | return candidate or None 197 | if isinstance(item, dict): 198 | name = item.get("name") 199 | if isinstance(name, str): 200 | candidate = name.strip() 201 | return candidate or None 202 | return None 203 | 204 | def _dedupe_and_limit(self, items: Sequence) -> List[str]: 205 | seeds: List[str] = [] 206 | seen: set[str] = set() 207 | for item in items: 208 | artist = self._normalize_artist_entry(item) 209 | if not artist: 210 | continue 211 | lower_name = artist.lower() 212 | if lower_name in seen: 213 | continue 214 | seeds.append(artist) 215 | seen.add(lower_name) 216 | if len(seeds) >= self.max_seed_artists: 217 | break 218 | return seeds 219 | 220 | def generate_seed_artists( 221 | self, 222 | prompt: str, 223 | existing_artists: Sequence[str] | None = None, 224 | ) -> List[str]: 225 | catalog_artists = existing_artists or [] 226 | system_prompt, user_prompt = self._build_prompts(prompt, catalog_artists) 227 | request_kwargs = self._prepare_request(system_prompt, user_prompt) 228 | response = self._execute_request(request_kwargs) 229 | 230 | content = self._extract_response_content(response).strip() 231 | if not content: 232 | return [] 233 | 234 | array_fragment = self._extract_array_fragment(content) 235 | if not array_fragment: 236 | raise RuntimeError( 237 | "The LLM response did not include a JSON array of artist names. " 238 | "Please try rephrasing your request." 239 | ) 240 | 241 | raw_payload = self._load_json_payload(array_fragment) 242 | normalized_items = self._coerce_artist_entries(raw_payload) 243 | return self._dedupe_and_limit(normalized_items) 244 | -------------------------------------------------------------------------------- /src/sonobarr_app/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | from typing import Any, Dict, Optional 6 | 7 | from flask import Flask, current_app 8 | from sqlalchemy import inspect, text 9 | from sqlalchemy.exc import OperationalError, ProgrammingError 10 | 11 | from .bootstrap import bootstrap_super_admin 12 | from .config import Config, STATIC_DIR, TEMPLATE_DIR 13 | from .extensions import csrf, db, login_manager, migrate, socketio 14 | from .services.data_handler import DataHandler 15 | from .services.releases import ReleaseClient 16 | from .sockets import register_socketio_handlers 17 | from .web import admin_bp, api_bp, auth_bp, main_bp 18 | 19 | def _configure_swagger(app: Flask) -> None: 20 | from flasgger import Swagger 21 | 22 | swagger_config = { 23 | "headers": [], 24 | "specs": [ 25 | { 26 | "endpoint": "apispec", 27 | "route": "/api/docs.json", 28 | } 29 | ], 30 | "static_url_path": "/flasgger_static", 31 | "swagger_ui": True, 32 | "specs_route": "/api/docs/", 33 | "swagger_ui_config": { 34 | "displayOperationId": False, 35 | "defaultModelsExpandDepth": 0, 36 | "displayRequestDuration": True, 37 | "deepLinking": True, 38 | "filter": False, 39 | "showExtensions": False, 40 | "showCommonExtensions": False, 41 | } 42 | } 43 | 44 | swagger_template = { 45 | "swagger": "2.0", 46 | "info": { 47 | "title": "Sonobarr API", 48 | "version": app.config.get("APP_VERSION", "unknown"), 49 | "description": "Sonobarr REST API documentation", 50 | }, 51 | "host": "", # Empty = use current host 52 | "basePath": "/api", 53 | "schemes": ["https", "http"], # HTTPS first 54 | "securityDefinitions": { 55 | "ApiKeyAuth": { 56 | "type": "apiKey", 57 | "name": "X-API-Key", 58 | "in": "header", 59 | "description": "Enter your API key" 60 | } 61 | }, 62 | "security": [ 63 | {"ApiKeyAuth": []} 64 | ] 65 | } 66 | 67 | Swagger(app, config=swagger_config, template=swagger_template) 68 | 69 | def create_app(config_class: type[Config] = Config) -> Flask: 70 | app = Flask( 71 | __name__, 72 | static_folder=str(STATIC_DIR), 73 | template_folder=str(TEMPLATE_DIR), 74 | ) 75 | app.config.from_object(config_class) 76 | 77 | _configure_logging(app) 78 | _init_core_extensions(app) 79 | _register_user_loader() 80 | 81 | data_handler = _initialize_services(app) 82 | 83 | # Blueprints ------------------------------------------------------ 84 | app.register_blueprint(main_bp) 85 | app.register_blueprint(auth_bp) 86 | app.register_blueprint(admin_bp) 87 | app.register_blueprint(api_bp, url_prefix="/api") 88 | 89 | # Swagger must be initialized AFTER blueprints are registered 90 | _configure_swagger(app) 91 | 92 | # Socket.IO ------------------------------------------------------- 93 | register_socketio_handlers(socketio, data_handler) 94 | 95 | # Database initialisation ---------------------------------------- 96 | _run_database_initialisation(app, data_handler) 97 | 98 | return app 99 | 100 | 101 | def _configure_logging(app: Flask) -> None: 102 | log_level_name = (app.config.get("LOG_LEVEL") or "INFO").upper() 103 | log_level = getattr(logging, log_level_name, logging.INFO) 104 | 105 | root_logger = logging.getLogger() 106 | if not root_logger.handlers: 107 | handler = logging.StreamHandler() 108 | handler.setFormatter(logging.Formatter("[%(asctime)s] %(levelname)s in %(name)s: %(message)s")) 109 | root_logger.addHandler(handler) 110 | root_logger.setLevel(log_level) 111 | for handler in root_logger.handlers: 112 | handler.setLevel(log_level) 113 | 114 | gunicorn_logger = logging.getLogger("gunicorn.error") 115 | if gunicorn_logger.handlers: 116 | app.logger.handlers = gunicorn_logger.handlers 117 | for handler in app.logger.handlers: 118 | handler.setLevel(log_level) 119 | elif not app.logger.handlers: 120 | app_handler = logging.StreamHandler() 121 | app_handler.setFormatter(logging.Formatter("[%(asctime)s] %(levelname)s in %(name)s: %(message)s")) 122 | app_handler.setLevel(log_level) 123 | app.logger.addHandler(app_handler) 124 | 125 | app.logger.setLevel(log_level) 126 | 127 | # Ensure our custom namespace follows the same level and doesn't duplicate output 128 | sonobarr_logger = logging.getLogger("sonobarr_app") 129 | sonobarr_logger.setLevel(log_level) 130 | sonobarr_logger.propagate = False 131 | logging.getLogger("sonobarr").setLevel(log_level) 132 | 133 | logging.captureWarnings(True) 134 | 135 | __all__ = ["create_app", "socketio"] 136 | 137 | 138 | def _init_core_extensions(app: Flask) -> None: 139 | db.init_app(app) 140 | migrate.init_app(app, db) 141 | login_manager.init_app(app) 142 | login_manager.login_view = "auth.login" 143 | login_manager.login_message = "Please log in to access Sonobarr." 144 | login_manager.login_message_category = "warning" 145 | csrf.init_app(app) 146 | socketio.init_app(app, async_mode="gevent") 147 | 148 | 149 | def _register_user_loader() -> None: 150 | from .models import User # Imported lazily to avoid circular imports 151 | 152 | @login_manager.user_loader 153 | def load_user(user_id: str) -> Optional[User]: 154 | if not user_id: 155 | return None 156 | try: 157 | return User.query.get(int(user_id)) 158 | except (TypeError, ValueError): 159 | return None 160 | except (OperationalError, ProgrammingError) as exc: 161 | current_app.logger.warning( 162 | "Database schema not ready when loading user %s: %s", user_id, exc 163 | ) 164 | db.session.rollback() 165 | return None 166 | 167 | 168 | def _initialize_services(app: Flask) -> DataHandler: 169 | data_handler = DataHandler(socketio=socketio, logger=app.logger, app_config=app.config) 170 | release_client = ReleaseClient( 171 | repo=app.config.get("GITHUB_REPO", "Dodelidoo-Labs/sonobarr"), 172 | user_agent=app.config.get("GITHUB_USER_AGENT", "sonobarr-app"), 173 | ttl_seconds=int(app.config.get("RELEASE_CACHE_TTL_SECONDS", 3600)), 174 | logger=app.logger, 175 | ) 176 | 177 | data_handler.set_flask_app(app) 178 | app.extensions["data_handler"] = data_handler 179 | app.extensions["release_client"] = release_client 180 | 181 | _register_footer_metadata(app, release_client) 182 | 183 | return data_handler 184 | 185 | 186 | def _register_footer_metadata(app: Flask, release_client: ReleaseClient) -> None: 187 | """Register context processor for footer metadata (version info, update status).""" 188 | 189 | @app.context_processor 190 | def inject_footer_metadata() -> Dict[str, Any]: 191 | current_version = (app.config.get("APP_VERSION") or "unknown").strip() or "unknown" 192 | release_info = release_client.fetch_latest() 193 | latest_version = release_info.get("tag_name") 194 | 195 | update_available, status_color = _calculate_update_status( 196 | current_version, latest_version, release_info.get("error") 197 | ) 198 | status_label = _get_update_status_label(update_available, latest_version) 199 | 200 | return { 201 | "repo_url": app.config.get("REPO_URL", "https://github.com/Dodelidoo-Labs/sonobarr"), 202 | "app_version": current_version, 203 | "latest_release_version": latest_version, 204 | "latest_release_url": release_info.get("html_url") 205 | or "https://github.com/Dodelidoo-Labs/sonobarr/releases", 206 | "update_available": update_available, 207 | "update_status_color": status_color, 208 | "update_status_label": status_label, 209 | } 210 | 211 | 212 | def _calculate_update_status( 213 | current_version: str, latest_version: Optional[str], has_error: bool 214 | ) -> tuple[Optional[bool], str]: 215 | """Calculate if update is available and determine status color.""" 216 | if not latest_version: 217 | return None, "muted" 218 | 219 | if current_version.lower() in {"", "unknown", "dev", "development"}: 220 | return None, "muted" 221 | 222 | update_available = latest_version != current_version 223 | status_color = "danger" if update_available else "success" 224 | 225 | if has_error and not latest_version: 226 | status_color = "muted" 227 | 228 | return update_available, status_color 229 | 230 | 231 | def _get_update_status_label(update_available: Optional[bool], latest_version: Optional[str]) -> str: 232 | """Get human-readable update status label.""" 233 | if update_available is True and latest_version: 234 | return f"Update available · {latest_version}" 235 | if update_available is False: 236 | return "Up to date" 237 | if update_available is None and latest_version: 238 | return f"Latest release: {latest_version}" 239 | return "Update status unavailable" 240 | 241 | 242 | def _run_database_initialisation(app: Flask, data_handler: DataHandler) -> None: 243 | with app.app_context(): 244 | db.create_all() 245 | _ensure_user_profile_columns(app.logger) 246 | bootstrap_super_admin(app.logger, data_handler) 247 | 248 | 249 | def _ensure_user_profile_columns(logger: logging.Logger) -> None: 250 | """Backfill the user listening columns if migrations have not run yet.""" 251 | 252 | if os.environ.get("SONOBARR_SKIP_PROFILE_BACKFILL") == "1": 253 | logger.debug("Profile column backfill skipped via environment flag.") 254 | return 255 | 256 | try: 257 | inspector = inspect(db.engine) 258 | user_columns = {column["name"] for column in inspector.get_columns("users")} 259 | except (OperationalError, ProgrammingError) as exc: 260 | logger.warning("Unable to inspect users table for backfill: %s", exc) 261 | db.session.rollback() 262 | return 263 | 264 | alter_statements: list[tuple[str, str]] = [] 265 | if "lastfm_username" not in user_columns: 266 | alter_statements.append(("lastfm_username", "ALTER TABLE users ADD COLUMN lastfm_username VARCHAR(120)")) 267 | if "listenbrainz_username" not in user_columns: 268 | alter_statements.append(( 269 | "listenbrainz_username", 270 | "ALTER TABLE users ADD COLUMN listenbrainz_username VARCHAR(120)", 271 | )) 272 | 273 | for column_name, statement in alter_statements: 274 | try: 275 | db.session.execute(text(statement)) 276 | db.session.commit() 277 | logger.info("Added missing column '%s' via automatic backfill", column_name) 278 | except (OperationalError, ProgrammingError) as exc: 279 | logger.warning("Failed to apply backfill for column '%s': %s", column_name, exc) 280 | db.session.rollback() 281 | # Keep attempting remaining columns; missing ones will be caught again on next start. 282 | -------------------------------------------------------------------------------- /src/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 16 | {% block title %}Sonobarr{% endblock %} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 32 | 37 | {% block extra_head %}{% endblock %} 38 | 39 | 40 |
41 | {% block header %} 42 |
43 |
44 |
45 |
46 | {% block topbar_leading %}{% endblock %} 47 |
48 |
49 | {% block topbar_title %} 50 |
51 |

Sonobarr

52 |
53 | {% endblock %} 54 |
55 |
56 | {% block topbar_actions %}{% endblock %} 57 | {% if current_user.is_authenticated %} 58 | 120 | {% else %} 121 |
122 | 123 |
124 | 125 |
126 | 127 |
128 | Sign in 129 | {% endif %} 130 |
131 |
132 |
133 |
134 | {% endblock %} 135 |
136 | {% block flashes %} 137 | {% with messages = get_flashed_messages(with_categories=true) %} 138 | {% if messages %} 139 |
140 | {% for category, message in messages %} 141 | 145 | {% endfor %} 146 |
147 | {% endif %} 148 | {% endwith %} 149 | {% endblock %} 150 | {% block main %}{% endblock %} 151 |
152 | {% block footer %} 153 |
154 |
155 |
156 | 157 | 158 | GitHub 159 | 160 | Brought to you by Dodelidoo Labs 161 |
162 |
163 | 164 | {{ update_status_label }} 165 | 166 | v{{ app_version }} 167 | {% if update_available is sameas(true) and latest_release_version %} 168 | 169 | {{ update_status_label }} 170 | 171 | {% elif update_available is sameas(false) %} 172 | {{ update_status_label }} 173 | {% else %} 174 | {{ update_status_label }} 175 | {% endif %} 176 |
177 |
178 |
179 | {% endblock %} 180 |
181 | 184 | {% block footer_scripts %}{% endblock %} 185 | 219 | {% block extra_scripts %}{% endblock %} 220 | 221 | 222 | -------------------------------------------------------------------------------- /src/sonobarr_app/web/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timedelta, timezone 4 | 5 | from flask import Blueprint, current_app, jsonify, request, redirect 6 | from flask_login import current_user 7 | 8 | from ..extensions import db 9 | from ..models import ArtistRequest, User 10 | 11 | 12 | bp = Blueprint("api", __name__, url_prefix="/api") 13 | 14 | _ERROR_KEY_INVALID = {"error": "Invalid API key"} 15 | _ERROR_INTERNAL = {"error": "Internal server error"} 16 | 17 | 18 | def _normalize_api_key(key_value): 19 | if key_value is None: 20 | return None 21 | return str(key_value).strip() 22 | 23 | 24 | def _configured_api_key(): 25 | configured_key = current_app.config.get("API_KEY") 26 | if configured_key: 27 | return _normalize_api_key(configured_key) 28 | 29 | data_handler = current_app.extensions.get("data_handler") 30 | if data_handler is not None: 31 | derived_key = getattr(data_handler, "api_key", None) 32 | return _normalize_api_key(derived_key) 33 | return None 34 | 35 | 36 | def _resolve_request_api_key(): 37 | header_key = request.headers.get("X-API-Key") 38 | if header_key is not None: 39 | return _normalize_api_key(header_key) 40 | 41 | header_key_alt = request.headers.get("X-Api-Key") 42 | if header_key_alt is not None: 43 | return _normalize_api_key(header_key_alt) 44 | 45 | query_key = request.args.get("api_key") or request.args.get("key") 46 | return _normalize_api_key(query_key) 47 | 48 | 49 | def api_key_required(view): 50 | """Decorator to require API key for API endpoints.""" 51 | from functools import wraps 52 | 53 | @wraps(view) 54 | def wrapped(*args, **kwargs): 55 | api_key = _resolve_request_api_key() 56 | configured_key = _configured_api_key() 57 | 58 | if configured_key and configured_key != api_key: 59 | return jsonify(_ERROR_KEY_INVALID), 401 60 | 61 | return view(*args, **kwargs) 62 | 63 | return wrapped 64 | 65 | 66 | @bp.route("/") 67 | def api_docs_index(): 68 | """Redirect to interactive API documentation UI.""" 69 | return redirect("/api/docs/", code=302) 70 | 71 | 72 | @bp.route("/status") 73 | @api_key_required 74 | def status(): 75 | """Get basic system status information 76 | --- 77 | tags: 78 | - System 79 | security: 80 | - ApiKeyAuth: [] 81 | responses: 82 | 200: 83 | description: Service status 84 | schema: 85 | type: object 86 | properties: 87 | status: 88 | type: string 89 | example: healthy 90 | version: 91 | type: string 92 | example: "1.0.0" 93 | users: 94 | type: object 95 | properties: 96 | total: 97 | type: integer 98 | admins: 99 | type: integer 100 | artist_requests: 101 | type: object 102 | properties: 103 | total: 104 | type: integer 105 | pending: 106 | type: integer 107 | services: 108 | type: object 109 | properties: 110 | lidarr_connected: 111 | type: boolean 112 | 401: 113 | description: Missing or invalid API key 114 | 500: 115 | description: Internal server error 116 | """ 117 | try: 118 | user_count = User.query.count() 119 | admin_count = User.query.filter_by(is_admin=True).count() 120 | pending_requests = ArtistRequest.query.filter_by(status="pending").count() 121 | total_requests = ArtistRequest.query.count() 122 | 123 | # Get data handler for Lidarr status 124 | data_handler = current_app.extensions.get("data_handler") 125 | lidarr_connected = False 126 | llm_connected = False 127 | if data_handler: 128 | # Simple check - if we have cached Lidarr data, assume connected 129 | lidarr_connected = bool(data_handler.cached_lidarr_names) 130 | llm_connected = bool(getattr(data_handler, "openai_recommender", None)) 131 | 132 | return jsonify( 133 | { 134 | "status": "healthy", 135 | "version": current_app.config.get("APP_VERSION", "unknown"), 136 | "users": {"total": user_count, "admins": admin_count}, 137 | "artist_requests": {"total": total_requests, "pending": pending_requests}, 138 | "services": { 139 | "lidarr_connected": lidarr_connected, 140 | "llm_connected": llm_connected, 141 | }, 142 | } 143 | ) 144 | except Exception as e: 145 | current_app.logger.error(f"API status error: {e}") 146 | return jsonify(_ERROR_INTERNAL), 500 147 | 148 | 149 | @bp.route("/artist-requests") 150 | @api_key_required 151 | def artist_requests(): 152 | """Get artist requests with optional filtering 153 | --- 154 | tags: 155 | - Artist Requests 156 | security: 157 | - ApiKeyAuth: [] 158 | parameters: 159 | - in: query 160 | name: status 161 | type: string 162 | enum: [pending, approved, rejected] 163 | required: false 164 | description: Filter requests by status 165 | - in: query 166 | name: limit 167 | type: integer 168 | default: 50 169 | minimum: 1 170 | maximum: 100 171 | required: false 172 | description: Maximum number of requests to return (max 100) 173 | responses: 174 | 200: 175 | description: A list of artist requests 176 | schema: 177 | type: object 178 | properties: 179 | count: 180 | type: integer 181 | requests: 182 | type: array 183 | items: 184 | type: object 185 | properties: 186 | id: 187 | type: integer 188 | artist_name: 189 | type: string 190 | status: 191 | type: string 192 | requested_by: 193 | type: string 194 | created_at: 195 | type: string 196 | format: date-time 197 | approved_by: 198 | type: string 199 | approved_at: 200 | type: string 201 | format: date-time 202 | 401: 203 | description: Missing or invalid API key 204 | 500: 205 | description: Internal server error 206 | """ 207 | try: 208 | status_filter = request.args.get("status") # pending, approved, rejected 209 | limit = min(int(request.args.get("limit", 50)), 100) # Max 100 210 | 211 | query = ArtistRequest.query 212 | 213 | if status_filter: 214 | query = query.filter_by(status=status_filter) 215 | 216 | requests = query.order_by(ArtistRequest.created_at.desc()).limit(limit).all() 217 | 218 | result = [] 219 | for req in requests: 220 | result.append( 221 | { 222 | "id": req.id, 223 | "artist_name": req.artist_name, 224 | "status": req.status, 225 | "requested_by": req.requested_by.name if req.requested_by else "Unknown", 226 | "created_at": req.created_at.isoformat() if req.created_at else None, 227 | "approved_by": req.approved_by.name if req.approved_by else None, 228 | "approved_at": req.approved_at.isoformat() if req.approved_at else None, 229 | } 230 | ) 231 | 232 | return jsonify({"count": len(result), "requests": result}) 233 | except Exception as e: 234 | current_app.logger.error(f"API artist-requests error: {e}") 235 | return jsonify(_ERROR_INTERNAL), 500 236 | 237 | 238 | @bp.route("/stats") 239 | @api_key_required 240 | def stats(): 241 | """Get detailed statistics 242 | --- 243 | tags: 244 | - Statistics 245 | security: 246 | - ApiKeyAuth: [] 247 | responses: 248 | 200: 249 | description: Aggregated statistics 250 | schema: 251 | type: object 252 | properties: 253 | users: 254 | type: object 255 | properties: 256 | total: 257 | type: integer 258 | admins: 259 | type: integer 260 | active: 261 | type: integer 262 | artist_requests: 263 | type: object 264 | properties: 265 | total: 266 | type: integer 267 | pending: 268 | type: integer 269 | approved: 270 | type: integer 271 | rejected: 272 | type: integer 273 | recent_week: 274 | type: integer 275 | top_requesters: 276 | type: array 277 | items: 278 | type: object 279 | properties: 280 | username: 281 | type: string 282 | requests: 283 | type: integer 284 | 401: 285 | description: Missing or invalid API key 286 | 500: 287 | description: Internal server error 288 | """ 289 | try: 290 | # User stats 291 | total_users = User.query.count() 292 | admin_users = User.query.filter_by(is_admin=True).count() 293 | active_users = User.query.filter_by(is_active=True).count() 294 | 295 | # Request stats 296 | total_requests = ArtistRequest.query.count() 297 | pending_requests = ArtistRequest.query.filter_by(status="pending").count() 298 | approved_requests = ArtistRequest.query.filter_by(status="approved").count() 299 | rejected_requests = ArtistRequest.query.filter_by(status="rejected").count() 300 | 301 | # Recent activity (last 7 days) 302 | week_ago = datetime.now(timezone.utc) - timedelta(days=7) 303 | recent_requests = ( 304 | ArtistRequest.query.filter(ArtistRequest.created_at >= week_ago).count() 305 | ) 306 | 307 | # Top requesters 308 | from sqlalchemy import func 309 | 310 | top_requesters = ( 311 | db.session.query( 312 | User.username, func.count(ArtistRequest.id).label("request_count") 313 | ) 314 | .join(ArtistRequest, User.id == ArtistRequest.requested_by_id) 315 | .group_by(User.id, User.username) 316 | .order_by(func.count(ArtistRequest.id).desc()) 317 | .limit(5) 318 | .all() 319 | ) 320 | 321 | return jsonify( 322 | { 323 | "users": { 324 | "total": total_users, 325 | "admins": admin_users, 326 | "active": active_users, 327 | }, 328 | "artist_requests": { 329 | "total": total_requests, 330 | "pending": pending_requests, 331 | "approved": approved_requests, 332 | "rejected": rejected_requests, 333 | "recent_week": recent_requests, 334 | }, 335 | "top_requesters": [ 336 | {"username": username, "requests": count} 337 | for username, count in top_requesters 338 | ], 339 | } 340 | ) 341 | except Exception as e: 342 | current_app.logger.error(f"API stats error: {e}") 343 | return jsonify(_ERROR_INTERNAL), 500 344 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sonobarr 2 | 3 | > Music discovery for Lidarr power users, blending Last.fm insights, ListenBrainz playlists, and a modern web UI. 4 | 5 | [![Release](https://img.shields.io/github/v/release/Dodelidoo-Labs/sonobarr?label=Latest%20release&cacheSeconds=60)](https://github.com/Dodelidoo-Labs/sonobarr/releases) 6 | [![Container](https://img.shields.io/badge/GHCR-sonobarr-blue?logo=github)](https://github.com/Dodelidoo-Labs/sonobarr/pkgs/container/sonobarr) 7 | [![License](https://img.shields.io/github/license/Dodelidoo-Labs/sonobarr)](./LICENSE) 8 | 9 | Sonobarr marries your existing Lidarr library with Last.fm’s discovery graph to surface artists you'll actually like. It runs as a Flask + Socket.IO application, ships with a polished Bootstrap UI, and includes admin tooling so folks can share a single instance safely. 10 | 11 |

12 | Sonobarr logo 13 |

14 | 15 | --- 16 | 17 | ## Table of contents 18 | 19 | 1. [Features at a glance](#features-at-a-glance) 20 | 2. [How it works](#how-it-works) 21 | 3. [Quick start (Docker)](#quick-start-docker) 22 | 4. [Environment reference](#environment-reference) 23 | 5. [Local development](#local-development) 24 | 6. [Using the app](#using-the-app) 25 | 7. [Screenshots](#screenshots) 26 | 8. [Troubleshooting & FAQ](#troubleshooting--faq) 27 | 9. [Contributing](#contributing) 28 | 10. [License](#license) 29 | 30 | --- 31 | 32 | ## Features at a glance 33 | 34 | - 🔌 **Deep Lidarr integration** – sync monitored artists, apply per-source monitor strategies, toggle monitor-new-albums policies, and send additions straight back to Lidarr. 35 | - 🧭 **Personal discovery hub** – stream batches sourced from your Lidarr library, your saved Last.fm scrobbles, and ListenBrainz Weekly Exploration playlists, all controllable from the sidebar. 36 | - 🤖 **AI assistant** – describe the vibe you want and let any OpenAI-compatible model seed new sessions with fresh artists, respecting optional library exclusions. 37 | - 🙋 **Artist requests workflow** – non-admins raise requests, admins approve or reject with a single click, and every action is audited in real time. 38 | - 🎧 **Preview & context panels** – launch YouTube or iTunes previews, inspect Last.fm biographies, and read key stats without leaving the grid. 39 | - ⚡️ **Real-time UX** – Socket.IO keeps discovery progress, toast alerts, and button states in sync across every connected client. 40 | - 👥 **Role-based access** – authentication, user management, profile controls for personal services, and admin-only settings live in one UI. 41 | - 🛡️ **Hardened configuration** – atomic settings writes, locked-down file permissions, and CSRF-protected forms keep secrets safe. 42 | - 🔔 **Update & schema self-healing** – footer badges surface new releases and the app backfills missing DB columns before loading users. 43 | - 🐳 **Docker-first deployment** – official GHCR image, rootless-friendly UID/GID mapping, and automatic migrations on start. 44 | - 🌐 Public API – REST API for integrating external tools such as custom dashboards (Documentation upcoming, for now study `/api/docs/` on your instance). 45 | 46 | 47 | --- 48 | 49 | ## How it works 50 | 51 | ```text 52 | ┌──────────────────────┐ ┌──────────────────────┐ 53 | │ Lidarr (HTTP API) │◀──────▶│ Sonobarr backend │ 54 | │ - Artist catalogue │ │ Flask + Socket.IO │ 55 | │ - API key auth │ │ Last.fm + Deezer │ 56 | └──────────────────────┘ │ Worker threads │ 57 | └─────────┬────────────┘ 58 | │ 59 | ▼ 60 | ┌──────────────────────┐ 61 | │ Sonobarr web client │ 62 | │ Bootstrap + JS │ 63 | │ Admin UX │ 64 | └──────────────────────┘ 65 | ``` 66 | 67 | 1. Sonobarr spins up with a persistent SQLite database inside the `config/` volume. 68 | 2. Admins provide Lidarr + Last.fm credentials through the settings modal. 69 | 3. When a user starts a discovery session, Sonobarr pulls artists from Lidarr, fans out to Last.fm, and streams cards back to the browser. 70 | 4. Optional preview and biography data is enriched via YouTube/iTunes/MusicBrainz. 71 | 72 | --- 73 | 74 | ## Quick start (Docker) 75 | 76 | > 🐳 **Requirements**: Docker Engine ≥ 24, Docker Compose plugin, Last.fm API key, Lidarr API key. 77 | 78 | 1. Create a working directory, cd into it, and make sure it’s owned by the UID/GID the container will use (defaults to `1000:1000`). This keeps every file the container writes accessible to you: 79 | ```bash 80 | mkdir -p sonobarr && cd sonobarr 81 | sudo chown -R 1000:1000 . 82 | ``` 83 | 2. Download the sample configuration: 84 | ```bash 85 | curl -L https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/develop/docker-compose.yml -o docker-compose.yml 86 | curl -L https://raw.githubusercontent.com/Dodelidoo-Labs/sonobarr/develop/.sample-env -o .env 87 | ``` 88 | 3. Open `.env` and populate **at least** these keys: 89 | ```env 90 | secret_key=change-me-to-a-long-random-string 91 | lidarr_address=http://your-lidarr:8686 92 | lidarr_api_key=xxxxxxxxxxxxxxxxxxxxxxxx 93 | last_fm_api_key=xxxxxxxxxxxxxxxxxxxxxxxx 94 | last_fm_api_secret=xxxxxxxxxxxxxxxxxxxxxxxx 95 | # Optional – enables the AI assistant modal (any OpenAI-compatible endpoint) 96 | # openai_api_key=sk-... 97 | # openai_api_base=https://api.openai.com/v1 98 | ``` 99 | > All keys in `.env` are lowercase by convention; the app will happily accept uppercase equivalents if you prefer exporting variables. 100 | 4. Start Sonobarr: 101 | ```bash 102 | docker compose up -d 103 | ``` 104 | 5. Browse to `http://localhost:5000` (or the host behind your reverse proxy) and sign in using the super-admin credentials defined in `.env`. 105 | 106 | ### Reverse proxy deployment 107 | 108 | The provided `docker-compose.yml` exposes port 5000. It is however a better practice to attache Sonobarr to an external network. To do so, add the network name and static IP so it fits your proxy stack (NGINX Proxy Manager, Traefik, etc.) to the docker compose file. No additional `environment:` stanza is needed - everything comes from the `.env` file referenced in `env_file`. 109 | 110 | For example: 111 | ``` 112 | ... 113 | networks: 114 | npm_proxy: 115 | ipv4_address: 192.168.97.23 116 | 117 | networks: 118 | npm_proxy: 119 | external: true 120 | ``` 121 | 122 | ### Updating 123 | 124 | ```bash 125 | docker compose pull 126 | docker compose up -d 127 | ``` 128 | 129 | The footer indicator will show a green dot when you are on the newest release and red when an update is available. 130 | 131 | --- 132 | 133 | ## Environment reference 134 | 135 | All variables can be supplied in lowercase (preferred for `.env`) or uppercase (useful for CI/CD systems). Defaults shown are the values Sonobarr falls back to when nothing is provided. 136 | 137 | | Key | Default | Description | 138 | | --- | --- | --- | 139 | | `secret_key` (**required**) | – | Flask session signing key. Must be a long random string; store it in `.env` so sessions survive restarts. | 140 | | `lidarr_address` | `http://192.168.1.1:8686` | Base URL of your Lidarr instance. | 141 | | `lidarr_api_key` | – | Lidarr API key for artist lookups and additions. | 142 | | `root_folder_path` | `/data/media/music/` | Default root path used when adding new artists in Lidarr. | 143 | | `lidarr_api_timeout` | `120` | Seconds to wait for Lidarr before timing out requests. | 144 | | `quality_profile_id` | `1` | Numeric profile ID from Lidarr (see [issue #1](https://github.com/Dodelidoo-Labs/sonobarr/issues/1)). | 145 | | `metadata_profile_id` | `1` | Numeric metadata profile ID. | 146 | | `fallback_to_top_result` | `false` | When MusicBrainz finds no strong match, fall back to the first Lidarr search result. | 147 | | `search_for_missing_albums` | `false` | Toggle Lidarr’s “search for missing” flag when adding an artist. | 148 | | `dry_run_adding_to_lidarr` | `false` | If `true`, Sonobarr will simulate additions without calling Lidarr. | 149 | | `last_fm_api_key` | – | Last.fm API key for similarity lookups. | 150 | | `last_fm_api_secret` | – | Last.fm API secret. | 151 | | `youtube_api_key` | – | Enables YouTube previews in the “Listen” modal. Optional but recommended. | 152 | | `openai_api_key` | – | Optional key for your OpenAI-compatible provider. Leave empty if your endpoint allows anonymous access. | 153 | | `openai_model` | `gpt-4o-mini` | Override the model slug sent to the provider. | 154 | | `openai_api_base` | – | Custom base URL for LiteLLM, Azure OpenAI, self-hosted Ollama gateways, etc. Blank uses the SDK default. **Must be complete base url such as `http://IP:PORT/v1` for example. | 155 | | `openai_extra_headers` | – | JSON object of additional headers sent with every LLM call (e.g., custom auth or routing hints). | 156 | | `openai_max_seed_artists` | `5` | Maximum number of seed artists returned from each AI prompt. | 157 | | `similar_artist_batch_size` | `10` | Number of cards sent per batch while streaming results. | 158 | | `auto_start` | `false` | Automatically start a discovery session on load. | 159 | | `auto_start_delay` | `60` | Delay (seconds) before auto-start kicks in. | 160 | | `sonobarr_superadmin_username` | `admin` | Username of the bootstrap admin account. | 161 | | `sonobarr_superadmin_password` | `change-me` | Password for the bootstrap admin. Set to a secure value before first launch. | 162 | | `sonobarr_superadmin_display_name` | `Super Admin` | Friendly display name shown in the UI. | 163 | | `sonobarr_superadmin_reset` | `false` | Set to `true` **once** to reapply the bootstrap credentials on next start. | 164 | | `release_version` | `unknown` | Populated automatically inside the Docker image; shown in the footer. No need to set manually. | 165 | | `sonobarr_config_dir` | `/sonobarr/config` | Override where Sonobarr writes `app.db`, `settings_config.json`, and migrations. | 166 | 167 | > ℹ️ `secret_key` is mandatory. If missing, the app refuses to boot to prevent insecure session cookies. With Docker Compose, make sure the key exists in `.env` and that `.env` is declared via `env_file:` as shown above. 168 | 169 | --- 170 | 171 | ## Local development 172 | 173 | See [CONTRIBUTING.md](https://github.com/Dodelidoo-Labs/sonobarr/blob/main/CONTRIBUTING.md) 174 | 175 | ### Tests 176 | 177 | Currently relying on manual testing. Contributions adding pytest coverage, especially around the data handler and settings flows, are very welcome. 178 | 179 | --- 180 | 181 | ## Using the app 182 | 183 | 1. **Sign in** with the bootstrap admin credentials. Create additional users from the **User management** page (top-right avatar → *User management*). 184 | 2. **Configure integrations** via the **Settings** button (top bar gear icon). Provide your Lidarr endpoint/key and optional YouTube key (can both be set in .env or UI) 185 | 3. **Fetch Lidarr artists** with the left sidebar button. Select the artists you want to base discovery on. 186 | 4. Hit **Start**. Sonobarr queues batches of similar artists and streams them to the grid. Cards show genre, popularity, listeners, similarity (from Last.fm), plus a status LED dot in the image corner. 187 | 5. Use **Bio** and **Listen** buttons for deeper context - the bio modal keeps Last.fm paragraph spacing intact. Click **Add to Lidarr** to push the candidate back into your library; feedback appears on the card immediately. 188 | 6. Stop or resume discovery anytime. Toast notifications keep everyone informed when conflicts or errors occur. 189 | 190 | ### AI-powered prompts 191 | 192 | - Click the **AI Assist** button on the top bar to open a prompt modal. 193 | - Describe the mood, genres, or examples you're craving (e.g. "dreamy synth-pop like M83 but calmer"). 194 | - Provide an API key and/or base URL in the settings modal (.env works too) for whichever OpenAI-compatible provider you use; without valid credentials the assistant stays disabled. 195 | - The assistant picks a handful of seed artists, kicks off a discovery session automatically, and keeps streaming cards just like a normal Lidarr-driven search. 196 | 197 | The footer shows: 198 | - GitHub repo shortcut. 199 | - Current version. 200 | - A red/green status dot indicating whether a newer release exists. 201 | 202 | --- 203 | 204 | ## Screenshots 205 | 206 |

207 | Login Window 208 | Profile Settings 209 | User Admin 210 | Configuration 211 | Configuration 212 | AI Assist 213 | Artist Suggestions 214 | Pre Hear 215 |

216 | 217 | --- 218 | 219 | ## Troubleshooting & FAQ 220 | 221 | ### The container exits with "SECRET_KEY environment variable is required" 222 | Ensure your Compose file references the `.env` file via `env_file:` and that `.env` contains a non-empty `secret_key`. Without it, Flask cannot sign sessions. 223 | 224 | ### UI says "Update available" even though I pulled latest 225 | The footer compares your runtime `release_version` with the GitHub Releases API once per hour. If you built your own image, set `RELEASE_VERSION` at build time (`docker build --build-arg RELEASE_VERSION=custom-tag`). 226 | 227 | ### Artists fail to add to Lidarr 228 | Check the container logs - Sonobarr prints the Lidarr error payload. Common causes are incorrect `root_folder_path`, missing write permissions on the Lidarr side, or duplicate artists already present. 229 | 230 | --- 231 | 232 | ## Contributing 233 | 234 | See [CONTRIBUTING.md](https://github.com/Dodelidoo-Labs/sonobarr/blob/main/CONTRIBUTING.md) 235 | 236 | --- 237 | 238 | ## License 239 | 240 | This project is released under the [MIT License](./LICENSE). 241 | 242 | Original work © 2024 TheWicklowWolf. Adaptations and ongoing maintenance © 2025 Dodelidoo Labs. 243 | -------------------------------------------------------------------------------- /src/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Sonobarr{% endblock %} 3 | {% block extra_head %} 4 | 7 | 10 | {% endblock %} 11 | {% block topbar_leading %} 12 | 16 | {% endblock %} 17 | {% block topbar_title %} 18 |
19 |

Sonobarr

20 |
21 | 24 |
25 | Loading... 26 |
27 |
28 |
29 | {% endblock %} 30 | {% block topbar_actions %}{% endblock %} 31 | {% block main %} 32 | {% if current_user.is_admin %} 33 | 34 | 213 | {% endif %} 214 | 215 | 216 |
217 |
218 |
219 |

Discovery console

220 |

Stream AI ideas, keep Lidarr in sync, and manage your queue from one place.

221 |
222 | 223 |
224 |
225 | 253 | 254 | 267 | 268 | 277 | 278 | 282 |
283 |
284 | 285 | 286 |
287 |
288 | 318 |
319 |
320 | 321 | 322 | 352 | 353 | 354 | 367 | 368 | 369 | 384 | 385 | 386 |
387 | 396 |
397 | {% endblock %} 398 | {% block extra_scripts %} 399 | 400 | {% endblock %} 401 | -------------------------------------------------------------------------------- /src/static/lidarr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 25 | --------------------------------------------------------------------------------