├── .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 | 
2 | 
3 |
4 |
5 |
6 |
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 |
65 |
66 |
67 |
68 |
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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
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 |
38 |
39 |
40 |
Lidify
41 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
97 |
98 |
99 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
162 |
163 |
164 |
165 |
166 |
167 |
168 | Add to Lidarr
169 | Preview
170 |
171 |
172 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
190 |
191 |
192 |
196 |
197 |
198 |
199 | Your browser does not support the audio element.
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
215 |
216 |
217 |
220 |
221 |
222 |
223 |
224 |
225 |
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 |
--------------------------------------------------------------------------------