Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
804b2bfe7e | ||
|
|
4c675e814c | ||
|
|
1509c37cd9 | ||
|
|
24ba4a0b70 | ||
|
|
7a7ef8d7bc | ||
|
|
be9a72701e | ||
|
|
c5de8d9841 | ||
|
|
671b813e6c | ||
|
|
b29a7bbbe3 | ||
|
|
d4c3a67249 | ||
|
|
4be027bb35 | ||
|
|
39e44e0606 | ||
|
|
8c30c6183d | ||
|
|
9e7f331c49 | ||
|
|
bb856c96a1 | ||
|
|
14a8fdc127 | ||
|
|
1d24972ea0 | ||
|
|
7b15a8b53d | ||
|
|
d41b901649 | ||
|
|
f7372fed38 | ||
|
|
89a1bc21be | ||
|
|
360c4e5b7a | ||
|
|
d69ac22998 | ||
|
|
d9dabd0a9c | ||
|
|
087d44836f | ||
|
|
1ee0087b8f | ||
|
|
e2d37b77b0 | ||
|
|
86f5bf118a | ||
|
|
e43d36dd24 | ||
|
|
30ea28ed6e | ||
|
|
07503a8003 | ||
|
|
9a5adfaa5b | ||
|
|
b9ad5be7bc | ||
|
|
b861a1a8f4 | ||
|
|
87791cf21d | ||
|
|
883294d74e | ||
|
|
2b3c400c10 | ||
|
|
00ba693fb9 | ||
|
|
cd498988ae | ||
|
|
3a26c054a0 | ||
|
|
d5aee793a0 | ||
|
|
6b78b90ee7 | ||
|
|
d70c3b3913 | ||
|
|
e428629928 | ||
|
|
94d401a99f | ||
|
|
56aaec603b | ||
|
|
aa718eb628 | ||
|
|
33ccbc470c | ||
|
|
3c25cd70ea | ||
|
|
25e51f1ef2 | ||
|
|
7232b3223d | ||
|
|
f81188f7e3 | ||
|
|
cbe172ff1f | ||
|
|
d8d677bc1b | ||
|
|
18dc6e18af | ||
|
|
3bcecfe6fd | ||
|
|
1867f982a0 | ||
|
|
b60a882dab | ||
|
|
c6eb95112e | ||
|
|
c9363104ec | ||
|
|
dc0165957a | ||
|
|
ddf73b77db | ||
|
|
da2b725b22 |
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.6
|
||||
current_version = 0.1.8
|
||||
commit = True
|
||||
tag = True
|
||||
|
||||
|
||||
17
.github/workflows/main.yml
vendored
17
.github/workflows/main.yml
vendored
@@ -37,6 +37,7 @@ jobs:
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ env.VERSION }}
|
||||
@@ -55,14 +56,14 @@ jobs:
|
||||
echo "CHANGELOG_CONTENT=No changelog available for this release." >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Generate auto-generated release notes
|
||||
#- name: Generate Auto Release Notes
|
||||
# id: release_notes
|
||||
# run: |
|
||||
# auto_notes=$(gh release create ${{ env.VERSION }} --generate-notes --prerelease --dry-run --json body --jq .body)
|
||||
# echo "AUTO_RELEASE_NOTES<<EOF" >> $GITHUB_ENV
|
||||
# echo "$auto_notes" >> $GITHUB_ENV
|
||||
# echo "EOF" >> $GITHUB_ENV
|
||||
# # Generate auto-generated release notes
|
||||
# - name: Generate Auto Release Notes
|
||||
# id: release_notes
|
||||
# run: |
|
||||
# auto_notes=$(gh release create ${{ env.VERSION }} --generate-notes --prerelease --json body --jq .body)
|
||||
# echo "AUTO_RELEASE_NOTES<<EOF" >> $GITHUB_ENV
|
||||
# echo "$auto_notes" >> $GITHUB_ENV
|
||||
# echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
# Create a release on GitHub
|
||||
- name: Create GitHub Release
|
||||
|
||||
23
.github/workflows/manual-build.yml
vendored
23
.github/workflows/manual-build.yml
vendored
@@ -18,12 +18,6 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
- name: Extract Version
|
||||
id: extract_version
|
||||
run: |
|
||||
version=$(python3 -c "import version; print(f'dev-{version.__version__}')")
|
||||
echo "VERSION=$version" >> $GITHUB_ENV
|
||||
|
||||
# Extract branch name and latest commit SHA
|
||||
- name: Extract branch name and commit SHA
|
||||
id: branch_info
|
||||
@@ -35,6 +29,11 @@ jobs:
|
||||
- name: Create DEV_BUILD file
|
||||
run: |
|
||||
echo "${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}" > DEV_BUILD
|
||||
- name: Extract Version
|
||||
id: extract_version
|
||||
run: |
|
||||
version=$(python3 -c "import version; print(f'${{ env.BRANCH_NAME}}-{version.__version__}')")
|
||||
echo "VERSION=$version" >> $GITHUB_ENV
|
||||
|
||||
# Set up Docker
|
||||
- name: Set up Docker Buildx
|
||||
@@ -53,9 +52,19 @@ jobs:
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||
ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }}
|
||||
ghcr.io/${{ github.repository }}:dev
|
||||
ghcr.io/${{ github.repository }}:${{ env.VERSION }}
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.VERSION }}
|
||||
name: Dev Release ${{ env.VERSION }}
|
||||
generate_release_notes: true
|
||||
make_latest: false
|
||||
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -73,6 +73,9 @@ coverage/
|
||||
# macOS
|
||||
.DS_Store
|
||||
.cache
|
||||
cookies*.txt
|
||||
*cookies*.txt
|
||||
*.code-workspace
|
||||
set_env.sh
|
||||
set_env.sh
|
||||
notes.md
|
||||
DEV_BUILD
|
||||
payload.json
|
||||
|
||||
@@ -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
|
||||
@@ -21,6 +22,7 @@ from flask_caching import Cache
|
||||
from .version import __version__
|
||||
|
||||
|
||||
|
||||
def check_db_connection(db_uri, retries=5, delay=5):
|
||||
"""
|
||||
Check if the database is reachable.
|
||||
@@ -76,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='50')
|
||||
}
|
||||
|
||||
celery.conf.timezone = 'UTC'
|
||||
return celery
|
||||
@@ -88,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)
|
||||
@@ -113,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
|
||||
@@ -160,7 +156,32 @@ celery.set_default()
|
||||
|
||||
app.logger.info(f'Jellyplist {__version__}{read_dev_build_file()} started')
|
||||
app.logger.debug(f"Debug logging active")
|
||||
from app import routes
|
||||
from app import jellyfin_routes, tasks
|
||||
if "worker" in sys.argv:
|
||||
tasks.release_lock("download_missing_tracks_lock")
|
||||
|
||||
from app.routes import pl_bp, routes, jellyfin_routes
|
||||
app.register_blueprint(pl_bp)
|
||||
|
||||
from app import filters # Import the filters dictionary
|
||||
|
||||
# Register all filters
|
||||
for name, func in filters.filters.items():
|
||||
app.jinja_env.filters[name] = func
|
||||
|
||||
|
||||
from .providers import SpotifyClient
|
||||
if app.config['SPOTIFY_COOKIE_FILE']:
|
||||
if os.path.exists(app.config['SPOTIFY_COOKIE_FILE']):
|
||||
spotify_client = SpotifyClient(app.config['SPOTIFY_COOKIE_FILE'])
|
||||
else:
|
||||
app.logger.error(f"Cookie file {app.config['SPOTIFY_COOKIE_FILE']} does not exist. Exiting.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
spotify_client = SpotifyClient()
|
||||
|
||||
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'])
|
||||
|
||||
116
app/classes.py
Normal file
116
app/classes.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import json
|
||||
from flask import current_app as app # Adjust this based on your app's structure
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class AudioProfile:
|
||||
def __init__(self, path: str, bitrate: int = 0, sample_rate: int = 0, channels: int = 0) -> None:
|
||||
"""
|
||||
Initialize an AudioProfile instance.
|
||||
|
||||
Args:
|
||||
path (str): The file path of the audio file.
|
||||
bitrate (int): The audio bitrate in kbps. Default is 0.
|
||||
sample_rate (int): The sample rate in Hz. Default is 0.
|
||||
channels (int): The number of audio channels. Default is 0.
|
||||
"""
|
||||
self.path: str = path
|
||||
self.bitrate: int = bitrate # in kbps
|
||||
self.sample_rate: int = sample_rate # in Hz
|
||||
self.channels: int = channels
|
||||
|
||||
@staticmethod
|
||||
def analyze_audio_quality_with_ffprobe(filepath: str) -> Optional['AudioProfile']:
|
||||
"""
|
||||
Static method to analyze audio quality using ffprobe and return an AudioProfile instance.
|
||||
|
||||
Args:
|
||||
filepath (str): Path to the audio file to analyze.
|
||||
|
||||
Returns:
|
||||
Optional[AudioProfile]: An instance of AudioProfile if analysis is successful, None otherwise.
|
||||
"""
|
||||
try:
|
||||
# ffprobe command to extract bitrate, sample rate, and channel count
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error', '-select_streams', 'a:0',
|
||||
'-show_entries', 'stream=bit_rate,sample_rate,channels',
|
||||
'-show_format',
|
||||
'-of', 'json', filepath
|
||||
]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode != 0:
|
||||
app.logger.error(f"ffprobe error for file {filepath}: {result.stderr}")
|
||||
return None
|
||||
|
||||
# Parse ffprobe output
|
||||
data = json.loads(result.stdout)
|
||||
stream = data.get('streams', [{}])[0]
|
||||
bitrate: int = int(stream.get('bit_rate', 0)) // 1000 # Convert to kbps
|
||||
if bitrate == 0: # Fallback if no bit_rate in stream
|
||||
bitrate = int(data.get('format').get('bit_rate', 0)) // 1000
|
||||
sample_rate: int = int(stream.get('sample_rate', 0)) # Hz
|
||||
channels: int = int(stream.get('channels', 0))
|
||||
|
||||
# Create an AudioProfile instance
|
||||
return AudioProfile(filepath, bitrate, sample_rate, channels)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error analyzing audio quality with ffprobe: {str(e)}")
|
||||
return None
|
||||
|
||||
def compute_quality_score(self) -> int:
|
||||
"""
|
||||
Compute a quality score based on bitrate, sample rate, and channels.
|
||||
|
||||
Returns:
|
||||
int: The computed quality score.
|
||||
"""
|
||||
return self.bitrate + (self.sample_rate // 1000) + (self.channels * 10)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Representation of the AudioProfile instance.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the AudioProfile instance.
|
||||
"""
|
||||
return (f"AudioProfile(path='{self.path}', bitrate={self.bitrate} kbps, "
|
||||
f"sample_rate={self.sample_rate} Hz, channels={self.channels})")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombinedTrackData():
|
||||
# Combines a track from a provider with a track from the db
|
||||
title: str
|
||||
artist: List[str]
|
||||
url: List[str]
|
||||
duration: str
|
||||
downloaded: bool
|
||||
filesystem_path: Optional[str]
|
||||
jellyfin_id: Optional[str]
|
||||
provider_id: str
|
||||
provider_track_id: str
|
||||
duration_ms: int
|
||||
download_status: Optional[str]
|
||||
provider: str
|
||||
|
||||
@dataclass
|
||||
class CombinedPlaylistData():
|
||||
name: str
|
||||
description: Optional[str]
|
||||
image: str
|
||||
url: str
|
||||
id: str
|
||||
jellyfin_id: Optional[str]
|
||||
can_add: bool
|
||||
can_remove: bool
|
||||
last_updated: Optional[datetime]
|
||||
last_changed: Optional[datetime]
|
||||
tracks_available: int
|
||||
track_count: int
|
||||
tracks_linked: int
|
||||
percent_available: float
|
||||
status: str
|
||||
99
app/filters.py
Normal file
99
app/filters.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import os
|
||||
import re
|
||||
from markupsafe import Markup
|
||||
|
||||
from app.classes import AudioProfile
|
||||
from app import app, functions, read_dev_build_file
|
||||
from .version import __version__
|
||||
|
||||
filters = {}
|
||||
|
||||
def template_filter(name):
|
||||
"""Decorator to register a Jinja2 filter."""
|
||||
def decorator(func):
|
||||
filters[name] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
@template_filter('highlight')
|
||||
def highlight_search(text: str, search_query: str) -> Markup:
|
||||
if not search_query:
|
||||
return text
|
||||
|
||||
search_query_escaped = re.escape(search_query)
|
||||
|
||||
# If the text matches the search query exactly, apply a different highlight
|
||||
if text.strip().lower() == search_query.strip().lower():
|
||||
return Markup(f'<mark style="background-color: lightgreen; color: black;">{text}</mark>')
|
||||
|
||||
# Highlight partial matches in the text
|
||||
highlighted_text = re.sub(
|
||||
f"({search_query_escaped})",
|
||||
r'<mark>\1</mark>',
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
return Markup(highlighted_text)
|
||||
|
||||
|
||||
@template_filter('audioprofile')
|
||||
def audioprofile(text: str, path: str) -> Markup:
|
||||
if not path or not os.path.exists(path):
|
||||
return Markup() # Return the original text if the file does not exist
|
||||
|
||||
# Create the AudioProfile instance using the static method
|
||||
audio_profile = AudioProfile.analyze_audio_quality_with_ffprobe(path)
|
||||
if not audio_profile:
|
||||
return Markup(f"<span style='color: red;'>ERROR</span>")
|
||||
|
||||
# Create a nicely formatted HTML representation
|
||||
audio_profile_html = (
|
||||
f"<strong>Bitrate:</strong> {audio_profile.bitrate} kbps<br>"
|
||||
f"<strong>Sample Rate:</strong> {audio_profile.sample_rate} Hz<br>"
|
||||
f"<strong>Channels:</strong> {audio_profile.channels}<br>"
|
||||
f"<strong>Quality Score:</strong> {audio_profile.compute_quality_score()}"
|
||||
|
||||
)
|
||||
return Markup(audio_profile_html)
|
||||
|
||||
@template_filter('version_check')
|
||||
def version_check(version: str) -> Markup:
|
||||
version = f"{__version__}{read_dev_build_file()}"
|
||||
# if version contains a dash and the text after the dash is LOCAL, return version as a blue badge
|
||||
if app.config['CHECK_FOR_UPDATES']:
|
||||
if '-' in version and version.split('-')[1] == 'LOCAL':
|
||||
return Markup(f"<span class='badge rounded-pill bg-primary'>{version}</span>")
|
||||
# else if the version string contains a dash and the text after the dash is not LOCAL, check whether it contains another dash (like in e.g. v0.1.7-dev-89a1bc2) and split both parts
|
||||
elif '-' in version and version.split('-')[1] != 'LOCAL' :
|
||||
branch, commit_sha = version.split('-')[1], version.split('-')[2]
|
||||
nra,url = functions.get_latest_dev_releases(branch_name = branch, commit_sha = commit_sha)
|
||||
if nra:
|
||||
return Markup(f"<a href='{url}' target='_blank'><span class='badge rounded-pill text-bg-warning btn-pulsing' data-bs-toggle='tooltip' title='An update for the {branch} branch is available.'>{version}</span></a>")
|
||||
else:
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-secondary'>{version}</span>")
|
||||
else:
|
||||
nra,url = functions.get_latest_release(version)
|
||||
if nra:
|
||||
return Markup(f"<a href='{url}' target='_blank'><span class='badge rounded-pill text-bg-warning btn-pulsing' data-bs-toggle='tooltip' title='An update is available.'>{version}</span></a>")
|
||||
|
||||
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-primary'>{version}</span>")
|
||||
else:
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-info'>{version}</span>")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@template_filter('jellyfin_link')
|
||||
def jellyfin_link(jellyfin_id: str) -> Markup:
|
||||
|
||||
jellyfin_server_url = app.config.get('JELLYFIN_SERVER_URL')
|
||||
if not jellyfin_server_url:
|
||||
return Markup(f"<span style='color: red;'>JELLYFIN_SERVER_URL not configured</span>")
|
||||
|
||||
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
|
||||
return Markup(f'<a href="{link}" target="_blank">{jellyfin_id}</a>')
|
||||
386
app/functions.py
386
app/functions.py
@@ -1,142 +1,102 @@
|
||||
from flask import flash, redirect, session, url_for
|
||||
import json
|
||||
from typing import List, Optional
|
||||
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.tasks import download_missing_tracks,check_for_playlist_updates, update_all_playlists_track_status, update_jellyfin_id_for_downloaded_tracks
|
||||
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
|
||||
|
||||
import re
|
||||
|
||||
TASK_STATUS = {
|
||||
'update_all_playlists_track_status': None,
|
||||
'download_missing_tracks': None,
|
||||
'check_for_playlist_updates': None,
|
||||
'update_jellyfin_id_for_downloaded_tracks' : 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'
|
||||
|
||||
]
|
||||
|
||||
def manage_task(task_name):
|
||||
task_id = TASK_STATUS.get(task_name)
|
||||
|
||||
if task_id:
|
||||
result = AsyncResult(task_id)
|
||||
if result.state in ['PENDING', 'STARTED']:
|
||||
return result.state, result.info if result.info else {}
|
||||
if task_name == 'update_all_playlists_track_status':
|
||||
result = update_all_playlists_track_status.delay()
|
||||
elif task_name == 'download_missing_tracks':
|
||||
result = download_missing_tracks.delay()
|
||||
elif task_name == 'check_for_playlist_updates':
|
||||
result = check_for_playlist_updates.delay()
|
||||
elif task_name == 'update_jellyfin_id_for_downloaded_tracks':
|
||||
result = update_jellyfin_id_for_downloaded_tracks.delay()
|
||||
|
||||
TASK_STATUS[task_name] = result.id
|
||||
return result.state, result.info if result.info else {}
|
||||
|
||||
|
||||
def prepPlaylistData(data):
|
||||
playlists = []
|
||||
def prepPlaylistData(playlist: base.Playlist) -> Optional[CombinedPlaylistData]:
|
||||
jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||
if not jellyfin_user:
|
||||
app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again")
|
||||
return None
|
||||
if not data.get('playlists'):
|
||||
|
||||
data['playlists']= {}
|
||||
data['playlists']['items'] = [data]
|
||||
|
||||
for playlist_data in data['playlists']['items']:
|
||||
# Fetch the playlist from the database if it exists
|
||||
db_playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_data['id']).first()
|
||||
|
||||
if db_playlist:
|
||||
# If the playlist is in the database, use the stored values
|
||||
if isinstance(playlist_data['tracks'],list):
|
||||
track_count = len(playlist_data['tracks'] )
|
||||
else:
|
||||
track_count = playlist_data['tracks']['total'] or 0
|
||||
tracks_available = db_playlist.tracks_available or 0
|
||||
tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) or 0
|
||||
percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0
|
||||
# Fetch the playlist from the database if it exists
|
||||
db_playlist : Playlist = Playlist.query.filter_by(provider_playlist_id=playlist.id).first() if playlist else None
|
||||
|
||||
# Initialize default values
|
||||
track_count = db_playlist.track_count if db_playlist else 0
|
||||
tracks_available = db_playlist.tracks_available if db_playlist else 0
|
||||
tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) if db_playlist else 0
|
||||
percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0
|
||||
|
||||
# Determine playlist status
|
||||
if tracks_available == track_count and track_count > 0:
|
||||
status = 'green' # Fully available
|
||||
elif tracks_available > 0:
|
||||
status = 'yellow' # Partially available
|
||||
else:
|
||||
status = 'red' # Not available
|
||||
|
||||
# Build and return the PlaylistResponse object
|
||||
return CombinedPlaylistData(
|
||||
name=playlist.name,
|
||||
description=playlist.description,
|
||||
image=playlist.images[0].url if playlist.images else '/static/images/placeholder.png',
|
||||
url=playlist.external_urls[0].url if playlist.external_urls else '',
|
||||
id=playlist.id,
|
||||
jellyfin_id=db_playlist.jellyfin_id if db_playlist else '',
|
||||
can_add=(db_playlist not in jellyfin_user.playlists) if db_playlist else True,
|
||||
can_remove=(db_playlist in jellyfin_user.playlists) if db_playlist else False,
|
||||
last_updated=db_playlist.last_updated if db_playlist else None,
|
||||
last_changed=db_playlist.last_changed if db_playlist else None,
|
||||
tracks_available=tracks_available,
|
||||
track_count=track_count,
|
||||
tracks_linked=tracks_linked,
|
||||
percent_available=percent_available,
|
||||
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
|
||||
|
||||
# Determine playlist status
|
||||
if tracks_available == track_count and track_count > 0:
|
||||
status = 'green' # Fully available
|
||||
elif tracks_available > 0:
|
||||
status = 'yellow' # Partially available
|
||||
else:
|
||||
status = 'red' # Not available
|
||||
else:
|
||||
# If the playlist is not in the database, initialize with 0
|
||||
track_count = 0
|
||||
tracks_available = 0
|
||||
tracks_linked = 0
|
||||
percent_available = 0
|
||||
status = 'red' # Not requested yet
|
||||
# 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
|
||||
|
||||
# Append playlist data to the list
|
||||
playlists.append({
|
||||
'name': playlist_data['name'],
|
||||
'description': playlist_data['description'],
|
||||
'image': playlist_data['images'][0]['url'] if playlist_data['images'] else 'default-image.jpg',
|
||||
'url': playlist_data['external_urls']['spotify'],
|
||||
'id': playlist_data['id'],
|
||||
'jellyfin_id': db_playlist.jellyfin_id if db_playlist else '',
|
||||
'can_add': (db_playlist not in jellyfin_user.playlists) if db_playlist else True,
|
||||
'can_remove' : (db_playlist in jellyfin_user.playlists) if db_playlist else False,
|
||||
'last_updated':db_playlist.last_updated if db_playlist else '',
|
||||
'last_changed':db_playlist.last_changed if db_playlist else '',
|
||||
'tracks_available': tracks_available,
|
||||
'track_count': track_count,
|
||||
'tracks_linked': tracks_linked,
|
||||
'percent_available': percent_available,
|
||||
'status': status # Red, yellow, or green based on availability
|
||||
})
|
||||
|
||||
return playlists
|
||||
|
||||
def get_cached_spotify_playlists(playlist_ids):
|
||||
"""
|
||||
Fetches multiple Spotify playlists by their IDs, utilizing individual caching.
|
||||
|
||||
:param playlist_ids: A list of Spotify playlist IDs.
|
||||
:return: A dictionary containing the fetched playlists.
|
||||
"""
|
||||
spotify_data = {'playlists': {'items': []}}
|
||||
|
||||
for playlist_id in playlist_ids:
|
||||
playlist_data = get_cached_spotify_playlist(playlist_id)
|
||||
if playlist_data:
|
||||
spotify_data['playlists']['items'].append(playlist_data)
|
||||
else:
|
||||
app.logger.warning(f"Playlist data for ID {playlist_id} could not be retrieved.")
|
||||
|
||||
return spotify_data
|
||||
|
||||
@cache.memoize(timeout=3600)
|
||||
def get_cached_spotify_playlist(playlist_id):
|
||||
"""
|
||||
Fetches a Spotify playlist by its ID, utilizing caching to minimize API calls.
|
||||
|
||||
:param playlist_id: The Spotify playlist ID.
|
||||
:return: Playlist data as a dictionary, or None if an error occurs.
|
||||
"""
|
||||
try:
|
||||
playlist_data = sp.playlist(playlist_id) # Fetch data from Spotify API
|
||||
return playlist_data
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching playlist {playlist_id} from Spotify: {str(e)}")
|
||||
return None
|
||||
@cache.memoize(timeout=3600*24*10)
|
||||
def get_cached_spotify_track(track_id):
|
||||
@cache.memoize(timeout=3600*24*10)
|
||||
def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track:
|
||||
"""
|
||||
Fetches a Spotify track by its ID, utilizing caching to minimize API calls.
|
||||
|
||||
@@ -144,80 +104,28 @@ def get_cached_spotify_track(track_id):
|
||||
:return: Track data as a dictionary, or None if an error occurs.
|
||||
"""
|
||||
try:
|
||||
track_data = sp.track(track_id=track_id) # Fetch data from Spotify API
|
||||
# get the provider from the registry
|
||||
provider = MusicProviderRegistry.get_provider(provider_id)
|
||||
track_data = provider.get_track(track_id)
|
||||
return track_data
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching track {track_id} from Spotify: {str(e)}")
|
||||
app.logger.error(f"Error fetching track {track_id} from {provider_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def prepAlbumData(data):
|
||||
items = []
|
||||
for item in data['albums']['items']:
|
||||
items.append({
|
||||
'name': item['name'],
|
||||
'description': f"Released: {item['release_date']}",
|
||||
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
|
||||
'url': item['external_urls']['spotify'],
|
||||
'id' : item['id'],
|
||||
'can_add' : False
|
||||
})
|
||||
return items
|
||||
|
||||
def prepArtistData(data):
|
||||
items = []
|
||||
for item in data['artists']['items']:
|
||||
items.append({
|
||||
'name': item['name'],
|
||||
'description': f"Popularity: {item['popularity']}",
|
||||
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
|
||||
'url': item['external_urls']['spotify'],
|
||||
'id' : item['id'],
|
||||
'can_add' : False
|
||||
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
|
||||
def getFeaturedPlaylists(country,offset):
|
||||
playlists_data = sp.featured_playlists(country=country, limit=16, offset=offset)
|
||||
|
||||
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],'Featured Playlists'
|
||||
|
||||
def getCategoryPlaylists(category,offset):
|
||||
playlists_data = sp.category_playlists(category_id=category, limit=16, offset=offset)
|
||||
|
||||
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],f"Category {playlists_data['message']}"
|
||||
|
||||
def getCategories(country,offset):
|
||||
categories_data = sp.categories(limit=16, offset= offset)
|
||||
categories = []
|
||||
|
||||
for cat in categories_data['categories']['items']:
|
||||
categories.append({
|
||||
'name': cat['name'],
|
||||
'description': '',
|
||||
'image': cat['icons'][0]['url'] if cat['icons'] else 'default-image.jpg',
|
||||
'url': f"/playlists?cat={cat['id']}",
|
||||
'id' : cat['id'],
|
||||
'type':'category'
|
||||
})
|
||||
return categories, categories_data['categories']['total'],'Browse Categories'
|
||||
|
||||
def get_tracks_for_playlist(data):
|
||||
results = data
|
||||
tracks = []
|
||||
def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]:
|
||||
is_admin = session.get('is_admin', False)
|
||||
tracks = []
|
||||
|
||||
for idx, item in enumerate(results['tracks']):
|
||||
track_data = item['track']
|
||||
for idx, item in enumerate(data):
|
||||
track_data = item.track
|
||||
if track_data:
|
||||
duration_ms = track_data['duration_ms']
|
||||
duration_ms = track_data.duration_ms
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
|
||||
track_db = Track.query.filter_by(spotify_track_id=track_data['id']).first()
|
||||
# Query track from the database
|
||||
track_db = Track.query.filter_by(provider_track_id=track_data.id).first()
|
||||
|
||||
if track_db:
|
||||
downloaded = track_db.downloaded
|
||||
@@ -230,40 +138,26 @@ def get_tracks_for_playlist(data):
|
||||
jellyfin_id = None
|
||||
download_status = None
|
||||
|
||||
tracks.append({
|
||||
'title': track_data['name'],
|
||||
'artist': ', '.join([artist['name'] for artist in track_data['artists']]),
|
||||
'url': track_data['external_urls']['spotify'],
|
||||
'duration': f'{minutes}:{seconds:02d}',
|
||||
'preview_url': track_data['preview_url'],
|
||||
'downloaded': downloaded,
|
||||
'filesystem_path': filesystem_path,
|
||||
'jellyfin_id': jellyfin_id,
|
||||
'spotify_id': track_data['id'],
|
||||
'duration_ms': duration_ms,
|
||||
'download_status' : download_status
|
||||
})
|
||||
|
||||
# Append a TrackResponse object
|
||||
tracks.append(
|
||||
CombinedTrackData(
|
||||
title=track_data.name,
|
||||
artist=[a.name for a in track_data.artists],
|
||||
url=[url.url for url in track_data.external_urls],
|
||||
duration=f'{minutes}:{seconds:02d}',
|
||||
downloaded=downloaded,
|
||||
filesystem_path=filesystem_path,
|
||||
jellyfin_id=jellyfin_id,
|
||||
provider_track_id=track_data.id,
|
||||
provider_id = provider_id,
|
||||
duration_ms=duration_ms,
|
||||
download_status=download_status,
|
||||
provider=provider_id
|
||||
)
|
||||
)
|
||||
|
||||
return tracks
|
||||
|
||||
def get_full_playlist_data(playlist_id):
|
||||
playlist_data = get_cached_spotify_playlist(playlist_id)
|
||||
all_tracks = []
|
||||
|
||||
offset = 0
|
||||
while True:
|
||||
response = sp.playlist_items(playlist_id, offset=offset, limit=100)
|
||||
items = response['items']
|
||||
all_tracks.extend(items)
|
||||
|
||||
if len(items) < 100:
|
||||
break
|
||||
offset += 100
|
||||
|
||||
playlist_data['tracks'] = all_tracks
|
||||
playlist_data['prepped_data'] = prepPlaylistData(playlist_data)
|
||||
return playlist_data
|
||||
|
||||
def jellyfin_login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
@@ -285,13 +179,13 @@ def jellyfin_admin_required(f):
|
||||
|
||||
|
||||
|
||||
def update_playlist_metadata(playlist,spotify_playlist_data):
|
||||
def update_playlist_metadata(playlist,provider_playlist_data : base.Playlist):
|
||||
metadata = PlaylistMetadata()
|
||||
metadata.Tags = [f'jellyplist:playlist:{playlist.id}',f'{playlist.tracks_available} of {playlist.track_count} Tracks available']
|
||||
metadata.Overview = spotify_playlist_data['description']
|
||||
metadata.Overview = provider_playlist_data.description
|
||||
jellyfin.update_playlist_metadata(session_token=_get_api_token(),playlist_id=playlist.jellyfin_id,updates= metadata , user_id= _get_admin_id())
|
||||
if spotify_playlist_data['images'] != None:
|
||||
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,spotify_image_url= spotify_playlist_data['images'][0]['url'])
|
||||
if provider_playlist_data.images:
|
||||
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,provider_image_url= provider_playlist_data.images[0].url)
|
||||
|
||||
|
||||
|
||||
@@ -300,7 +194,7 @@ def _get_token_from_sessioncookie() -> str:
|
||||
def _get_api_token() -> str:
|
||||
#return app.config['JELLYFIN_ACCESS_TOKEN']
|
||||
return jellyfin_admin_token
|
||||
def _get_logged_in_user():
|
||||
def _get_logged_in_user() -> JellyfinUser:
|
||||
return JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||
def _get_admin_id():
|
||||
#return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id
|
||||
@@ -312,4 +206,42 @@ def get_longest_substring(input_string):
|
||||
pattern = "[" + re.escape("".join(special_chars)) + "]"
|
||||
substrings = re.split(pattern, input_string)
|
||||
longest_substring = max(substrings, key=len, default="")
|
||||
return longest_substring
|
||||
return longest_substring
|
||||
|
||||
@cache.memoize(timeout=3600*2)
|
||||
def get_latest_dev_releases(branch_name :str, commit_sha : str):
|
||||
try:
|
||||
response = requests.get('https://api.github.com/repos/kamilkosek/jellyplist/releases')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
latest_release = None
|
||||
for release in data:
|
||||
if branch_name in release['tag_name']:
|
||||
if latest_release is None or release['published_at'] > latest_release['published_at']:
|
||||
latest_release = release
|
||||
|
||||
if latest_release:
|
||||
response = requests.get(f'https://api.github.com/repos/kamilkosek/jellyplist/git/ref/tags/{latest_release["tag_name"]}')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if commit_sha != data['object']['sha'][:7]:
|
||||
return True, latest_release['html_url']
|
||||
|
||||
|
||||
return False, ''
|
||||
except requests.exceptions.RequestException as e:
|
||||
app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||
return False, ''
|
||||
|
||||
@cache.memoize(timeout=3600*2)
|
||||
def get_latest_release(tag_name :str):
|
||||
try:
|
||||
response = requests.get('https://api.github.com/repos/kamilkosek/jellyplist/releases/latest')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data['tag_name'] != tag_name:
|
||||
return True, data['html_url']
|
||||
return False, ''
|
||||
except requests.exceptions.RequestException as e:
|
||||
app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||
return False,''
|
||||
@@ -22,8 +22,8 @@ user_playlists = db.Table('user_playlists',
|
||||
class Playlist(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
spotify_playlist_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
spotify_uri = db.Column(db.String(120), unique=True, nullable=False)
|
||||
provider_playlist_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
provider_uri = db.Column(db.String(120), unique=True, nullable=False)
|
||||
|
||||
# Relationship with Tracks
|
||||
tracks = db.relationship('Track', secondary='playlist_tracks', back_populates='playlists')
|
||||
@@ -35,9 +35,10 @@ class Playlist(db.Model):
|
||||
snapshot_id = db.Column(db.String(120), nullable=True)
|
||||
# Many-to-Many relationship with JellyfinUser
|
||||
users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists')
|
||||
provider_id = db.Column(db.String(20))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Playlist {self.name}:{self.spotify_playlist_id}>'
|
||||
return f'<Playlist {self.name}:{self.provider_playlist_id}>'
|
||||
|
||||
# Association table between Playlists and Tracks
|
||||
playlist_tracks = db.Table('playlist_tracks',
|
||||
@@ -50,14 +51,16 @@ playlist_tracks = db.Table('playlist_tracks',
|
||||
class Track(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
spotify_track_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
spotify_uri = db.Column(db.String(120), unique=True, nullable=False)
|
||||
provider_track_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
provider_uri = db.Column(db.String(120), unique=True, nullable=False)
|
||||
downloaded = db.Column(db.Boolean())
|
||||
filesystem_path = db.Column(db.String(), nullable=True)
|
||||
jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field
|
||||
download_status = db.Column(db.String(2048), nullable=True)
|
||||
provider_id = db.Column(db.String(20))
|
||||
|
||||
# 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.spotify_track_id}>'
|
||||
return f'<Track {self.name}:{self.provider_track_id}>'
|
||||
|
||||
3
app/providers/__init__.py
Normal file
3
app/providers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .spotify import SpotifyClient
|
||||
|
||||
__all__ = ["SpotifyClient"]
|
||||
173
app/providers/base.py
Normal file
173
app/providers/base.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalUrl:
|
||||
url: str
|
||||
|
||||
@dataclass
|
||||
class ItemBase:
|
||||
id: str
|
||||
name: str
|
||||
uri: str
|
||||
external_urls: Optional[List[ExternalUrl]]
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
avatar: Optional[str] # Avatar URL or None
|
||||
avatar_background_color: Optional[int]
|
||||
name: str
|
||||
uri: str
|
||||
username: str
|
||||
|
||||
@dataclass
|
||||
class AccountAttributes:
|
||||
catalogue: str
|
||||
dsa_mode_available: bool
|
||||
dsa_mode_enabled: bool
|
||||
multi_user_plan_current_size: Optional[int]
|
||||
multi_user_plan_member_type: Optional[str]
|
||||
on_demand: bool
|
||||
opt_in_trial_premium_only_market: bool
|
||||
country: str
|
||||
product: str
|
||||
|
||||
@dataclass
|
||||
class Image:
|
||||
url: str
|
||||
height: Optional[int]
|
||||
width: Optional[int]
|
||||
|
||||
@dataclass
|
||||
class Artist(ItemBase):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class Album(ItemBase):
|
||||
artists: List[Artist]
|
||||
images: List[Image]
|
||||
|
||||
@dataclass
|
||||
class Track(ItemBase):
|
||||
duration_ms: int
|
||||
explicit: Optional[bool]
|
||||
album: Optional[Album]
|
||||
artists: List[Artist]
|
||||
|
||||
@dataclass
|
||||
class PlaylistTrack:
|
||||
added_at: Optional[str]
|
||||
added_by: Optional[str]
|
||||
is_local: bool
|
||||
track: Track
|
||||
|
||||
@dataclass
|
||||
class Owner(ItemBase):
|
||||
pass
|
||||
|
||||
@dataclass #tbc
|
||||
class Category(ItemBase):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class Playlist(ItemBase):
|
||||
|
||||
description: Optional[str]
|
||||
public: Optional[bool]
|
||||
collaborative: Optional[bool]
|
||||
followers: Optional[int]
|
||||
images: Optional[List[Image]]
|
||||
owner: Optional[Owner]
|
||||
tracks: List[PlaylistTrack] = field(default_factory=list)
|
||||
@dataclass
|
||||
class BrowseCard:
|
||||
title: str
|
||||
uri: str
|
||||
background_color: str
|
||||
artwork: List[Image]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowseSection:
|
||||
title: str
|
||||
items: List[BrowseCard]
|
||||
uri: str
|
||||
|
||||
# Abstract base class for music providers
|
||||
class MusicProviderClient(ABC):
|
||||
"""
|
||||
Abstract base class defining the interface for music provider clients.
|
||||
"""
|
||||
@property
|
||||
@abstractmethod
|
||||
def _identifier(self) -> str:
|
||||
"""
|
||||
A unique identifier for the music provider.
|
||||
Must be implemented by all subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def authenticate(self, credentials: dict) -> None:
|
||||
"""
|
||||
Authenticates the client with the provider using the provided credentials.
|
||||
:param credentials: A dictionary containing credentials (e.g., API keys, tokens).
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_playlist(self, playlist_id: str) -> Playlist:
|
||||
"""
|
||||
Fetches a playlist by its ID.
|
||||
:param playlist_id: The ID of the playlist to fetch.
|
||||
:return: A Playlist object.
|
||||
"""
|
||||
pass
|
||||
@abstractmethod
|
||||
def extract_playlist_id(self, uri: str) -> str:
|
||||
"""
|
||||
Extracts the playlist ID from a playlist URI.
|
||||
:param uri: The playlist URI.
|
||||
:return: The playlist ID.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]:
|
||||
"""
|
||||
Searches for tracks based on a query string.
|
||||
:param query: The search query.
|
||||
:param limit: Maximum number of results to return.
|
||||
:return: A list of Track objects.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_track(self, track_id: str) -> Track:
|
||||
"""
|
||||
Fetches details for a specific track.
|
||||
:param track_id: The ID of the track to fetch.
|
||||
:return: A Track object.
|
||||
"""
|
||||
pass
|
||||
@abstractmethod
|
||||
def browse(self, **kwargs) -> List[BrowseSection]:
|
||||
"""
|
||||
Generic browse method for the music provider.
|
||||
:param kwargs: Variable keyword arguments to support different browse parameters
|
||||
:return: A dictionary containing browse results
|
||||
"""
|
||||
pass
|
||||
@abstractmethod
|
||||
def browse_page(self, uri: str) -> List[Playlist]:
|
||||
"""
|
||||
Fetches a specific page of browse results.
|
||||
:param uri: The uri to query.
|
||||
:return: A list of Playlist objects.
|
||||
"""
|
||||
pass
|
||||
647
app/providers/spotify.py
Normal file
647
app/providers/spotify.py
Normal file
@@ -0,0 +1,647 @@
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category
|
||||
import requests
|
||||
|
||||
import json
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlencode
|
||||
from typing import List, Dict, Optional
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
import logging
|
||||
|
||||
l = logging.getLogger(__name__)
|
||||
|
||||
class SpotifyClient(MusicProviderClient):
|
||||
"""
|
||||
Spotify implementation of the MusicProviderClient.
|
||||
"""
|
||||
@property
|
||||
def _identifier(self) -> str:
|
||||
return "Spotify"
|
||||
|
||||
def __init__(self, cookie_file: Optional[str] = None):
|
||||
self.base_url = "https://api-partner.spotify.com"
|
||||
self.session_data = None
|
||||
self.config_data = None
|
||||
self.client_token = None
|
||||
self.cookies = None
|
||||
if cookie_file:
|
||||
self._load_cookies(cookie_file)
|
||||
|
||||
def _load_cookies(self, cookie_file: str) -> None:
|
||||
"""
|
||||
Load cookies from a file.
|
||||
|
||||
:param cookie_file: Path to the cookie file.
|
||||
"""
|
||||
if not os.path.exists(cookie_file):
|
||||
l.error(f"Cookie file not found: {cookie_file}")
|
||||
raise FileNotFoundError(f"Cookie file not found: {cookie_file}")
|
||||
|
||||
cookie_jar = MozillaCookieJar(cookie_file)
|
||||
cookie_jar.load(ignore_discard=True, ignore_expires=True)
|
||||
self.cookies = requests.utils.dict_from_cookiejar(cookie_jar)
|
||||
|
||||
def authenticate(self, credentials: Optional[dict] = None) -> None:
|
||||
"""
|
||||
Authenticate with Spotify using cookies if available, or fetch session and config data.
|
||||
|
||||
:param credentials: Optional dictionary of credentials.
|
||||
"""
|
||||
if self.cookies:
|
||||
l.debug("Authenticating using cookies.")
|
||||
self.session_data, self.config_data = self._fetch_session_data()
|
||||
self.client_token = self._fetch_client_token()
|
||||
else:
|
||||
l.debug("Authenticating without cookies.")
|
||||
self.session_data, self.config_data = self._fetch_session_data(fetch_with_cookies=False)
|
||||
self.client_token = self._fetch_client_token()
|
||||
|
||||
def _fetch_session_data(self, fetch_with_cookies: bool = True):
|
||||
"""
|
||||
Fetch session data from Spotify.
|
||||
|
||||
:param fetch_with_cookies: Whether to include cookies in the request.
|
||||
:return: Tuple containing session and config data.
|
||||
"""
|
||||
url = 'https://open.spotify.com/'
|
||||
headers = {
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
}
|
||||
cookies = self.cookies if fetch_with_cookies else None
|
||||
response = requests.get(url, headers=headers, cookies=cookies)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
session_script = soup.find('script', {'id': 'session'})
|
||||
config_script = soup.find('script', {'id': 'config'})
|
||||
if session_script and config_script:
|
||||
l.debug("fetched session and config scripts")
|
||||
return json.loads(session_script.string), json.loads(config_script.string)
|
||||
else:
|
||||
raise ValueError("Failed to fetch session or config data.")
|
||||
|
||||
def _fetch_client_token(self):
|
||||
"""
|
||||
Fetch the client token using session data and cookies.
|
||||
|
||||
:return: The client token as a string.
|
||||
"""
|
||||
url = f'https://clienttoken.spotify.com/v1/clienttoken'
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'origin': 'https://open.spotify.com',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
}
|
||||
payload = {
|
||||
"client_data": {
|
||||
"client_version": "1.2.52.404.gcb99a997",
|
||||
"client_id": self.session_data.get("clientId", ""),
|
||||
"js_sdk_data": {
|
||||
"device_brand": "unknown",
|
||||
"device_model": "unknown",
|
||||
"os": "windows",
|
||||
"os_version": "NT 10.0",
|
||||
"device_id": self.config_data.get("correlationId", ""),
|
||||
"device_type": "computer"
|
||||
}
|
||||
}
|
||||
}
|
||||
response = requests.post(url, headers=headers, json=payload, cookies=self.cookies)
|
||||
response.raise_for_status()
|
||||
l.debug("fetched granted_token")
|
||||
return response.json().get("granted_token", "")
|
||||
|
||||
def _make_request(self, endpoint: str, params: dict = None) -> dict:
|
||||
"""
|
||||
Helper method to make authenticated requests to Spotify APIs.
|
||||
"""
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'app-platform': 'WebPlayer',
|
||||
'authorization': f'Bearer {self.session_data.get("accessToken", "")}',
|
||||
'client-token': self.client_token.get('token',''),
|
||||
}
|
||||
l.debug(f"starting request: {self.base_url}/{endpoint}")
|
||||
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
|
||||
# if the response is unauthorized, we need to reauthenticate
|
||||
if response.status_code == 401:
|
||||
l.debug("reauthenticating")
|
||||
self.authenticate()
|
||||
headers['authorization'] = f'Bearer {self.session_data.get("accessToken", "")}'
|
||||
headers['client-token'] = self.client_token.get('token','')
|
||||
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
#region utility functions to help parsing objects
|
||||
def _parse_external_urls(self, uri: str, entity_type: str) -> List[ExternalUrl]:
|
||||
"""
|
||||
Create ExternalUrl instances for an entity.
|
||||
|
||||
:param uri: The URI of the entity.
|
||||
:param entity_type: The type of entity ('track', 'album', 'artist', 'playlist', etc.).
|
||||
:return: A list of ExternalUrl instances.
|
||||
"""
|
||||
return [ExternalUrl(url=f"https://open.spotify.com/{entity_type}/{uri.split(':')[-1]}")]
|
||||
|
||||
|
||||
def _parse_images(self, image_data: List[Dict]) -> List[Image]:
|
||||
"""
|
||||
Parse images from the API response.
|
||||
|
||||
:param image_data: List of dictionaries containing image data.
|
||||
:return: A list of Image objects.
|
||||
"""
|
||||
images = []
|
||||
for img in image_data:
|
||||
# Extract the first source if available
|
||||
sources = img.get("sources", [])
|
||||
if sources:
|
||||
source = sources[0] # Take the first source as the default
|
||||
images.append(Image(
|
||||
url=source.get("url"),
|
||||
height=source.get("height"),
|
||||
width=source.get("width")
|
||||
))
|
||||
return images
|
||||
|
||||
|
||||
def _parse_artist(self, artist_data: Dict) -> Artist:
|
||||
"""
|
||||
Parse an artist object from API data.
|
||||
|
||||
:param artist_data: Dictionary representing an artist.
|
||||
:return: An Artist instance.
|
||||
"""
|
||||
return Artist(
|
||||
id=artist_data["uri"].split(":")[-1],
|
||||
name=artist_data["profile"]["name"],
|
||||
uri=artist_data["uri"],
|
||||
external_urls=self._parse_external_urls(artist_data["uri"], "artist")
|
||||
)
|
||||
|
||||
|
||||
def _parse_album(self, album_data: Dict) -> Album:
|
||||
"""
|
||||
Parse an album object from API data.
|
||||
|
||||
:param album_data: Dictionary representing an album.
|
||||
:return: An Album instance.
|
||||
"""
|
||||
artists = []
|
||||
if album_data.get("artists"):
|
||||
artists = [self._parse_artist(artist) for artist in album_data.get("artists").get('items', [])]
|
||||
return Album(
|
||||
id=album_data["uri"].split(":")[-1],
|
||||
name=album_data["name"],
|
||||
uri=album_data["uri"],
|
||||
external_urls=self._parse_external_urls(album_data["uri"], "album"),
|
||||
artists=artists,
|
||||
images=self._parse_images(album_data["coverArt"]["sources"])
|
||||
)
|
||||
|
||||
|
||||
def _parse_track(self, track_data: Dict) -> Track:
|
||||
"""
|
||||
Parse a track object from API data.
|
||||
|
||||
:param track_data: Dictionary representing a track.
|
||||
:return: A Track instance.
|
||||
"""
|
||||
duration_ms = 0
|
||||
aritsts = []
|
||||
if track_data.get("duration"):
|
||||
duration_ms = int(track_data.get("duration", 0).get("totalMilliseconds", 0))
|
||||
elif track_data.get("trackDuration"):
|
||||
duration_ms = track_data["trackDuration"]["totalMilliseconds"]
|
||||
|
||||
if track_data.get("firstArtist"):
|
||||
for artist in track_data.get("firstArtist").get('items', []):
|
||||
aritsts.append(self._parse_artist(artist))
|
||||
elif track_data.get("artists"):
|
||||
for artist in track_data.get("artists").get('items', []):
|
||||
aritsts.append(self._parse_artist(artist))
|
||||
|
||||
if track_data.get("albumOfTrack"):
|
||||
album = self._parse_album(track_data["albumOfTrack"])
|
||||
|
||||
|
||||
return Track(
|
||||
id=track_data["uri"].split(":")[-1],
|
||||
name=track_data["name"],
|
||||
uri=track_data["uri"],
|
||||
external_urls=self._parse_external_urls(track_data["uri"], "track"),
|
||||
duration_ms=duration_ms,
|
||||
explicit=track_data.get("explicit", False),
|
||||
album=self._parse_album(track_data["albumOfTrack"]),
|
||||
artists=aritsts
|
||||
)
|
||||
def _parse_owner(self, owner_data: Dict) -> Optional[Owner]:
|
||||
"""
|
||||
Parse an owner object from API data.
|
||||
|
||||
:param owner_data: Dictionary representing an owner.
|
||||
:return: An Owner instance or None if the owner data is empty.
|
||||
"""
|
||||
if not owner_data:
|
||||
return None
|
||||
|
||||
return Owner(
|
||||
id=owner_data.get("uri", "").split(":")[-1],
|
||||
name=owner_data.get("name", ""),
|
||||
uri=owner_data.get("uri", ""),
|
||||
external_urls=self._parse_external_urls(owner_data.get("uri", ""), "user")
|
||||
)
|
||||
def _parse_card_artwork(self, sources: List[Dict]) -> List[Image]:
|
||||
"""
|
||||
Parse artwork for a browse card.
|
||||
|
||||
:param sources: List of artwork source dictionaries.
|
||||
:return: A list of CardArtwork instances.
|
||||
"""
|
||||
return [Image(url=source["url"], height=source.get("height"), width=source.get("width")) for source in sources]
|
||||
|
||||
|
||||
def _parse_browse_card(self, card_data: Dict) -> BrowseCard:
|
||||
"""
|
||||
Parse a single browse card.
|
||||
|
||||
:param card_data: Dictionary containing card data.
|
||||
:return: A BrowseCard instance.
|
||||
"""
|
||||
card_content = card_data["content"]["data"]["data"]["cardRepresentation"]
|
||||
artwork_sources = card_content["artwork"]["sources"]
|
||||
|
||||
return BrowseCard(
|
||||
title=card_content["title"]["transformedLabel"],
|
||||
uri=card_data["uri"],
|
||||
background_color=card_content["backgroundColor"]["hex"],
|
||||
artwork=self._parse_card_artwork(artwork_sources)
|
||||
)
|
||||
|
||||
def _parse_playlist(self, playlist_data: Dict) -> Playlist:
|
||||
"""
|
||||
Parse a playlist object from API response data.
|
||||
|
||||
:param playlist_data: Dictionary containing playlist data.
|
||||
:return: A Playlist object.
|
||||
"""
|
||||
images = self._parse_images(playlist_data.get("images", {}).get("items", []))
|
||||
|
||||
owner_data = playlist_data.get("ownerV2", {}).get("data", {})
|
||||
owner = self._parse_owner(owner_data)
|
||||
|
||||
|
||||
valid_tracks = []
|
||||
for item in playlist_data.get("content", {}).get("items", []):
|
||||
data = item.get("itemV2", {}).get("data", {})
|
||||
uri = data.get("uri", "")
|
||||
if uri.startswith("spotify:track"):
|
||||
valid_tracks.append(self._parse_track(data))
|
||||
tracks = valid_tracks
|
||||
|
||||
return Playlist(
|
||||
id=playlist_data.get("uri", "").split(":")[-1],
|
||||
name=playlist_data.get("name", ""),
|
||||
uri=playlist_data.get("uri", ""),
|
||||
external_urls=self._parse_external_urls(playlist_data.get("uri", "").split(":")[-1], "playlist"),
|
||||
description=playlist_data.get("description", ""),
|
||||
public=playlist_data.get("public", None),
|
||||
collaborative=playlist_data.get("collaborative", None),
|
||||
followers=playlist_data.get("followers", 0),
|
||||
images=images,
|
||||
owner=owner,
|
||||
tracks=[
|
||||
PlaylistTrack(
|
||||
added_at=item.get("addedAt", {}).get("isoString", ""),
|
||||
added_by=None,
|
||||
is_local=False,
|
||||
track=track
|
||||
)
|
||||
for item, track in zip(
|
||||
playlist_data.get("content", {}).get("items", []),
|
||||
tracks
|
||||
)
|
||||
]
|
||||
)
|
||||
def _parse_browse_section(self, section_data: Dict) -> BrowseSection:
|
||||
"""
|
||||
Parse a single browse section.
|
||||
|
||||
:param section_data: Dictionary containing section data.
|
||||
:return: A BrowseSection instance.
|
||||
"""
|
||||
section_title = section_data["data"]["title"]["transformedLabel"]
|
||||
section_items = [
|
||||
item for item in section_data["sectionItems"]["items"]
|
||||
if not item["uri"].startswith("spotify:xlink")
|
||||
]
|
||||
return BrowseSection(
|
||||
title=section_title,
|
||||
items=[self._parse_browse_card(item) for item in section_items],
|
||||
uri=section_data["uri"]
|
||||
)
|
||||
|
||||
#endregion
|
||||
|
||||
def get_playlist(self, playlist_id: str) -> Playlist:
|
||||
"""
|
||||
Fetch a playlist by ID with all tracks.
|
||||
"""
|
||||
limit = 50
|
||||
offset = 0
|
||||
all_items = []
|
||||
|
||||
while True:
|
||||
query_parameters = {
|
||||
"operationName": "fetchPlaylist",
|
||||
"variables": json.dumps({
|
||||
"uri": f"spotify:playlist:{playlist_id}",
|
||||
"offset": offset,
|
||||
"limit": limit
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "19ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
data = self._make_request(f"pathfinder/v1/query?{encoded_query}")
|
||||
playlist_data = data.get('data', {}).get('playlistV2', {})
|
||||
content = playlist_data.get('content', {})
|
||||
items = content.get('items', [])
|
||||
all_items.extend(items)
|
||||
|
||||
if len(all_items) >= content.get('totalCount', 0):
|
||||
break
|
||||
|
||||
offset += limit
|
||||
|
||||
playlist_data["content"]["items"] = all_items
|
||||
return self._parse_playlist(playlist_data)
|
||||
|
||||
def extract_playlist_id(self, uri: str) -> str:
|
||||
"""
|
||||
Extract the playlist ID from a Spotify URI.
|
||||
"""
|
||||
# check whether the uri is a full url with https or just a uri
|
||||
if uri.startswith("https://open.spotify.com/"):
|
||||
#if it starts with https, we need to extract the playlist id from the url
|
||||
return uri.split('/')[-1]
|
||||
elif uri.startswith("spotify:playlist:"):
|
||||
return uri.split(':')[-1]
|
||||
else :
|
||||
raise ValueError("Invalid Spotify URI.")
|
||||
|
||||
def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]:
|
||||
"""
|
||||
Searches for playlists on Spotify.
|
||||
:param query: Search query.
|
||||
:param limit: Maximum number of results.
|
||||
:return: A list of Playlist objects.
|
||||
"""
|
||||
query_parameters = {
|
||||
"operationName": "searchDesktop",
|
||||
"variables": json.dumps({
|
||||
"searchTerm": query,
|
||||
"offset": 0,
|
||||
"limit": limit,
|
||||
"numberOfTopResults": 5,
|
||||
"includeAudiobooks": False,
|
||||
"includeArtistHasConcertsField": False,
|
||||
"includePreReleases": False,
|
||||
"includeLocalConcertsField": False
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "f1f1c151cd392433ef4d2683a10deb9adeefd660f29692d8539ce450d2dfdb96"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
search_data = response.get("data", {}).get("searchV2", {})
|
||||
playlists_data = search_data.get("playlists", {}).get("items", [])
|
||||
|
||||
playlists = [self._parse_playlist(item["data"]) for item in playlists_data]
|
||||
return playlists
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while searching for playlists: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_track(self, track_id: str) -> Track:
|
||||
"""
|
||||
Fetches details for a specific track.
|
||||
:param track_id: The ID of the track.
|
||||
:return: A Track object.
|
||||
"""
|
||||
query_parameters = {
|
||||
"operationName": "getTrack",
|
||||
"variables": json.dumps({
|
||||
"uri": f"spotify:track:{track_id}"
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "5c5ec8c973a0ac2d5b38d7064056c45103c5a062ee12b62ce683ab397b5fbe7d"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
track_data = response.get("data", {}).get("trackUnion", {})
|
||||
return self._parse_track(track_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching the track: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# non generic method implementations:
|
||||
def get_profile(self) -> Optional[Profile]:
|
||||
"""
|
||||
Fetch the profile attributes of the authenticated Spotify user.
|
||||
|
||||
:return: A Profile object containing the user's profile information or None if an error occurs.
|
||||
"""
|
||||
query_parameters = {
|
||||
"operationName": "profileAttributes",
|
||||
"variables": json.dumps({}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "53bcb064f6cd18c23f752bc324a791194d20df612d8e1239c735144ab0399ced"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
encoded_query = urlencode(query_parameters)
|
||||
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
profile_data = response.get('data', {}).get('me', {}).get('profile', {})
|
||||
if not profile_data:
|
||||
raise ValueError("Invalid profile data received.")
|
||||
return Profile(
|
||||
avatar=profile_data.get("avatar"),
|
||||
avatar_background_color=profile_data.get("avatarBackgroundColor"),
|
||||
name=profile_data.get("name", ""),
|
||||
uri=profile_data.get("uri", ""),
|
||||
username=profile_data.get("username", "")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching profile attributes: {e}")
|
||||
return None
|
||||
def get_account_attributes(self) -> Optional[AccountAttributes]:
|
||||
"""
|
||||
Fetch the account attributes of the authenticated Spotify user.
|
||||
|
||||
:return: An AccountAttributes object containing the user's account information or None if an error occurs.
|
||||
"""
|
||||
# Define the query parameters
|
||||
query_parameters = {
|
||||
"operationName": "accountAttributes",
|
||||
"variables": json.dumps({}), # Empty variables for this query
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "4fbd57be3c6ec2157adcc5b8573ec571f61412de23bbb798d8f6a156b7d34cdf"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Encode the query parameters
|
||||
encoded_query = urlencode(query_parameters)
|
||||
|
||||
# API endpoint
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
# Perform the request
|
||||
response = self._make_request(url)
|
||||
|
||||
# Extract and validate the account data
|
||||
account_data = response.get('data', {}).get('me', {}).get('account', {})
|
||||
attributes = account_data.get("attributes", {})
|
||||
if not attributes or not account_data.get("country") or not account_data.get("product"):
|
||||
raise ValueError("Invalid account data received.")
|
||||
|
||||
# Map the response to the AccountAttributes class
|
||||
return AccountAttributes(
|
||||
catalogue=attributes.get("catalogue", ""),
|
||||
dsa_mode_available=attributes.get("dsaModeAvailable", False),
|
||||
dsa_mode_enabled=attributes.get("dsaModeEnabled", False),
|
||||
multi_user_plan_current_size=attributes.get("multiUserPlanCurrentSize"),
|
||||
multi_user_plan_member_type=attributes.get("multiUserPlanMemberType"),
|
||||
on_demand=attributes.get("onDemand", False),
|
||||
opt_in_trial_premium_only_market=attributes.get("optInTrialPremiumOnlyMarket", False),
|
||||
country=account_data.get("country", ""),
|
||||
product=account_data.get("product", "")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching account attributes: {e}")
|
||||
return None
|
||||
def browse(self, **kwargs) -> List[BrowseSection]:
|
||||
"""
|
||||
Fetch all browse sections with cards.
|
||||
|
||||
:param kwargs: Keyword arguments. Supported:
|
||||
- page_limit: Maximum number of pages to fetch (default: 50)
|
||||
- section_limit: Maximum number of sections per page (default: 99)
|
||||
:return: A list of BrowseSection objects.
|
||||
"""
|
||||
page_limit = kwargs.get('page_limit', 50)
|
||||
section_limit = kwargs.get('section_limit', 99)
|
||||
query_parameters = {
|
||||
"operationName": "browseAll",
|
||||
"variables": json.dumps({
|
||||
"pagePagination": {"offset": 0, "limit": page_limit},
|
||||
"sectionPagination": {"offset": 0, "limit": section_limit}
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "cd6fcd0ce9d1849477645646601a6d444597013355467e24066dad2c1dc9b740"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
browse_data = response.get("data", {}).get("browseStart", {}).get("sections", {})
|
||||
sections = browse_data.get("items", [])
|
||||
|
||||
return [self._parse_browse_section(section) for section in sections]
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching browse sections: {e}")
|
||||
return []
|
||||
|
||||
def browse_page(self, uri: str) -> List[Playlist]:
|
||||
"""
|
||||
Fetch the content of a browse page using the URI.
|
||||
|
||||
:param uri: Should start with 'spotify:page'.
|
||||
:return: A list of Playlist objects from the browse page.
|
||||
"""
|
||||
|
||||
if not uri or not uri.startswith("spotify:page"):
|
||||
raise ValueError("The 'uri' parameter must be provided and start with 'spotify:page'.")
|
||||
|
||||
query_parameters = {
|
||||
"operationName": "browsePage",
|
||||
"variables": json.dumps({
|
||||
"pagePagination": {"offset": 0, "limit": 10},
|
||||
"sectionPagination": {"offset": 0, "limit": 10},
|
||||
"uri": uri
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "d8346883162a16a62a5b69e73e70c66a68c27b14265091cd9e1517f48334bbb3"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
browse_data = response.get("data", {}).get("browse", {})
|
||||
sections = browse_data.get("sections", {}).get("items", [])
|
||||
|
||||
playlists = []
|
||||
for section in sections:
|
||||
section_items = section.get("sectionItems", {}).get("items", [])
|
||||
for item in section_items:
|
||||
content = item.get("content", {}).get("data", {})
|
||||
if content.get("__typename") == "Playlist":
|
||||
playlists.append(self._parse_playlist(content))
|
||||
|
||||
return playlists
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching the browse page: {e}")
|
||||
return []
|
||||
3
app/registry/__init__.py
Normal file
3
app/registry/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .music_provider_registry import MusicProviderRegistry
|
||||
|
||||
__all__ = ["MusicProviderRegistry"]
|
||||
35
app/registry/music_provider_registry.py
Normal file
35
app/registry/music_provider_registry.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from app.providers.base import MusicProviderClient
|
||||
|
||||
|
||||
class MusicProviderRegistry:
|
||||
"""
|
||||
Registry to manage and retrieve music provider clients.
|
||||
"""
|
||||
_providers = {}
|
||||
|
||||
@classmethod
|
||||
def register_provider(cls, provider: MusicProviderClient):
|
||||
"""
|
||||
Registers a music provider client instance.
|
||||
:param provider: An instance of a MusicProviderClient subclass.
|
||||
"""
|
||||
cls._providers[provider._identifier] = provider
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, identifier: str) -> MusicProviderClient:
|
||||
"""
|
||||
Retrieves a registered music provider client by its identifier.
|
||||
:param identifier: The unique identifier for the provider.
|
||||
:return: An instance of MusicProviderClient.
|
||||
"""
|
||||
if identifier not in cls._providers:
|
||||
raise ValueError(f"No provider found with identifier '{identifier}'.")
|
||||
return cls._providers[identifier]
|
||||
|
||||
@classmethod
|
||||
def list_providers(cls) -> list:
|
||||
"""
|
||||
Lists all registered providers.
|
||||
:return: A list of registered provider identifiers.
|
||||
"""
|
||||
return list(cls._providers.keys())
|
||||
282
app/routes.py
282
app/routes.py
@@ -1,282 +0,0 @@
|
||||
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash
|
||||
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.models import JellyfinUser,Playlist,Track
|
||||
from celery.result import AsyncResult
|
||||
from .version import __version__
|
||||
|
||||
@app.context_processor
|
||||
def add_context():
|
||||
unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all())
|
||||
version = f"v{__version__}{read_dev_build_file()}"
|
||||
return dict(unlinked_track_count = unlinked_track_count, version = version)
|
||||
|
||||
|
||||
# this feels wrong
|
||||
skip_endpoints = ['task_status']
|
||||
@app.after_request
|
||||
def render_messages(response: Response) -> Response:
|
||||
if request.headers.get("HX-Request"):
|
||||
if request.endpoint not in skip_endpoints:
|
||||
messages = render_template("partials/alerts.jinja2")
|
||||
response.headers['HX-Trigger'] = 'showToastMessages'
|
||||
response.data = response.data + messages.encode("utf-8")
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@app.route('/admin/tasks')
|
||||
@functions.jellyfin_admin_required
|
||||
def task_manager():
|
||||
statuses = {}
|
||||
for task_name, task_id in functions.TASK_STATUS.items():
|
||||
if task_id:
|
||||
result = AsyncResult(task_id)
|
||||
statuses[task_name] = {'state': result.state, 'info': result.info if result.info else {}}
|
||||
else:
|
||||
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
|
||||
|
||||
return render_template('admin/tasks.html', tasks=statuses,lock_keys = functions.LOCK_KEYS)
|
||||
|
||||
@app.route('/admin')
|
||||
@app.route('/admin/link_issues')
|
||||
@functions.jellyfin_admin_required
|
||||
def link_issues():
|
||||
unlinked_tracks = Track.query.filter_by(downloaded=True,jellyfin_id=None).all()
|
||||
tracks = []
|
||||
for ult in unlinked_tracks:
|
||||
sp_track = functions.get_cached_spotify_track(ult.spotify_track_id)
|
||||
duration_ms = sp_track['duration_ms']
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
tracks.append({
|
||||
'title': sp_track['name'],
|
||||
'artist': ', '.join([artist['name'] for artist in sp_track['artists']]),
|
||||
'url': sp_track['external_urls']['spotify'],
|
||||
'duration': f'{minutes}:{seconds:02d}',
|
||||
'preview_url': sp_track['preview_url'],
|
||||
'downloaded': ult.downloaded,
|
||||
'filesystem_path': ult.filesystem_path,
|
||||
'jellyfin_id': ult.jellyfin_id,
|
||||
'spotify_id': sp_track['id'],
|
||||
'duration_ms': duration_ms,
|
||||
'download_status' : ult.download_status
|
||||
})
|
||||
|
||||
return render_template('admin/link_issues.html' , tracks = tracks )
|
||||
|
||||
|
||||
|
||||
@app.route('/run_task/<task_name>', methods=['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def run_task(task_name):
|
||||
status, info = functions.manage_task(task_name)
|
||||
|
||||
# Rendere nur die aktualisierte Zeile der Task
|
||||
task_info = {task_name: {'state': status, 'info': info}}
|
||||
return render_template('partials/_task_status.html', tasks=task_info)
|
||||
|
||||
|
||||
@app.route('/task_status')
|
||||
@functions.jellyfin_admin_required
|
||||
def task_status():
|
||||
statuses = {}
|
||||
for task_name, task_id in functions.TASK_STATUS.items():
|
||||
if task_id:
|
||||
result = AsyncResult(task_id)
|
||||
statuses[task_name] = {'state': result.state, 'info': result.info if result.info else {}}
|
||||
else:
|
||||
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
|
||||
|
||||
# Render the HTML partial template instead of returning JSON
|
||||
return render_template('partials/_task_status.html', tasks=statuses)
|
||||
|
||||
|
||||
|
||||
@app.route('/')
|
||||
@functions.jellyfin_login_required
|
||||
def index():
|
||||
users = JellyfinUser.query.all()
|
||||
return render_template('index.html', user=session['jellyfin_user_name'], users=users)
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
try:
|
||||
jellylogin = jellyfin.login_with_password(username=username, password=password)
|
||||
if jellylogin:
|
||||
session['jellyfin_access_token'], session['jellyfin_user_id'], session['jellyfin_user_name'],session['is_admin'] = jellylogin
|
||||
session['debug'] = app.debug
|
||||
# Check if the user already exists
|
||||
user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||
if not user:
|
||||
# Add the user to the database if they don't exist
|
||||
new_user = JellyfinUser(name=session['jellyfin_user_name'], jellyfin_user_id=session['jellyfin_user_id'], is_admin = session['is_admin'])
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
return redirect('/playlists')
|
||||
except:
|
||||
flash('Login failed. Please check your Jellyfin credentials and try again.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('jellyfin_user_name', None)
|
||||
session.pop('jellyfin_access_token', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
@app.route('/playlists')
|
||||
@app.route('/categories')
|
||||
@app.route('/playlists/monitored')
|
||||
@functions.jellyfin_login_required
|
||||
def loaditems():
|
||||
country = 'DE'
|
||||
offset = int(request.args.get('offset', 0)) # Get the offset (default to 0 for initial load)
|
||||
limit = 20 # Define a limit for pagination
|
||||
additional_query = ''
|
||||
items_subtitle = ''
|
||||
|
||||
if request.path == '/playlists/monitored':
|
||||
# Step 1: Query the database for monitored playlists
|
||||
db_playlists = db.session.query(Playlist).offset(offset).limit(limit).all()
|
||||
max_items = db.session.query(Playlist).count()
|
||||
|
||||
# Collect Spotify Playlist IDs from the database
|
||||
spotify_playlist_ids = [playlist.spotify_playlist_id for playlist in db_playlists]
|
||||
|
||||
spotify_data = functions.get_cached_spotify_playlists(tuple(spotify_playlist_ids))
|
||||
|
||||
# Step 3: Pass the Spotify data to prepPlaylistData for processing
|
||||
data = functions.prepPlaylistData(spotify_data)
|
||||
items_title = "Monitored Playlists"
|
||||
items_subtitle = "This playlists are already monitored by the Server, if you add one of these to your Jellyfin account, they will be available immediately."
|
||||
|
||||
elif request.path == '/playlists':
|
||||
cat = request.args.get('cat', None)
|
||||
if cat is not None:
|
||||
data, max_items, items_title = functions.getCategoryPlaylists(category=cat, offset=offset)
|
||||
additional_query += f"&cat={cat}"
|
||||
else:
|
||||
data, max_items, items_title = functions.getFeaturedPlaylists(country=country, offset=offset)
|
||||
|
||||
elif request.path == '/categories':
|
||||
data, max_items, items_title = functions.getCategories(country=country, offset=offset)
|
||||
|
||||
next_offset = offset + len(data)
|
||||
total_items = max_items
|
||||
context = {
|
||||
'items': data,
|
||||
'next_offset': next_offset,
|
||||
'total_items': total_items,
|
||||
'endpoint': request.path,
|
||||
'items_title': items_title,
|
||||
'items_subtitle' : items_subtitle,
|
||||
'additional_query': additional_query
|
||||
}
|
||||
|
||||
if request.headers.get('HX-Request'): # Check if the request is from HTMX
|
||||
return render_template('partials/_spotify_items.html', **context)
|
||||
else:
|
||||
return render_template('items.html', **context)
|
||||
|
||||
|
||||
@app.route('/search')
|
||||
@functions.jellyfin_login_required
|
||||
def searchResults():
|
||||
query = request.args.get('query')
|
||||
context = {}
|
||||
if query:
|
||||
# Add your logic here to perform the search on Spotify (or Jellyfin)
|
||||
search_result = sp.search(q = query, type= 'track,album,artist,playlist')
|
||||
context = {
|
||||
'artists' : functions.prepArtistData(search_result ),
|
||||
'playlists' : functions.prepPlaylistData(search_result ),
|
||||
'albums' : functions.prepAlbumData(search_result ),
|
||||
'query' : query
|
||||
}
|
||||
return render_template('search.html', **context)
|
||||
else:
|
||||
return render_template('search.html', query=None, results={})
|
||||
|
||||
|
||||
@app.route('/playlist/view/<playlist_id>')
|
||||
@functions.jellyfin_login_required
|
||||
def get_playlist_tracks(playlist_id):
|
||||
# Hol dir alle Tracks für die Playlist
|
||||
data = functions.get_full_playlist_data(playlist_id) # Diese neue Funktion holt alle Tracks der Playlist
|
||||
tracks = functions.get_tracks_for_playlist(data) # Deine Funktion, um Tracks zu holen
|
||||
# Berechne die gesamte Dauer der Playlist
|
||||
total_duration_ms = sum([track['track']['duration_ms'] for track in data['tracks'] if track['track']])
|
||||
|
||||
# Konvertiere die Gesamtdauer in ein lesbares Format
|
||||
hours, remainder = divmod(total_duration_ms // 1000, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
# Formatierung der Dauer
|
||||
if hours > 0:
|
||||
total_duration = f"{hours}h {minutes}min"
|
||||
else:
|
||||
total_duration = f"{minutes}min"
|
||||
|
||||
return render_template(
|
||||
'tracks_table.html',
|
||||
tracks=tracks,
|
||||
total_duration=total_duration,
|
||||
track_count=len(data['tracks']),
|
||||
playlist_name=data['name'],
|
||||
playlist_cover=data['images'][0]['url'],
|
||||
playlist_description=data['description'],
|
||||
last_updated = data['prepped_data'][0]['last_updated'],
|
||||
last_changed = data['prepped_data'][0]['last_changed'],
|
||||
item = data['prepped_data'][0],
|
||||
|
||||
)
|
||||
@app.route('/associate_track', methods=['POST'])
|
||||
@functions.jellyfin_login_required
|
||||
def associate_track():
|
||||
jellyfin_id = request.form.get('jellyfin_id')
|
||||
spotify_id = request.form.get('spotify_id')
|
||||
|
||||
if not jellyfin_id or not spotify_id:
|
||||
flash('Missing Jellyfin or Spotify ID')
|
||||
|
||||
# Retrieve the track by Spotify ID
|
||||
track = Track.query.filter_by(spotify_track_id=spotify_id).first()
|
||||
|
||||
if not track:
|
||||
flash('Track not found')
|
||||
return ''
|
||||
|
||||
# Associate the Jellyfin ID with the track
|
||||
track.jellyfin_id = jellyfin_id
|
||||
|
||||
try:
|
||||
# Commit the changes to the database
|
||||
db.session.commit()
|
||||
flash("Track associated","success")
|
||||
return ''
|
||||
except Exception as e:
|
||||
db.session.rollback() # Roll back the session in case of an error
|
||||
flash(str(e))
|
||||
return ''
|
||||
|
||||
|
||||
@app.route("/unlock_key",methods = ['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def unlock_key():
|
||||
|
||||
key_name = request.form.get('inputLockKey')
|
||||
if key_name:
|
||||
tasks.release_lock(key_name)
|
||||
flash(f'Lock {key_name} released', category='success')
|
||||
return ''
|
||||
|
||||
|
||||
@app.route('/test')
|
||||
def test():
|
||||
return ''
|
||||
17
app/routes/__init__.py
Normal file
17
app/routes/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from flask import Blueprint, request, g
|
||||
from app import app
|
||||
from app.registry.music_provider_registry import MusicProviderRegistry
|
||||
|
||||
pl_bp = Blueprint('playlist', __name__)
|
||||
|
||||
@pl_bp.before_request
|
||||
def set_active_provider():
|
||||
"""
|
||||
Middleware to select the active provider based on request parameters.
|
||||
"""
|
||||
app.logger.debug(f"Setting active provider: {request.args.get('provider', 'Spotify')}")
|
||||
provider_id = request.args.get('provider', 'Spotify') # Default to Spotify
|
||||
try:
|
||||
g.music_provider = MusicProviderRegistry.get_provider(provider_id)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}, 400
|
||||
@@ -1,100 +1,90 @@
|
||||
from collections import defaultdict
|
||||
import time
|
||||
from flask import Flask, jsonify, render_template, request, redirect, url_for, session, flash
|
||||
from flask import Blueprint, Flask, jsonify, render_template, request, redirect, url_for, session, flash
|
||||
from sqlalchemy import insert
|
||||
from app import app, db, jellyfin, functions, device_id,sp
|
||||
from app.models import Playlist,Track, playlist_tracks
|
||||
|
||||
|
||||
from app.models import JellyfinUser, Playlist,Track, playlist_tracks
|
||||
from spotipy.exceptions import SpotifyException
|
||||
from app.tasks import task_manager
|
||||
|
||||
from app.registry.music_provider_registry import MusicProviderRegistry
|
||||
from jellyfin.objects import PlaylistMetadata
|
||||
|
||||
|
||||
from app.routes import pl_bp
|
||||
|
||||
@app.route('/jellyfin_playlists')
|
||||
@functions.jellyfin_login_required
|
||||
def jellyfin_playlists():
|
||||
try:
|
||||
# Fetch playlists from Jellyfin
|
||||
playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie())
|
||||
|
||||
# Extract Spotify playlist IDs from the database
|
||||
spotify_playlist_ids = []
|
||||
playlists_by_provider = defaultdict(list)
|
||||
provider_playlists_data = {}
|
||||
|
||||
for pl in playlists:
|
||||
# Retrieve the playlist from the database using Jellyfin ID
|
||||
from_db = Playlist.query.filter_by(jellyfin_id=pl['Id']).first()
|
||||
if from_db and from_db.spotify_playlist_id:
|
||||
spotify_playlist_ids.append(from_db.spotify_playlist_id)
|
||||
else:
|
||||
app.logger.warning(f"No database entry found for Jellyfin playlist ID: {pl['Id']}")
|
||||
from_db : Playlist | None = Playlist.query.filter_by(jellyfin_id=pl['Id']).first()
|
||||
if from_db and from_db.provider_playlist_id:
|
||||
pl_id = from_db.provider_playlist_id
|
||||
playlists_by_provider[from_db.provider_id].append(from_db)
|
||||
|
||||
if not spotify_playlist_ids:
|
||||
flash('No Spotify playlists found to display.', 'warning')
|
||||
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}}))
|
||||
|
||||
# Use the cached function to fetch Spotify playlists
|
||||
spotify_data = functions.get_cached_spotify_playlists(spotify_playlist_ids)
|
||||
|
||||
# Prepare the data for the template
|
||||
prepared_data = functions.prepPlaylistData(spotify_data)
|
||||
|
||||
return render_template('jellyfin_playlists.html', playlists=prepared_data)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error in /jellyfin_playlists route: {str(e)}")
|
||||
flash('An error occurred while fetching playlists.', 'danger')
|
||||
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}}))
|
||||
|
||||
# 3. Fetch all Data from the provider using the get_playlist() method
|
||||
for provider_id, playlists in playlists_by_provider.items():
|
||||
try:
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
except ValueError:
|
||||
flash(f"Provider {provider_id} not found.", "error")
|
||||
continue
|
||||
|
||||
@app.route('/addplaylist', methods=['POST'])
|
||||
combined_playlists = []
|
||||
for pl in playlists:
|
||||
provider_playlist = provider_client.get_playlist(pl.provider_playlist_id)
|
||||
# 4. Convert the playlists to CombinedPlaylistData
|
||||
combined_data = functions.prepPlaylistData(provider_playlist)
|
||||
if combined_data:
|
||||
combined_playlists.append(combined_data)
|
||||
|
||||
provider_playlists_data[provider_id] = combined_playlists
|
||||
|
||||
# 5. Display the resulting Groups in a template called 'monitored_playlists.html', one Heading per Provider
|
||||
return render_template('monitored_playlists.html', provider_playlists_data=provider_playlists_data,title="Jellyfin Playlists" , subtitle="Playlists you have added to Jellyfin")
|
||||
|
||||
@pl_bp.route('/addplaylist', methods=['POST'])
|
||||
@functions.jellyfin_login_required
|
||||
def add_playlist():
|
||||
playlist_id = request.form.get('item_id') # HTMX sends the form data
|
||||
playlist_name = request.form.get('item_name') # Optionally retrieve playlist name from the form
|
||||
playlist_id = request.form.get('item_id')
|
||||
playlist_name = request.form.get('item_name')
|
||||
# also get the provider id from the query params
|
||||
provider_id = request.args.get('provider')
|
||||
if not playlist_id:
|
||||
flash('No playlist ID provided')
|
||||
return ''
|
||||
|
||||
# if no provider_id is provided, then show an error and return an empty string
|
||||
if not provider_id:
|
||||
flash('No provider ID provided')
|
||||
return ''
|
||||
try:
|
||||
# Fetch playlist from Spotify API (or any relevant API)
|
||||
playlist_data = functions.get_cached_spotify_playlist(playlist_id)
|
||||
|
||||
# Check if playlist already exists in the database
|
||||
playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_id).first()
|
||||
|
||||
# get the playlist from the correct provider
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
playlist_data = provider_client.get_playlist(playlist_id)
|
||||
# Check if playlist already exists in the database, using the provider_id and the provider_playlist_id
|
||||
playlist = Playlist.query.filter_by(provider_playlist_id=playlist_id, provider_id=provider_id).first()
|
||||
# Add new playlist in the database if it doesn't exist
|
||||
# create the playlist via api key, with the first admin as 'owner'
|
||||
if not playlist:
|
||||
# Add new playlist if it doesn't exist
|
||||
# create the playlist via api key, with the first admin as 'owner'
|
||||
fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data['name'],[],functions._get_admin_id())['Id']
|
||||
playlist = Playlist(name=playlist_data['name'], spotify_playlist_id=playlist_id,spotify_uri=playlist_data['uri'],track_count = playlist_data['tracks']['total'], tracks_available=0, jellyfin_id = fromJellyfin)
|
||||
fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data.name,[],functions._get_admin_id())['Id']
|
||||
playlist = Playlist(name=playlist_data.name, provider_playlist_id=playlist_id,provider_uri=playlist_data.uri,track_count = len(playlist_data.tracks), tracks_available=0, jellyfin_id = fromJellyfin, provider_id=provider_id)
|
||||
db.session.add(playlist)
|
||||
db.session.commit()
|
||||
if app.config['START_DOWNLOAD_AFTER_PLAYLIST_ADD']:
|
||||
functions.manage_task('download_missing_tracks')
|
||||
|
||||
|
||||
task_manager.start_task('download_missing_tracks')
|
||||
# Get the logged-in user
|
||||
user = functions._get_logged_in_user()
|
||||
user : JellyfinUser = functions._get_logged_in_user()
|
||||
playlist.tracks_available = 0
|
||||
|
||||
spotify_tracks = {}
|
||||
offset = 0
|
||||
while True:
|
||||
playlist_items = sp.playlist_items(playlist.spotify_playlist_id, offset=offset, limit=100)
|
||||
items = playlist_items['items']
|
||||
spotify_tracks.update({offset + idx: track['track'] for idx, track in enumerate(items) if track['track']})
|
||||
for idx, track_data in enumerate(playlist_data.tracks):
|
||||
|
||||
if len(items) < 100: # No more tracks to fetch
|
||||
break
|
||||
offset += 100 # Move to the next batch
|
||||
for idx, track_data in spotify_tracks.items():
|
||||
track_info = track_data
|
||||
if not track_info:
|
||||
continue
|
||||
track = Track.query.filter_by(spotify_track_id=track_info['id']).first()
|
||||
track = Track.query.filter_by(provider_track_id=track_data.track.id, provider_id=provider_id).first()
|
||||
|
||||
if not track:
|
||||
# Add new track if it doesn't exist
|
||||
track = Track(name=track_info['name'], spotify_track_id=track_info['id'], spotify_uri=track_info['uri'], downloaded=False)
|
||||
track = Track(name=track_data.track.name, provider_track_id=track_data.track.id, provider_uri=track_data.track.uri, downloaded=False,provider_id = provider_id)
|
||||
db.session.add(track)
|
||||
db.session.commit()
|
||||
elif track.downloaded:
|
||||
@@ -118,12 +108,12 @@ def add_playlist():
|
||||
user.playlists.append(playlist)
|
||||
db.session.commit()
|
||||
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(),playlist_id = playlist.jellyfin_id,user_ids= [user.jellyfin_user_id])
|
||||
flash(f'Playlist "{playlist_data["name"]}" successfully added','success')
|
||||
flash(f'Playlist "{playlist_data.name}" successfully added','success')
|
||||
|
||||
else:
|
||||
flash(f'Playlist "{playlist_data["name"]}" already in your list')
|
||||
flash(f'Playlist "{playlist_data.name}" already in your list')
|
||||
item = {
|
||||
"name" : playlist_data["name"],
|
||||
"name" : playlist_data.name,
|
||||
"id" : playlist_id,
|
||||
"can_add":False,
|
||||
"can_remove":True,
|
||||
@@ -154,7 +144,7 @@ def delete_playlist(playlist_id):
|
||||
flash('Playlist removed')
|
||||
item = {
|
||||
"name" : playlist.name,
|
||||
"id" : playlist.spotify_playlist_id,
|
||||
"id" : playlist.provider_playlist_id,
|
||||
"can_add":True,
|
||||
"can_remove":False,
|
||||
"jellyfin_id" : playlist.jellyfin_id
|
||||
@@ -178,7 +168,7 @@ def wipe_playlist(playlist_id):
|
||||
if playlist:
|
||||
# Delete the playlist
|
||||
name = playlist.name
|
||||
id = playlist.spotify_playlist_id
|
||||
id = playlist.provider_playlist_id
|
||||
jf_id = playlist.jellyfin_id
|
||||
db.session.delete(playlist)
|
||||
db.session.commit()
|
||||
@@ -204,9 +194,9 @@ def get_jellyfin_stream(jellyfin_id):
|
||||
@functions.jellyfin_login_required
|
||||
def search_jellyfin():
|
||||
search_query = request.args.get('search_query')
|
||||
spotify_id = request.args.get('spotify_id')
|
||||
provider_track_id = request.args.get('provider_track_id')
|
||||
if search_query:
|
||||
results = jellyfin.search_music_tracks(functions._get_token_from_sessioncookie(), search_query)
|
||||
# Render only the search results section as response
|
||||
return render_template('partials/_jf_search_results.html', results=results,spotify_id= spotify_id)
|
||||
return render_template('partials/_jf_search_results.html', results=results,provider_track_id= provider_track_id,search_query = search_query)
|
||||
return jsonify({'error': 'No search query provided'}), 400
|
||||
395
app/routes/routes.py
Normal file
395
app/routes/routes.py
Normal file
@@ -0,0 +1,395 @@
|
||||
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, jellyfin, read_dev_build_file, tasks
|
||||
from app.classes import AudioProfile, CombinedPlaylistData
|
||||
from app.models import JellyfinUser,Playlist,Track
|
||||
from celery.result import AsyncResult
|
||||
from typing import List
|
||||
|
||||
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
|
||||
from app.routes import pl_bp
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def add_context():
|
||||
unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all())
|
||||
version = f"v{__version__}{read_dev_build_file()}"
|
||||
return dict(unlinked_track_count = unlinked_track_count, version = version, config = app.config , registered_providers = MusicProviderRegistry.list_providers())
|
||||
|
||||
|
||||
# this feels wrong
|
||||
skip_endpoints = ['task_status']
|
||||
@app.after_request
|
||||
def render_messages(response: Response) -> Response:
|
||||
if request.headers.get("HX-Request"):
|
||||
if request.endpoint not in skip_endpoints:
|
||||
messages = render_template("partials/alerts.jinja2")
|
||||
response.headers['HX-Trigger'] = 'showToastMessages'
|
||||
response.data = response.data + messages.encode("utf-8")
|
||||
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
|
||||
def task_manager():
|
||||
statuses = {}
|
||||
for task_name, task_id in tasks.task_manager.tasks.items():
|
||||
statuses[task_name] = tasks.task_manager.get_task_status(task_name)
|
||||
|
||||
|
||||
return render_template('admin/tasks.html', tasks=statuses)
|
||||
|
||||
@app.route('/admin')
|
||||
@app.route('/admin/link_issues')
|
||||
@functions.jellyfin_admin_required
|
||||
def link_issues():
|
||||
# add the ability to pass a query parameter to dislplay even undownloaded tracks
|
||||
list_undownloaded = request.args.get('list_undownloaded')
|
||||
if list_undownloaded:
|
||||
unlinked_tracks = Track.query.filter_by(jellyfin_id=None).all()
|
||||
else:
|
||||
unlinked_tracks = Track.query.filter_by(downloaded=True,jellyfin_id=None).all()
|
||||
tracks = []
|
||||
for ult in unlinked_tracks:
|
||||
provider_track = functions.get_cached_provider_track(ult.provider_track_id, ult.provider_id)
|
||||
duration_ms = provider_track.duration_ms
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
tracks.append({
|
||||
'title': provider_track.name,
|
||||
'artist': ', '.join([artist.name for artist in provider_track.artists]),
|
||||
'url': provider_track.external_urls,
|
||||
'duration': f'{minutes}:{seconds:02d}',
|
||||
'preview_url': '',
|
||||
'downloaded': ult.downloaded,
|
||||
'filesystem_path': ult.filesystem_path,
|
||||
'jellyfin_id': ult.jellyfin_id,
|
||||
'provider_track_id': provider_track.id,
|
||||
'duration_ms': duration_ms,
|
||||
'download_status' : ult.download_status,
|
||||
'provider_id' : ult.provider_id
|
||||
})
|
||||
|
||||
return render_template('admin/link_issues.html' , tracks = tracks )
|
||||
|
||||
|
||||
|
||||
@app.route('/run_task/<task_name>', methods=['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def run_task(task_name):
|
||||
status, info = tasks.task_manager.start_task(task_name)
|
||||
|
||||
# Rendere nur die aktualisierte Zeile der Task
|
||||
task_info = {task_name: {'state': status, 'info': info}}
|
||||
return render_template('partials/_task_status.html', tasks=task_info)
|
||||
|
||||
|
||||
@app.route('/task_status')
|
||||
@functions.jellyfin_admin_required
|
||||
def task_status():
|
||||
statuses = {}
|
||||
for task_name, task_id in tasks.task_manager.tasks.items():
|
||||
statuses[task_name] = tasks.task_manager.get_task_status(task_name)
|
||||
|
||||
|
||||
# Render the HTML partial template instead of returning JSON
|
||||
return render_template('partials/_task_status.html', tasks=statuses)
|
||||
|
||||
|
||||
|
||||
@app.route('/')
|
||||
@functions.jellyfin_login_required
|
||||
def index():
|
||||
users = JellyfinUser.query.all()
|
||||
return render_template('index.html', user=session['jellyfin_user_name'], users=users)
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
try:
|
||||
jellylogin = jellyfin.login_with_password(username=username, password=password)
|
||||
if jellylogin:
|
||||
session['jellyfin_access_token'], session['jellyfin_user_id'], session['jellyfin_user_name'],session['is_admin'] = jellylogin
|
||||
session['debug'] = app.debug
|
||||
# Check if the user already exists
|
||||
user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||
if not user:
|
||||
# Add the user to the database if they don't exist
|
||||
new_user = JellyfinUser(name=session['jellyfin_user_name'], jellyfin_user_id=session['jellyfin_user_id'], is_admin = session['is_admin'])
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
return redirect('/')
|
||||
except:
|
||||
flash('Login failed. Please check your Jellyfin credentials and try again.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('jellyfin_user_name', None)
|
||||
session.pop('jellyfin_access_token', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/playlist/open',methods=['GET'])
|
||||
@functions.jellyfin_login_required
|
||||
def openPlaylist():
|
||||
playlist = request.args.get('playlist')
|
||||
error = None
|
||||
errdata= None
|
||||
if playlist:
|
||||
for provider_id in MusicProviderRegistry.list_providers():
|
||||
try:
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
extracted_playlist_id = provider_client.extract_playlist_id(playlist)
|
||||
provider_playlist = provider_client.get_playlist(extracted_playlist_id)
|
||||
|
||||
combined_data = functions.prepPlaylistData(provider_playlist)
|
||||
if combined_data:
|
||||
# If the playlist is found, redirect to the playlist view, but also include the provider ID in the URL
|
||||
return redirect(url_for('playlist.get_playlist_tracks', playlist_id=extracted_playlist_id, provider=provider_id))
|
||||
except Exception as e:
|
||||
error = f"Error fetching playlist from {provider_id}: {str(e)}"
|
||||
errdata = e
|
||||
|
||||
return render_template('index.html',error_message = error, error_data = errdata)
|
||||
|
||||
@pl_bp.route('/browse')
|
||||
@functions.jellyfin_login_required
|
||||
def browse():
|
||||
provider: MusicProviderClient = g.music_provider
|
||||
|
||||
browse_data = provider.browse()
|
||||
return render_template('browse.html', browse_data=browse_data,provider_id=provider._identifier)
|
||||
|
||||
@pl_bp.route('/browse/page/<page_id>')
|
||||
@functions.jellyfin_login_required
|
||||
def browse_page(page_id):
|
||||
provider: MusicProviderClient = g.music_provider
|
||||
combined_playlist_data : List[CombinedPlaylistData] = []
|
||||
|
||||
data = provider.browse_page(page_id)
|
||||
for item in data:
|
||||
cpd = functions.prepPlaylistData(item)
|
||||
if cpd:
|
||||
combined_playlist_data.append(cpd)
|
||||
return render_template('browse_page.html', data=combined_playlist_data,provider_id=provider._identifier)
|
||||
|
||||
@pl_bp.route('/playlists/monitored')
|
||||
@functions.jellyfin_login_required
|
||||
def monitored_playlists():
|
||||
|
||||
# 1. Get all Playlists from the Database.
|
||||
all_playlists = Playlist.query.all()
|
||||
|
||||
# 2. Group them by provider
|
||||
playlists_by_provider = defaultdict(list)
|
||||
for playlist in all_playlists:
|
||||
playlists_by_provider[playlist.provider_id].append(playlist)
|
||||
|
||||
provider_playlists_data = {}
|
||||
# 3. Fetch all Data from the provider using the get_playlist() method
|
||||
for provider_id, playlists in playlists_by_provider.items():
|
||||
try:
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
except ValueError:
|
||||
flash(f"Provider {provider_id} not found.", "error")
|
||||
continue
|
||||
|
||||
combined_playlists = []
|
||||
for pl in playlists:
|
||||
provider_playlist = provider_client.get_playlist(pl.provider_playlist_id)
|
||||
# 4. Convert the playlists to CombinedPlaylistData
|
||||
combined_data = functions.prepPlaylistData(provider_playlist)
|
||||
if combined_data:
|
||||
combined_playlists.append(combined_data)
|
||||
|
||||
provider_playlists_data[provider_id] = combined_playlists
|
||||
|
||||
# 5. Display the resulting Groups in a template called 'monitored_playlists.html', one Heading per Provider
|
||||
return render_template('monitored_playlists.html', provider_playlists_data=provider_playlists_data, title="Monitored Playlists", subtitle="Playlists which are already monitored by Jellyplist and are available immediately")
|
||||
|
||||
@app.route('/search')
|
||||
@functions.jellyfin_login_required
|
||||
def searchResults():
|
||||
query = request.args.get('query')
|
||||
context = {}
|
||||
if query:
|
||||
#iterate through every registered music provider and perform the search with it.
|
||||
# Group the results by provider and display them using monitorerd_playlists.html
|
||||
search_results = defaultdict(list)
|
||||
for provider_id in MusicProviderRegistry.list_providers():
|
||||
try:
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
results = provider_client.search_playlist(query)
|
||||
for result in results:
|
||||
search_results[provider_id].append(result)
|
||||
except Exception as e:
|
||||
flash(f"Error fetching search results from {provider_id}: {str(e)}", "error")
|
||||
# the grouped search results, must be prepared using the prepPlaylistData function
|
||||
for provider_id, playlists in search_results.items():
|
||||
combined_playlists = []
|
||||
for pl in playlists:
|
||||
combined_data = functions.prepPlaylistData(pl)
|
||||
if combined_data:
|
||||
combined_playlists.append(combined_data)
|
||||
search_results[provider_id] = combined_playlists
|
||||
|
||||
context['provider_playlists_data'] = search_results
|
||||
context['title'] = 'Search Results'
|
||||
context['subtitle'] = 'Search results from all providers'
|
||||
return render_template('monitored_playlists.html', **context)
|
||||
|
||||
@pl_bp.route('/track_details/<track_id>')
|
||||
@functions.jellyfin_login_required
|
||||
def track_details(track_id):
|
||||
provider_id = request.args.get('provider')
|
||||
if not provider_id:
|
||||
return jsonify({'error': 'Provider not specified'}), 400
|
||||
|
||||
track = Track.query.filter_by(provider_track_id=track_id, provider_id=provider_id).first()
|
||||
if not track:
|
||||
return jsonify({'error': 'Track not found'}), 404
|
||||
|
||||
provider_track = functions.get_cached_provider_track(track.provider_track_id, track.provider_id)
|
||||
# query also this track using the jellyfin id directly from jellyfin
|
||||
if track.jellyfin_id:
|
||||
jellyfin_track = jellyfin.get_item(session_token=functions._get_api_token(), item_id=track.jellyfin_id)
|
||||
if jellyfin_track:
|
||||
jellyfin_filesystem_path = jellyfin_track['Path']
|
||||
duration_ms = provider_track.duration_ms
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
|
||||
track_details = {
|
||||
'title': provider_track.name,
|
||||
'artist': ', '.join([artist.name for artist in provider_track.artists]),
|
||||
'url': provider_track.external_urls,
|
||||
'duration': f'{minutes}:{seconds:02d}',
|
||||
'downloaded': track.downloaded,
|
||||
'filesystem_path': track.filesystem_path,
|
||||
'jellyfin_id': track.jellyfin_id,
|
||||
'provider_track_id': provider_track.id,
|
||||
'provider_track_url': provider_track.external_urls[0].url if provider_track.external_urls else None,
|
||||
'duration_ms': duration_ms,
|
||||
'download_status': track.download_status,
|
||||
'provider_id': track.provider_id,
|
||||
'jellyfin_filesystem_path': jellyfin_filesystem_path if track.jellyfin_id else None,
|
||||
}
|
||||
|
||||
return render_template('partials/track_details.html', track=track_details)
|
||||
|
||||
@pl_bp.route('/playlist/view/<playlist_id>')
|
||||
@functions.jellyfin_login_required
|
||||
def get_playlist_tracks(playlist_id):
|
||||
provider: MusicProviderClient = g.music_provider
|
||||
playlist: base.Playlist = provider.get_playlist(playlist_id)
|
||||
tracks = functions.get_tracks_for_playlist(playlist.tracks, provider_id=provider._identifier)
|
||||
total_duration_ms = sum([track.duration_ms for track in tracks])
|
||||
|
||||
# Convert the total duration to a readable format
|
||||
hours, remainder = divmod(total_duration_ms // 1000, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
# Format the duration
|
||||
if hours > 0:
|
||||
total_duration = f"{hours}h {minutes}min"
|
||||
else:
|
||||
total_duration = f"{minutes}min"
|
||||
|
||||
return render_template(
|
||||
'tracks_table.html',
|
||||
tracks=tracks,
|
||||
total_duration=total_duration,
|
||||
track_count=len(tracks),
|
||||
provider_id = provider._identifier,
|
||||
item=functions.prepPlaylistData(playlist),
|
||||
|
||||
)
|
||||
|
||||
@app.route('/associate_track', methods=['POST'])
|
||||
@functions.jellyfin_login_required
|
||||
def associate_track():
|
||||
jellyfin_id = request.form.get('jellyfin_id')
|
||||
provider_track_id = request.form.get('provider_track_id')
|
||||
|
||||
if not jellyfin_id or not provider_track_id:
|
||||
flash('Missing Jellyfin or Spotify ID')
|
||||
|
||||
# Retrieve the track by Spotify ID
|
||||
track = Track.query.filter_by(provider_track_id=provider_track_id).first()
|
||||
|
||||
if not track:
|
||||
flash('Track not found')
|
||||
return ''
|
||||
|
||||
# Associate the Jellyfin ID with the track
|
||||
track.jellyfin_id = jellyfin_id
|
||||
track.downloaded = True
|
||||
|
||||
|
||||
try:
|
||||
# Commit the changes to the database
|
||||
db.session.commit()
|
||||
flash("Track associated","success")
|
||||
return ''
|
||||
except Exception as e:
|
||||
db.session.rollback() # Roll back the session in case of an error
|
||||
flash(str(e))
|
||||
return ''
|
||||
|
||||
|
||||
@app.route("/unlock_key",methods = ['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def unlock_key():
|
||||
|
||||
key_name = request.form.get('inputLockKey')
|
||||
if key_name:
|
||||
tasks.release_lock(key_name)
|
||||
flash(f'Lock {key_name} released', category='success')
|
||||
return ''
|
||||
|
||||
|
||||
@pl_bp.route('/test')
|
||||
def test():
|
||||
tasks.update_all_playlists_track_status()
|
||||
return ''
|
||||
|
||||
430
app/tasks.py
430
app/tasks.py
@@ -1,26 +1,23 @@
|
||||
from datetime import datetime,timezone
|
||||
import logging
|
||||
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
|
||||
import os
|
||||
import redis
|
||||
from celery import current_task,signals
|
||||
import asyncio
|
||||
import requests
|
||||
from celery.result import AsyncResult
|
||||
|
||||
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)
|
||||
|
||||
def release_lock(lock_name):
|
||||
redis_client.delete(lock_name)
|
||||
def prepare_logger():
|
||||
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(message)s"
|
||||
logging.basicConfig(format=FORMAT)
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def setup_log_format(sender, conf, **kwargs):
|
||||
@@ -33,7 +30,7 @@ def setup_log_format(sender, conf, **kwargs):
|
||||
def update_all_playlists_track_status(self):
|
||||
lock_key = "update_all_playlists_track_status_lock"
|
||||
|
||||
if acquire_lock(lock_key, expiration=600):
|
||||
if task_manager.acquire_lock(lock_key, expiration=600):
|
||||
try:
|
||||
with app.app_context():
|
||||
playlists = Playlist.query.all()
|
||||
@@ -48,12 +45,37 @@ def update_all_playlists_track_status(self):
|
||||
for playlist in playlists:
|
||||
total_tracks = 0
|
||||
available_tracks = 0
|
||||
app.logger.debug(f"Current Playlist: {playlist.name} [{playlist.id}:{playlist.spotify_playlist_id}]" )
|
||||
app.logger.info(f"Current Playlist: {playlist.name} [{playlist.id}:{playlist.provider_playlist_id}]" )
|
||||
for track in playlist.tracks:
|
||||
total_tracks += 1
|
||||
app.logger.debug(f"Processing track: {track.name} [{track.provider_track_id}]")
|
||||
app.logger.debug(f"\tPath = {track.filesystem_path}")
|
||||
if track.filesystem_path:
|
||||
app.logger.debug(f"\tPath exists = {os.path.exists(track.filesystem_path)}")
|
||||
app.logger.debug(f"\tJellyfinID = {track.jellyfin_id}")
|
||||
if track.filesystem_path and os.path.exists(track.filesystem_path):
|
||||
app.logger.info(f"Track {track.name} is already downloaded at {track.filesystem_path}.")
|
||||
available_tracks += 1
|
||||
track.downloaded = True
|
||||
db.session.commit()
|
||||
#If not found in filesystem, but a jellyfin_id is set, query the jellyfin server for the track and populate the filesystem_path from the response with the path
|
||||
elif track.jellyfin_id:
|
||||
jellyfin_track = jellyfin.get_item(jellyfin_admin_token, track.jellyfin_id)
|
||||
app.logger.debug(f"\tJellyfin Path: {jellyfin_track['Path']}")
|
||||
app.logger.debug(f"\tJellyfin Path exists: {os.path.exists(jellyfin_track['Path'])}")
|
||||
if jellyfin_track and os.path.exists(jellyfin_track['Path']):
|
||||
app.logger.info(f"Track {track.name} found in Jellyfin at {jellyfin_track['Path']}.")
|
||||
track.filesystem_path = jellyfin_track['Path']
|
||||
track.downloaded = True
|
||||
db.session.commit()
|
||||
available_tracks += 1
|
||||
else:
|
||||
track.downloaded = False
|
||||
track.filesystem_path = None
|
||||
db.session.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
else:
|
||||
track.downloaded = False
|
||||
@@ -73,7 +95,7 @@ def update_all_playlists_track_status(self):
|
||||
app.logger.info("All playlists' track statuses updated.")
|
||||
return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists}
|
||||
finally:
|
||||
release_lock(lock_key)
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
app.logger.info("Skipping task. Another instance is already running.")
|
||||
return {'status': 'Task skipped, another instance is running'}
|
||||
@@ -83,7 +105,7 @@ def update_all_playlists_track_status(self):
|
||||
def download_missing_tracks(self):
|
||||
lock_key = "download_missing_tracks_lock"
|
||||
|
||||
if acquire_lock(lock_key, expiration=1800):
|
||||
if task_manager.acquire_lock(lock_key, expiration=1800):
|
||||
try:
|
||||
app.logger.info("Starting track download job...")
|
||||
|
||||
@@ -95,7 +117,8 @@ def download_missing_tracks(self):
|
||||
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
|
||||
search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD']
|
||||
|
||||
undownloaded_tracks = Track.query.filter_by(downloaded=False).all()
|
||||
# Downloading using SpotDL only works for Spotify tracks
|
||||
undownloaded_tracks : List[Track] = Track.query.filter_by(downloaded=False,provider_id = "Spotify").all()
|
||||
total_tracks = len(undownloaded_tracks)
|
||||
if not undownloaded_tracks:
|
||||
app.logger.info("No undownloaded tracks found.")
|
||||
@@ -105,53 +128,61 @@ def download_missing_tracks(self):
|
||||
processed_tracks = 0
|
||||
failed_downloads = 0
|
||||
for track in undownloaded_tracks:
|
||||
app.logger.info(f"Processing track: {track.name} [{track.spotify_track_id}]")
|
||||
|
||||
app.logger.info(f"Processing track: {track.name} [{track.provider_track_id}]")
|
||||
self.update_state(state=f'[{processed_tracks}/{total_tracks}] {track.name} [{track.provider_track_id}]', meta={
|
||||
'current': processed_tracks,
|
||||
'total': total_tracks,
|
||||
'percent': (processed_tracks / total_tracks) * 100 if processed_tracks > 0 else 0,
|
||||
'failed': failed_downloads
|
||||
})
|
||||
# Check if the track already exists in the output directory
|
||||
file_path = f"{output_dir.replace('{track-id}', track.spotify_track_id)}.mp3"
|
||||
file_path = f"{output_dir.replace('{track-id}', track.provider_track_id)}.mp3"
|
||||
# region search before download
|
||||
if search_before_download:
|
||||
app.logger.info(f"Searching for track in Jellyfin: {track.name}")
|
||||
spotify_track = functions.get_cached_spotify_track(track.spotify_track_id)
|
||||
spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify")
|
||||
# at first try to find the track without fingerprinting it
|
||||
best_match = 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.spotify_track_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']
|
||||
|
||||
db.session.commit()
|
||||
processed_tracks+=1
|
||||
db.session.commit()
|
||||
processed_tracks+=1
|
||||
continue
|
||||
|
||||
# region search with fingerprinting
|
||||
preview_url = spotify_track.get('preview_url')
|
||||
if not preview_url:
|
||||
app.logger.error(f"Preview URL not found for track {track.name}.")
|
||||
# Decide whether to skip or proceed to download
|
||||
# For now, we'll proceed to download
|
||||
else:
|
||||
# Get the list of Spotify artist names
|
||||
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
|
||||
# region search with fingerprinting
|
||||
# as long as there is no endpoint found providing a preview url, we can't use this feature
|
||||
# if spotify_track:
|
||||
# preview_url = spotify_track.get('preview_url')
|
||||
# if not preview_url:
|
||||
# app.logger.error(f"Preview URL not found for track {track.name}.")
|
||||
# # Decide whether to skip or proceed to download
|
||||
# # For now, we'll proceed to download
|
||||
# else:
|
||||
# # Get the list of Spotify artist names
|
||||
# spotify_artists = [artist['name'] for artist in spotify_track['artists']]
|
||||
|
||||
# Perform the search in Jellyfin
|
||||
match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
|
||||
session_token=jellyfin_admin_token,
|
||||
preview_url=preview_url,
|
||||
song_name=track.name,
|
||||
artist_names=spotify_artists
|
||||
)
|
||||
if match_found:
|
||||
app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
|
||||
track.downloaded = True
|
||||
track.filesystem_path = jellyfin_file_path
|
||||
db.session.commit()
|
||||
continue
|
||||
else:
|
||||
app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
|
||||
# # Perform the search in Jellyfin
|
||||
# match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
|
||||
# session_token=jellyfin_admin_token,
|
||||
# preview_url=preview_url,
|
||||
# song_name=track.name,
|
||||
# artist_names=spotify_artists
|
||||
# )
|
||||
# if match_found:
|
||||
# app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
|
||||
# track.downloaded = True
|
||||
# track.filesystem_path = jellyfin_file_path
|
||||
# db.session.commit()
|
||||
# continue
|
||||
# else:
|
||||
# app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
|
||||
# else:
|
||||
# app.logger.warning(f"spotify_track not set, see previous log messages")
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
@@ -167,8 +198,8 @@ def download_missing_tracks(self):
|
||||
|
||||
# Attempt to download the track using spotdl
|
||||
try:
|
||||
app.logger.info(f"Trying to download track: {track.name} ({track.spotify_track_id}), spotdl timeout = 90")
|
||||
s_url = f"https://open.spotify.com/track/{track.spotify_track_id}"
|
||||
app.logger.info(f"Trying to download track: {track.name} ({track.provider_track_id}), spotdl timeout = 90")
|
||||
s_url = f"https://open.spotify.com/track/{track.provider_track_id}"
|
||||
|
||||
command = [
|
||||
"spotdl", "download", s_url,
|
||||
@@ -203,7 +234,7 @@ def download_missing_tracks(self):
|
||||
progress = (processed_tracks / total_tracks) * 100
|
||||
db.session.commit()
|
||||
|
||||
self.update_state(state='PROGRESS', meta={
|
||||
self.update_state(state=f'[{processed_tracks}/{total_tracks}] {track.name} [{track.provider_track_id}]', meta={
|
||||
'current': processed_tracks,
|
||||
'total': total_tracks,
|
||||
'percent': progress,
|
||||
@@ -218,7 +249,7 @@ def download_missing_tracks(self):
|
||||
'failed': failed_downloads
|
||||
}
|
||||
finally:
|
||||
release_lock(lock_key)
|
||||
task_manager.release_lock(lock_key)
|
||||
if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']:
|
||||
libraries = jellyfin.get_libraries(jellyfin_admin_token)
|
||||
for lib in libraries:
|
||||
@@ -232,11 +263,11 @@ def download_missing_tracks(self):
|
||||
def check_for_playlist_updates(self):
|
||||
lock_key = "check_for_playlist_updates_lock"
|
||||
|
||||
if acquire_lock(lock_key, expiration=600):
|
||||
if task_manager.acquire_lock(lock_key, expiration=600):
|
||||
try:
|
||||
app.logger.info('Starting playlist update check...')
|
||||
with app.app_context():
|
||||
playlists = Playlist.query.all()
|
||||
playlists: List[Playlist] = Playlist.query.all()
|
||||
total_playlists = len(playlists)
|
||||
if not playlists:
|
||||
app.logger.info("No playlists found.")
|
||||
@@ -247,40 +278,28 @@ def check_for_playlist_updates(self):
|
||||
|
||||
for playlist in playlists:
|
||||
playlist.last_updated = datetime.now( timezone.utc)
|
||||
sp_playlist = sp.playlist(playlist.spotify_playlist_id)
|
||||
# get the correct MusicProvider from the registry
|
||||
provider = MusicProviderRegistry.get_provider(playlist.provider_id)
|
||||
provider_playlist = provider.get_playlist(playlist.provider_playlist_id)
|
||||
provider_tracks = provider_playlist.tracks
|
||||
full_update = True
|
||||
app.logger.info(f'Checking updates for playlist: {playlist.name}, s_snapshot = {sp_playlist['snapshot_id']}')
|
||||
app.logger.info(f'Checking updates for playlist: {playlist.name}')
|
||||
db.session.commit()
|
||||
if sp_playlist['snapshot_id'] == playlist.snapshot_id:
|
||||
app.logger.info(f'playlist: {playlist.name} , no changes detected, snapshot_id {sp_playlist['snapshot_id']}')
|
||||
full_update = False
|
||||
|
||||
try:
|
||||
#region Check for updates
|
||||
# Fetch all playlist data from Spotify
|
||||
if full_update:
|
||||
spotify_tracks = {}
|
||||
offset = 0
|
||||
playlist.snapshot_id = sp_playlist['snapshot_id']
|
||||
while True:
|
||||
playlist_data = sp.playlist_items(playlist.spotify_playlist_id, offset=offset, limit=100)
|
||||
items = playlist_data['items']
|
||||
spotify_tracks.update({offset + idx: track['track'] for idx, track in enumerate(items) if track['track']})
|
||||
|
||||
if len(items) < 100: # No more tracks to fetch
|
||||
break
|
||||
offset += 100 # Move to the next batch
|
||||
|
||||
existing_tracks = {track.spotify_track_id: track for track in playlist.tracks}
|
||||
existing_tracks = {track.provider_track_id: track for track in playlist.tracks}
|
||||
|
||||
# Determine tracks to add and remove
|
||||
tracks_to_add = []
|
||||
for idx, track_info in spotify_tracks.items():
|
||||
for idx, track_info in enumerate(provider_tracks):
|
||||
if track_info:
|
||||
track_id = track_info['id']
|
||||
track_id = track_info.track.id
|
||||
if track_id not in existing_tracks:
|
||||
track = Track.query.filter_by(spotify_track_id=track_id).first()
|
||||
track = Track.query.filter_by(provider_track_id=track_id,provider_id = playlist.provider_id).first()
|
||||
if not track:
|
||||
track = Track(name=track_info['name'], spotify_track_id=track_id, spotify_uri=track_info['uri'], downloaded=False)
|
||||
track = Track(name=track_info.track.name, provider_track_id=track_id, provider_uri=track_info.track.uri, downloaded=False,provider_id = playlist.provider_id)
|
||||
db.session.add(track)
|
||||
db.session.commit()
|
||||
app.logger.info(f'Added new track: {track.name}')
|
||||
@@ -289,7 +308,7 @@ def check_for_playlist_updates(self):
|
||||
tracks_to_remove = [
|
||||
existing_tracks[track_id]
|
||||
for track_id in existing_tracks
|
||||
if track_id not in {track['id'] for track in spotify_tracks.values() if track}
|
||||
if track_id not in {track.track.id for track in provider_tracks if track}
|
||||
]
|
||||
|
||||
if tracks_to_add or tracks_to_remove:
|
||||
@@ -317,7 +336,7 @@ def check_for_playlist_updates(self):
|
||||
#endregion
|
||||
|
||||
#region Update Playlist Items and Metadata
|
||||
functions.update_playlist_metadata(playlist, sp_playlist)
|
||||
functions.update_playlist_metadata(playlist, provider_playlist)
|
||||
ordered_tracks = db.session.execute(
|
||||
db.select(Track, playlist_tracks.c.track_order)
|
||||
.join(playlist_tracks, playlist_tracks.c.track_id == Track.id)
|
||||
@@ -342,7 +361,7 @@ def check_for_playlist_updates(self):
|
||||
|
||||
return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists}
|
||||
finally:
|
||||
release_lock(lock_key)
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
app.logger.info("Skipping task. Another instance is already running.")
|
||||
return {'status': 'Task skipped, another instance is running'}
|
||||
@@ -350,15 +369,15 @@ def check_for_playlist_updates(self):
|
||||
@celery.task(bind=True)
|
||||
def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
lock_key = "update_jellyfin_id_for_downloaded_tracks_lock"
|
||||
full_update_key = 'full_update_jellyfin_ids'
|
||||
if acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
|
||||
full_update_key = 'full_update_jellyfin_ids_lock'
|
||||
if task_manager.acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
|
||||
try:
|
||||
app.logger.info("Starting Jellyfin ID update for tracks...")
|
||||
|
||||
with app.app_context():
|
||||
downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all()
|
||||
|
||||
if acquire_lock(full_update_key, expiration=60*60*24):
|
||||
if task_manager.acquire_lock(full_update_key, expiration=60*60*24):
|
||||
app.logger.info(f"performing full update on jellyfin track ids. (Update tracks and playlists if better quality will be found)")
|
||||
downloaded_tracks = Track.query.all()
|
||||
else:
|
||||
@@ -378,10 +397,10 @@ def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
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.spotify_track_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.spotify_track_id})")
|
||||
app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})")
|
||||
|
||||
|
||||
|
||||
@@ -396,13 +415,101 @@ def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
|
||||
processed_tracks += 1
|
||||
progress = (processed_tracks / total_tracks) * 100
|
||||
self.update_state(state='PROGRESS', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
|
||||
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}
|
||||
|
||||
finally:
|
||||
release_lock(lock_key)
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
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 task_manager.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:
|
||||
task_manager.release_lock(lock_key)
|
||||
|
||||
else:
|
||||
app.logger.info('Lidarr API key or URL not set. Skipping request.')
|
||||
task_manager.release_lock(lock_key)
|
||||
|
||||
|
||||
else:
|
||||
app.logger.info("Skipping task. Another instance is already running.")
|
||||
return {'status': 'Task skipped, another instance is running'}
|
||||
@@ -410,44 +517,52 @@ def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
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))
|
||||
spotify_track = None
|
||||
provider_track = None
|
||||
try:
|
||||
best_match = None
|
||||
best_quality_score = -1 # Initialize with the lowest possible score
|
||||
|
||||
|
||||
for result in search_results:
|
||||
app.logger.debug(f"Processing search result: {result['Id']}, Path = {result['Path']}")
|
||||
quality_score = compute_quality_score(result, app.config['FIND_BEST_MATCH_USE_FFPROBE'])
|
||||
|
||||
if len(search_results) == 1:
|
||||
app.logger.debug(f"Only 1 search_result, assuming best match: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})")
|
||||
best_match = result
|
||||
break
|
||||
|
||||
jellyfin_path = result.get('Path', '')
|
||||
# if jellyfin_path == track.filesystem_path:
|
||||
# app.logger.debug(f"Best match found through equal file-system paths: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})")
|
||||
# best_match = result
|
||||
# break
|
||||
|
||||
if not spotify_track:
|
||||
try:
|
||||
spotify_track = functions.get_cached_spotify_track(track.spotify_track_id)
|
||||
spotify_track_name = spotify_track['name']
|
||||
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching track details from Spotify for {track.name}: {str(e)}")
|
||||
continue
|
||||
|
||||
try:
|
||||
provider_track = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id)
|
||||
provider_track_name = provider_track.name.lower()
|
||||
provider_artists = [artist.name.lower() for artist in provider_track.artists]
|
||||
except Exception as e:
|
||||
app.logger.error(f"\tError fetching track details from Spotify for {track.name}: {str(e)}")
|
||||
continue
|
||||
jellyfin_track_name = result.get('Name', '').lower()
|
||||
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
|
||||
if (spotify_track_name.lower() == jellyfin_track_name and
|
||||
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists)):
|
||||
app.logger.debug(f"Quality score for track {result['Name']}: {quality_score} [{result['Path']}]")
|
||||
if len(result.get('Artists', [])) == 1:
|
||||
jellyfin_artists = [a.lower() for a in result.get('Artists', [])[0].split('/')]
|
||||
else:
|
||||
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
|
||||
jellyfin_album_artists = [artist['Name'].lower() for artist in result.get('AlbumArtists', [])]
|
||||
|
||||
if provider_track and jellyfin_track_name and jellyfin_artists and provider_artists:
|
||||
app.logger.debug("\tTrack details to compare: ")
|
||||
app.logger.debug(f"\t\tJellyfin-Trackname : {jellyfin_track_name}")
|
||||
app.logger.debug(f"\t\t Spotify-Trackname : {provider_track_name}")
|
||||
app.logger.debug(f"\t\t Jellyfin-Artists : {jellyfin_artists}")
|
||||
app.logger.debug(f"\t\t Spotify-Artists : {provider_artists}")
|
||||
app.logger.debug(f"\t\t Jellyfin-Alb.Art.: {jellyfin_album_artists}")
|
||||
if len(search_results) == 1:
|
||||
app.logger.debug(f"\tOnly 1 search_result: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})")
|
||||
|
||||
if (provider_track_name.lower() == jellyfin_track_name and
|
||||
(set(artist.lower() for artist in provider_artists) == set(jellyfin_artists) or set(jellyfin_album_artists) == set(artist.lower() for artist in provider_artists))):
|
||||
|
||||
app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
|
||||
best_match = result
|
||||
break
|
||||
|
||||
if quality_score > best_quality_score:
|
||||
best_match = result
|
||||
best_quality_score = quality_score
|
||||
|
||||
if (provider_track_name.lower() == jellyfin_track_name and
|
||||
(set(artist.lower() for artist in provider_artists) == set(jellyfin_artists) or set(jellyfin_album_artists) == set(artist.lower() for artist in provider_artists))):
|
||||
app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
|
||||
|
||||
if quality_score > best_quality_score:
|
||||
best_match = result
|
||||
best_quality_score = quality_score
|
||||
|
||||
return best_match
|
||||
except Exception as e:
|
||||
@@ -478,44 +593,55 @@ def compute_quality_score(result, use_ffprobe=False) -> float:
|
||||
if use_ffprobe:
|
||||
path = result.get('Path')
|
||||
if path:
|
||||
ffprobe_score = analyze_audio_quality_with_ffprobe(path)
|
||||
profile = AudioProfile.analyze_audio_quality_with_ffprobe(path)
|
||||
ffprobe_score = profile.compute_quality_score()
|
||||
score += ffprobe_score
|
||||
else:
|
||||
app.logger.warning(f"No valid file path for track {result.get('Name')} - Skipping ffprobe analysis.")
|
||||
|
||||
return score
|
||||
|
||||
def analyze_audio_quality_with_ffprobe(filepath):
|
||||
"""
|
||||
Use ffprobe to extract quality attributes from an audio file and compute a score.
|
||||
"""
|
||||
try:
|
||||
# ffprobe command to extract bitrate, sample rate, and channel count
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error', '-select_streams', 'a:0',
|
||||
'-show_entries', 'stream=bit_rate,sample_rate,channels',
|
||||
'-show_format',
|
||||
'-of', 'json', filepath
|
||||
]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode != 0:
|
||||
app.logger.error(f"ffprobe error for file {filepath}: {result.stderr}")
|
||||
return 0
|
||||
|
||||
# Parse ffprobe output
|
||||
import json
|
||||
data = json.loads(result.stdout)
|
||||
stream = data.get('streams', [{}])[0]
|
||||
bitrate = int(stream.get('bit_rate', 0)) // 1000 # Convert to kbps
|
||||
if bitrate == 0:
|
||||
bitrate = int(data.get('format')['bit_rate']) // 1000
|
||||
sample_rate = int(stream.get('sample_rate', 0)) # Hz
|
||||
|
||||
channels = int(stream.get('channels', 0))
|
||||
|
||||
# Compute score based on extracted quality parameters
|
||||
score = bitrate + (sample_rate // 1000) + (channels * 10) # Example scoring formula
|
||||
return score
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error analyzing audio quality with ffprobe: {str(e)}")
|
||||
return 0
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self):
|
||||
self.tasks = {
|
||||
'update_all_playlists_track_status': None,
|
||||
'download_missing_tracks': None,
|
||||
'check_for_playlist_updates': None,
|
||||
'update_jellyfin_id_for_downloaded_tracks': None
|
||||
}
|
||||
if app.config['LIDARR_API_KEY']:
|
||||
self.tasks['request_lidarr'] = None
|
||||
|
||||
def start_task(self, task_name, *args, **kwargs):
|
||||
if task_name not in self.tasks:
|
||||
raise ValueError(f"Task {task_name} is not defined.")
|
||||
task = globals()[task_name].delay(*args, **kwargs)
|
||||
self.tasks[task_name] = task.id
|
||||
return task.id
|
||||
|
||||
def get_task_status(self, task_name):
|
||||
if task_name not in self.tasks:
|
||||
raise ValueError(f"Task {task_name} is not defined.")
|
||||
task_id = self.tasks[task_name]
|
||||
if not task_id:
|
||||
return {'state': 'NOT STARTED', 'info': {}, 'lock_status': False}
|
||||
result = AsyncResult(task_id)
|
||||
lock_status = True if self.get_lock(f"{task_name}_lock") else False
|
||||
return {'state': result.state, 'info': result.info if result.info else {}, 'lock_status': lock_status}
|
||||
|
||||
def acquire_lock(self, lock_name, expiration=60):
|
||||
return redis_client.set(lock_name, "locked", ex=expiration, nx=True)
|
||||
|
||||
def release_lock(self, lock_name):
|
||||
redis_client.delete(lock_name)
|
||||
|
||||
def get_lock(self, lock_name):
|
||||
return redis_client.get(lock_name)
|
||||
|
||||
def prepare_logger(self):
|
||||
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(message)s"
|
||||
logging.basicConfig(format=FORMAT)
|
||||
|
||||
task_manager = TaskManager()
|
||||
7
app/typings.pyi
Normal file
7
app/typings.pyi
Normal file
@@ -0,0 +1,7 @@
|
||||
from flask import g
|
||||
from providers.base import MusicProviderClient
|
||||
|
||||
g: "Global"
|
||||
|
||||
class Global:
|
||||
music_provider: MusicProviderClient
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.8"
|
||||
|
||||
62
changelogs/0.1.7.md
Normal file
62
changelogs/0.1.7.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Whats up in Jellyplist 0.1.7?
|
||||
### Major overhaul
|
||||
I´ve been working the past week to make this project work again, after [Spotify announced to deprecate](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api) the playlist discover API´s , which were a crucial part of this project.
|
||||
I also took this opportunity at the same time to do a major overhaul, on how Jellyplist gathers data from a music provider. Music provider API implementations must now implement defined abstract classes to work with Jellyplist, think of it like _plugins_. Jellyplist now, in theory, can gather data from any music provider - just the _plugins_ must be written. It also doesn´t matter, if it have 1,2 or 10 Music Providers to playlists. So stay tuned for more to come.
|
||||
The next ones will be Deezer and YTM
|
||||
|
||||
After the providers will be implemented, I will at some point do the same with the media backend - so Jellyplist will be able to support other media backends like Navidrome, Plex, Emby and so on...
|
||||
|
||||
### 🆕New API Implementation for Spotify
|
||||
As mentioned above, I needed a new way to get playlists.
|
||||
Now, to get them , you don´t need an API Key to authenticate, you even don´t need to be authenticated at all. If you like to have Playlists recommended or created for you, you can use authentication via a cookie.
|
||||
To do this, add a env var to you `.env` file:
|
||||
```bash
|
||||
SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt'
|
||||
```
|
||||
And map the cookie from your local filesystem to the container path you´ve set in the `.env`file
|
||||
```yaml
|
||||
...
|
||||
...
|
||||
volumes:
|
||||
- /your/local/path/open.spotify.com_cookies.txt:${SPOTIFY_COOKIE_FILE}
|
||||
...
|
||||
...
|
||||
```
|
||||
|
||||
### 🆕Lidarr integration is here
|
||||
To enable the Lidarr integration add these to your `.env` file
|
||||
```bash
|
||||
LIDARR_API_KEY = aabbccddeeffgghh11223344 # self explaining
|
||||
LIDARR_URL = http://<your_lidarr_ip>:8686 # too
|
||||
LIDARR_MONITOR_ARTISTS = false # If false, only the corresponding
|
||||
# album will be set to monitored in lidarr, if true the whole artist
|
||||
# will be set as monitored. Be careful in the beginning as you might
|
||||
# hammer your lidarr instance and you indexers. Defaults to false
|
||||
```
|
||||
After you enabled Lidarr integration, make sure to go to _"Admin -> Lidarr"_ and set the default quality profile and music root folder.
|
||||
|
||||
With the Lidarr integration you get a nice workflow:
|
||||
1. Add Playlist
|
||||
2. Playlist gets downloaded via SpotDL and is available after some time
|
||||
3. At some point (every hour on x:50) the requests to Lidarr are made.
|
||||
4. Lidarr gets all files.
|
||||
5. Once a day Jellyplist is doing a full update on all tracks, and searches for the same track but with a better quality profile.
|
||||
|
||||
### ⚠️ New required env var
|
||||
Ensure to add `MUSIC_STORAGE_BASE_PATH` to your `.env` file.
|
||||
```bash
|
||||
MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where
|
||||
# your music library is located.
|
||||
# Must be the same value as your music library in jellyfin
|
||||
```
|
||||
|
||||
### ⚠️ Breaking change
|
||||
As some table columns has been renamed, make sure to wipe your existing Jellyplist pgdata. Sorry for the inconvenience.
|
||||
|
||||
### Other changes, improvements and fixes
|
||||
- UI/UX: The index page now has content. From there you can directly drop a playlist link
|
||||
- UI/UX: The Search bar now works with the new API implementation
|
||||
- UI/UX: A new `Browse All` (per Music Provider) Page from where you can discover playlists
|
||||
- UI/UX: Check technical details on a track. Just doubleclick a row in the details view of a playlist.
|
||||
- UI/UX: Allow to link a track even it´s not marked as downloaded.
|
||||
- UI/UX: Reworked celery task management and the /admin/tasks UI
|
||||
15
changelogs/0.1.8.md
Normal file
15
changelogs/0.1.8.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Whats up in Jellyplist 0.1.8?
|
||||
Not much this time, just some small fixes and one enhancement.
|
||||
|
||||
### 🆕Jellyplist now checks for updates
|
||||
Jellyplist now checks the GitHub releases for new version.
|
||||
If a new version is available, you will notice the small badge on the lower left will pulsate slighty, so you don´t miss any new release :smile:
|
||||
|
||||
If you don´t like that Jellyplist is doing this, you can opt out by setting this env var in your `.env` file
|
||||
```bash
|
||||
CHECK_FOR_UPDATES = false
|
||||
```
|
||||
|
||||
### Other changes, improvements and fixes
|
||||
- Fix for #30 , where the output path for spotDL wasn´t created correctly
|
||||
|
||||
18
config.py
18
config.py
@@ -11,12 +11,14 @@ class Config:
|
||||
JELLYFIN_REQUEST_TIMEOUT = int(os.getenv('JELLYFIN_REQUEST_TIMEOUT','10'))
|
||||
SPOTIFY_CLIENT_ID = os.getenv('SPOTIFY_CLIENT_ID')
|
||||
SPOTIFY_CLIENT_SECRET = os.getenv('SPOTIFY_CLIENT_SECRET')
|
||||
SPOTIFY_COOKIE_FILE = os.getenv('SPOTIFY_COOKIE_FILE')
|
||||
JELLYPLIST_DB_HOST = os.getenv('JELLYPLIST_DB_HOST')
|
||||
JELLYPLIST_DB_PORT = int(os.getenv('JELLYPLIST_DB_PORT','5432'))
|
||||
JELLYPLIST_DB_USER = os.getenv('JELLYPLIST_DB_USER')
|
||||
JELLYPLIST_DB_PASSWORD = os.getenv('JELLYPLIST_DB_PASSWORD')
|
||||
START_DOWNLOAD_AFTER_PLAYLIST_ADD = os.getenv('START_DOWNLOAD_AFTER_PLAYLIST_ADD',"false").lower() == 'true' # If a new Playlist is added, the Download Task will be scheduled immediately
|
||||
REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = os.getenv('REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK',"false").lower() == 'true'
|
||||
DISPLAY_EXTENDED_AUDIO_DATA = os.getenv('DISPLAY_EXTENDED_AUDIO_DATA',"false").lower() == 'true'
|
||||
CACHE_TYPE = 'redis'
|
||||
CACHE_REDIS_PORT = 6379
|
||||
CACHE_REDIS_HOST = 'redis'
|
||||
@@ -25,12 +27,23 @@ class Config:
|
||||
REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0')
|
||||
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'
|
||||
MUSIC_STORAGE_BASE_PATH = os.getenv('MUSIC_STORAGE_BASE_PATH')
|
||||
CHECK_FOR_UPDATES = os.getenv('CHECK_FOR_UPDATES','true').lower() == 'true'
|
||||
# SpotDL specific configuration
|
||||
SPOTDL_CONFIG = {
|
||||
'cookie_file': '/jellyplist/cookies.txt',
|
||||
'output': '/jellyplist_downloads/__jellyplist/{track-id}',
|
||||
# combine the path provided in MUSIC_STORAGE_BASE_PATH with the following path __jellyplist/{track-id} to get the value for output
|
||||
|
||||
'threads': 12
|
||||
}
|
||||
if os.getenv('MUSIC_STORAGE_BASE_PATH'):
|
||||
|
||||
output_path = os.path.join(MUSIC_STORAGE_BASE_PATH,'__jellyplist/{track-id}')
|
||||
SPOTDL_CONFIG.update({'output': output_path})
|
||||
|
||||
@classmethod
|
||||
def validate_env_vars(cls):
|
||||
@@ -45,7 +58,8 @@ class Config:
|
||||
'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST,
|
||||
'JELLYPLIST_DB_USER' : cls.JELLYPLIST_DB_USER,
|
||||
'JELLYPLIST_DB_PASSWORD' : cls.JELLYPLIST_DB_PASSWORD,
|
||||
'REDIS_URL': cls.REDIS_URL
|
||||
'REDIS_URL': cls.REDIS_URL,
|
||||
'MUSIC_STORAGE_BASE_PATH': cls.MUSIC_STORAGE_BASE_PATH
|
||||
}
|
||||
|
||||
missing_vars = [var for var, value in required_vars.items() if not value]
|
||||
|
||||
@@ -310,11 +310,21 @@ class JellyfinClient:
|
||||
|
||||
response = requests.delete(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||
|
||||
if response.status_code == 204: # 204 No Content indicates successful deletion
|
||||
return {"status": "success", "message": "Playlist removed successfully"}
|
||||
else:
|
||||
raise Exception(f"Failed to remove playlist: {response.content}")
|
||||
|
||||
def get_item(self, session_token: str, item_id: str):
|
||||
url = f'{self.base_url}/Items/{item_id}'
|
||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get item: {response.content}")
|
||||
|
||||
def remove_user_from_playlist(self, session_token: str, playlist_id: str, user_id: str):
|
||||
"""
|
||||
@@ -341,7 +351,7 @@ class JellyfinClient:
|
||||
raise Exception(f"Failed to remove user from playlist: {response.content}")
|
||||
|
||||
|
||||
def set_playlist_cover_image(self, session_token: str, playlist_id: str, spotify_image_url: str):
|
||||
def set_playlist_cover_image(self, session_token: str, playlist_id: str, provider_image_url: str):
|
||||
"""
|
||||
Set the cover image of a playlist in Jellyfin using an image URL from Spotify.
|
||||
|
||||
@@ -351,7 +361,7 @@ class JellyfinClient:
|
||||
:return: Success message or raises an exception on failure.
|
||||
"""
|
||||
# Step 1: Download the image from the Spotify URL
|
||||
response = requests.get(spotify_image_url, timeout = self.timeout)
|
||||
response = requests.get(provider_image_url, timeout = self.timeout)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Failed to download image from Spotify: {response.content}")
|
||||
@@ -444,7 +454,6 @@ class JellyfinClient:
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def search_track_in_jellyfin(self, session_token: str, preview_url: str, song_name: str, artist_names: list):
|
||||
"""
|
||||
Search for a track in Jellyfin by comparing the preview audio to tracks in the library.
|
||||
@@ -546,8 +555,6 @@ class JellyfinClient:
|
||||
print(f"Error in search_track_in_jellyfin: {str(e)}")
|
||||
return False, None
|
||||
|
||||
|
||||
|
||||
# Helper methods used in search_track_in_jellyfin
|
||||
def download_preview_to_tempfile(self, preview_url):
|
||||
try:
|
||||
|
||||
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,70 @@
|
||||
"""refacotring db to work with multiple music provider
|
||||
|
||||
Revision ID: 18d056f49f59
|
||||
Revises: d4fef99d5d3c
|
||||
Create Date: 2024-11-29 22:51:41.271688
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '18d056f49f59'
|
||||
down_revision = 'd4fef99d5d3c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('provider_playlist_id', sa.String(length=120), nullable=False))
|
||||
batch_op.add_column(sa.Column('provider_uri', sa.String(length=120), nullable=False))
|
||||
batch_op.add_column(sa.Column('provider_id', sa.String(length=20), nullable=True))
|
||||
batch_op.drop_constraint('playlist_spotify_playlist_id_key', type_='unique')
|
||||
batch_op.drop_constraint('playlist_spotify_uri_key', type_='unique')
|
||||
batch_op.create_unique_constraint(None, ['provider_uri'])
|
||||
batch_op.create_unique_constraint(None, ['provider_playlist_id'])
|
||||
batch_op.drop_column('spotify_playlist_id')
|
||||
batch_op.drop_column('spotify_uri')
|
||||
|
||||
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('provider_track_id', sa.String(length=120), nullable=False))
|
||||
batch_op.add_column(sa.Column('provider_uri', sa.String(length=120), nullable=False))
|
||||
batch_op.add_column(sa.Column('provider_id', sa.String(length=20), nullable=True))
|
||||
batch_op.drop_constraint('track_spotify_track_id_key', type_='unique')
|
||||
batch_op.drop_constraint('track_spotify_uri_key', type_='unique')
|
||||
batch_op.create_unique_constraint(None, ['provider_track_id'])
|
||||
batch_op.create_unique_constraint(None, ['provider_uri'])
|
||||
batch_op.drop_column('spotify_track_id')
|
||||
batch_op.drop_column('spotify_uri')
|
||||
|
||||
# ### 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.add_column(sa.Column('spotify_uri', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
|
||||
batch_op.add_column(sa.Column('spotify_track_id', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.create_unique_constraint('track_spotify_uri_key', ['spotify_uri'])
|
||||
batch_op.create_unique_constraint('track_spotify_track_id_key', ['spotify_track_id'])
|
||||
batch_op.drop_column('provider_id')
|
||||
batch_op.drop_column('provider_uri')
|
||||
batch_op.drop_column('provider_track_id')
|
||||
|
||||
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('spotify_uri', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
|
||||
batch_op.add_column(sa.Column('spotify_playlist_id', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.create_unique_constraint('playlist_spotify_uri_key', ['spotify_uri'])
|
||||
batch_op.create_unique_constraint('playlist_spotify_playlist_id_key', ['spotify_playlist_id'])
|
||||
batch_op.drop_column('provider_id')
|
||||
batch_op.drop_column('provider_uri')
|
||||
batch_op.drop_column('provider_playlist_id')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -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 ###
|
||||
41
readme.md
41
readme.md
@@ -9,22 +9,22 @@ Jellyplist aims to be a companion app for your self-hosted [Jellyfin](https://je
|
||||
It´s definitely not a general Playlist Manager for Jellyfin.
|
||||
|
||||
## Features
|
||||
- **Discover Playlists**: Use well-known *Featured Playlists* listings.
|
||||
- **Categories**: Browse playlists by categories
|
||||
- **Discover Playlists**: Browse playlists like its nothing.
|
||||
- **View Monitored Playlists**: View playlists which are already synced by the server, adding these to your Jellyfin account will make them available immediately
|
||||
- **Search Playlist**: Search for playlists
|
||||
- **No Sign-Up or User-Accounts**: Jellyplist uses your local Jellyfin server for authentication
|
||||
- **Automatically keep track of changes**: Changes in order, added or removed songs will be tracked and synced with Jellyfin.
|
||||
- **Metadata Sync**: Playlist Metadata will be available at your Jellyfin Server
|
||||
- **Lidarr Integrations**: Automatically submit Artists or only Albums to your Lidarr instance
|
||||
- **Automatic Quality Upgrades**: When the same track from a playlist is added later with better quality, the playlist in Jellyfin will be updated to use the better sounding track.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The easiest way to start is by using docker and compose.
|
||||
1. Log in on https://developers.spotify.com/. Go to the dashboard, create an app and get your Client ID and Secret
|
||||
2. Get your [cookies.txt file for spot-dl ](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium)
|
||||
> [!IMPORTANT]
|
||||
> Currently a [youtube premium account](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium) is required, the next release will mitigate this.
|
||||
3. Prepare a `.env` File
|
||||
2. Get your [cookies.txt file for spot-dl ](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium) if you want downloaded files to have 256kbit/s, otherwise 128kbit/s
|
||||
3. Get your cookie-file from open.spotify.com , this works the same way as in step 2.
|
||||
4. Prepare a `.env` File
|
||||
```
|
||||
POSTGRES_USER = jellyplist
|
||||
POSTGRES_PASSWORD = jellyplist
|
||||
@@ -37,10 +37,27 @@ SPOTIFY_CLIENT_SECRET = <Secret from Step 1>
|
||||
JELLYPLIST_DB_HOST = postgres-jellyplist #Hostname of the db Container
|
||||
JELLYPLIST_DB_USER = jellyplist
|
||||
JELLYPLIST_DB_PASSWORD = jellyplist
|
||||
# Optional:
|
||||
MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where your music library is located. Must be the same value as your music library in jellyfin
|
||||
|
||||
### Optional:
|
||||
|
||||
# SEARCH_JELLYFIN_BEFORE_DOWNLOAD = false # defaults to true, before attempting to do a download with spotDL , the song will be searched first in the local library
|
||||
|
||||
# START_DOWNLOAD_AFTER_PLAYLIST_ADD = true # defaults to false, If a new Playlist is added, the Download Task will be scheduled immediately
|
||||
#
|
||||
|
||||
# FIND_BEST_MATCH_USE_FFPROBE = true # Use ffprobe to gather quality details from a file to calculate quality score. Otherwise jellyplist will use details provided by jellyfin. defaults to false.
|
||||
|
||||
#REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = true # jellyplist will trigger a music library update on your Jellyfin server, in case you dont have `Realtime Monitoring` enabled on your Jellyfin library. Defaults to false.
|
||||
|
||||
# LOG_LEVEL = DEBUG # Defaults to INFO
|
||||
|
||||
# SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt' # Not necesarily needed, but if you like to browse your personal recomendations you must provide it so that the new api implementation is able to authenticate
|
||||
|
||||
### Lidarr integration
|
||||
# LIDARR_API_KEY = aabbccddeeffgghh11223344 # self explaining
|
||||
# LIDARR_URL = http://<your_lidarr_ip>:8686 # too
|
||||
# LIDARR_MONITOR_ARTISTS = false # If false, only the corresponding album will be set to monitored in lidarr, if true the whole artist will be set as monitored. Be careful in the beginning as you might hammer your lidarr instance and you indexers. Defaults to false
|
||||
|
||||
|
||||
```
|
||||
|
||||
@@ -81,8 +98,9 @@ services:
|
||||
- jellyplist-network
|
||||
volumes:
|
||||
# Map Your cookies.txt file to exac
|
||||
- /your/local/path/cookies.txt:/jellyplist/cookies.txt
|
||||
- /storage/media/music:/jellyplist_downloads
|
||||
- /your/local/path/cookies.txt:/jellyplist/cookies.txt #
|
||||
- /your/local/path/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
|
||||
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} # Jellyplist must be able to access the file paths like they are stored in Jellyfin
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -95,7 +113,8 @@ services:
|
||||
volumes:
|
||||
# Map Your cookies.txt file to exac
|
||||
- /your/local/path/cookies.txt:/jellyplist/cookies.txt
|
||||
- /storage/media/music:/jellyplist_downloads
|
||||
- /your/local/path/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
|
||||
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} # Jellyplist must be able to access the file paths like they are stored in Jellyfin
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
|
||||
@@ -3,16 +3,18 @@ body {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #1a1d21;
|
||||
background-color: #1a1d21;
|
||||
height: 100vh;
|
||||
padding-top: 20px;
|
||||
padding-left: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background-color: #1a1d21;
|
||||
background-color: #1a1d21;
|
||||
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
color: white;
|
||||
padding-left: 15px;
|
||||
@@ -50,6 +52,76 @@ body {
|
||||
width: 140px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.logo img{
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1600px) {
|
||||
.modal-dialog {
|
||||
max-width: 90%;
|
||||
/* New width for default modal */
|
||||
}
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
margin-bottom: auto;
|
||||
margin-top: auto;
|
||||
height: 60px;
|
||||
background-color: #353b48;
|
||||
border-radius: 30px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.search_input {
|
||||
color: white;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: none;
|
||||
width: 450px;
|
||||
caret-color: transparent;
|
||||
line-height: 40px;
|
||||
transition: width 0.4s linear;
|
||||
}
|
||||
|
||||
.searchbar:hover>.search_input {
|
||||
/* padding: 0 10px; */
|
||||
width: 450px;
|
||||
caret-color: red;
|
||||
/* transition: width 0.4s linear; */
|
||||
}
|
||||
|
||||
.searchbar:hover>.search_icon {
|
||||
background: white;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.search_icon {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
float: right;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-pulsing {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
BIN
static/images/placeholder.png
Normal file
BIN
static/images/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -5,14 +5,14 @@ var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
})
|
||||
|
||||
// Function to open the search modal and trigger the search automatically
|
||||
function openSearchModal(trackTitle, spotify_id) {
|
||||
function openSearchModal(trackTitle, provider_track_id) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('searchModal'));
|
||||
const searchQueryInput = document.getElementById('search-query');
|
||||
const spotifyIdInput = document.getElementById('spotify-id');
|
||||
const providerTrackIdInput = document.getElementById('provider-track-id');
|
||||
|
||||
// Pre-fill the input fields
|
||||
searchQueryInput.value = trackTitle;
|
||||
spotifyIdInput.value = spotify_id;
|
||||
providerTrackIdInput.value = provider_track_id;
|
||||
|
||||
// Show the modal
|
||||
modal.show();
|
||||
@@ -85,10 +85,10 @@ function playJellyfinTrack(button, jellyfinId) {
|
||||
.catch(error => console.error('Error fetching Jellyfin stream URL:', error));
|
||||
}
|
||||
|
||||
function handleJellyfinClick(event, jellyfinId, trackTitle, spotifyId) {
|
||||
function handleJellyfinClick(event, jellyfinId, trackTitle, providerTrackId) {
|
||||
if (event.ctrlKey) {
|
||||
// CTRL key is pressed, open the search modal
|
||||
openSearchModal(trackTitle, spotifyId);
|
||||
openSearchModal(trackTitle, providerTrackId);
|
||||
} else {
|
||||
// CTRL key is not pressed, play the track
|
||||
playJellyfinTrack(event.target, jellyfinId);
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,9 +1,10 @@
|
||||
{% extends "admin.html" %}
|
||||
{% block admin_content %}
|
||||
<div class="container mt-5">
|
||||
<table class="table">
|
||||
<div class="">
|
||||
<table class="table ">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Locked</th>
|
||||
<th>Task Name</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
@@ -15,6 +16,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h4>Unlock blocked tasks</h4>
|
||||
<div>
|
||||
<form hx-post="/unlock_key" hx-swap="outerHTML" hx-target="#empty">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -34,12 +34,22 @@
|
||||
<nav>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="fab fa-house"></i> Home</a>
|
||||
</li>
|
||||
{% for provider in registered_providers %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link " href="/browse?provider={{provider}}">
|
||||
<i class="fab fa-{{provider.lower()}}"></i> Browse {{provider}}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/categories"><i class="fa-solid fa-layer-group"></i>
|
||||
Categories</a>
|
||||
</li>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/playlists/monitored"><i
|
||||
class="fa-solid fa-tower-observation"></i> Monitored</a>
|
||||
@@ -69,12 +79,22 @@
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="fas fa-house"></i> Home</a>
|
||||
</li>
|
||||
{% for provider in registered_providers %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link " href="/browse?provider={{provider}}">
|
||||
<i class="fab fa-{{provider.lower()}}"></i> Browse {{provider}}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link text-white" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white" href="/categories"><i class="fa-solid fa-layer-group"></i>
|
||||
Categories</a>
|
||||
</li>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white" href="/playlists/monitored"><i
|
||||
class="fa-solid fa-tower-observation"></i> Monitored </a>
|
||||
@@ -96,7 +116,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<span class="fixed-bottom m-3">{{version}}</span>
|
||||
<span class="fixed-bottom m-3 ms-5">{{version | version_check}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main content with toggle button for mobile sidebar -->
|
||||
@@ -108,29 +128,23 @@
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<h1 class="mb-4 ms-3">{{ title }}</h1>
|
||||
|
||||
<div class="d-flex align-items-center ">
|
||||
<form action="/search" method="GET" class="w-100">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="search"
|
||||
class="form-control"
|
||||
name="query"
|
||||
placeholder="Search Spotify..."
|
||||
aria-label="Search"
|
||||
>
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="ms-4">
|
||||
<!-- Display Initials Badge -->
|
||||
<span >{{ session.get('jellyfin_user_name') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search Form -->
|
||||
<form action="/search" method="GET" class="d-flex flex-grow-1 mb-1 me-2">
|
||||
<input
|
||||
type="search"
|
||||
class="form-control me-2"
|
||||
name="query"
|
||||
placeholder="Search ..."
|
||||
aria-label="Search"
|
||||
>
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<!-- Display Initials Badge -->
|
||||
<span>{{ session.get('jellyfin_user_name') }}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-1 ">{{ title }}</h1>
|
||||
<h3 class="mb-4 ">{{ subtitle }}</h3>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
61
templates/browse.html
Normal file
61
templates/browse.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% for section in browse_data %}
|
||||
<div class="browse-section">
|
||||
<h1>{{ section.title }}</h1>
|
||||
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4">
|
||||
{% for card in section.items %}
|
||||
<div class="col">
|
||||
<div class="card shadow h-100 d-flex flex-column position-relative" >
|
||||
<a href="/browse/page/{{ card.uri }}?provider={{provider_id}}">
|
||||
<img src="{{ card.artwork.0.url }}" class="card-img-top" alt="{{ card.title }}">
|
||||
<div class="card-body d-flex flex-column justify-content-between">
|
||||
<h5 class="card-title">{{ card.title }}</h5>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
<style>
|
||||
.browse-section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.browse-section h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.browse-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.browse-card {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.browse-card img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.browse-card .title {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
font-size: 18px;
|
||||
text-shadow: 1px 1px 2px #000;
|
||||
}
|
||||
</style>
|
||||
12
templates/browse_page.html
Normal file
12
templates/browse_page.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- <h1 class="mb-4">{{ data.title }}</h1>
|
||||
<h6 class="mb-4">{{ data.subtitle }}</h6> -->
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4 mt-4" id="items-container">
|
||||
{% for item in data %}
|
||||
{% include 'partials/playlist_item.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,5 +2,36 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container h-100">
|
||||
<div class="d-flex justify-content-center h-100">
|
||||
<div class="searchbar">
|
||||
<!-- Form element with HTMX attributes -->
|
||||
<form method="GET" action="/playlist/open" >
|
||||
<input
|
||||
class="search_input"
|
||||
type="text"
|
||||
name="playlist"
|
||||
placeholder="Paste a Playlist ID or a complete link to a playlist"
|
||||
required
|
||||
>
|
||||
<button type="submit" class="search_icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger mt-5" role="alert">
|
||||
<h4 class="alert-heading">🚨Cant fetch playlist🚨</h4>
|
||||
<p>{{ error_message }}</p>
|
||||
<hr>
|
||||
<p>Additional Information:</p>
|
||||
<p>{{error_data}}</p>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger mt-5" role="alert">
|
||||
<h4 class="alert-heading">🚨Something went wrong🚨</h4>
|
||||
<p>{{ error_message }}</p>
|
||||
<hr>
|
||||
<p>Additional Information:</p>
|
||||
<p>{{error_data}}</p>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="mb-4">{{ items_title }}</h1>
|
||||
<h6 class="mb-4">{{ items_subtitle }}</h6>
|
||||
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
|
||||
@@ -8,5 +18,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger mt-5" role="alert">
|
||||
<h4 class="alert-heading">🚨Something went wrong🚨</h4>
|
||||
<p>{{ error_message }}</p>
|
||||
<hr>
|
||||
<p>Additional Information:</p>
|
||||
<p>{{error_data}}</p>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 >Your subscribed Jellyfin Playlists</h1>
|
||||
<h6 ></h6>
|
||||
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
|
||||
@@ -10,6 +20,6 @@
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
{%endif%}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
16
templates/monitored_playlists.html
Normal file
16
templates/monitored_playlists.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
{% for provider_id, playlists in provider_playlists_data.items() %}
|
||||
<div class="provider-section mb-5">
|
||||
<h2>{{ provider_id }}</h2>
|
||||
<div class="row row-cols-2 row-cols-md-6 g-4">
|
||||
{% for item in playlists %}
|
||||
{% include 'partials/playlist_item.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% if item.can_add %}
|
||||
<button class="btn btn-success" hx-post="/addplaylist" hx-include="this" hx-swap="outerHTML" hx-target="this"
|
||||
<button class="btn btn-success" hx-post="/addplaylist?provider={{provider_id}}" hx-include="this" hx-swap="outerHTML" hx-target="this"
|
||||
data-bs-toggle="tooltip" title="Add to my Jellyfin"
|
||||
hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'>
|
||||
<i class="fa-solid fa-circle-plus"> </i>
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<th>Name</th>
|
||||
<th>Artist(s)</th>
|
||||
<th>Path</th>
|
||||
<th>Container</th>
|
||||
{% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -14,17 +18,21 @@
|
||||
<tbody>
|
||||
{% for track in results %}
|
||||
<tr>
|
||||
<td>{{ track.Name }}</td>
|
||||
<td>{{ track.Name | highlight(search_query) }}</td>
|
||||
<td>{{ ', '.join(track.Artists) }}</td>
|
||||
<td>{{ track.Path}}</td>
|
||||
<td>{{ track.Container }}</td>
|
||||
{% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %}
|
||||
<td> {{track.Path | audioprofile(track.Path) }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="playJellyfinTrack(this, '{{ track.Id }}')">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button hx-swap="beforebegin" class="btn btn-sm btn-success" hx-post="/associate_track" hx-vals='{"jellyfin_id": "{{ track.Id }}","spotify_id": "{{ spotify_id}}"}'>
|
||||
Associate Track
|
||||
<button hx-swap="beforebegin" class="btn btn-sm btn-success" hx-post="/associate_track" hx-vals='{"jellyfin_id": "{{ track.Id }}","provider_track_id": "{{ provider_track_id}}"}' data-bs-toggle="tooltip" title="Link this Track [{{track.Id}}] with Provider-Track-ID {{ provider_track_id }}">
|
||||
Link Track
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<div class="d-flex align-items-center row sticky-top py-3 mb-3 bg-dark" style="top: 0; z-index: 1000;">
|
||||
<div class="col-6">
|
||||
<img src="{{ playlist_cover }}" class="img-fluid">
|
||||
<img src="{{ item.image }}" class="img-fluid">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="playlist-info">
|
||||
<h1>{{ playlist_name }}</h1>
|
||||
<p>{{ playlist_description }}</p>
|
||||
<p>{{ track_count }} songs, {{ total_duration }}</p>
|
||||
<p>Last Updated: {{ last_updated}} | Last Change: {{ last_changed}}</p>
|
||||
<h1>{{ item.name }}</h1>
|
||||
<p>{{ item.description }}</p>
|
||||
<p>{{ item.track_count }} songs, {{ total_duration }}</p>
|
||||
<p>Last Updated: {{ item.last_updated}} | Last Change: {{ item.last_changed}}</p>
|
||||
{% include 'partials/_add_remove_button.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{% for item in items %}
|
||||
{% include 'partials/_spotify_item.html' %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% if next_offset < total_items %}
|
||||
<div hx-get="{{ endpoint }}?offset={{ next_offset }}{{ additional_query }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="beforeend"
|
||||
hx-indicator=".loading-indicator"
|
||||
hx-target="#items-container"
|
||||
class="loading-indicator text-center">
|
||||
Loading more items...
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Show the loading indicator only when it is active
|
||||
document.querySelectorAll('.loading-indicator').forEach(indicator => {
|
||||
indicator.addEventListener('htmx:afterRequest', () => {
|
||||
indicator.style.display = 'none'; // Hide the indicator after the request completes
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,7 +1,14 @@
|
||||
{% for task_name, task in tasks.items() %}
|
||||
<tr id="task-row-{{ task_name }}">
|
||||
<td>{{ task_name }}</td>
|
||||
<td>{{ task.state }}</td>
|
||||
<td class="w-auto">
|
||||
{% if task.lock_status %}
|
||||
<i class="fas fa-lock text-warning"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-unlock text-success"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="w-25">{{ task_name }}</td>
|
||||
<td class="w-50">{{ task.state }}</td>
|
||||
<td>
|
||||
{% if task.info.percent %}
|
||||
<div class="progress" style="height: 20px;">
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<div class="toast align-items-center text-white {{ 'bg-success' if success else 'bg-danger' }} border-0" role="alert"
|
||||
aria-live="assertive" aria-atomic="true" style="position: fixed; bottom: 20px; right: 20px; z-index: 1000;">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var toastElList = [].slice.call(document.querySelectorAll('.toast'))
|
||||
var toastList = toastElList.map(function (toastEl) {
|
||||
return new bootstrap.Toast(toastEl)
|
||||
})
|
||||
toastList.forEach(toast => toast.show());
|
||||
</script>
|
||||
@@ -1,77 +1,100 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Duration</th>
|
||||
<th scope="col">Spotify</th>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Jellyfin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for track in tracks %}
|
||||
<tr>
|
||||
<th scope="row">{{ loop.index }}</th>
|
||||
<td>{{ track.title }}</td>
|
||||
<td>{{ track.artist }}</td>
|
||||
<td>{{ track.duration }}</td>
|
||||
<td>
|
||||
<a href="{{ track.url }}" target="_blank" class="text-success" data-bs-toggle="tooltip" title="Open in Spotify">
|
||||
<i class="fab fa-spotify fa-lg"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if track.preview_url %}
|
||||
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')" data-bs-toggle="tooltip" title="Play Preview">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span data-bs-toggle="tooltip" title="No Preview Available">
|
||||
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not track.downloaded %}
|
||||
<button class="btn btn-sm btn-danger"
|
||||
data-bs-toggle="tooltip" title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Track downloaded">
|
||||
<i class="fa-solid fa-circle-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set title = track.title | replace("'","") %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Duration</th>
|
||||
<th scope="col">{{provider_id}}</th>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Jellyfin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for track in tracks %}
|
||||
<tr hx-get="/track_details/{{track.provider_track_id}}?provider={{ provider_id }}"
|
||||
hx-target="#trackDetailsModalcontent" hx-trigger="dblclick" hx-on="htmx:afterOnLoad:showModal">
|
||||
<th scope="row">{{ loop.index }}</th>
|
||||
<td>{{ track.title }}</td>
|
||||
<td>{{ track.artist }}</td>
|
||||
<td>{{ track.duration }}</td>
|
||||
<td>
|
||||
<a href="{{ track.url[0] }}" target="_blank" class="text-success" data-bs-toggle="tooltip"
|
||||
title="Open in {{ track.provider_id }}">
|
||||
<i class="fab fa-{{ track.provider_id.lower() }} fa-lg"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if track.preview_url %}
|
||||
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')"
|
||||
data-bs-toggle="tooltip" title="Play Preview">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span data-bs-toggle="tooltip" title="No Preview Available">
|
||||
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not track.downloaded %}
|
||||
<button class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
|
||||
title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Downloaded">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set title = track.title | replace("'","") %}
|
||||
|
||||
{% if track.jellyfin_id %}
|
||||
<button class="btn btn-sm btn-success" onclick="handleJellyfinClick(event, '{{ track.jellyfin_id }}', '{{ title }}', '{{ track.spotify_id }}')" data-bs-toggle="tooltip" title="Play from Jellyfin (Hold CTRL Key to reassing a new track)">
|
||||
<i class="fas fa-play"></i>
|
||||
{% if track.jellyfin_id %}
|
||||
<button class="btn btn-sm btn-success"
|
||||
onclick="handleJellyfinClick(event, '{{ track.jellyfin_id }}', '{{ title }}', '{{ track.provider_track_id }}')"
|
||||
data-bs-toggle="tooltip" title="Play from Jellyfin (Hold CTRL Key to reassing a new track)">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% elif track.downloaded %}
|
||||
<span data-bs-toggle="tooltip"
|
||||
title="Track Downloaded, but not in Jellyfin or could not be associated automatically. You can try to do the association manually">
|
||||
<button class="btn btn-sm btn-warning"
|
||||
onclick="openSearchModal('{{ title }}','{{track.provider_track_id}}')">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
{% elif track.downloaded %}
|
||||
<span data-bs-toggle="tooltip" title="Track Downloaded, but not in Jellyfin or could not be associated automatically. You can try to do the association manually">
|
||||
<button class="btn btn-sm btn-warning" onclick="openSearchModal('{{ title }}','{{track.spotify_id}}')">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
</span>
|
||||
{% else %}
|
||||
<span data-bs-toggle="tooltip" title="Not Available">
|
||||
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</span>
|
||||
{% else %}
|
||||
<span>
|
||||
<button class="btn btn-sm" onclick="openSearchModal('{{ title }}','{{track.provider_track_id}}')"
|
||||
data-bs-toggle="tooltip" title="Click to assign a track"><i class="fas fa-ban"></i></button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="modal fade" id="trackDetailsModal" tabindex="-1" aria-labelledby="trackDetailsModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content" id="trackDetailsModalcontent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('htmx:afterOnLoad', function(event) {
|
||||
if (event.detail.target.id === 'trackDetailsModalcontent') {
|
||||
const modal = new bootstrap.Modal(document.getElementById('trackDetailsModal'));
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="searchModalLabel">Search Jellyfin for Track</h5>
|
||||
@@ -81,15 +104,21 @@
|
||||
<!-- htmx-enabled form -->
|
||||
<form id="search-form" hx-get="/search_jellyfin" hx-target="#search-results" hx-trigger="submit">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" id="search-query" name="search_query" placeholder="Search for a track...">
|
||||
<input type="hidden" class="form-control" id="spotify-id" name="spotify_id" >
|
||||
<input type="text" class="form-control" id="search-query" name="search_query"
|
||||
placeholder="Search for a track...">
|
||||
<input type="hidden" class="form-control" id="provider-track-id" name="provider_track_id">
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="search-results">
|
||||
<!-- Search results will be inserted here by htmx -->
|
||||
<div id="loading-spinner" class="d-flex justify-content-center my-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,12 +31,12 @@
|
||||
</div>
|
||||
<div class="mt-auto pt-3">
|
||||
{% if item.type == 'category'%}
|
||||
<a href="{{ item.url }}" class="btn btn-primary" data-bs-toggle="tooltip" title="View Playlists">
|
||||
<a href="{{ item.url }}" class="btn btn-primary" data-bs-toggle="tooltip" title="View Playlist">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</a>
|
||||
{%else%}
|
||||
|
||||
<a href="/playlist/view/{{ item.id }}" class="btn btn-primary" data-bs-toggle="tooltip"
|
||||
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}" class="btn btn-primary" data-bs-toggle="tooltip"
|
||||
title="View Playlist details">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</a>
|
||||
23
templates/partials/track_details.html
Normal file
23
templates/partials/track_details.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="trackDetailsModalLabel">Track Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p><strong>Title:</strong> {{ track.title }}</p>
|
||||
<p><strong>Artist:</strong> {{ track.artist }}</p>
|
||||
<p><strong>Duration:</strong> {{ track.duration }}</p>
|
||||
<p><strong>Duration (ms):</strong> {{ track.duration_ms }}</p>
|
||||
<p><strong>Provider:</strong> {{ track.provider_id }}</p>
|
||||
<p><strong>Provider Track URL:</strong> <a href="{{track.provider_track_url}}" target="_blank">{{track.provider_track_url}}</a></p>
|
||||
<!-- <p><strong>Preview URL:</strong> <a href="{{ track.preview_url }}" target="_blank">{{ track.preview_url if track.preview_url else 'No Preview Available' }}</a></p> -->
|
||||
<p><strong>Status:</strong> {{ 'Downloaded' if track.downloaded else 'Not Downloaded' }}</p>
|
||||
<p><strong>Jellyfin ID:</strong> {{ track.jellyfin_id | jellyfin_link }}</p>
|
||||
<p><strong>Provider Track ID:</strong> {{ track.provider_track_id }}</p>
|
||||
<p><strong>Download Status:</strong> {{ track.download_status }}</p>
|
||||
<p><strong>Filesystem Path:</strong> {{ track.filesystem_path }}</p>
|
||||
<p><strong>Jellyfin Filesystem Path:</strong> {{ track.jellyfin_filesystem_path if track.jellyfin_filesystem_path else 'N/A' }}</p>
|
||||
<p>{{ track.jellyfin_filesystem_path | audioprofile(track.jellyfin_filesystem_path) if track.jellyfin_filesystem_path else 'N/A' }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.8"
|
||||
|
||||
Reference in New Issue
Block a user