feat: added lidarr support

This commit is contained in:
Kamil
2024-12-03 23:11:05 +00:00
parent 87791cf21d
commit b861a1a8f4
12 changed files with 570 additions and 59 deletions

View File

@@ -8,6 +8,7 @@ from flask import Flask, has_request_context
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from psycopg2 import OperationalError
import redis
import spotipy
from spotipy.oauth2 import SpotifyClientCredentials
from celery import Celery
@@ -77,9 +78,13 @@ def make_celery(app):
'update_jellyfin_id_for_downloaded_tracks-schedule': {
'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks',
'schedule': crontab(minute='*/10'),
}
}
if app.config['LIDARR_API_KEY']:
celery.conf.beat_schedule['request-lidarr-schedule'] = {
'task': 'app.tasks.request_lidarr',
'schedule': crontab(minute='*/15')
}
celery.conf.timezone = 'UTC'
return celery
@@ -89,17 +94,6 @@ device_id = f'JellyPlist_{'_'.join(sys.argv)}'
# Initialize Flask app
app = Flask(__name__, template_folder="../templates", static_folder='../static')
# log_file = 'app.log'
# handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3)
# handler.setLevel(logging.DEBUG)
# handler.setFormatter(log_formatter)
# stream_handler = logging.StreamHandler(sys.stdout)
# stream_handler.setLevel(logging.DEBUG)
# stream_handler.setFormatter(log_formatter)
# # app.logger.addHandler(handler)
# app.logger.addHandler(stream_handler)
app.config.from_object(Config)
@@ -114,6 +108,7 @@ logging.basicConfig(format=FORMAT)
Config.validate_env_vars()
cache = Cache(app)
redis_client = redis.StrictRedis(host=app.config['CACHE_REDIS_HOST'], port=app.config['CACHE_REDIS_PORT'], db=0, decode_responses=True)
# Spotify, Jellyfin, and Spotdl setup
@@ -181,3 +176,8 @@ spotify_client = SpotifyClient('/jellyplist/open.spotify.com_cookies.txt')
spotify_client.authenticate()
from .registry import MusicProviderRegistry
MusicProviderRegistry.register_provider(spotify_client)
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
app.logger.info(f'Creating Lidarr Client with URL: {app.config["LIDARR_URL"]}')
from lidarr.client import LidarrClient
lidarr_client = LidarrClient(app.config['LIDARR_URL'], app.config['LIDARR_API_KEY'])

View File

@@ -4,12 +4,13 @@ from flask import flash, redirect, session, url_for,g
import requests
from app.classes import CombinedPlaylistData, CombinedTrackData
from app.models import JellyfinUser, Playlist,Track
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache, redis_client
from functools import wraps
from celery.result import AsyncResult
from app.providers import base
from app.providers.base import PlaylistTrack
from app.registry.music_provider_registry import MusicProviderRegistry
from lidarr.classes import Album, Artist
from . import tasks
from jellyfin.objects import PlaylistMetadata
from spotipy.exceptions import SpotifyException
@@ -20,15 +21,20 @@ TASK_STATUS = {
'update_all_playlists_track_status': None,
'download_missing_tracks': None,
'check_for_playlist_updates': None,
'update_jellyfin_id_for_downloaded_tracks' : None
'update_jellyfin_id_for_downloaded_tracks' : None,
}
if app.config['LIDARR_API_KEY']:
TASK_STATUS['request_lidarr'] = None
LOCK_KEYS = [
'update_all_playlists_track_status_lock',
'download_missing_tracks_lock',
'check_for_playlist_updates_lock',
'update_jellyfin_id_for_downloaded_tracks_lock' ,
'full_update_jellyfin_ids'
'full_update_jellyfin_ids',
'request_lidarr_lock'
]
def manage_task(task_name):
@@ -46,6 +52,8 @@ def manage_task(task_name):
result = tasks.check_for_playlist_updates.delay()
elif task_name == 'update_jellyfin_id_for_downloaded_tracks':
result = tasks.update_jellyfin_id_for_downloaded_tracks.delay()
elif task_name == 'request_lidarr':
result = tasks.request_lidarr.delay()
TASK_STATUS[task_name] = result.id
return result.state, result.info if result.info else {}
@@ -93,6 +101,41 @@ def prepPlaylistData(playlist: base.Playlist) -> Optional[CombinedPlaylistData]:
status=status
)
def lidarr_quality_profile_id(profile_id=None):
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
from app import lidarr_client
if profile_id:
redis_client.set('lidarr_quality_profile_id', profile_id)
else:
value = redis_client.get('lidarr_quality_profile_id')
if not value:
value = lidarr_client.get_quality_profiles()[0]
lidarr_quality_profile_id(value.id)
return value
return value
def lidarr_root_folder_path(folder_path=None):
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
from app import lidarr_client
if folder_path:
redis_client.set('lidarr_root_folder_path', folder_path)
else:
value = redis_client.get('lidarr_root_folder_path')
if not value:
value = lidarr_client.get_root_folders()[0]
lidarr_root_folder_path(value.path)
return value.path
return value
# a function which takes a lidarr.class.Artist object as a parameter, and applies the lidarr_quality_profile_id to the artist if its 0
def apply_default_profile_and_root_folder(object : Artist ) -> Artist:
if object.qualityProfileId == 0:
object.qualityProfileId = int(lidarr_quality_profile_id())
if object.rootFolderPath == '' or object.rootFolderPath == None:
object.rootFolderPath = str(lidarr_root_folder_path())
if object.metadataProfileId == 0:
object.metadataProfileId = 1
return object
@cache.memoize(timeout=3600*24*10)
def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track:

