├── .github ├── dependabot.yml └── workflows │ ├── issue-handler.yml │ └── main.yml ├── Dockerfile ├── LICENSE ├── README.md ├── gunicorn_config.py ├── requirements.txt ├── src ├── Lidify.py ├── static │ ├── dark.png │ ├── lidarr.svg │ ├── lidify.png │ ├── light.png │ ├── logo.png │ ├── script.js │ ├── spotify.svg │ └── style.css └── templates │ └── base.html └── thewicklowwolf-init.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | time: "13:00" 12 | -------------------------------------------------------------------------------- /.github/workflows/issue-handler.yml: -------------------------------------------------------------------------------- 1 | name: Close Non-Bug Issues 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | issues: 7 | types: [opened] 8 | 9 | jobs: 10 | close-issue-if-not-bug: 11 | runs-on: ubuntu-latest 12 | env: 13 | GH_TOKEN: ${{ secrets.PAT_TOKEN }} 14 | steps: 15 | - name: Check if the issue contains "bug" 16 | id: check_bug 17 | env: 18 | BODY: ${{ github.event.issue.body }} 19 | TITLE: ${{ github.event.issue.title }} 20 | run: | 21 | # Check if the title or body contains "bug" 22 | if echo "$TITLE" | grep -qi 'bug' || echo "$BODY" | grep -qi 'bug'; then 23 | echo "This is a bug-related issue. Keeping it open." 24 | echo "is_bug=true" >> $GITHUB_ENV 25 | else 26 | echo "This is not a bug-related issue. Closing it." 27 | echo "is_bug=false" >> $GITHUB_ENV 28 | fi 29 | 30 | - name: Close issue and add comment if not bug 31 | if: env.is_bug == 'false' 32 | env: 33 | COMMENT: | 34 | ### Issue 35 | - **Feature Request:** 36 | If this is a feature request, unfortunately no new features are planned at present. The goal of this project is to keep the feature set as minimal as possible. Consider forking this repository and creating your own image to suit your requirements. 37 | **PRs** are open, but only for essential changes/features. 38 | 39 | - **Specific Issues:** 40 | If you're experiencing an issue, please check through previous issues first, as it may have already been addressed. 41 | If it hasn’t been covered, you'll need to clone this repository and run it locally to investigate the issue further. There are plenty of resources available to help you get familiar with Docker and the code used here, so please ensure you explore those fully. 42 | Please also note that this project may not work across all setups and systems. 43 | 44 | - **Genuine Bugs:** 45 | If you believe you've found a genuine bug that affects the main functionality, please raise an issue with detailed logs and a specific bug report. It would also be greatly appreciated if you can suggest a possible solution. 46 | 47 | Thanks, and best of luck! 48 | 49 | --- 50 | 51 | It can be frustrating when an **issue** gets closed automatically, but this process helps keep track of actionable bugs. 52 | **Feature requests** are only considered if the requester contributes code or takes significant steps toward implementing the feature themselves. Without this commitment or partial coding effort, the request will not be considered. Thank you for your understanding! 53 | 54 | --- 55 | 56 | **NOTE:** THIS IS AN AUTOMATICALLY GENERATED COMMENT. 57 | 58 | run: | 59 | gh issue close ${{ github.event.issue.number }} --comment "$COMMENT" --repo ${{ github.repository }} 60 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/*.md' 9 | 10 | jobs: 11 | bump-version-and-create-release-tag: 12 | runs-on: ubuntu-latest 13 | env: 14 | GH_TOKEN: ${{ secrets.PAT_TOKEN }} 15 | outputs: 16 | new_version: ${{ steps.increment_version.outputs.new_version }} 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | with: 22 | token: ${{ secrets.PAT_TOKEN }} 23 | 24 | - name: Fetch and list tags 25 | run: | 26 | git fetch --tags 27 | echo "Tags:" 28 | git tag --list 29 | 30 | - name: Get current version 31 | id: get_version 32 | run: | 33 | VERSION=$(git tag --list | sed 's/^v//' | awk -F. '{ if (NF == 2) printf("%s.0.%s\n", $1, $2); else print $0 }' | sort -V | tail -n 1 | sed 's/^/v/') 34 | echo "CURRENT_VERSION=$VERSION" >> $GITHUB_ENV 35 | echo "Current version: $VERSION" 36 | 37 | - name: Increment version 38 | id: increment_version 39 | run: | 40 | NEW_VERSION=$(echo ${{ env.CURRENT_VERSION }} | awk -F. '{printf("%d.%d.%d", $1, $2, $3+1)}') 41 | echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV 42 | echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT 43 | echo "New version: $NEW_VERSION" 44 | 45 | - name: Create new Git tag 46 | run: | 47 | git config --global user.name 'github-actions[bot]' 48 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 49 | git tag -a v${{ env.NEW_VERSION }} -m "Release version ${{ env.NEW_VERSION }}" 50 | git push origin --tags 51 | 52 | - name: Create release 53 | run: | 54 | gh release create "v${{ env.NEW_VERSION }}" \ 55 | --repo="${GITHUB_REPOSITORY}" \ 56 | --title="v${{ env.NEW_VERSION }}" \ 57 | --generate-notes 58 | 59 | build-docker-image: 60 | runs-on: ubuntu-latest 61 | needs: bump-version-and-create-release-tag 62 | steps: 63 | - name: Checkout 64 | uses: actions/checkout@v4 65 | 66 | - name: Set up QEMU 67 | uses: docker/setup-qemu-action@v3 68 | 69 | - name: Set up Docker Buildx 70 | uses: docker/setup-buildx-action@v3 71 | 72 | - name: Login to Docker Hub 73 | uses: docker/login-action@v3 74 | with: 75 | username: ${{ secrets.DOCKERHUB_USERNAME }} 76 | password: ${{ secrets.DOCKERHUB_TOKEN }} 77 | 78 | - name: Convert repository name to lowercase 79 | id: lowercase_repo 80 | run: | 81 | REPO_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') 82 | echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV 83 | 84 | - name: Build and push 85 | uses: docker/build-push-action@v5 86 | with: 87 | context: . 88 | platforms: linux/amd64,linux/arm64 89 | file: ./Dockerfile 90 | push: true 91 | build-args: | 92 | RELEASE_VERSION=${{ needs.bump-version-and-create-release-tag.outputs.new_version }} 93 | tags: | 94 | ${{ env.REPO_NAME }}:${{ needs.bump-version-and-create-release-tag.outputs.new_version }} 95 | ${{ env.REPO_NAME }}:latest 96 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine 2 | 3 | # Set build arguments 4 | ARG RELEASE_VERSION 5 | ENV RELEASE_VERSION=${RELEASE_VERSION} 6 | 7 | # Install su-exec 8 | RUN apk update && apk add --no-cache su-exec 9 | 10 | # Create directories and set permissions 11 | COPY . /lidify 12 | WORKDIR /lidify 13 | 14 | # Install requirements 15 | RUN pip install --no-cache-dir -r requirements.txt 16 | 17 | # Make the script executable 18 | RUN chmod +x thewicklowwolf-init.sh 19 | 20 | # Expose port 21 | EXPOSE 5000 22 | 23 | # Start the app 24 | ENTRYPOINT ["./thewicklowwolf-init.sh"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 TheWicklowWolf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/TheWicklowWolf/Lidify/actions/workflows/main.yml/badge.svg) 2 | ![Docker Pulls](https://img.shields.io/docker/pulls/thewicklowwolf/lidify.svg) 3 | 4 | 5 |

6 | image 7 |

