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))
|
||||
|
||||
@@ -27,6 +27,10 @@ class Config:
|
||||
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = os.getenv('SEARCH_JELLYFIN_BEFORE_DOWNLOAD',"true").lower() == 'true'
|
||||
FIND_BEST_MATCH_USE_FFPROBE = os.getenv('FIND_BEST_MATCH_USE_FFPROBE','false').lower() == 'true'
|
||||
SPOTIFY_COUNTRY_CODE = os.getenv('SPOTIFY_COUNTRY_CODE','DE')
|
||||
LIDARR_API_KEY = os.getenv('LIDARR_API_KEY','')
|
||||
LIDARR_URL = os.getenv('LIDARR_URL','')
|
||||
LIDARR_MONITOR_ARTISTS = os.getenv('LIDARR_MONITOR_ARTISTS','false').lower() == 'true'
|
||||
|
||||
# SpotDL specific configuration
|
||||
SPOTDL_CONFIG = {
|
||||
'cookie_file': '/jellyplist/cookies.txt',
|
||||
|
||||
2
lidarr/__init__.py
Normal file
2
lidarr/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .client import LidarrClient
|
||||
__all__ = ["LidarrClient"]
|
||||
174
lidarr/classes.py
Normal file
174
lidarr/classes.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
@dataclass
|
||||
class Image:
|
||||
url: str
|
||||
coverType: str
|
||||
extension: str
|
||||
remoteUrl: str
|
||||
|
||||
@dataclass
|
||||
class Link:
|
||||
url: str
|
||||
name: str
|
||||
|
||||
@dataclass
|
||||
class Ratings:
|
||||
votes: int
|
||||
value: float
|
||||
|
||||
@dataclass
|
||||
class AddOptions:
|
||||
monitor: str
|
||||
albumsToMonitor: List[str]
|
||||
monitored: bool
|
||||
searchForMissingAlbums: bool
|
||||
|
||||
@dataclass
|
||||
class Statistics:
|
||||
albumCount: int
|
||||
trackFileCount: int
|
||||
trackCount: int
|
||||
totalTrackCount: int
|
||||
sizeOnDisk: int
|
||||
percentOfTracks: float
|
||||
|
||||
@dataclass
|
||||
class Member:
|
||||
name: str
|
||||
instrument: str
|
||||
images: List[Image]
|
||||
|
||||
@dataclass
|
||||
class Artist:
|
||||
mbId: Optional[str] = None
|
||||
tadbId: Optional[int] = None
|
||||
discogsId: Optional[int] = None
|
||||
allMusicId: Optional[str] = None
|
||||
overview: str = ""
|
||||
artistType: str = ""
|
||||
disambiguation: str = ""
|
||||
links: List[Link] = field(default_factory=list)
|
||||
nextAlbum: str = ""
|
||||
lastAlbum: str = ""
|
||||
images: List[Image] = field(default_factory=list)
|
||||
members: List[Member] = field(default_factory=list)
|
||||
remotePoster: str = ""
|
||||
path: str = ""
|
||||
qualityProfileId: int = 0
|
||||
metadataProfileId: int = 0
|
||||
monitored: bool = False
|
||||
monitorNewItems: str = ""
|
||||
rootFolderPath: Optional[str] = None
|
||||
folder: str = ""
|
||||
genres: List[str] = field(default_factory=list)
|
||||
cleanName: str = ""
|
||||
sortName: str = ""
|
||||
tags: List[int] = field(default_factory=list)
|
||||
added: str = ""
|
||||
addOptions: Optional[AddOptions] = None
|
||||
ratings: Optional[Ratings] = None
|
||||
statistics: Optional[Statistics] = None
|
||||
status : str = ""
|
||||
ended : bool = False
|
||||
artistName : str = ""
|
||||
foreignArtistId : str = ""
|
||||
id : int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Media:
|
||||
mediumNumber: int
|
||||
mediumName: str
|
||||
mediumFormat: str
|
||||
|
||||
@dataclass
|
||||
class Release:
|
||||
id: int
|
||||
albumId: int
|
||||
foreignReleaseId: str
|
||||
title: str
|
||||
status: str
|
||||
duration: int
|
||||
trackCount: int
|
||||
media: List[Media]
|
||||
mediumCount: int
|
||||
disambiguation: str
|
||||
country: List[str]
|
||||
label: List[str]
|
||||
format: str
|
||||
monitored: bool
|
||||
|
||||
@dataclass
|
||||
class Album:
|
||||
id: int = 0
|
||||
title: str = ""
|
||||
disambiguation: str = ""
|
||||
overview: str = ""
|
||||
artistId: int = 0
|
||||
foreignAlbumId: str = ""
|
||||
monitored: bool = False
|
||||
anyReleaseOk: bool = False
|
||||
profileId: int = 0
|
||||
duration: int = 0
|
||||
albumType: str = ""
|
||||
secondaryTypes: List[str] = field(default_factory=list)
|
||||
mediumCount: int = 0
|
||||
ratings: Ratings = None
|
||||
releaseDate: str = ""
|
||||
releases: List[Release] = field(default_factory=list)
|
||||
genres: List[str] = field(default_factory=list)
|
||||
media: List[Media] = field(default_factory=list)
|
||||
artist: Artist = field(default_factory=Artist)
|
||||
images: List[Image] = field(default_factory=list)
|
||||
links: List[Link] = field(default_factory=list)
|
||||
lastSearchTime: str = ""
|
||||
statistics: Statistics = None
|
||||
addOptions: Optional[dict] = field(default_factory=dict)
|
||||
remoteCover: str = ""
|
||||
|
||||
@dataclass
|
||||
class RootFolder:
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
path: str = ""
|
||||
defaultMetadataProfileId: int = 0
|
||||
defaultQualityProfileId: int = 0
|
||||
defaultMonitorOption: str = ""
|
||||
defaultNewItemMonitorOption: str = ""
|
||||
defaultTags: List[int] = field(default_factory=list)
|
||||
accessible: bool = False
|
||||
freeSpace: int = 0
|
||||
totalSpace: int = 0
|
||||
|
||||
@dataclass
|
||||
class Quality:
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
quality: Quality = field(default_factory=Quality)
|
||||
items: List[str] = field(default_factory=list)
|
||||
allowed: bool = False
|
||||
|
||||
@dataclass
|
||||
class FormatItem:
|
||||
id: int = 0
|
||||
format: int = 0
|
||||
name: str = ""
|
||||
score: int = 0
|
||||
|
||||
@dataclass
|
||||
class QualityProfile:
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
upgradeAllowed: bool = False
|
||||
cutoff: int = 0
|
||||
items: List[Item] = field(default_factory=list)
|
||||
minFormatScore: int = 0
|
||||
cutoffFormatScore: int = 0
|
||||
formatItems: List[FormatItem] = field(default_factory=list)
|
||||
143
lidarr/client.py
Normal file
143
lidarr/client.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import json
|
||||
import re
|
||||
from flask import jsonify
|
||||
import requests
|
||||
from typing import List, Optional
|
||||
from .classes import Album, Artist, QualityProfile, RootFolder
|
||||
import logging
|
||||
l = logging.getLogger(__name__)
|
||||
|
||||
class LidarrClient:
|
||||
def __init__(self, base_url: str, api_token: str):
|
||||
self.base_url = base_url
|
||||
self.api_token = api_token
|
||||
self.headers = {
|
||||
'X-Api-Key': self.api_token
|
||||
}
|
||||
|
||||
def _get(self, endpoint: str, params: Optional[dict] = None):
|
||||
response = requests.get(f"{self.base_url}{endpoint}", headers=self.headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
def _post(self, endpoint: str, json: dict):
|
||||
response = requests.post(f"{self.base_url}{endpoint}", headers=self.headers, json=json)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _put(self, endpoint: str, json: dict):
|
||||
response = requests.put(f"{self.base_url}{endpoint}", headers=self.headers, json=json)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_album(self, album_id: int) -> Album:
|
||||
l.debug(f"Getting album {album_id}")
|
||||
data = self._get(f"/api/v1/album/{album_id}")
|
||||
return Album(**data)
|
||||
|
||||
def get_artist(self, artist_id: int) -> Artist:
|
||||
l.debug(f"Getting artist {artist_id}")
|
||||
data = self._get(f"/api/v1/artist/{artist_id}")
|
||||
return Artist(**data)
|
||||
|
||||
def search(self, term: str) -> List[object]:
|
||||
l.debug(f"Searching for {term}")
|
||||
data = self._get("/api/v1/search", params={"term": term})
|
||||
results = []
|
||||
for item in data:
|
||||
if 'artist' in item:
|
||||
results.append(Artist(**item['artist']))
|
||||
elif 'album' in item:
|
||||
results.append(Album(**item['album']))
|
||||
return results
|
||||
# A method which takes a List[object] end external URL as parameter, and returns the object from the List[object] which has the same external URL as the parameter.
|
||||
def get_object_by_external_url(self, objects: List[object], external_url: str) -> object:
|
||||
l.debug(f"Getting object by external URL {external_url}")
|
||||
# We need to check whether the external_url matches intl-[a-zA-Z]{2}\/ it has to be replaced by an empty string
|
||||
external_url = re.sub(r"intl-[a-zA-Z]{2}\/", "", external_url)
|
||||
for obj in objects:
|
||||
# object can either be an Album or an Artist, so it can be verified and casted
|
||||
if isinstance(obj, Album):
|
||||
for link in obj.links:
|
||||
if link['url'] == external_url:
|
||||
return obj
|
||||
elif isinstance(obj, Artist):
|
||||
for link in obj.links:
|
||||
if link['url'] == external_url:
|
||||
return obj
|
||||
|
||||
return None
|
||||
# A method to get all Albums from List[object] where the name equals the parameter name
|
||||
def get_albums_by_name(self, objects: List[object], name: str) -> List[Album]:
|
||||
l.debug(f"Getting albums by name {name}")
|
||||
albums = []
|
||||
for obj in objects:
|
||||
if isinstance(obj, Album) and obj.title == name:
|
||||
artist = Artist(**obj.artist)
|
||||
obj.artist = artist
|
||||
albums.append(obj)
|
||||
return albums
|
||||
|
||||
# a method to get all artists from List[object] where the name equals the parameter name
|
||||
def get_artists_by_name(self, objects: List[object], name: str) -> List[Artist]:
|
||||
l.debug(f"Getting artists by name {name}")
|
||||
artists = []
|
||||
for obj in objects:
|
||||
if isinstance(obj, Artist) and obj.artistName == name:
|
||||
artists.append(obj)
|
||||
return artists
|
||||
|
||||
def create_album(self, album: Album) -> Album:
|
||||
l.debug(f"Creating album {album.title}")
|
||||
json_artist = album.artist.__dict__
|
||||
album.artist = json_artist
|
||||
data = self._post("/api/v1/album", json=album.__dict__)
|
||||
return Album(**data)
|
||||
|
||||
def update_album(self, album_id: int, album: Album) -> Album:
|
||||
l.debug(f"Updating album {album_id}")
|
||||
json_artist = album.artist.__dict__
|
||||
album.artist = json_artist
|
||||
data = self._put(f"/api/v1/album/{album_id}", json=album.__dict__)
|
||||
return Album(**data)
|
||||
|
||||
def create_artist(self, artist: Artist) -> Artist:
|
||||
l.debug(f"Creating artist {artist.artistName}")
|
||||
data = self._post("/api/v1/artist", json=artist.__dict__)
|
||||
return Artist(**data)
|
||||
|
||||
def update_artist(self, artist_id: int, artist: Artist) -> Artist:
|
||||
l.debug(f"Updating artist {artist_id}")
|
||||
data = self._put(f"/api/v1/artist/{artist_id}", json=artist.__dict__)
|
||||
return Artist(**data)
|
||||
|
||||
# shorthand method to set artist to monitored
|
||||
def monitor_artist(self, artist: Artist):
|
||||
artist.monitored = True
|
||||
l.debug(f"Monitoring artist {artist.artistName}")
|
||||
if artist.id == 0:
|
||||
artist = self.create_artist(artist)
|
||||
else:
|
||||
self.update_artist(artist.id, artist)
|
||||
# shorthand method to set album to monitored
|
||||
def monitor_album(self, album: Album):
|
||||
album.monitored = True
|
||||
|
||||
l.debug(f"Monitoring album {album.title}")
|
||||
if album.id == 0:
|
||||
album = self.create_album(album)
|
||||
else:
|
||||
self.update_album(album.id, album)
|
||||
|
||||
# a method to query /api/v1/rootfolder and return a List[RootFolder]
|
||||
def get_root_folders(self) -> List[RootFolder]:
|
||||
l.debug("Getting root folders")
|
||||
data = self._get("/api/v1/rootfolder")
|
||||
return [RootFolder(**folder) for folder in data]
|
||||
|
||||
# a method to query /api/v1/qualityprofile and return a List[QualityProfile]
|
||||
def get_quality_profiles(self) -> List[QualityProfile]:
|
||||
l.debug("Getting quality profiles")
|
||||
data = self._get("/api/v1/qualityprofile")
|
||||
return [QualityProfile(**profile) for profile in data]
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add lidarr_processed flag
|
||||
|
||||
Revision ID: d13088ebddc5
|
||||
Revises: 18d056f49f59
|
||||
Create Date: 2024-12-03 22:44:21.287754
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd13088ebddc5'
|
||||
down_revision = '18d056f49f59'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('lidarr_processed', sa.Boolean(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||
batch_op.drop_column('lidarr_processed')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -11,6 +11,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/tasks">Tasks</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/lidarr">Lidarr</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
32
templates/admin/lidarr.html
Normal file
32
templates/admin/lidarr.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="container mt-5">
|
||||
<h1>Lidarr Configuration</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% else %}
|
||||
<form id="lidarrConfigForm" method="POST" action="{{ url_for('save_lidarr_config') }}">
|
||||
<div class="mb-3">
|
||||
<label for="qualityProfile" class="form-label">Default Quality Profile</label>
|
||||
<select class="form-select" id="qualityProfile" name="qualityProfile" required>
|
||||
{% for profile in quality_profiles %}
|
||||
<option value="{{ profile.id }}" {% if profile.id == current_quality_profile|int %}selected{% endif %}>{{ profile.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rootFolder" class="form-label">Default Root Folder</label>
|
||||
<select class="form-select" id="rootFolder" name="rootFolder" required>
|
||||
{% for folder in root_folders %}
|
||||
<option value="{{ folder.path }}" {% if folder.path == current_root_folder %}selected{% endif %}>{{ folder.path }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user