View File

@@ -61,5 +61,6 @@ class Track(db.Model):
# Many-to-Many relationship with Playlists
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
lidarr_processed = db.Column(db.Boolean(), default=False)
def __repr__(self):
return f'<Track {self.name}:{self.provider_track_id}>'

View File

@@ -1,8 +1,9 @@
from dbm import error
import json
import os
import re
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash, Blueprint, g
from app import app, db, functions, sp, jellyfin, celery, jellyfin_admin_token, jellyfin_admin_id,device_id, cache, read_dev_build_file, tasks
from app import app, db, functions, jellyfin, read_dev_build_file, tasks
from app.classes import AudioProfile, CombinedPlaylistData
from app.models import JellyfinUser,Playlist,Track
from celery.result import AsyncResult
@@ -12,6 +13,8 @@ from app.providers import base
from app.providers.base import MusicProviderClient
from app.providers.spotify import SpotifyClient
from app.registry.music_provider_registry import MusicProviderRegistry
from lidarr.classes import Album, Artist
from lidarr.client import LidarrClient
from ..version import __version__
from spotipy.exceptions import SpotifyException
from collections import defaultdict
@@ -37,6 +40,29 @@ def render_messages(response: Response) -> Response:
return response
@app.route('/admin/lidarr')
@functions.jellyfin_admin_required
def admin_lidarr():
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
from app import lidarr_client
q_profiles = lidarr_client.get_quality_profiles()
root_folders = lidarr_client.get_root_folders()
return render_template('admin/lidarr.html',quality_profiles = q_profiles, root_folders = root_folders, current_quality_profile = functions.lidarr_quality_profile_id(), current_root_folder = functions.lidarr_root_folder_path())
return render_template('admin/lidarr.html', error = 'Lidarr not configured')
@app.route('/admin/lidarr/save', methods=['POST'])
@functions.jellyfin_admin_required
def save_lidarr_config():
quality_profile_id = request.form.get('qualityProfile')
root_folder_id = request.form.get('rootFolder')
if not quality_profile_id or not root_folder_id:
flash('Both Quality Profile and Root Folder must be selected', 'danger')
return redirect(url_for('admin_lidarr'))
functions.lidarr_quality_profile_id(quality_profile_id)
functions.lidarr_root_folder_path(root_folder_id)
flash('Configuration saved successfully', 'success')
return redirect(url_for('admin_lidarr'))
@app.route('/admin/tasks')
@functions.jellyfin_admin_required
@@ -370,43 +396,5 @@ def unlock_key():
@pl_bp.route('/test')
def test():
#return ''
app.logger.info(f"performing full update on jellyfin track ids. (Update tracks and playlists if better quality will be found)")
downloaded_tracks : List[Track] = Track.query.all()
total_tracks = len(downloaded_tracks)
if not downloaded_tracks:
app.logger.info("No downloaded tracks without Jellyfin ID found.")
return {'status': 'No tracks to update'}
app.logger.info(f"Found {total_tracks} tracks to update ")
processed_tracks = 0
for track in downloaded_tracks:
try:
best_match = tasks.find_best_match_from_jellyfin(track)
if best_match:
track.downloaded = True
if track.jellyfin_id != best_match['Id']:
track.jellyfin_id = best_match['Id']
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.provider_track_id})")
if track.filesystem_path != best_match['Path']:
track.filesystem_path = best_match['Path']
app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})")
db.session.commit()
else:
app.logger.warning(f"No matching track found in Jellyfin for {track.name}.")
spotify_track = None
except Exception as e:
app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}")
processed_tracks += 1
progress = (processed_tracks / total_tracks) * 100
#self.update_state(state=f'{processed_tracks}/{total_tracks}: {track.name}', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
app.logger.info("Finished updating Jellyfin IDs for all tracks.")
return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks}
return ''