8 | 9 | Music discovery tool that provides recommendations based on selected Lidarr artists. 10 | 11 | #### Note: 12 | As of November 2024 changes to the Spotify API prevent its use in this application, see https://github.com/TheWicklowWolf/Lidify/issues/24 for details. 13 | This application now exclusively supports Last.fm. To integrate it, log in to your Last.fm account and create an API account at [this page](https://www.last.fm/api/account/create). Then, copy the provided API key and secret into the Docker Compose configuration. 14 | 15 | 16 | ## Run using docker-compose 17 | 18 | ```yaml 19 | services: 20 | lidify: 21 | image: thewicklowwolf/lidify:latest 22 | container_name: lidify 23 | volumes: 24 | - /path/to/config:/lidify/config 25 | - /etc/localtime:/etc/localtime:ro 26 | ports: 27 | - 5000:5000 28 | restart: unless-stopped 29 | environment: 30 | - last_fm_api_key= 31 | - last_fm_api_secret= 32 | - mode=LastFM 33 | ``` 34 | 35 | ## Configuration via environment variables 36 | 37 | Certain values can be set via environment variables: 38 | 39 | * __PUID__: The user ID to run the app with. Defaults to `1000`. 40 | * __PGID__: The group ID to run the app with. Defaults to `1000`. 41 | * __lidarr_address__: The URL for Lidarr. Defaults to `http://192.168.1.2:8686`. Can be configured from the application as well. 42 | * __lidarr_api_key__: The API key for Lidarr. Defaults to ``. Can be configured from the application as well. 43 | * __root_folder_path__: The root folder path for music. Defaults to `/data/media/music/`. Can be configured from the application as well. 44 | * __spotify_client_id__: The Client ID for Spotify. Defaults to ``. Can be configured from the application as well, but see https://github.com/TheWicklowWolf/Lidify/issues/24 . 45 | * __spotify_client_secret__: The Client Secret for Spotify. Defaults to ``. Can be configured from the application as well, but see https://github.com/TheWicklowWolf/Lidify/issues/24 . 46 | * __fallback_to_top_result__: Whether to use the top result if no match is found. Defaults to `False`. 47 | * __lidarr_api_timeout__: Timeout duration for Lidarr API calls. Defaults to `120`. 48 | * __quality_profile_id__: Quality profile ID in Lidarr. Defaults to `1`. 49 | * __metadata_profile_id__: Metadata profile ID in Lidarr. Defaults to `1` 50 | * __search_for_missing_albums__: Whether to start searching for albums when adding artists. Defaults to `False` 51 | * __dry_run_adding_to_lidarr__: Whether to run without adding artists in Lidarr. Defaults to `False` 52 | * __app_name__: Name of the application. Defaults to `Lidify`. 53 | * __app_rev__: Application revision. Defaults to `0.01`. 54 | * __app_url__: URL of the application. Defaults to `Random URL`. 55 | * __last_fm_api_key__: The API key for LastFM. Defaults to ``. 56 | * __last_fm_api_secret__: The API secret for LastFM. Defaults to ``. 57 | * __mode__: Mode for discovery (Spotify or LastFM). Defaults to `Spotify`. 58 | * __auto_start__: Whether to run automatically at startup. Defaults to `False`. 59 | * __auto_start_delay__: Delay duration for Auto Start in Seconds (if enabled). Defaults to `60`. 60 | 61 | --- 62 | 63 |

64 | image 65 |

66 | 67 |

68 | image 69 |

