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_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from psycopg2 import OperationalError from psycopg2 import OperationalError
import redis
import spotipy import spotipy
from spotipy.oauth2 import SpotifyClientCredentials from spotipy.oauth2 import SpotifyClientCredentials
from celery import Celery from celery import Celery
@@ -77,9 +78,13 @@ def make_celery(app):
'update_jellyfin_id_for_downloaded_tracks-schedule': { 'update_jellyfin_id_for_downloaded_tracks-schedule': {
'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks', 'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks',
'schedule': crontab(minute='*/10'), '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' celery.conf.timezone = 'UTC'
return celery return celery
@@ -89,17 +94,6 @@ device_id = f'JellyPlist_{'_'.join(sys.argv)}'
# Initialize Flask app # Initialize Flask app
app = Flask(__name__, template_folder="../templates", static_folder='../static') 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) app.config.from_object(Config)
@@ -114,6 +108,7 @@ logging.basicConfig(format=FORMAT)
Config.validate_env_vars() Config.validate_env_vars()
cache = Cache(app) 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 # Spotify, Jellyfin, and Spotdl setup
@@ -181,3 +176,8 @@ spotify_client = SpotifyClient('/jellyplist/open.spotify.com_cookies.txt')
spotify_client.authenticate() spotify_client.authenticate()
from .registry import MusicProviderRegistry from .registry import MusicProviderRegistry
MusicProviderRegistry.register_provider(spotify_client) 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 import requests
from app.classes import CombinedPlaylistData, CombinedTrackData from app.classes import CombinedPlaylistData, CombinedTrackData
from app.models import JellyfinUser, Playlist,Track 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 functools import wraps
from celery.result import AsyncResult from celery.result import AsyncResult
from app.providers import base from app.providers import base
from app.providers.base import PlaylistTrack from app.providers.base import PlaylistTrack
from app.registry.music_provider_registry import MusicProviderRegistry from app.registry.music_provider_registry import MusicProviderRegistry
from lidarr.classes import Album, Artist
from . import tasks from . import tasks
from jellyfin.objects import PlaylistMetadata from jellyfin.objects import PlaylistMetadata
from spotipy.exceptions import SpotifyException from spotipy.exceptions import SpotifyException
@@ -20,15 +21,20 @@ TASK_STATUS = {
'update_all_playlists_track_status': None, 'update_all_playlists_track_status': None,
'download_missing_tracks': None, 'download_missing_tracks': None,
'check_for_playlist_updates': 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 = [ LOCK_KEYS = [
'update_all_playlists_track_status_lock', 'update_all_playlists_track_status_lock',
'download_missing_tracks_lock', 'download_missing_tracks_lock',
'check_for_playlist_updates_lock', 'check_for_playlist_updates_lock',
'update_jellyfin_id_for_downloaded_tracks_lock' , 'update_jellyfin_id_for_downloaded_tracks_lock' ,
'full_update_jellyfin_ids' 'full_update_jellyfin_ids',
'request_lidarr_lock'
] ]
def manage_task(task_name): def manage_task(task_name):
@@ -46,6 +52,8 @@ def manage_task(task_name):
result = tasks.check_for_playlist_updates.delay() result = tasks.check_for_playlist_updates.delay()
elif task_name == 'update_jellyfin_id_for_downloaded_tracks': elif task_name == 'update_jellyfin_id_for_downloaded_tracks':
result = tasks.update_jellyfin_id_for_downloaded_tracks.delay() 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 TASK_STATUS[task_name] = result.id
return result.state, result.info if result.info else {} return result.state, result.info if result.info else {}
@@ -93,6 +101,41 @@ def prepPlaylistData(playlist: base.Playlist) -> Optional[CombinedPlaylistData]:
status=status 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) @cache.memoize(timeout=3600*24*10)
def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track: 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 # Many-to-Many relationship with Playlists
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks') playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
lidarr_processed = db.Column(db.Boolean(), default=False)
def __repr__(self): def __repr__(self):
return f'<Track {self.name}:{self.provider_track_id}>' return f'<Track {self.name}:{self.provider_track_id}>'

View File

@@ -1,8 +1,9 @@
from dbm import error
import json import json
import os import os
import re import re
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash, Blueprint, g 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.classes import AudioProfile, CombinedPlaylistData
from app.models import JellyfinUser,Playlist,Track from app.models import JellyfinUser,Playlist,Track
from celery.result import AsyncResult from celery.result import AsyncResult
@@ -12,6 +13,8 @@ from app.providers import base
from app.providers.base import MusicProviderClient from app.providers.base import MusicProviderClient
from app.providers.spotify import SpotifyClient from app.providers.spotify import SpotifyClient
from app.registry.music_provider_registry import MusicProviderRegistry from app.registry.music_provider_registry import MusicProviderRegistry
from lidarr.classes import Album, Artist
from lidarr.client import LidarrClient
from ..version import __version__ from ..version import __version__
from spotipy.exceptions import SpotifyException from spotipy.exceptions import SpotifyException
from collections import defaultdict from collections import defaultdict
@@ -37,6 +40,29 @@ def render_messages(response: Response) -> Response:
return 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') @app.route('/admin/tasks')
@functions.jellyfin_admin_required @functions.jellyfin_admin_required
@@ -370,43 +396,5 @@ def unlock_key():
@pl_bp.route('/test') @pl_bp.route('/test')
def test(): def test():
#return '' 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}

View File

@@ -4,7 +4,7 @@ import subprocess
from typing import List from typing import List
from sqlalchemy import insert 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.classes import AudioProfile
from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks
@@ -12,9 +12,10 @@ import os
import redis import redis
from celery import current_task,signals from celery import current_task,signals
from app.providers import base
from app.registry.music_provider_registry import MusicProviderRegistry 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): def acquire_lock(lock_name, expiration=60):
return redis_client.set(lock_name, "locked", ex=expiration, nx=True) 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.") app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is 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): def find_best_match_from_jellyfin(track: Track):
app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}") 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)) search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name))

View File

@@ -27,6 +27,10 @@ class Config:
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = os.getenv('SEARCH_JELLYFIN_BEFORE_DOWNLOAD',"true").lower() == 'true' 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' FIND_BEST_MATCH_USE_FFPROBE = os.getenv('FIND_BEST_MATCH_USE_FFPROBE','false').lower() == 'true'
SPOTIFY_COUNTRY_CODE = os.getenv('SPOTIFY_COUNTRY_CODE','DE') 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 specific configuration
SPOTDL_CONFIG = { SPOTDL_CONFIG = {
'cookie_file': '/jellyplist/cookies.txt', 'cookie_file': '/jellyplist/cookies.txt',

2
lidarr/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .client import LidarrClient
__all__ = ["LidarrClient"]

174
lidarr/classes.py Normal file
View 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
View 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]

View File

@@ -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 ###

View File

@@ -11,6 +11,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/admin/tasks">Tasks</a> <a class="nav-link" href="/admin/tasks">Tasks</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="/admin/lidarr">Lidarr</a>
</li>
</ul> </ul>
</div> </div>

View 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 %}