View File

@@ -4,7 +4,7 @@ import subprocess
from typing import List
from sqlalchemy import insert
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id, redis_client
from app.classes import AudioProfile
from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks
@@ -12,9 +12,10 @@ import os
import redis
from celery import current_task,signals
from app.providers import base
from app.registry.music_provider_registry import MusicProviderRegistry
from lidarr.classes import Artist
redis_client = redis.StrictRedis(host='redis', port=6379, db=0)
def acquire_lock(lock_name, expiration=60):
return redis_client.set(lock_name, "locked", ex=expiration, nx=True)
@@ -401,6 +402,94 @@ def update_jellyfin_id_for_downloaded_tracks(self):
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
@celery.task(bind=True)
def request_lidarr(self):
lock_key = "request_lidarr_lock"
if acquire_lock(lock_key, expiration=600):
with app.app_context():
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
from app import lidarr_client
try:
app.logger.info('Submitting request to Lidarr...')
# get all tracks from db
tracks = Track.query.filter_by(lidarr_processed=False).all()
total_items = len(tracks)
processed_items = 0
for track in tracks:
tfp = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id)
if tfp:
if app.config['LIDARR_MONITOR_ARTISTS']:
app.logger.debug("Monitoring artists instead of albums")
# get all artists from all tracks_from_provider and unique them
artists : dict[str,base.Artist] = {}
for artist in tfp.artists:
artists[artist.name] = artist
app.logger.debug(f"Found {len(artists)} artists to monitor")
#pylint: disable=consider-using-dict-items
for artist in artists:
artist_from_lidarr = None
search_result = lidarr_client.search(artists[artist].name)
for url in artists[artist].external_urls:
artist_from_lidarr : Artist = lidarr_client.get_object_by_external_url(search_result, url.url)
if artist_from_lidarr:
app.logger.debug(f"Found artist {artist_from_lidarr.artistName} by external url {url.url}")
functions.apply_default_profile_and_root_folder(artist_from_lidarr)
try:
lidarr_client.monitor_artist(artist_from_lidarr)
track.lidarr_processed = True
db.session.commit()
except Exception as e:
app.logger.error(f"Error monitoring artist {artist_from_lidarr.artistName}: {str(e)}")
if not artist_from_lidarr:
# if the artist isnt found by the external url, search by name
artist_from_lidarr = lidarr_client.get_artists_by_name(search_result, artists[artist].name)
for artist2 in artist_from_lidarr:
functions.apply_default_profile_and_root_folder(artist2)
try:
lidarr_client.monitor_artist(artist2)
track.lidarr_processed = True
db.session.commit()
except Exception as e:
app.logger.error(f"Error monitoring artist {artist2.artistName}: {str(e)}")
processed_items += 1
self.update_state(state=f'{processed_items}/{total_items}: {artist}', meta={'current': processed_items, 'total': total_items, 'percent': (processed_items / total_items) * 100})
else:
if tfp.album:
album_from_lidarr = None
search_result = lidarr_client.search(tfp.album.name)
# if the album isnt found by the external url, search by name
album_from_lidarr = lidarr_client.get_albums_by_name(search_result, tfp.album.name)
for album2 in album_from_lidarr:
functions.apply_default_profile_and_root_folder(album2.artist)
try:
lidarr_client.monitor_album(album2)
track.lidarr_processed = True
db.session.commit()
except Exception as e:
app.logger.error(f"Error monitoring album {album2.title}: {str(e)}")
processed_items += 1
self.update_state(state=f'{processed_items}/{total_items}: {tfp.album.name}', meta={'current': processed_items, 'total': total_items, 'percent': (processed_items / total_items) * 100})
app.logger.info(f'Requests sent to Lidarr. Total items: {total_items}')
return {'status': 'Request sent to Lidarr'}
finally:
release_lock(lock_key)
else:
app.logger.info('Lidarr API key or URL not set. Skipping request.')
release_lock(lock_key)
else:
app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'}
def find_best_match_from_jellyfin(track: Track):
app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}")
search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name))