70 | 71 | --- 72 | 73 | https://hub.docker.com/r/thewicklowwolf/lidify 74 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gunicorn 2 | gevent 3 | gevent-websocket 4 | flask 5 | flask_socketio 6 | requests 7 | spotipy 8 | musicbrainzngs 9 | thefuzz 10 | Unidecode 11 | pylast -------------------------------------------------------------------------------- /src/Lidify.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import logging 4 | import os 5 | import random 6 | import string 7 | import threading 8 | import urllib.parse 9 | from flask import Flask, render_template, request 10 | from flask_socketio import SocketIO 11 | import requests 12 | import spotipy 13 | from spotipy.oauth2 import SpotifyClientCredentials 14 | import musicbrainzngs 15 | from thefuzz import fuzz 16 | from unidecode import unidecode 17 | import pylast 18 | 19 | 20 | class DataHandler: 21 | def __init__(self): 22 | logging.basicConfig(level=logging.INFO, format="%(message)s") 23 | self.lidify_logger = logging.getLogger() 24 | self.musicbrainzngs_logger = logging.getLogger("musicbrainzngs") 25 | self.musicbrainzngs_logger.setLevel("WARNING") 26 | self.pylast_logger = logging.getLogger("pylast") 27 | self.pylast_logger.setLevel("WARNING") 28 | 29 | app_name_text = os.path.basename(__file__).replace(".py", "") 30 | release_version = os.environ.get("RELEASE_VERSION", "unknown") 31 | self.lidify_logger.warning(f"{'*' * 50}\n") 32 | self.lidify_logger.warning(f"{app_name_text} Version: {release_version}\n") 33 | self.lidify_logger.warning(f"{'*' * 50}") 34 | 35 | self.search_in_progress_flag = False 36 | self.new_found_artists_counter = 0 37 | self.clients_connected_counter = 0 38 | self.config_folder = "config" 39 | self.recommended_artists = [] 40 | self.lidarr_items = [] 41 | self.cleaned_lidarr_items = [] 42 | self.stop_event = threading.Event() 43 | self.stop_event.set() 44 | if not os.path.exists(self.config_folder): 45 | os.makedirs(self.config_folder) 46 | self.load_environ_or_config_settings() 47 | if self.auto_start: 48 | try: 49 | auto_start_thread = threading.Timer(self.auto_start_delay, self.automated_startup) 50 | auto_start_thread.daemon = True 51 | auto_start_thread.start() 52 | 53 | except Exception as e: 54 | self.lidify_logger.error(f"Auto Start Error: {str(e)}") 55 | 56 | def load_environ_or_config_settings(self): 57 | # Defaults 58 | default_settings = { 59 | "lidarr_address": "http://192.168.1.2:8686", 60 | "lidarr_api_key": "", 61 | "root_folder_path": "/data/media/music/", 62 | "spotify_client_id": "", 63 | "spotify_client_secret": "", 64 | "fallback_to_top_result": False, 65 | "lidarr_api_timeout": 120.0, 66 | "quality_profile_id": 1, 67 | "metadata_profile_id": 1, 68 | "search_for_missing_albums": False, 69 | "dry_run_adding_to_lidarr": False, 70 | "app_name": "Lidify", 71 | "app_rev": "0.10", 72 | "app_url": "http://" + "".join(random.choices(string.ascii_lowercase, k=10)) + ".com", 73 | "last_fm_api_key": "", 74 | "last_fm_api_secret": "", 75 | "mode": "Spotify", 76 | "auto_start": False, 77 | "auto_start_delay": 60, 78 | } 79 | 80 | # Load settings from environmental variables (which take precedence) over the configuration file. 81 | self.lidarr_address = os.environ.get("lidarr_address", "") 82 | self.lidarr_api_key = os.environ.get("lidarr_api_key", "") 83 | self.root_folder_path = os.environ.get("root_folder_path", "") 84 | self.spotify_client_id = os.environ.get("spotify_client_id", "") 85 | self.spotify_client_secret = os.environ.get("spotify_client_secret", "") 86 | fallback_to_top_result = os.environ.get("fallback_to_top_result", "") 87 | self.fallback_to_top_result = fallback_to_top_result.lower() == "true" if fallback_to_top_result != "" else "" 88 | lidarr_api_timeout = os.environ.get("lidarr_api_timeout", "") 89 | self.lidarr_api_timeout = float(lidarr_api_timeout) if lidarr_api_timeout else "" 90 | quality_profile_id = os.environ.get("quality_profile_id", "") 91 | self.quality_profile_id = int(quality_profile_id) if quality_profile_id else "" 92 | metadata_profile_id = os.environ.get("metadata_profile_id", "") 93 | self.metadata_profile_id = int(metadata_profile_id) if metadata_profile_id else "" 94 | search_for_missing_albums = os.environ.get("search_for_missing_albums", "") 95 | self.search_for_missing_albums = search_for_missing_albums.lower() == "true" if search_for_missing_albums != "" else "" 96 | dry_run_adding_to_lidarr = os.environ.get("dry_run_adding_to_lidarr", "") 97 | self.dry_run_adding_to_lidarr = dry_run_adding_to_lidarr.lower() == "true" if dry_run_adding_to_lidarr != "" else "" 98 | self.app_name = os.environ.get("app_name", "") 99 | self.app_rev = os.environ.get("app_rev", "") 100 | self.app_url = os.environ.get("app_url", "") 101 | self.last_fm_api_key = os.environ.get("last_fm_api_key", "") 102 | self.last_fm_api_secret = os.environ.get("last_fm_api_secret", "") 103 | self.mode = os.environ.get("mode", "") 104 | auto_start = os.environ.get("auto_start", "") 105 | self.auto_start = auto_start.lower() == "true" if auto_start != "" else "" 106 | auto_start_delay = os.environ.get("auto_start_delay", "") 107 | self.auto_start_delay = float(auto_start_delay) if auto_start_delay else "" 108 | 109 | # Load variables from the configuration file if not set by environmental variables. 110 | try: 111 | self.settings_config_file = os.path.join(self.config_folder, "settings_config.json") 112 | if os.path.exists(self.settings_config_file): 113 | self.lidify_logger.info(f"Loading Config via file") 114 | with open(self.settings_config_file, "r") as json_file: 115 | ret = json.load(json_file) 116 | for key in ret: 117 | if getattr(self, key) == "": 118 | setattr(self, key, ret[key]) 119 | except Exception as e: 120 | self.lidify_logger.error(f"Error Loading Config: {str(e)}") 121 | 122 | # Load defaults if not set by an environmental variable or configuration file. 123 | for key, value in default_settings.items(): 124 | if getattr(self, key) == "": 125 | setattr(self, key, value) 126 | 127 | # Save config. 128 | self.save_config_to_file() 129 | 130 | def automated_startup(self): 131 | self.get_artists_from_lidarr(checked=True) 132 | artists = [x["name"] for x in self.lidarr_items] 133 | self.start(artists) 134 | 135 | def connection(self): 136 | if self.recommended_artists: 137 | if self.clients_connected_counter == 0: 138 | if len(self.recommended_artists) > 25: 139 | self.recommended_artists = random.sample(self.recommended_artists, 25) 140 | else: 141 | self.lidify_logger.info(f"Shuffling Artists") 142 | random.shuffle(self.recommended_artists) 143 | socketio.emit("more_artists_loaded", self.recommended_artists) 144 | 145 | self.clients_connected_counter += 1 146 | 147 | def disconnection(self): 148 | self.clients_connected_counter = max(0, self.clients_connected_counter - 1) 149 | 150 | def start(self, data): 151 | try: 152 | socketio.emit("clear") 153 | self.new_found_artists_counter = 1 154 | self.artists_to_use_in_search = [] 155 | self.recommended_artists = [] 156 | 157 | for item in self.lidarr_items: 158 | item_name = item["name"] 159 | if item_name in data: 160 | item["checked"] = True 161 | self.artists_to_use_in_search.append(item_name) 162 | else: 163 | item["checked"] = False 164 | 165 | if self.artists_to_use_in_search: 166 | self.stop_event.clear() 167 | else: 168 | self.stop_event.set() 169 | raise Exception("No Lidarr Artists Selected") 170 | 171 | except Exception as e: 172 | self.lidify_logger.error(f"Statup Error: {str(e)}") 173 | self.stop_event.set() 174 | ret = {"Status": "Error", "Code": str(e), "Data": self.lidarr_items, "Running": not self.stop_event.is_set()} 175 | socketio.emit("lidarr_sidebar_update", ret) 176 | 177 | else: 178 | self.find_similar_artists() 179 | 180 | def get_artists_from_lidarr(self, checked=False): 181 | try: 182 | self.lidify_logger.info(f"Getting Artists from Lidarr") 183 | self.lidarr_items = [] 184 | endpoint = f"{self.lidarr_address}/api/v1/artist" 185 | headers = {"X-Api-Key": self.lidarr_api_key} 186 | response = requests.get(endpoint, headers=headers, timeout=self.lidarr_api_timeout) 187 | 188 | if response.status_code == 200: 189 | self.full_lidarr_artist_list = response.json() 190 | self.lidarr_items = [{"name": unidecode(artist["artistName"], replace_str=" "), "checked": checked} for artist in self.full_lidarr_artist_list] 191 | self.lidarr_items.sort(key=lambda x: x["name"].lower()) 192 | self.cleaned_lidarr_items = [item["name"].lower() for item in self.lidarr_items] 193 | status = "Success" 194 | data = self.lidarr_items 195 | else: 196 | status = "Error" 197 | data = response.text 198 | 199 | ret = {"Status": status, "Code": response.status_code if status == "Error" else None, "Data": data, "Running": not self.stop_event.is_set()} 200 | 201 | except Exception as e: 202 | self.lidify_logger.error(f"Getting Artist Error: {str(e)}") 203 | ret = {"Status": "Error", "Code": 500, "Data": str(e), "Running": not self.stop_event.is_set()} 204 | 205 | finally: 206 | socketio.emit("lidarr_sidebar_update", ret) 207 | 208 | def find_similar_artists(self): 209 | if self.stop_event.is_set() or self.search_in_progress_flag: 210 | return 211 | elif self.mode == "Spotify" and self.new_found_artists_counter > 0: 212 | try: 213 | self.lidify_logger.info(f"Searching for new artists via {self.mode}") 214 | self.new_found_artists_counter = 0 215 | self.search_in_progress_flag = True 216 | random_artists = random.sample(self.artists_to_use_in_search, min(7, len(self.artists_to_use_in_search))) 217 | 218 | sp = spotipy.Spotify(retries=0, auth_manager=SpotifyClientCredentials(client_id=self.spotify_client_id, client_secret=self.spotify_client_secret)) 219 | 220 | for artist_name in random_artists: 221 | if self.stop_event.is_set(): 222 | break 223 | search_id = None 224 | results = sp.search(q=artist_name, type="artist") 225 | items = results.get("artists", {}).get("items", []) 226 | search_id = items[0]["id"] 227 | related_artists = sp.artist_related_artists(search_id) 228 | for related_artist in related_artists["artists"]: 229 | if self.stop_event.is_set(): 230 | break 231 | cleaned_artist = unidecode(related_artist.get("name")).lower() 232 | if cleaned_artist not in self.cleaned_lidarr_items: 233 | for item in self.recommended_artists: 234 | if related_artist.get("name") == item["Name"]: 235 | break 236 | else: 237 | genres = ", ".join([genre.title() for genre in related_artist.get("genres", [])]) if related_artist.get("genres") else "Unknown Genre" 238 | followers = self.format_numbers(related_artist.get("followers", {}).get("total", 0)) 239 | pop = related_artist.get("popularity", "0") 240 | img_link = related_artist.get("images")[0]["url"] if related_artist.get("images") else None 241 | exclusive_artist = { 242 | "Name": related_artist["name"], 243 | "Genre": genres, 244 | "Status": "", 245 | "Img_Link": img_link, 246 | "Popularity": f"Popularity: {pop}/100", 247 | "Followers": f"Followers: {followers}", 248 | } 249 | self.recommended_artists.append(exclusive_artist) 250 | socketio.emit("more_artists_loaded", [exclusive_artist]) 251 | self.new_found_artists_counter += 1 252 | 253 | if self.new_found_artists_counter == 0: 254 | self.lidify_logger.info("Search Exhausted - Try selecting more artists from existing Lidarr library") 255 | socketio.emit("new_toast_msg", {"title": "Search Exhausted", "message": "Try selecting more artists from existing Lidarr library"}) 256 | 257 | except Exception as e: 258 | self.lidify_logger.error(f"Spotify Error: {str(e)}") 259 | 260 | finally: 261 | self.search_in_progress_flag = False 262 | 263 | elif self.mode == "LastFM" and self.new_found_artists_counter > 0: 264 | try: 265 | self.lidify_logger.info(f"Searching for new artists via {self.mode}") 266 | self.new_found_artists_counter = 0 267 | self.search_in_progress_flag = True 268 | random_artists = random.sample(self.artists_to_use_in_search, min(7, len(self.artists_to_use_in_search))) 269 | 270 | lfm = pylast.LastFMNetwork(api_key=self.last_fm_api_key, api_secret=self.last_fm_api_secret) 271 | for artist_name in random_artists: 272 | if self.stop_event.is_set(): 273 | break 274 | search_id = None 275 | 276 | try: 277 | chosen_artist = lfm.get_artist(artist_name) 278 | related_artists = chosen_artist.get_similar() 279 | 280 | except Exception as e: 281 | self.lidify_logger.error(f"Error with LastFM on artist - '{artist_name}': {str(e)}") 282 | self.lidify_logger.info("Trying next artist...") 283 | continue 284 | 285 | random_related_artists = random.sample(related_artists, min(30, len(related_artists))) 286 | for related_artist in random_related_artists: 287 | if self.stop_event.is_set(): 288 | break 289 | cleaned_artist = unidecode(related_artist.item.name).lower() 290 | if cleaned_artist not in self.cleaned_lidarr_items: 291 | for item in self.recommended_artists: 292 | if related_artist.item.name == item["Name"]: 293 | break 294 | else: 295 | artist_obj = lfm.get_artist(related_artist.item.name) 296 | genres = ", ".join([tag.item.get_name().title() for tag in artist_obj.get_top_tags()[:5]]) or "Unknown Genre" 297 | listeners = artist_obj.get_listener_count() or 0 298 | play_count = artist_obj.get_playcount() or 0 299 | try: 300 | img_link = None 301 | endpoint = "https://api.deezer.com/search/artist" 302 | params = {"q": related_artist.item.name} 303 | response = requests.get(endpoint, params=params) 304 | data = response.json() 305 | if "data" in data and data["data"]: 306 | artist_info = data["data"][0] 307 | img_link = artist_info.get("picture_xl", artist_info.get("picture_large", artist_info.get("picture_medium", artist_info.get("picture", "")))) 308 | 309 | except Exception as e: 310 | self.lidify_logger.error(f"Deezer Error: {str(e)}") 311 | 312 | exclusive_artist = { 313 | "Name": related_artist.item.name, 314 | "Genre": genres, 315 | "Status": "", 316 | "Img_Link": img_link if img_link else "https://via.placeholder.com/300x200", 317 | "Popularity": f"Play Count: {self.format_numbers(play_count)}", 318 | "Followers": f"Listeners: {self.format_numbers(listeners)}", 319 | } 320 | self.recommended_artists.append(exclusive_artist) 321 | socketio.emit("more_artists_loaded", [exclusive_artist]) 322 | self.new_found_artists_counter += 1 323 | 324 | if self.new_found_artists_counter == 0: 325 | self.lidify_logger.info("Search Exhausted - Try selecting more artists from existing Lidarr library") 326 | socketio.emit("new_toast_msg", {"title": "Search Exhausted", "message": "Try selecting more artists from existing Lidarr library"}) 327 | 328 | except Exception as e: 329 | self.lidify_logger.error(f"LastFM Error: {str(e)}") 330 | 331 | finally: 332 | self.search_in_progress_flag = False 333 | 334 | elif self.new_found_artists_counter == 0: 335 | try: 336 | self.search_in_progress_flag = True 337 | self.lidify_logger.info("Search Exhausted - Try selecting more artists from existing Lidarr library") 338 | socketio.emit("new_toast_msg", {"title": "Search Exhausted", "message": "Try selecting more artists from existing Lidarr library"}) 339 | time.sleep(2) 340 | 341 | except Exception as e: 342 | self.lidify_logger.error(f"Search Exhausted Error: {str(e)}") 343 | 344 | finally: 345 | self.search_in_progress_flag = False 346 | 347 | def add_artists(self, raw_artist_name): 348 | try: 349 | artist_name = urllib.parse.unquote(raw_artist_name) 350 | artist_folder = artist_name.replace("/", " ") 351 | musicbrainzngs.set_useragent(self.app_name, self.app_rev, self.app_url) 352 | mbid = self.get_mbid_from_musicbrainz(artist_name) 353 | if mbid: 354 | lidarr_url = f"{self.lidarr_address}/api/v1/artist" 355 | headers = {"X-Api-Key": self.lidarr_api_key} 356 | payload = { 357 | "ArtistName": artist_name, 358 | "qualityProfileId": self.quality_profile_id, 359 | "metadataProfileId": self.metadata_profile_id, 360 | "path": os.path.join(self.root_folder_path, artist_folder, ""), 361 | "rootFolderPath": self.root_folder_path, 362 | "foreignArtistId": mbid, 363 | "monitored": True, 364 | "addOptions": {"searchForMissingAlbums": self.search_for_missing_albums}, 365 | } 366 | if self.dry_run_adding_to_lidarr: 367 | response = requests.Response() 368 | response.status_code = 201 369 | else: 370 | response = requests.post(lidarr_url, headers=headers, json=payload) 371 | 372 | if response.status_code == 201: 373 | self.lidify_logger.info(f"Artist '{artist_name}' added successfully to Lidarr.") 374 | status = "Added" 375 | self.lidarr_items.append({"name": artist_name, "checked": False}) 376 | self.cleaned_lidarr_items.append(unidecode(artist_name).lower()) 377 | else: 378 | self.lidify_logger.error(f"Failed to add artist '{artist_name}' to Lidarr.") 379 | error_data = json.loads(response.content) 380 | error_message = error_data[0].get("errorMessage", "No Error Message Returned") if error_data else "Error Unknown" 381 | self.lidify_logger.error(error_message) 382 | if "already been added" in error_message: 383 | status = "Already in Lidarr" 384 | self.lidify_logger.info(f"Artist '{artist_name}' is already in Lidarr.") 385 | elif "configured for an existing artist" in error_message: 386 | status = "Already in Lidarr" 387 | self.lidify_logger.info(f"'{artist_folder}' folder already configured for an existing artist.") 388 | elif "Invalid Path" in error_message: 389 | status = "Invalid Path" 390 | self.lidify_logger.info(f"Path: {os.path.join(self.root_folder_path, artist_folder, '')} not valid.") 391 | else: 392 | status = "Failed to Add" 393 | 394 | else: 395 | status = "Failed to Add" 396 | self.lidify_logger.info(f"No Matching Artist for: '{artist_name}' in MusicBrainz.") 397 | socketio.emit("new_toast_msg", {"title": "Failed to add Artist", "message": f"No Matching Artist for: '{artist_name}' in MusicBrainz."}) 398 | 399 | for item in self.recommended_artists: 400 | if item["Name"] == artist_name: 401 | item["Status"] = status 402 | socketio.emit("refresh_artist", item) 403 | break 404 | 405 | except Exception as e: 406 | self.lidify_logger.error(f"Adding Artist Error: {str(e)}") 407 | 408 | def get_mbid_from_musicbrainz(self, artist_name): 409 | result = musicbrainzngs.search_artists(artist=artist_name) 410 | mbid = None 411 | 412 | if "artist-list" in result: 413 | artists = result["artist-list"] 414 | 415 | for artist in artists: 416 | match_ratio = fuzz.ratio(artist_name.lower(), artist["name"].lower()) 417 | decoded_match_ratio = fuzz.ratio(unidecode(artist_name.lower()), unidecode(artist["name"].lower())) 418 | if match_ratio > 90 or decoded_match_ratio > 90: 419 | mbid = artist["id"] 420 | self.lidify_logger.info(f"Artist '{artist_name}' matched '{artist['name']}' with MBID: {mbid} Match Ratio: {max(match_ratio, decoded_match_ratio)}") 421 | break 422 | else: 423 | if self.fallback_to_top_result and artists: 424 | mbid = artists[0]["id"] 425 | self.lidify_logger.info(f"Artist '{artist_name}' matched '{artists[0]['name']}' with MBID: {mbid} Match Ratio: {max(match_ratio, decoded_match_ratio)}") 426 | 427 | return mbid 428 | 429 | def load_settings(self): 430 | try: 431 | data = { 432 | "lidarr_address": self.lidarr_address, 433 | "lidarr_api_key": self.lidarr_api_key, 434 | "root_folder_path": self.root_folder_path, 435 | "spotify_client_id": self.spotify_client_id, 436 | "spotify_client_secret": self.spotify_client_secret, 437 | } 438 | socketio.emit("settingsLoaded", data) 439 | except Exception as e: 440 | self.lidify_logger.error(f"Failed to load settings: {str(e)}") 441 | 442 | def update_settings(self, data): 443 | try: 444 | self.lidarr_address = data["lidarr_address"] 445 | self.lidarr_api_key = data["lidarr_api_key"] 446 | self.root_folder_path = data["root_folder_path"] 447 | self.spotify_client_id = data["spotify_client_id"] 448 | self.spotify_client_secret = data["spotify_client_secret"] 449 | except Exception as e: 450 | self.lidify_logger.error(f"Failed to update settings: {str(e)}") 451 | 452 | def format_numbers(self, count): 453 | if count >= 1000000: 454 | return f"{count / 1000000:.1f}M" 455 | elif count >= 1000: 456 | return f"{count / 1000:.1f}K" 457 | else: 458 | return count 459 | 460 | def save_config_to_file(self): 461 | try: 462 | with open(self.settings_config_file, "w") as json_file: 463 | json.dump( 464 | { 465 | "lidarr_address": self.lidarr_address, 466 | "lidarr_api_key": self.lidarr_api_key, 467 | "root_folder_path": self.root_folder_path, 468 | "spotify_client_id": self.spotify_client_id, 469 | "spotify_client_secret": self.spotify_client_secret, 470 | "fallback_to_top_result": self.fallback_to_top_result, 471 | "lidarr_api_timeout": float(self.lidarr_api_timeout), 472 | "quality_profile_id": self.quality_profile_id, 473 | "metadata_profile_id": self.metadata_profile_id, 474 | "search_for_missing_albums": self.search_for_missing_albums, 475 | "dry_run_adding_to_lidarr": self.dry_run_adding_to_lidarr, 476 | "app_name": self.app_name, 477 | "app_rev": self.app_rev, 478 | "app_url": self.app_url, 479 | "last_fm_api_key": self.last_fm_api_key, 480 | "last_fm_api_secret": self.last_fm_api_secret, 481 | "mode": self.mode, 482 | "auto_start": self.auto_start, 483 | "auto_start_delay": self.auto_start_delay, 484 | }, 485 | json_file, 486 | indent=4, 487 | ) 488 | 489 | except Exception as e: 490 | self.lidify_logger.error(f"Error Saving Config: {str(e)}") 491 | 492 | def preview(self, raw_artist_name): 493 | artist_name = urllib.parse.unquote(raw_artist_name) 494 | if self.mode == "Spotify": 495 | try: 496 | preview_info = None 497 | sp = spotipy.Spotify(retries=0, auth_manager=SpotifyClientCredentials(client_id=self.spotify_client_id, client_secret=self.spotify_client_secret)) 498 | results = sp.search(q=artist_name, type="artist") 499 | items = results.get("artists", {}).get("items", []) 500 | cleaned_artist_name = unidecode(artist_name).lower() 501 | for item in items: 502 | match_ratio = fuzz.ratio(cleaned_artist_name, item.get("name", "").lower()) 503 | decoded_match_ratio = fuzz.ratio(unidecode(cleaned_artist_name), unidecode(item.get("name", "").lower())) 504 | if match_ratio > 90 or decoded_match_ratio > 90: 505 | artist_id = item.get("id", "") 506 | top_tracks = sp.artist_top_tracks(artist_id) 507 | random.shuffle(top_tracks["tracks"]) 508 | for track in top_tracks["tracks"]: 509 | if track.get("preview_url"): 510 | preview_info = {"artist": track["artists"][0]["name"], "song": track["name"], "preview_url": track["preview_url"]} 511 | break 512 | else: 513 | preview_info = f"No preview tracks available for artist: {artist_name}" 514 | self.lidify_logger.error(preview_info) 515 | break 516 | else: 517 | preview_info = f"No Artist match for: {artist_name}" 518 | self.lidify_logger.error(preview_info) 519 | 520 | except Exception as e: 521 | preview_info = f"Error retrieving artist previews: {str(e)}" 522 | self.lidify_logger.error(preview_info) 523 | 524 | finally: 525 | socketio.emit("spotify_preview", preview_info, room=request.sid) 526 | 527 | elif self.mode == "LastFM": 528 | try: 529 | preview_info = {} 530 | biography = None 531 | lfm = pylast.LastFMNetwork(api_key=self.last_fm_api_key, api_secret=self.last_fm_api_secret) 532 | search_results = lfm.search_for_artist(artist_name) 533 | artists = search_results.get_next_page() 534 | cleaned_artist_name = unidecode(artist_name).lower() 535 | for artist_obj in artists: 536 | match_ratio = fuzz.ratio(cleaned_artist_name, artist_obj.name.lower()) 537 | decoded_match_ratio = fuzz.ratio(unidecode(cleaned_artist_name), unidecode(artist_obj.name.lower())) 538 | if match_ratio > 90 or decoded_match_ratio > 90: 539 | biography = artist_obj.get_bio_content() 540 | preview_info["artist_name"] = artist_obj.name 541 | preview_info["biography"] = biography 542 | break 543 | else: 544 | preview_info = f"No Artist match for: {artist_name}" 545 | self.lidify_logger.error(preview_info) 546 | 547 | if biography is None: 548 | preview_info = f"No Biography available for: {artist_name}" 549 | self.lidify_logger.error(preview_info) 550 | 551 | except Exception as e: 552 | preview_info = {"error": f"Error retrieving artist bio: {str(e)}"} 553 | self.lidify_logger.error(preview_info) 554 | 555 | finally: 556 | socketio.emit("lastfm_preview", preview_info, room=request.sid) 557 | 558 | 559 | app = Flask(__name__) 560 | app.secret_key = "secret_key" 561 | socketio = SocketIO(app) 562 | data_handler = DataHandler() 563 | 564 | 565 | @app.route("/") 566 | def home(): 567 | return render_template("base.html") 568 | 569 | 570 | @socketio.on("side_bar_opened") 571 | def side_bar_opened(): 572 | if data_handler.lidarr_items: 573 | ret = {"Status": "Success", "Data": data_handler.lidarr_items, "Running": not data_handler.stop_event.is_set()} 574 | socketio.emit("lidarr_sidebar_update", ret) 575 | 576 | 577 | @socketio.on("get_lidarr_artists") 578 | def get_lidarr_artists(): 579 | thread = threading.Thread(target=data_handler.get_artists_from_lidarr, name="Lidarr_Thread") 580 | thread.daemon = True 581 | thread.start() 582 | 583 | 584 | @socketio.on("finder") 585 | def find_similar_artists(data): 586 | thread = threading.Thread(target=data_handler.find_similar_artists, args=(data,), name="Find_Similar_Thread") 587 | thread.daemon = True 588 | thread.start() 589 | 590 | 591 | @socketio.on("adder") 592 | def add_artists(data): 593 | thread = threading.Thread(target=data_handler.add_artists, args=(data,), name="Add_Artists_Thread") 594 | thread.daemon = True 595 | thread.start() 596 | 597 | 598 | @socketio.on("connect") 599 | def connection(): 600 | data_handler.connection() 601 | 602 | 603 | @socketio.on("disconnect") 604 | def disconnection(): 605 | data_handler.disconnection() 606 | 607 | 608 | @socketio.on("load_settings") 609 | def load_settings(): 610 | data_handler.load_settings() 611 | 612 | 613 | @socketio.on("update_settings") 614 | def update_settings(data): 615 | data_handler.update_settings(data) 616 | data_handler.save_config_to_file() 617 | 618 | 619 | @socketio.on("start_req") 620 | def starter(data): 621 | data_handler.start(data) 622 | 623 | 624 | @socketio.on("stop_req") 625 | def stopper(): 626 | data_handler.stop_event.set() 627 | 628 | 629 | @socketio.on("load_more_artists") 630 | def load_more_artists(): 631 | thread = threading.Thread(target=data_handler.find_similar_artists, name="FindSimilar") 632 | thread.daemon = True 633 | thread.start() 634 | 635 | 636 | @socketio.on("preview_req") 637 | def preview(artist): 638 | data_handler.preview(artist) 639 | 640 | 641 | if __name__ == "__main__": 642 | socketio.run(app, host="0.0.0.0", port=5000) 643 | -------------------------------------------------------------------------------- /src/static/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheWicklowWolf/Lidify/c3b018a91335bf4fa9d39005e4e296ff98919bd5/src/static/dark.png -------------------------------------------------------------------------------- /src/static/lidarr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 25 | -------------------------------------------------------------------------------- /src/static/lidify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheWicklowWolf/Lidify/c3b018a91335bf4fa9d39005e4e296ff98919bd5/src/static/lidify.png -------------------------------------------------------------------------------- /src/static/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheWicklowWolf/Lidify/c3b018a91335bf4fa9d39005e4e296ff98919bd5/src/static/light.png -------------------------------------------------------------------------------- /src/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheWicklowWolf/Lidify/c3b018a91335bf4fa9d39005e4e296ff98919bd5/src/static/logo.png -------------------------------------------------------------------------------- /src/static/script.js: -------------------------------------------------------------------------------- 1 | var return_to_top = document.getElementById("return-to-top"); 2 | 3 | var lidarr_get_artists_button = document.getElementById('lidarr-get-artists-button'); 4 | var start_stop_button = document.getElementById('start-stop-button'); 5 | var lidarr_status = document.getElementById('lidarr-status'); 6 | var lidarr_spinner = document.getElementById('lidarr-spinner'); 7 | 8 | var lidarr_item_list = document.getElementById("lidarr-item-list"); 9 | var lidarr_select_all_checkbox = document.getElementById("lidarr-select-all"); 10 | var lidarr_select_all_container = document.getElementById("lidarr-select-all-container"); 11 | 12 | var config_modal = document.getElementById('config-modal'); 13 | var lidarr_sidebar = document.getElementById('lidarr-sidebar'); 14 | 15 | var save_message = document.getElementById("save-message"); 16 | var save_changes_button = document.getElementById("save-changes-button"); 17 | const lidarr_address = document.getElementById("lidarr-address"); 18 | const lidarr_api_key = document.getElementById("lidarr-api-key"); 19 | const root_folder_path = document.getElementById("root-folder-path"); 20 | const spotify_client_id = document.getElementById("spotify-client-id"); 21 | const spotify_client_secret = document.getElementById("spotify-client-secret"); 22 | 23 | var lidarr_items = []; 24 | var socket = io(); 25 | 26 | function check_if_all_selected() { 27 | var checkboxes = document.querySelectorAll('input[name="lidarr-item"]'); 28 | var all_checked = true; 29 | for (var i = 0; i < checkboxes.length; i++) { 30 | if (!checkboxes[i].checked) { 31 | all_checked = false; 32 | break; 33 | } 34 | } 35 | lidarr_select_all_checkbox.checked = all_checked; 36 | } 37 | 38 | function load_lidarr_data(response) { 39 | var every_check_box = document.querySelectorAll('input[name="lidarr-item"]'); 40 | if (response.Running) { 41 | start_stop_button.classList.remove('btn-success'); 42 | start_stop_button.classList.add('btn-warning'); 43 | start_stop_button.textContent = "Stop"; 44 | every_check_box.forEach(item => { 45 | item.disabled = true; 46 | }); 47 | lidarr_select_all_checkbox.disabled = true; 48 | lidarr_get_artists_button.disabled = true; 49 | } else { 50 | start_stop_button.classList.add('btn-success'); 51 | start_stop_button.classList.remove('btn-warning'); 52 | start_stop_button.textContent = "Start"; 53 | every_check_box.forEach(item => { 54 | item.disabled = false; 55 | }); 56 | lidarr_select_all_checkbox.disabled = false; 57 | lidarr_get_artists_button.disabled = false; 58 | } 59 | check_if_all_selected(); 60 | } 61 | 62 | function append_artists(artists) { 63 | var artist_row = document.getElementById('artist-row'); 64 | var template = document.getElementById('artist-template'); 65 | 66 | artists.forEach(function (artist) { 67 | var clone = document.importNode(template.content, true); 68 | var artist_col = clone.querySelector('#artist-column'); 69 | 70 | artist_col.querySelector('.card-title').textContent = artist.Name; 71 | artist_col.querySelector('.genre').textContent = artist.Genre; 72 | if (artist.Img_Link) { 73 | artist_col.querySelector('.card-img-top').src = artist.Img_Link; 74 | artist_col.querySelector('.card-img-top').alt = artist.Name; 75 | } else { 76 | artist_col.querySelector('.artist-img-container').removeChild(artist_col.querySelector('.card-img-top')); 77 | } 78 | artist_col.querySelector('.add-to-lidarr-btn').addEventListener('click', function () { 79 | add_to_lidarr(artist.Name); 80 | }); 81 | artist_col.querySelector('.get-preview-btn').addEventListener('click', function () { 82 | preview_req(artist.Name); 83 | }); 84 | artist_col.querySelector('.followers').textContent = artist.Followers; 85 | artist_col.querySelector('.popularity').textContent = artist.Popularity; 86 | 87 | var add_button = artist_col.querySelector('.add-to-lidarr-btn'); 88 | if (artist.Status === "Added" || artist.Status === "Already in Lidarr") { 89 | artist_col.querySelector('.card-body').classList.add('status-green'); 90 | add_button.classList.remove('btn-primary'); 91 | add_button.classList.add('btn-secondary'); 92 | add_button.disabled = true; 93 | add_button.textContent = artist.Status; 94 | } else if (artist.Status === "Failed to Add" || artist.Status === "Invalid Path") { 95 | artist_col.querySelector('.card-body').classList.add('status-red'); 96 | add_button.classList.remove('btn-primary'); 97 | add_button.classList.add('btn-danger'); 98 | add_button.disabled = true; 99 | add_button.textContent = artist.Status; 100 | } else { 101 | artist_col.querySelector('.card-body').classList.add('status-blue'); 102 | } 103 | artist_row.appendChild(clone); 104 | }); 105 | } 106 | 107 | function add_to_lidarr(artist_name) { 108 | if (socket.connected) { 109 | socket.emit('adder', encodeURIComponent(artist_name)); 110 | } 111 | else { 112 | show_toast("Connection Lost", "Please reload to continue."); 113 | } 114 | } 115 | 116 | function show_toast(header, message) { 117 | var toast_container = document.querySelector('.toast-container'); 118 | var toast_template = document.getElementById('toast-template').cloneNode(true); 119 | toast_template.classList.remove('d-none'); 120 | 121 | toast_template.querySelector('.toast-header strong').textContent = header; 122 | toast_template.querySelector('.toast-body').textContent = message; 123 | toast_template.querySelector('.text-muted').textContent = new Date().toLocaleString(); 124 | 125 | toast_container.appendChild(toast_template); 126 | 127 | var toast = new bootstrap.Toast(toast_template); 128 | toast.show(); 129 | 130 | toast_template.addEventListener('hidden.bs.toast', function () { 131 | toast_template.remove(); 132 | }); 133 | } 134 | 135 | return_to_top.addEventListener("click", function () { 136 | window.scrollTo({ top: 0, behavior: "smooth" }); 137 | }); 138 | 139 | lidarr_select_all_checkbox.addEventListener("change", function () { 140 | var is_checked = this.checked; 141 | var checkboxes = document.querySelectorAll('input[name="lidarr-item"]'); 142 | checkboxes.forEach(function (checkbox) { 143 | checkbox.checked = is_checked; 144 | }); 145 | }); 146 | 147 | lidarr_get_artists_button.addEventListener('click', function () { 148 | lidarr_get_artists_button.disabled = true; 149 | lidarr_spinner.classList.remove('d-none'); 150 | lidarr_status.textContent = "Accessing Lidarr API"; 151 | lidarr_item_list.innerHTML = ''; 152 | socket.emit("get_lidarr_artists"); 153 | }); 154 | 155 | start_stop_button.addEventListener('click', function () { 156 | var running_state = start_stop_button.textContent.trim() === "Start" ? true : false; 157 | if (running_state) { 158 | start_stop_button.classList.remove('btn-success'); 159 | start_stop_button.classList.add('btn-warning'); 160 | start_stop_button.textContent = "Stop"; 161 | var checked_items = Array.from(document.querySelectorAll('input[name="lidarr-item"]:checked')) 162 | .map(item => item.value); 163 | document.querySelectorAll('input[name="lidarr-item"]').forEach(item => { 164 | item.disabled = true; 165 | }); 166 | lidarr_get_artists_button.disabled = true; 167 | lidarr_select_all_checkbox.disabled = true; 168 | socket.emit("start_req", checked_items); 169 | if (checked_items.length > 0) { 170 | show_toast("Loading new artists"); 171 | } 172 | } 173 | else { 174 | start_stop_button.classList.add('btn-success'); 175 | start_stop_button.classList.remove('btn-warning'); 176 | start_stop_button.textContent = "Start"; 177 | document.querySelectorAll('input[name="lidarr-item"]').forEach(item => { 178 | item.disabled = false; 179 | }); 180 | lidarr_get_artists_button.disabled = false; 181 | lidarr_select_all_checkbox.disabled = false; 182 | socket.emit("stop_req"); 183 | } 184 | }); 185 | 186 | save_changes_button.addEventListener("click", () => { 187 | socket.emit("update_settings", { 188 | "lidarr_address": lidarr_address.value, 189 | "lidarr_api_key": lidarr_api_key.value, 190 | "root_folder_path": root_folder_path.value, 191 | "spotify_client_id": spotify_client_id.value, 192 | "spotify_client_secret": spotify_client_secret.value, 193 | }); 194 | save_message.style.display = "block"; 195 | setTimeout(function () { 196 | save_message.style.display = "none"; 197 | }, 1000); 198 | }); 199 | 200 | config_modal.addEventListener('show.bs.modal', function (event) { 201 | socket.emit("load_settings"); 202 | 203 | function handle_settings_loaded(settings) { 204 | lidarr_address.value = settings.lidarr_address; 205 | lidarr_api_key.value = settings.lidarr_api_key; 206 | root_folder_path.value = settings.root_folder_path; 207 | spotify_client_id.value = settings.spotify_client_id; 208 | spotify_client_secret.value = settings.spotify_client_secret; 209 | socket.off("settingsLoaded", handle_settings_loaded); 210 | } 211 | socket.on("settingsLoaded", handle_settings_loaded); 212 | }); 213 | 214 | lidarr_sidebar.addEventListener('show.bs.offcanvas', function (event) { 215 | socket.emit("side_bar_opened"); 216 | }); 217 | 218 | window.addEventListener('scroll', function () { 219 | if (window.innerHeight + window.scrollY >= document.body.offsetHeight) { 220 | socket.emit('load_more_artists'); 221 | } 222 | }); 223 | 224 | window.addEventListener('touchmove', function () { 225 | if (window.innerHeight + window.scrollY >= document.body.offsetHeight) { 226 | socket.emit('load_more_artists'); 227 | } 228 | }); 229 | 230 | window.addEventListener('touchend', () => { 231 | const { scrollHeight, scrollTop, clientHeight } = document.documentElement; 232 | if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) { 233 | socket.emit('load_more_artists'); 234 | } 235 | }); 236 | 237 | socket.on("lidarr_sidebar_update", (response) => { 238 | if (response.Status == "Success") { 239 | lidarr_status.textContent = "Lidarr List Retrieved"; 240 | lidarr_items = response.Data; 241 | lidarr_item_list.innerHTML = ''; 242 | lidarr_select_all_container.classList.remove('d-none'); 243 | 244 | for (var i = 0; i < lidarr_items.length; i++) { 245 | var item = lidarr_items[i]; 246 | 247 | var div = document.createElement("div"); 248 | div.className = "form-check"; 249 | 250 | var input = document.createElement("input"); 251 | input.type = "checkbox"; 252 | input.className = "form-check-input"; 253 | input.id = "lidarr-" + i; 254 | input.name = "lidarr-item"; 255 | input.value = item.name; 256 | 257 | if (item.checked) { 258 | input.checked = true; 259 | } 260 | 261 | var label = document.createElement("label"); 262 | label.className = "form-check-label"; 263 | label.htmlFor = "lidarr-" + i; 264 | label.textContent = item.name; 265 | 266 | input.addEventListener("change", function () { 267 | check_if_all_selected(); 268 | }); 269 | 270 | div.appendChild(input); 271 | div.appendChild(label); 272 | 273 | lidarr_item_list.appendChild(div); 274 | } 275 | } 276 | else { 277 | lidarr_status.textContent = response.Code; 278 | } 279 | lidarr_get_artists_button.disabled = false; 280 | lidarr_spinner.classList.add('d-none'); 281 | load_lidarr_data(response); 282 | }); 283 | 284 | socket.on("refresh_artist", (artist) => { 285 | var artist_cards = document.querySelectorAll('#artist-column'); 286 | artist_cards.forEach(function (card) { 287 | var card_body = card.querySelector('.card-body'); 288 | var card_artist_name = card_body.querySelector('.card-title').textContent.trim(); 289 | 290 | if (card_artist_name === artist.Name) { 291 | card_body.classList.remove('status-green', 'status-red', 'status-blue'); 292 | 293 | var add_button = card_body.querySelector('.add-to-lidarr-btn'); 294 | 295 | if (artist.Status === "Added" || artist.Status === "Already in Lidarr") { 296 | card_body.classList.add('status-green'); 297 | add_button.classList.remove('btn-primary'); 298 | add_button.classList.add('btn-secondary'); 299 | add_button.disabled = true; 300 | add_button.textContent = artist.Status; 301 | } else if (artist.Status === "Failed to Add" || artist.Status === "Invalid Path") { 302 | card_body.classList.add('status-red'); 303 | add_button.classList.remove('btn-primary'); 304 | add_button.classList.add('btn-danger'); 305 | add_button.disabled = true; 306 | add_button.textContent = artist.Status; 307 | } else { 308 | card_body.classList.add('status-blue'); 309 | add_button.disabled = false; 310 | } 311 | return; 312 | } 313 | }); 314 | }); 315 | 316 | socket.on('more_artists_loaded', function (data) { 317 | append_artists(data); 318 | }); 319 | 320 | socket.on('clear', function () { 321 | clear_all(); 322 | }); 323 | 324 | socket.on("new_toast_msg", function (data) { 325 | show_toast(data.title, data.message); 326 | }); 327 | 328 | socket.on("disconnect", function () { 329 | show_toast("Connection Lost", "Please reconnect to continue."); 330 | clear_all(); 331 | }); 332 | 333 | function clear_all() { 334 | var artist_row = document.getElementById('artist-row'); 335 | var artist_cards = artist_row.querySelectorAll('#artist-column'); 336 | artist_cards.forEach(function (card) { 337 | card.remove(); 338 | }); 339 | } 340 | 341 | var preview_modal; 342 | let preview_request_flag = false; 343 | 344 | function preview_req(artist_name) { 345 | if (!preview_request_flag) { 346 | preview_request_flag = true; 347 | socket.emit("preview_req", encodeURIComponent(artist_name)); 348 | setTimeout(() => { 349 | preview_request_flag = false; 350 | }, 1500); 351 | } 352 | } 353 | 354 | function show_audio_player_modal(artist, song) { 355 | preview_modal = new bootstrap.Modal(document.getElementById('audio-player-modal')); 356 | const scrollbar_width = window.innerWidth - document.documentElement.clientWidth; 357 | document.body.style.overflow = 'hidden'; 358 | document.body.style.paddingRight = `${scrollbar_width}px`; 359 | preview_modal.show(); 360 | preview_modal._element.addEventListener('hidden.bs.modal', function () { 361 | stop_audio(); 362 | document.body.style.overflow = 'auto'; 363 | document.body.style.paddingRight = '0'; 364 | }); 365 | 366 | var modal_title_label = document.getElementById('audio-player-modal-label'); 367 | if (modal_title_label) { 368 | modal_title_label.textContent = `${artist} - ${song}`; 369 | } 370 | } 371 | 372 | function play_audio(audio_url) { 373 | var audio_player = document.getElementById('audio-player'); 374 | audio_player.src = audio_url; 375 | audio_player.play(); 376 | } 377 | 378 | function stop_audio() { 379 | var audio_player = document.getElementById('audio-player'); 380 | audio_player.pause(); 381 | audio_player.currentTime = 0; 382 | audio_player.removeAttribute('src'); 383 | preview_modal = null; 384 | } 385 | 386 | socket.on("spotify_preview", function (preview_info) { 387 | if (typeof preview_info === 'string') { 388 | show_toast("Error Retrieving Preview", preview_info); 389 | } else { 390 | var artist = preview_info.artist; 391 | var song = preview_info.song; 392 | show_audio_player_modal(artist, song); 393 | play_audio(preview_info.preview_url); 394 | } 395 | }); 396 | 397 | socket.on("lastfm_preview", function (preview_info) { 398 | if (typeof preview_info === 'string') { 399 | show_toast("Error Retrieving Bio", preview_info); 400 | } 401 | else { 402 | const scrollbar_width = window.innerWidth - document.documentElement.clientWidth; 403 | document.body.style.overflow = 'hidden'; 404 | document.body.style.paddingRight = `${scrollbar_width}px`; 405 | 406 | var artist_name = preview_info.artist_name; 407 | var biography = preview_info.biography; 408 | var modal_title = document.getElementById('bio-modal-title'); 409 | var modal_body = document.getElementById('modal-body'); 410 | modal_title.textContent = artist_name; 411 | modal_body.innerHTML = DOMPurify.sanitize(biography); 412 | 413 | var lastfm_modal = new bootstrap.Modal(document.getElementById('bio-modal-modal')); 414 | lastfm_modal.show(); 415 | 416 | lastfm_modal._element.addEventListener('hidden.bs.modal', function () { 417 | document.body.style.overflow = 'auto'; 418 | document.body.style.paddingRight = '0'; 419 | }); 420 | } 421 | }); 422 | 423 | const theme_switch = document.getElementById('theme-switch'); 424 | const saved_theme = localStorage.getItem('theme'); 425 | const saved_switch_position = localStorage.getItem('switch-position'); 426 | 427 | if (saved_switch_position) { 428 | theme_switch.checked = saved_switch_position === 'true'; 429 | } 430 | 431 | if (saved_theme) { 432 | document.documentElement.setAttribute('data-bs-theme', saved_theme); 433 | } 434 | 435 | theme_switch.addEventListener('click', () => { 436 | if (document.documentElement.getAttribute('data-bs-theme') === 'dark') { 437 | document.documentElement.setAttribute('data-bs-theme', 'light'); 438 | } else { 439 | document.documentElement.setAttribute('data-bs-theme', 'dark'); 440 | } 441 | localStorage.setItem('theme', document.documentElement.getAttribute('data-bs-theme')); 442 | localStorage.setItem('switch_position', theme_switch.checked); 443 | }); 444 | -------------------------------------------------------------------------------- /src/static/spotify.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/static/style.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | .logo { 8 | width: 60px; 9 | margin-right: 0px; 10 | } 11 | 12 | .scrollable-content { 13 | flex: 1; 14 | overflow: auto; 15 | max-width: 100%; 16 | } 17 | 18 | .form-group { 19 | margin-bottom: 0rem !important; 20 | } 21 | 22 | .logo-and-title { 23 | display: flex; 24 | } 25 | 26 | .artist-img-container { 27 | position: relative; 28 | overflow: hidden; 29 | } 30 | 31 | .artist-img-overlay { 32 | position: absolute; 33 | top: 0; 34 | left: 0; 35 | width: 100%; 36 | height: 100%; 37 | background-color: rgba(0, 0, 0, 0.7); 38 | opacity: 0; 39 | transition: opacity 0.3s ease; 40 | } 41 | 42 | .artist-img-container:hover .artist-img-overlay { 43 | opacity: 1; 44 | } 45 | 46 | .button-container { 47 | position: absolute; 48 | top: 50%; 49 | left: 50%; 50 | transform: translate(-50%, -50%); 51 | z-index: 1; 52 | display: flex; 53 | flex-direction: column; 54 | } 55 | 56 | .add-to-lidarr-btn, 57 | .get-preview-btn { 58 | margin: 2px 0px; 59 | z-index: 1; 60 | opacity: 0; 61 | } 62 | 63 | .artist-img-container:hover .add-to-lidarr-btn, 64 | .artist-img-container:hover .get-preview-btn { 65 | transition: opacity 0.6s ease; 66 | opacity: 1; 67 | } 68 | 69 | .status-indicator { 70 | position: absolute; 71 | top: 0; 72 | right: 0; 73 | width: 20px; 74 | height: 20px; 75 | } 76 | 77 | .led { 78 | width: 12px; 79 | height: 12px; 80 | border-radius: 50%; 81 | border: 2px solid; 82 | position: absolute; 83 | top: 4px; 84 | right: 4px; 85 | } 86 | 87 | .status-green .led { 88 | background-color: #28a745; 89 | border-color: #28a745; 90 | background-color: var(--bs-success); 91 | border-color: var(--bs-success); 92 | } 93 | 94 | .status-red .led { 95 | background-color: #dc3545; 96 | border-color: #dc3545; 97 | background-color: var(--bs-danger); 98 | border-color: var(--bs-danger); 99 | } 100 | 101 | .status-blue .led { 102 | background-color: #007bff; 103 | border-color: #007bff; 104 | background-color: var(--bs-primary); 105 | border-color: var(--bs-primary); 106 | } 107 | 108 | @media screen and (max-width: 600px) { 109 | h1{ 110 | margin-bottom: 0rem!important; 111 | } 112 | .logo{ 113 | height: 40px; 114 | width: 40px; 115 | } 116 | .container { 117 | width: 98%; 118 | } 119 | .custom-spacing .form-group-modal { 120 | margin-bottom: 5px; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 16 | 17 | 20 | 21 | 24 | 25 | 28 | Lidify 29 | 30 | 31 | 32 | 33 |
34 |
35 |
36 | 40 |

