feat: added lidarr support
This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}>'
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
93
app/tasks.py
93
app/tasks.py
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user