Lidify

41 | 45 |
46 |
47 |
48 | 49 | 50 | 97 | 98 | 99 |
100 |
101 |
102 | 103 |
104 |

Lidarr

105 |
106 |
107 | 108 |
109 | 110 |
111 |
112 |
113 |
114 | 119 |
120 |
121 | 122 |
123 |
124 | 125 |
126 |
127 |
128 | 129 |
130 |
131 |
132 |
133 | 134 |
135 |
136 |
137 |
138 | 139 | 140 |
141 |
142 |
143 |
144 |
145 | 146 |
147 |
148 |
149 |
150 |
151 | 152 | 153 |
154 |
155 | 184 |
185 |
186 | 187 | 188 | 206 | 207 | 208 | 223 | 224 | 225 |
226 | 235 |
236 | 237 | 238 | 239 | 240 | -------------------------------------------------------------------------------- /thewicklowwolf-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo -e "\033[1;32mTheWicklowWolf\033[0m" 4 | echo -e "\033[1;34mLidify\033[0m" 5 | echo "Initializing app..." 6 | 7 | cat << 'EOF' 8 | _____________________________________ 9 | 10 | .-'''''-. 11 | .' `. 12 | : : 13 | : : 14 | : _/| : 15 | : =/_/ : 16 | `._/ | .' 17 | ( / ,|...-' 18 | \_/^\/||__ 19 | _/~ `""~`"` \_ 20 | __/ -'/ `-._ `\_\__ 21 | / /-'` `\ \ \-.\ 22 | _____________________________________ 23 | Brought to you by TheWicklowWolf 24 | _____________________________________ 25 | 26 | If you'd like to buy me a coffee: 27 | https://buymeacoffee.com/thewicklow 28 | 29 | EOF 30 | 31 | PUID=${PUID:-1000} 32 | PGID=${PGID:-1000} 33 | 34 | echo "-----------------" 35 | echo -e "\033[1mRunning with:\033[0m" 36 | echo "PUID=${PUID}" 37 | echo "PGID=${PGID}" 38 | echo "-----------------" 39 | 40 | # Create the required directories with the correct permissions 41 | echo "Setting up directories.." 42 | mkdir -p /lidify/config 43 | chown -R ${PUID}:${PGID} /lidify 44 | 45 | # Start the application with the specified user permissions 46 | echo "Running Lidify..." 47 | exec su-exec ${PUID}:${PGID} gunicorn src.Lidify:app -c gunicorn_config.py 48 | --------------------------------------------------------------------------------