63 Commits
0.1.6 ... 0.1.8

Author SHA1 Message Date
Kamil Kosek
804b2bfe7e Merge pull request #31 from kamilkosek/dev
Merge dev 0.1.8 into main
2024-12-06 01:44:26 +01:00
Kamil
4c675e814c Bump version: 0.1.7 → 0.1.8 2024-12-06 00:43:43 +00:00
Kamil
1509c37cd9 Enhance download progress reporting with detailed state updates 2024-12-06 00:40:45 +00:00
Kamil
24ba4a0b70 doesnt work 2024-12-06 00:34:08 +00:00
Kamil
7a7ef8d7bc another update to build workflow 2024-12-06 00:22:35 +00:00
Kamil
be9a72701e added changelog 0.1.8 2024-12-06 00:06:49 +00:00
Kamil
c5de8d9841 Added check for update 2024-12-06 00:05:36 +00:00
Kamil
671b813e6c Updated manual_build.yml to not mark dev builds as latest 2024-12-06 00:04:45 +00:00
Kamil
b29a7bbbe3 typo fixed 2024-12-05 23:28:21 +00:00
Kamil
d4c3a67249 remove static branch name from version 2024-12-05 09:40:56 +00:00
Kamil
4be027bb35 create github release on dev release 2024-12-05 09:39:17 +00:00
Kamil
39e44e0606 update manual build 2024-12-05 09:26:13 +00:00
Kamil
8c30c6183d Added notice about breaking change in 0.1.7 2024-12-05 09:15:05 +00:00
Kamil
9e7f331c49 Updated manual-build workflow to include dev version tag 2024-12-05 09:14:49 +00:00
Kamil
bb856c96a1 Fixed wrong output_path creation 2024-12-05 09:14:28 +00:00
Kamil Kosek
14a8fdc127 Merge pull request #28 from kamilkosek/dev
Merge dev into main , part 2
2024-12-05 00:55:33 +01:00
Kamil
1d24972ea0 Updated gitignore again 2024-12-04 23:54:33 +00:00
Kamil
7b15a8b53d Remove auto gen release notes temporarliy 2024-12-04 23:54:24 +00:00
Kamil
d41b901649 Fix Typo 2024-12-04 23:53:57 +00:00
Kamil Kosek
f7372fed38 Merge pull request #27 from kamilkosek/dev
Merge dev into main
2024-12-05 00:46:51 +01:00
Kamil
89a1bc21be Updated readme and changelog 0.1.7 2024-12-04 23:38:13 +00:00
Kamil
360c4e5b7a changed request-lidarr-schedule to x:50 2024-12-04 23:37:40 +00:00
Kamil
d69ac22998 fixed function call 2024-12-04 22:58:22 +00:00
Kamil
d9dabd0a9c test arm build on dev 2024-12-04 22:26:42 +00:00
Kamil
087d44836f Merge branch 'pr-26' into dev 2024-12-04 22:24:42 +00:00
Kamil
1ee0087b8f reworked the celery task management 2024-12-04 22:22:04 +00:00
Kamil
e2d37b77b0 Added: MUSIC_STORAGE_BASE_PATH env variable 2024-12-04 22:21:15 +00:00
Kamil
86f5bf118a updated .gitignore 2024-12-04 22:19:44 +00:00
Artur Y
e43d36dd24 build docker image for amd64 and arm64 2024-12-04 21:52:56 +01:00
Kamil
30ea28ed6e session commit :[ 2024-12-04 00:18:34 +00:00
Kamil
07503a8003 update path and download state if the track has a jellyfin id set. 2024-12-04 00:03:29 +00:00
Kamil
9a5adfaa5b Add SPOTIFY_COOKIE_FILE env var and handle correctly when its missing 2024-12-03 23:20:35 +00:00
Kamil
b9ad5be7bc Bump version: 0.1.6 → 0.1.7 2024-12-03 23:11:50 +00:00
Kamil
b861a1a8f4 feat: added lidarr support 2024-12-03 23:11:05 +00:00
Kamil
87791cf21d - Support multiple music providers
- feat: Doubleclick on track in Table view to get technical information about it
2024-12-03 12:48:27 +00:00
Kamil
883294d74e added get_item to jellyfin-client 2024-12-03 12:45:13 +00:00
Kamil
2b3c400c10 Major Overhaul:
- No more dict´s , goal is to have type safety and a generic approach to support multiple music (playlist) providers
- removed unneeded functions
2024-12-03 12:44:40 +00:00
Kamil
00ba693fb9 added jellyfin_link filter 2024-12-03 12:39:43 +00:00
Kamil
cd498988ae added dataclasses for combined information about track/playlist from provider and database 2024-12-03 12:39:25 +00:00
Kamil
3a26c054a0 added blueprint and restructured existing routes 2024-12-03 12:38:47 +00:00
Kamil
d5aee793a0 updated .gitignore to not include any cookies at all 2024-12-03 12:35:45 +00:00
Kamil
6b78b90ee7 feature: add possibility to directly add a playlist from the / page 2024-12-03 12:35:23 +00:00
Kamil
d70c3b3913 Major Overhaul: Cleanup Unused Files 2024-12-03 12:32:11 +00:00
Kamil
e428629928 refacotring db to work with multiple music provider 2024-11-29 22:54:22 +00:00
Kamil
94d401a99f changed "spotify" to "provider" 2024-11-29 22:50:10 +00:00
Kamil
56aaec603b refactor to start working with blueprints 2024-11-29 22:48:46 +00:00
Kamil
aa718eb628 Typings pyi 2024-11-29 22:48:29 +00:00
Kamil
33ccbc470c Added Identifier to base and implementation 2024-11-29 22:48:06 +00:00
Kamil
3c25cd70ea Added MusicProviderRegistry 2024-11-29 22:47:46 +00:00
Kamil
25e51f1ef2 implemented browse_all and browse_page , should be enough for jellyplist 2024-11-29 22:07:11 +00:00
Kamil
7232b3223d further implementations 2024-11-29 20:49:36 +00:00
Kamil
f81188f7e3 spotify client using generic base classes 2024-11-29 19:55:27 +00:00
Kamil
cbe172ff1f base classes for generic musicProviderClient 2024-11-29 19:55:07 +00:00
Kamil
d8d677bc1b UI Fix on Task overview 2024-11-27 20:08:03 +00:00
Kamil
18dc6e18af rework on find_best_match_from_jellyfin
-  Artists will be compared, even if only one search result from jellyfin #24
2024-11-27 20:07:13 +00:00
Kamil
3bcecfe6fd Show loading spinner on jellyfin library search 2024-11-27 17:26:00 +00:00
Kamil
1867f982a0 use typed AudioProfile class 2024-11-27 17:25:39 +00:00
Kamil
b60a882dab Better Errorhandling in case of spotify api errors
Addresses issue #20
2024-11-27 17:20:47 +00:00
Kamil
c6eb95112e adjusted highlight filter, to highlight a perfect match 2024-11-27 17:15:35 +00:00
Kamil
c9363104ec Added SPOTIFY_COUNTRY_CODE env var, defaults to 'DE'
- before it was hard coded to DE
2024-11-27 16:07:08 +00:00
Kamil
dc0165957a updated gitignore 2024-11-27 16:04:45 +00:00
Kamil
ddf73b77db provide more technical track details in the ui
Fixes #15
2024-11-27 16:03:39 +00:00
Kamil
da2b725b22 updated github workflow no2 2024-11-26 15:49:40 +00:00
54 changed files with 3086 additions and 959 deletions

View File

@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.1.6 current_version = 0.1.8
commit = True commit = True
tag = True tag = True

View File

@@ -37,6 +37,7 @@ jobs:
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/${{ github.repository }}:${{ env.VERSION }} ghcr.io/${{ github.repository }}:${{ env.VERSION }}
@@ -55,14 +56,14 @@ jobs:
echo "CHANGELOG_CONTENT=No changelog available for this release." >> $GITHUB_ENV echo "CHANGELOG_CONTENT=No changelog available for this release." >> $GITHUB_ENV
fi fi
# Generate auto-generated release notes # # Generate auto-generated release notes
#- name: Generate Auto Release Notes # - name: Generate Auto Release Notes
# id: release_notes # id: release_notes
# run: | # run: |
# auto_notes=$(gh release create ${{ env.VERSION }} --generate-notes --prerelease --dry-run --json body --jq .body) # auto_notes=$(gh release create ${{ env.VERSION }} --generate-notes --prerelease --json body --jq .body)
# echo "AUTO_RELEASE_NOTES<<EOF" >> $GITHUB_ENV # echo "AUTO_RELEASE_NOTES<<EOF" >> $GITHUB_ENV
# echo "$auto_notes" >> $GITHUB_ENV # echo "$auto_notes" >> $GITHUB_ENV
# echo "EOF" >> $GITHUB_ENV # echo "EOF" >> $GITHUB_ENV
# Create a release on GitHub # Create a release on GitHub
- name: Create GitHub Release - name: Create GitHub Release

View File

@@ -18,12 +18,6 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
ref: ${{ github.event.inputs.branch }} 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 # Extract branch name and latest commit SHA
- name: Extract branch name and commit SHA - name: Extract branch name and commit SHA
id: branch_info id: branch_info
@@ -35,6 +29,11 @@ jobs:
- name: Create DEV_BUILD file - name: Create DEV_BUILD file
run: | run: |
echo "${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}" > DEV_BUILD 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 # Set up Docker
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -53,9 +52,19 @@ jobs:
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }} ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }}
ghcr.io/${{ github.repository }}:dev 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

5
.gitignore vendored
View File

@@ -73,6 +73,9 @@ coverage/
# macOS # macOS
.DS_Store .DS_Store
.cache .cache
cookies*.txt *cookies*.txt
*.code-workspace *.code-workspace
set_env.sh set_env.sh
notes.md
DEV_BUILD
payload.json

View File

@@ -8,6 +8,7 @@ from flask import Flask, has_request_context
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from psycopg2 import OperationalError from psycopg2 import OperationalError
import redis
import spotipy import spotipy
from spotipy.oauth2 import SpotifyClientCredentials from spotipy.oauth2 import SpotifyClientCredentials
from celery import Celery from celery import Celery
@@ -21,6 +22,7 @@ from flask_caching import Cache
from .version import __version__ from .version import __version__
def check_db_connection(db_uri, retries=5, delay=5): def check_db_connection(db_uri, retries=5, delay=5):
""" """
Check if the database is reachable. Check if the database is reachable.
@@ -76,9 +78,13 @@ def make_celery(app):
'update_jellyfin_id_for_downloaded_tracks-schedule': { 'update_jellyfin_id_for_downloaded_tracks-schedule': {
'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks', 'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks',
'schedule': crontab(minute='*/10'), 'schedule': crontab(minute='*/10'),
} }
} }
if app.config['LIDARR_API_KEY']:
celery.conf.beat_schedule['request-lidarr-schedule'] = {
'task': 'app.tasks.request_lidarr',
'schedule': crontab(minute='50')
}
celery.conf.timezone = 'UTC' celery.conf.timezone = 'UTC'
return celery return celery
@@ -88,17 +94,6 @@ device_id = f'JellyPlist_{'_'.join(sys.argv)}'
# Initialize Flask app # Initialize Flask app
app = Flask(__name__, template_folder="../templates", static_folder='../static') app = Flask(__name__, template_folder="../templates", static_folder='../static')
# log_file = 'app.log'
# handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3)
# handler.setLevel(logging.DEBUG)
# handler.setFormatter(log_formatter)
# stream_handler = logging.StreamHandler(sys.stdout)
# stream_handler.setLevel(logging.DEBUG)
# stream_handler.setFormatter(log_formatter)
# # app.logger.addHandler(handler)
# app.logger.addHandler(stream_handler)
app.config.from_object(Config) app.config.from_object(Config)
@@ -113,6 +108,7 @@ logging.basicConfig(format=FORMAT)
Config.validate_env_vars() Config.validate_env_vars()
cache = Cache(app) cache = Cache(app)
redis_client = redis.StrictRedis(host=app.config['CACHE_REDIS_HOST'], port=app.config['CACHE_REDIS_PORT'], db=0, decode_responses=True)
# Spotify, Jellyfin, and Spotdl setup # Spotify, Jellyfin, and Spotdl setup
@@ -160,7 +156,32 @@ celery.set_default()
app.logger.info(f'Jellyplist {__version__}{read_dev_build_file()} started') app.logger.info(f'Jellyplist {__version__}{read_dev_build_file()} started')
app.logger.debug(f"Debug logging active") app.logger.debug(f"Debug logging active")
from app import routes
from app import jellyfin_routes, tasks from app.routes import pl_bp, routes, jellyfin_routes
if "worker" in sys.argv: app.register_blueprint(pl_bp)
tasks.release_lock("download_missing_tracks_lock")
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
View 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
View 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>')

View File

@@ -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.models import JellyfinUser, Playlist,Track
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache, redis_client
from functools import wraps from functools import wraps
from celery.result import AsyncResult from celery.result import AsyncResult
from app.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 jellyfin.objects import PlaylistMetadata
from spotipy.exceptions import SpotifyException
import re import re
TASK_STATUS = { def prepPlaylistData(playlist: base.Playlist) -> Optional[CombinedPlaylistData]:
'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 = []
jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first() jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
if not jellyfin_user: if not jellyfin_user:
app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again") app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again")
return None return None
if not data.get('playlists'):
data['playlists']= {} # Fetch the playlist from the database if it exists
data['playlists']['items'] = [data] db_playlist : Playlist = Playlist.query.filter_by(provider_playlist_id=playlist.id).first() if playlist else None
for playlist_data in data['playlists']['items']: # Initialize default values
# Fetch the playlist from the database if it exists track_count = db_playlist.track_count if db_playlist else 0
db_playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_data['id']).first() 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
if db_playlist: # Determine playlist status
# If the playlist is in the database, use the stored values if tracks_available == track_count and track_count > 0:
if isinstance(playlist_data['tracks'],list): status = 'green' # Fully available
track_count = len(playlist_data['tracks'] ) elif tracks_available > 0:
else: status = 'yellow' # Partially available
track_count = playlist_data['tracks']['total'] or 0 else:
tracks_available = db_playlist.tracks_available or 0 status = 'red' # Not available
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
# Determine playlist status # Build and return the PlaylistResponse object
if tracks_available == track_count and track_count > 0: return CombinedPlaylistData(
status = 'green' # Fully available name=playlist.name,
elif tracks_available > 0: description=playlist.description,
status = 'yellow' # Partially available image=playlist.images[0].url if playlist.images else '/static/images/placeholder.png',
else: url=playlist.external_urls[0].url if playlist.external_urls else '',
status = 'red' # Not available 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: else:
# If the playlist is not in the database, initialize with 0 value = redis_client.get('lidarr_quality_profile_id')
track_count = 0 if not value:
tracks_available = 0 value = lidarr_client.get_quality_profiles()[0]
tracks_linked = 0 lidarr_quality_profile_id(value.id)
percent_available = 0 return value
status = 'red' # Not requested yet return value
# Append playlist data to the list def lidarr_root_folder_path(folder_path=None):
playlists.append({ if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
'name': playlist_data['name'], from app import lidarr_client
'description': playlist_data['description'], if folder_path:
'image': playlist_data['images'][0]['url'] if playlist_data['images'] else 'default-image.jpg', redis_client.set('lidarr_root_folder_path', folder_path)
'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: else:
app.logger.warning(f"Playlist data for ID {playlist_id} could not be retrieved.") 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
return spotify_data # a function which takes a lidarr.class.Artist object as a parameter, and applies the lidarr_quality_profile_id to the artist if its 0
def apply_default_profile_and_root_folder(object : Artist ) -> Artist:
if object.qualityProfileId == 0:
object.qualityProfileId = int(lidarr_quality_profile_id())
if object.rootFolderPath == '' or object.rootFolderPath == None:
object.rootFolderPath = str(lidarr_root_folder_path())
if object.metadataProfileId == 0:
object.metadataProfileId = 1
return object
@cache.memoize(timeout=3600)
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) @cache.memoize(timeout=3600*24*10)
def get_cached_spotify_track(track_id): 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. 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. :return: Track data as a dictionary, or None if an error occurs.
""" """
try: 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 return track_data
except Exception as e: 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 return None
def prepAlbumData(data): def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]:
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 = []
is_admin = session.get('is_admin', False) is_admin = session.get('is_admin', False)
tracks = []
for idx, item in enumerate(results['tracks']): for idx, item in enumerate(data):
track_data = item['track'] track_data = item.track
if track_data: if track_data:
duration_ms = track_data['duration_ms'] duration_ms = track_data.duration_ms
minutes = duration_ms // 60000 minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000 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: if track_db:
downloaded = track_db.downloaded downloaded = track_db.downloaded
@@ -230,40 +138,26 @@ def get_tracks_for_playlist(data):
jellyfin_id = None jellyfin_id = None
download_status = None download_status = None
tracks.append({ # Append a TrackResponse object
'title': track_data['name'], tracks.append(
'artist': ', '.join([artist['name'] for artist in track_data['artists']]), CombinedTrackData(
'url': track_data['external_urls']['spotify'], title=track_data.name,
'duration': f'{minutes}:{seconds:02d}', artist=[a.name for a in track_data.artists],
'preview_url': track_data['preview_url'], url=[url.url for url in track_data.external_urls],
'downloaded': downloaded, duration=f'{minutes}:{seconds:02d}',
'filesystem_path': filesystem_path, downloaded=downloaded,
'jellyfin_id': jellyfin_id, filesystem_path=filesystem_path,
'spotify_id': track_data['id'], jellyfin_id=jellyfin_id,
'duration_ms': duration_ms, provider_track_id=track_data.id,
'download_status' : download_status provider_id = provider_id,
}) duration_ms=duration_ms,
download_status=download_status,
provider=provider_id
)
)
return tracks 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): def jellyfin_login_required(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): 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 = PlaylistMetadata()
metadata.Tags = [f'jellyplist:playlist:{playlist.id}',f'{playlist.tracks_available} of {playlist.track_count} Tracks available'] 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()) 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: if provider_playlist_data.images:
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,spotify_image_url= spotify_playlist_data['images'][0]['url']) 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: def _get_api_token() -> str:
#return app.config['JELLYFIN_ACCESS_TOKEN'] #return app.config['JELLYFIN_ACCESS_TOKEN']
return jellyfin_admin_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() return JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
def _get_admin_id(): def _get_admin_id():
#return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id #return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id
@@ -313,3 +207,41 @@ def get_longest_substring(input_string):
substrings = re.split(pattern, input_string) substrings = re.split(pattern, input_string)
longest_substring = max(substrings, key=len, default="") 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,''

View File

@@ -22,8 +22,8 @@ user_playlists = db.Table('user_playlists',
class Playlist(db.Model): class Playlist(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False) name = db.Column(db.String(150), nullable=False)
spotify_playlist_id = db.Column(db.String(120), unique=True, nullable=False) provider_playlist_id = db.Column(db.String(120), unique=True, nullable=False)
spotify_uri = db.Column(db.String(120), unique=True, nullable=False) provider_uri = db.Column(db.String(120), unique=True, nullable=False)
# Relationship with Tracks # Relationship with Tracks
tracks = db.relationship('Track', secondary='playlist_tracks', back_populates='playlists') 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) snapshot_id = db.Column(db.String(120), nullable=True)
# Many-to-Many relationship with JellyfinUser # Many-to-Many relationship with JellyfinUser
users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists') users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists')
provider_id = db.Column(db.String(20))
def __repr__(self): 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 # Association table between Playlists and Tracks
playlist_tracks = db.Table('playlist_tracks', playlist_tracks = db.Table('playlist_tracks',
@@ -50,14 +51,16 @@ playlist_tracks = db.Table('playlist_tracks',
class Track(db.Model): class Track(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False) name = db.Column(db.String(150), nullable=False)
spotify_track_id = db.Column(db.String(120), unique=True, nullable=False) provider_track_id = db.Column(db.String(120), unique=True, nullable=False)
spotify_uri = 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()) downloaded = db.Column(db.Boolean())
filesystem_path = db.Column(db.String(), nullable=True) filesystem_path = db.Column(db.String(), nullable=True)
jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field
download_status = db.Column(db.String(2048), nullable=True) download_status = db.Column(db.String(2048), nullable=True)
provider_id = db.Column(db.String(20))
# Many-to-Many relationship with Playlists # Many-to-Many relationship with Playlists
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks') playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
lidarr_processed = db.Column(db.Boolean(), default=False)
def __repr__(self): def __repr__(self):
return f'<Track {self.name}:{self.spotify_track_id}>' return f'<Track {self.name}:{self.provider_track_id}>'

View File

@@ -0,0 +1,3 @@
from .spotify import SpotifyClient
__all__ = ["SpotifyClient"]

173
app/providers/base.py Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
from .music_provider_registry import MusicProviderRegistry
__all__ = ["MusicProviderRegistry"]

View 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())

View File

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

View File

@@ -1,100 +1,90 @@
from collections import defaultdict
import time 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 sqlalchemy import insert
from app import app, db, jellyfin, functions, device_id,sp 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 jellyfin.objects import PlaylistMetadata
from app.routes import pl_bp
@app.route('/jellyfin_playlists') @app.route('/jellyfin_playlists')
@functions.jellyfin_login_required @functions.jellyfin_login_required
def jellyfin_playlists(): def jellyfin_playlists():
try:
# Fetch playlists from Jellyfin
playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie()) playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie())
playlists_by_provider = defaultdict(list)
provider_playlists_data = {}
# Extract Spotify playlist IDs from the database
spotify_playlist_ids = []
for pl in playlists: for pl in playlists:
# Retrieve the playlist from the database using Jellyfin ID from_db : Playlist | None = Playlist.query.filter_by(jellyfin_id=pl['Id']).first()
from_db = Playlist.query.filter_by(jellyfin_id=pl['Id']).first() if from_db and from_db.provider_playlist_id:
if from_db and from_db.spotify_playlist_id: pl_id = from_db.provider_playlist_id
spotify_playlist_ids.append(from_db.spotify_playlist_id) playlists_by_provider[from_db.provider_id].append(from_db)
else:
app.logger.warning(f"No database entry found for Jellyfin playlist ID: {pl['Id']}")
if not spotify_playlist_ids: # 3. Fetch all Data from the provider using the get_playlist() method
flash('No Spotify playlists found to display.', 'warning') for provider_id, playlists in playlists_by_provider.items():
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}})) try:
provider_client = MusicProviderRegistry.get_provider(provider_id)
except ValueError:
flash(f"Provider {provider_id} not found.", "error")
continue
# Use the cached function to fetch Spotify playlists combined_playlists = []
spotify_data = functions.get_cached_spotify_playlists(spotify_playlist_ids) 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)
# Prepare the data for the template provider_playlists_data[provider_id] = combined_playlists
prepared_data = functions.prepPlaylistData(spotify_data)
return render_template('jellyfin_playlists.html', playlists=prepared_data) # 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")
except Exception as e: @pl_bp.route('/addplaylist', methods=['POST'])
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': []}}))
@app.route('/addplaylist', methods=['POST'])
@functions.jellyfin_login_required @functions.jellyfin_login_required
def add_playlist(): def add_playlist():
playlist_id = request.form.get('item_id') # HTMX sends the form data playlist_id = request.form.get('item_id')
playlist_name = request.form.get('item_name') # Optionally retrieve playlist name from the form 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: if not playlist_id:
flash('No playlist ID provided') flash('No playlist ID provided')
return '' 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: try:
# Fetch playlist from Spotify API (or any relevant API) # get the playlist from the correct provider
playlist_data = functions.get_cached_spotify_playlist(playlist_id) provider_client = MusicProviderRegistry.get_provider(provider_id)
playlist_data = provider_client.get_playlist(playlist_id)
# Check if playlist already exists in the database # Check if playlist already exists in the database, using the provider_id and the provider_playlist_id
playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_id).first() 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: if not playlist:
# Add new playlist if it doesn't exist fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data.name,[],functions._get_admin_id())['Id']
# create the playlist via api key, with the first admin as 'owner' 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)
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)
db.session.add(playlist) db.session.add(playlist)
db.session.commit() db.session.commit()
if app.config['START_DOWNLOAD_AFTER_PLAYLIST_ADD']: 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 # Get the logged-in user
user = functions._get_logged_in_user() user : JellyfinUser = functions._get_logged_in_user()
playlist.tracks_available = 0 playlist.tracks_available = 0
spotify_tracks = {} for idx, track_data in enumerate(playlist_data.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']})
if len(items) < 100: # No more tracks to fetch track = Track.query.filter_by(provider_track_id=track_data.track.id, provider_id=provider_id).first()
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()
if not track: if not track:
# Add new track if it doesn't exist # 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.add(track)
db.session.commit() db.session.commit()
elif track.downloaded: elif track.downloaded:
@@ -118,12 +108,12 @@ def add_playlist():
user.playlists.append(playlist) user.playlists.append(playlist)
db.session.commit() 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]) 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: else:
flash(f'Playlist "{playlist_data["name"]}" already in your list') flash(f'Playlist "{playlist_data.name}" already in your list')
item = { item = {
"name" : playlist_data["name"], "name" : playlist_data.name,
"id" : playlist_id, "id" : playlist_id,
"can_add":False, "can_add":False,
"can_remove":True, "can_remove":True,
@@ -154,7 +144,7 @@ def delete_playlist(playlist_id):
flash('Playlist removed') flash('Playlist removed')
item = { item = {
"name" : playlist.name, "name" : playlist.name,
"id" : playlist.spotify_playlist_id, "id" : playlist.provider_playlist_id,
"can_add":True, "can_add":True,
"can_remove":False, "can_remove":False,
"jellyfin_id" : playlist.jellyfin_id "jellyfin_id" : playlist.jellyfin_id
@@ -178,7 +168,7 @@ def wipe_playlist(playlist_id):
if playlist: if playlist:
# Delete the playlist # Delete the playlist
name = playlist.name name = playlist.name
id = playlist.spotify_playlist_id id = playlist.provider_playlist_id
jf_id = playlist.jellyfin_id jf_id = playlist.jellyfin_id
db.session.delete(playlist) db.session.delete(playlist)
db.session.commit() db.session.commit()
@@ -204,9 +194,9 @@ def get_jellyfin_stream(jellyfin_id):
@functions.jellyfin_login_required @functions.jellyfin_login_required
def search_jellyfin(): def search_jellyfin():
search_query = request.args.get('search_query') 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: if search_query:
results = jellyfin.search_music_tracks(functions._get_token_from_sessioncookie(), search_query) results = jellyfin.search_music_tracks(functions._get_token_from_sessioncookie(), search_query)
# Render only the search results section as response # 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 return jsonify({'error': 'No search query provided'}), 400

395
app/routes/routes.py Normal file
View 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 ''

View File

@@ -1,26 +1,23 @@
from datetime import datetime,timezone from datetime import datetime,timezone
import logging import logging
import subprocess import subprocess
from typing import List
from sqlalchemy import insert from sqlalchemy import insert
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id, redis_client
from app.classes import AudioProfile
from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks
import os import os
import redis import redis
from celery import current_task,signals from celery import current_task,signals
import asyncio from celery.result import AsyncResult
import requests
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 @signals.celeryd_init.connect
def setup_log_format(sender, conf, **kwargs): 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): def update_all_playlists_track_status(self):
lock_key = "update_all_playlists_track_status_lock" 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: try:
with app.app_context(): with app.app_context():
playlists = Playlist.query.all() playlists = Playlist.query.all()
@@ -48,12 +45,37 @@ def update_all_playlists_track_status(self):
for playlist in playlists: for playlist in playlists:
total_tracks = 0 total_tracks = 0
available_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: for track in playlist.tracks:
total_tracks += 1 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): 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 available_tracks += 1
track.downloaded = True 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: else:
track.downloaded = False track.downloaded = False
@@ -73,7 +95,7 @@ def update_all_playlists_track_status(self):
app.logger.info("All playlists' track statuses updated.") app.logger.info("All playlists' track statuses updated.")
return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists} return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists}
finally: finally:
release_lock(lock_key) task_manager.release_lock(lock_key)
else: else:
app.logger.info("Skipping task. Another instance is already running.") app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'} return {'status': 'Task skipped, another instance is running'}
@@ -83,7 +105,7 @@ def update_all_playlists_track_status(self):
def download_missing_tracks(self): def download_missing_tracks(self):
lock_key = "download_missing_tracks_lock" lock_key = "download_missing_tracks_lock"
if acquire_lock(lock_key, expiration=1800): if task_manager.acquire_lock(lock_key, expiration=1800):
try: try:
app.logger.info("Starting track download job...") app.logger.info("Starting track download job...")
@@ -95,7 +117,8 @@ def download_missing_tracks(self):
client_secret = app.config['SPOTIFY_CLIENT_SECRET'] client_secret = app.config['SPOTIFY_CLIENT_SECRET']
search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD'] 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) total_tracks = len(undownloaded_tracks)
if not undownloaded_tracks: if not undownloaded_tracks:
app.logger.info("No undownloaded tracks found.") app.logger.info("No undownloaded tracks found.")
@@ -105,53 +128,61 @@ def download_missing_tracks(self):
processed_tracks = 0 processed_tracks = 0
failed_downloads = 0 failed_downloads = 0
for track in undownloaded_tracks: 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 # 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 # region search before download
if search_before_download: if search_before_download:
app.logger.info(f"Searching for track in Jellyfin: {track.name}") 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 # at first try to find the track without fingerprinting it
best_match = find_best_match_from_jellyfin(track) best_match = find_best_match_from_jellyfin(track)
if best_match: if best_match:
track.downloaded = True track.downloaded = True
if track.jellyfin_id != best_match['Id']: if track.jellyfin_id != best_match['Id']:
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']: if track.filesystem_path != best_match['Path']:
track.filesystem_path = best_match['Path'] track.filesystem_path = best_match['Path']
db.session.commit() db.session.commit()
processed_tracks+=1 processed_tracks+=1
continue continue
# region search with fingerprinting # region search with fingerprinting
preview_url = spotify_track.get('preview_url') # as long as there is no endpoint found providing a preview url, we can't use this feature
if not preview_url: # if spotify_track:
app.logger.error(f"Preview URL not found for track {track.name}.") # preview_url = spotify_track.get('preview_url')
# Decide whether to skip or proceed to download # if not preview_url:
# For now, we'll proceed to download # app.logger.error(f"Preview URL not found for track {track.name}.")
else: # # Decide whether to skip or proceed to download
# Get the list of Spotify artist names # # For now, we'll proceed to download
spotify_artists = [artist['name'] for artist in spotify_track['artists']] # else:
# # Get the list of Spotify artist names
# spotify_artists = [artist['name'] for artist in spotify_track['artists']]
# Perform the search in Jellyfin # # Perform the search in Jellyfin
match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin( # match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
session_token=jellyfin_admin_token, # session_token=jellyfin_admin_token,
preview_url=preview_url, # preview_url=preview_url,
song_name=track.name, # song_name=track.name,
artist_names=spotify_artists # artist_names=spotify_artists
) # )
if match_found: # if match_found:
app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.") # app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
track.downloaded = True # track.downloaded = True
track.filesystem_path = jellyfin_file_path # track.filesystem_path = jellyfin_file_path
db.session.commit() # db.session.commit()
continue # continue
else: # else:
app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.") # 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
#endregion #endregion
@@ -167,8 +198,8 @@ def download_missing_tracks(self):
# Attempt to download the track using spotdl # Attempt to download the track using spotdl
try: try:
app.logger.info(f"Trying to download track: {track.name} ({track.spotify_track_id}), spotdl timeout = 90") 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.spotify_track_id}" s_url = f"https://open.spotify.com/track/{track.provider_track_id}"
command = [ command = [
"spotdl", "download", s_url, "spotdl", "download", s_url,
@@ -203,7 +234,7 @@ def download_missing_tracks(self):
progress = (processed_tracks / total_tracks) * 100 progress = (processed_tracks / total_tracks) * 100
db.session.commit() 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, 'current': processed_tracks,
'total': total_tracks, 'total': total_tracks,
'percent': progress, 'percent': progress,
@@ -218,7 +249,7 @@ def download_missing_tracks(self):
'failed': failed_downloads 'failed': failed_downloads
} }
finally: finally:
release_lock(lock_key) task_manager.release_lock(lock_key)
if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']: if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']:
libraries = jellyfin.get_libraries(jellyfin_admin_token) libraries = jellyfin.get_libraries(jellyfin_admin_token)
for lib in libraries: for lib in libraries:
@@ -232,11 +263,11 @@ def download_missing_tracks(self):
def check_for_playlist_updates(self): def check_for_playlist_updates(self):
lock_key = "check_for_playlist_updates_lock" lock_key = "check_for_playlist_updates_lock"
if acquire_lock(lock_key, expiration=600): if task_manager.acquire_lock(lock_key, expiration=600):
try: try:
app.logger.info('Starting playlist update check...') app.logger.info('Starting playlist update check...')
with app.app_context(): with app.app_context():
playlists = Playlist.query.all() playlists: List[Playlist] = Playlist.query.all()
total_playlists = len(playlists) total_playlists = len(playlists)
if not playlists: if not playlists:
app.logger.info("No playlists found.") app.logger.info("No playlists found.")
@@ -247,40 +278,28 @@ def check_for_playlist_updates(self):
for playlist in playlists: for playlist in playlists:
playlist.last_updated = datetime.now( timezone.utc) 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 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() 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: try:
#region Check for updates #region Check for updates
# Fetch all playlist data from Spotify
if full_update: if full_update:
spotify_tracks = {} existing_tracks = {track.provider_track_id: track for track in playlist.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}
# Determine tracks to add and remove # Determine tracks to add and remove
tracks_to_add = [] tracks_to_add = []
for idx, track_info in spotify_tracks.items(): for idx, track_info in enumerate(provider_tracks):
if track_info: if track_info:
track_id = track_info['id'] track_id = track_info.track.id
if track_id not in existing_tracks: 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: 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.add(track)
db.session.commit() db.session.commit()
app.logger.info(f'Added new track: {track.name}') app.logger.info(f'Added new track: {track.name}')
@@ -289,7 +308,7 @@ def check_for_playlist_updates(self):
tracks_to_remove = [ tracks_to_remove = [
existing_tracks[track_id] existing_tracks[track_id]
for track_id in existing_tracks 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: if tracks_to_add or tracks_to_remove:
@@ -317,7 +336,7 @@ def check_for_playlist_updates(self):
#endregion #endregion
#region Update Playlist Items and Metadata #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( ordered_tracks = db.session.execute(
db.select(Track, playlist_tracks.c.track_order) db.select(Track, playlist_tracks.c.track_order)
.join(playlist_tracks, playlist_tracks.c.track_id == Track.id) .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} return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists}
finally: finally:
release_lock(lock_key) task_manager.release_lock(lock_key)
else: else:
app.logger.info("Skipping task. Another instance is already running.") app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'} return {'status': 'Task skipped, another instance is running'}
@@ -350,15 +369,15 @@ def check_for_playlist_updates(self):
@celery.task(bind=True) @celery.task(bind=True)
def update_jellyfin_id_for_downloaded_tracks(self): def update_jellyfin_id_for_downloaded_tracks(self):
lock_key = "update_jellyfin_id_for_downloaded_tracks_lock" lock_key = "update_jellyfin_id_for_downloaded_tracks_lock"
full_update_key = 'full_update_jellyfin_ids' full_update_key = 'full_update_jellyfin_ids_lock'
if acquire_lock(lock_key, expiration=600): # Lock for 10 minutes if task_manager.acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
try: try:
app.logger.info("Starting Jellyfin ID update for tracks...") app.logger.info("Starting Jellyfin ID update for tracks...")
with app.app_context(): with app.app_context():
downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all() 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)") 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() downloaded_tracks = Track.query.all()
else: else:
@@ -378,10 +397,10 @@ def update_jellyfin_id_for_downloaded_tracks(self):
track.downloaded = True track.downloaded = True
if track.jellyfin_id != best_match['Id']: if track.jellyfin_id != best_match['Id']:
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']: if track.filesystem_path != best_match['Path']:
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 processed_tracks += 1
progress = (processed_tracks / total_tracks) * 100 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.") app.logger.info("Finished updating Jellyfin IDs for all tracks.")
return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks} return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks}
finally: 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: else:
app.logger.info("Skipping task. Another instance is already running.") app.logger.info("Skipping task. Another instance is already running.")
return {'status': 'Task skipped, another instance is running'} return {'status': 'Task skipped, another instance is running'}
@@ -410,44 +517,52 @@ def update_jellyfin_id_for_downloaded_tracks(self):
def find_best_match_from_jellyfin(track: Track): def find_best_match_from_jellyfin(track: Track):
app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}") app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}")
search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name)) search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name))
spotify_track = None provider_track = None
try: try:
best_match = None best_match = None
best_quality_score = -1 # Initialize with the lowest possible score best_quality_score = -1 # Initialize with the lowest possible score
for result in search_results: 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']) quality_score = compute_quality_score(result, app.config['FIND_BEST_MATCH_USE_FFPROBE'])
try:
if len(search_results) == 1: provider_track = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id)
app.logger.debug(f"Only 1 search_result, assuming best match: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})") provider_track_name = provider_track.name.lower()
best_match = result provider_artists = [artist.name.lower() for artist in provider_track.artists]
break except Exception as e:
app.logger.error(f"\tError fetching track details from Spotify for {track.name}: {str(e)}")
jellyfin_path = result.get('Path', '') continue
# 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
jellyfin_track_name = result.get('Name', '').lower() jellyfin_track_name = result.get('Name', '').lower()
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])] if len(result.get('Artists', [])) == 1:
if (spotify_track_name.lower() == jellyfin_track_name and jellyfin_artists = [a.lower() for a in result.get('Artists', [])[0].split('/')]
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists)): else:
app.logger.debug(f"Quality score for track {result['Name']}: {quality_score} [{result['Path']}]") jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
jellyfin_album_artists = [artist['Name'].lower() for artist in result.get('AlbumArtists', [])]
if quality_score > best_quality_score: if provider_track and jellyfin_track_name and jellyfin_artists and provider_artists:
best_match = result app.logger.debug("\tTrack details to compare: ")
best_quality_score = quality_score 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 (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 return best_match
except Exception as e: except Exception as e:
@@ -478,44 +593,55 @@ def compute_quality_score(result, use_ffprobe=False) -> float:
if use_ffprobe: if use_ffprobe:
path = result.get('Path') path = result.get('Path')
if 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 score += ffprobe_score
else: else:
app.logger.warning(f"No valid file path for track {result.get('Name')} - Skipping ffprobe analysis.") app.logger.warning(f"No valid file path for track {result.get('Name')} - Skipping ffprobe analysis.")
return score 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)) 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
# Compute score based on extracted quality parameters def start_task(self, task_name, *args, **kwargs):
score = bitrate + (sample_rate // 1000) + (channels * 10) # Example scoring formula if task_name not in self.tasks:
return score raise ValueError(f"Task {task_name} is not defined.")
except Exception as e: task = globals()[task_name].delay(*args, **kwargs)
app.logger.error(f"Error analyzing audio quality with ffprobe: {str(e)}") self.tasks[task_name] = task.id
return 0 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
View File

@@ -0,0 +1,7 @@
from flask import g
from providers.base import MusicProviderClient
g: "Global"
class Global:
music_provider: MusicProviderClient

View File

@@ -1 +1 @@
__version__ = "0.1.6" __version__ = "0.1.8"

62
changelogs/0.1.7.md Normal file
View 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
View 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

View File

@@ -11,12 +11,14 @@ class Config:
JELLYFIN_REQUEST_TIMEOUT = int(os.getenv('JELLYFIN_REQUEST_TIMEOUT','10')) JELLYFIN_REQUEST_TIMEOUT = int(os.getenv('JELLYFIN_REQUEST_TIMEOUT','10'))
SPOTIFY_CLIENT_ID = os.getenv('SPOTIFY_CLIENT_ID') SPOTIFY_CLIENT_ID = os.getenv('SPOTIFY_CLIENT_ID')
SPOTIFY_CLIENT_SECRET = os.getenv('SPOTIFY_CLIENT_SECRET') 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_HOST = os.getenv('JELLYPLIST_DB_HOST')
JELLYPLIST_DB_PORT = int(os.getenv('JELLYPLIST_DB_PORT','5432')) JELLYPLIST_DB_PORT = int(os.getenv('JELLYPLIST_DB_PORT','5432'))
JELLYPLIST_DB_USER = os.getenv('JELLYPLIST_DB_USER') JELLYPLIST_DB_USER = os.getenv('JELLYPLIST_DB_USER')
JELLYPLIST_DB_PASSWORD = os.getenv('JELLYPLIST_DB_PASSWORD') 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 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' 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_TYPE = 'redis'
CACHE_REDIS_PORT = 6379 CACHE_REDIS_PORT = 6379
CACHE_REDIS_HOST = 'redis' CACHE_REDIS_HOST = 'redis'
@@ -25,12 +27,23 @@ class Config:
REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0') REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0')
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = os.getenv('SEARCH_JELLYFIN_BEFORE_DOWNLOAD',"true").lower() == 'true' SEARCH_JELLYFIN_BEFORE_DOWNLOAD = os.getenv('SEARCH_JELLYFIN_BEFORE_DOWNLOAD',"true").lower() == 'true'
FIND_BEST_MATCH_USE_FFPROBE = os.getenv('FIND_BEST_MATCH_USE_FFPROBE','false').lower() == 'true' FIND_BEST_MATCH_USE_FFPROBE = os.getenv('FIND_BEST_MATCH_USE_FFPROBE','false').lower() == 'true'
SPOTIFY_COUNTRY_CODE = os.getenv('SPOTIFY_COUNTRY_CODE','DE')
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 specific configuration
SPOTDL_CONFIG = { SPOTDL_CONFIG = {
'cookie_file': '/jellyplist/cookies.txt', '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 '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 @classmethod
def validate_env_vars(cls): def validate_env_vars(cls):
@@ -45,7 +58,8 @@ class Config:
'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST, 'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST,
'JELLYPLIST_DB_USER' : cls.JELLYPLIST_DB_USER, 'JELLYPLIST_DB_USER' : cls.JELLYPLIST_DB_USER,
'JELLYPLIST_DB_PASSWORD' : cls.JELLYPLIST_DB_PASSWORD, '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] missing_vars = [var for var, value in required_vars.items() if not value]

View File

@@ -310,12 +310,22 @@ class JellyfinClient:
response = requests.delete(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout) response = requests.delete(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
self.logger.debug(f"Response = {response.status_code}") 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 if response.status_code == 204: # 204 No Content indicates successful deletion
return {"status": "success", "message": "Playlist removed successfully"} return {"status": "success", "message": "Playlist removed successfully"}
else: else:
raise Exception(f"Failed to remove playlist: {response.content}") 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): def remove_user_from_playlist(self, session_token: str, playlist_id: str, user_id: str):
""" """
Remove a user from a playlist. Remove a user from a playlist.
@@ -341,7 +351,7 @@ class JellyfinClient:
raise Exception(f"Failed to remove user from playlist: {response.content}") 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. 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. :return: Success message or raises an exception on failure.
""" """
# Step 1: Download the image from the Spotify URL # 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: if response.status_code != 200:
raise Exception(f"Failed to download image from Spotify: {response.content}") raise Exception(f"Failed to download image from Spotify: {response.content}")
@@ -444,7 +454,6 @@ class JellyfinClient:
return response.json() return response.json()
def search_track_in_jellyfin(self, session_token: str, preview_url: str, song_name: str, artist_names: list): 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. 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)}") print(f"Error in search_track_in_jellyfin: {str(e)}")
return False, None return False, None
# Helper methods used in search_track_in_jellyfin # Helper methods used in search_track_in_jellyfin
def download_preview_to_tempfile(self, preview_url): def download_preview_to_tempfile(self, preview_url):
try: try:

2
lidarr/__init__.py Normal file
View File

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

174
lidarr/classes.py Normal file
View File

@@ -0,0 +1,174 @@
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class Image:
url: str
coverType: str
extension: str
remoteUrl: str
@dataclass
class Link:
url: str
name: str
@dataclass
class Ratings:
votes: int
value: float
@dataclass
class AddOptions:
monitor: str
albumsToMonitor: List[str]
monitored: bool
searchForMissingAlbums: bool
@dataclass
class Statistics:
albumCount: int
trackFileCount: int
trackCount: int
totalTrackCount: int
sizeOnDisk: int
percentOfTracks: float
@dataclass
class Member:
name: str
instrument: str
images: List[Image]
@dataclass
class Artist:
mbId: Optional[str] = None
tadbId: Optional[int] = None
discogsId: Optional[int] = None
allMusicId: Optional[str] = None
overview: str = ""
artistType: str = ""
disambiguation: str = ""
links: List[Link] = field(default_factory=list)
nextAlbum: str = ""
lastAlbum: str = ""
images: List[Image] = field(default_factory=list)
members: List[Member] = field(default_factory=list)
remotePoster: str = ""
path: str = ""
qualityProfileId: int = 0
metadataProfileId: int = 0
monitored: bool = False
monitorNewItems: str = ""
rootFolderPath: Optional[str] = None
folder: str = ""
genres: List[str] = field(default_factory=list)
cleanName: str = ""
sortName: str = ""
tags: List[int] = field(default_factory=list)
added: str = ""
addOptions: Optional[AddOptions] = None
ratings: Optional[Ratings] = None
statistics: Optional[Statistics] = None
status : str = ""
ended : bool = False
artistName : str = ""
foreignArtistId : str = ""
id : int = 0
@dataclass
class Media:
mediumNumber: int
mediumName: str
mediumFormat: str
@dataclass
class Release:
id: int
albumId: int
foreignReleaseId: str
title: str
status: str
duration: int
trackCount: int
media: List[Media]
mediumCount: int
disambiguation: str
country: List[str]
label: List[str]
format: str
monitored: bool
@dataclass
class Album:
id: int = 0
title: str = ""
disambiguation: str = ""
overview: str = ""
artistId: int = 0
foreignAlbumId: str = ""
monitored: bool = False
anyReleaseOk: bool = False
profileId: int = 0
duration: int = 0
albumType: str = ""
secondaryTypes: List[str] = field(default_factory=list)
mediumCount: int = 0
ratings: Ratings = None
releaseDate: str = ""
releases: List[Release] = field(default_factory=list)
genres: List[str] = field(default_factory=list)
media: List[Media] = field(default_factory=list)
artist: Artist = field(default_factory=Artist)
images: List[Image] = field(default_factory=list)
links: List[Link] = field(default_factory=list)
lastSearchTime: str = ""
statistics: Statistics = None
addOptions: Optional[dict] = field(default_factory=dict)
remoteCover: str = ""
@dataclass
class RootFolder:
id: int = 0
name: str = ""
path: str = ""
defaultMetadataProfileId: int = 0
defaultQualityProfileId: int = 0
defaultMonitorOption: str = ""
defaultNewItemMonitorOption: str = ""
defaultTags: List[int] = field(default_factory=list)
accessible: bool = False
freeSpace: int = 0
totalSpace: int = 0
@dataclass
class Quality:
id: int = 0
name: str = ""
@dataclass
class Item:
id: int = 0
name: str = ""
quality: Quality = field(default_factory=Quality)
items: List[str] = field(default_factory=list)
allowed: bool = False
@dataclass
class FormatItem:
id: int = 0
format: int = 0
name: str = ""
score: int = 0
@dataclass
class QualityProfile:
id: int = 0
name: str = ""
upgradeAllowed: bool = False
cutoff: int = 0
items: List[Item] = field(default_factory=list)
minFormatScore: int = 0
cutoffFormatScore: int = 0
formatItems: List[FormatItem] = field(default_factory=list)

143
lidarr/client.py Normal file
View File

@@ -0,0 +1,143 @@
import json
import re
from flask import jsonify
import requests
from typing import List, Optional
from .classes import Album, Artist, QualityProfile, RootFolder
import logging
l = logging.getLogger(__name__)
class LidarrClient:
def __init__(self, base_url: str, api_token: str):
self.base_url = base_url
self.api_token = api_token
self.headers = {
'X-Api-Key': self.api_token
}
def _get(self, endpoint: str, params: Optional[dict] = None):
response = requests.get(f"{self.base_url}{endpoint}", headers=self.headers, params=params)
response.raise_for_status()
return response.json()
def _post(self, endpoint: str, json: dict):
response = requests.post(f"{self.base_url}{endpoint}", headers=self.headers, json=json)
response.raise_for_status()
return response.json()
def _put(self, endpoint: str, json: dict):
response = requests.put(f"{self.base_url}{endpoint}", headers=self.headers, json=json)
response.raise_for_status()
return response.json()
def get_album(self, album_id: int) -> Album:
l.debug(f"Getting album {album_id}")
data = self._get(f"/api/v1/album/{album_id}")
return Album(**data)
def get_artist(self, artist_id: int) -> Artist:
l.debug(f"Getting artist {artist_id}")
data = self._get(f"/api/v1/artist/{artist_id}")
return Artist(**data)
def search(self, term: str) -> List[object]:
l.debug(f"Searching for {term}")
data = self._get("/api/v1/search", params={"term": term})
results = []
for item in data:
if 'artist' in item:
results.append(Artist(**item['artist']))
elif 'album' in item:
results.append(Album(**item['album']))
return results
# A method which takes a List[object] end external URL as parameter, and returns the object from the List[object] which has the same external URL as the parameter.
def get_object_by_external_url(self, objects: List[object], external_url: str) -> object:
l.debug(f"Getting object by external URL {external_url}")
# We need to check whether the external_url matches intl-[a-zA-Z]{2}\/ it has to be replaced by an empty string
external_url = re.sub(r"intl-[a-zA-Z]{2}\/", "", external_url)
for obj in objects:
# object can either be an Album or an Artist, so it can be verified and casted
if isinstance(obj, Album):
for link in obj.links:
if link['url'] == external_url:
return obj
elif isinstance(obj, Artist):
for link in obj.links:
if link['url'] == external_url:
return obj
return None
# A method to get all Albums from List[object] where the name equals the parameter name
def get_albums_by_name(self, objects: List[object], name: str) -> List[Album]:
l.debug(f"Getting albums by name {name}")
albums = []
for obj in objects:
if isinstance(obj, Album) and obj.title == name:
artist = Artist(**obj.artist)
obj.artist = artist
albums.append(obj)
return albums
# a method to get all artists from List[object] where the name equals the parameter name
def get_artists_by_name(self, objects: List[object], name: str) -> List[Artist]:
l.debug(f"Getting artists by name {name}")
artists = []
for obj in objects:
if isinstance(obj, Artist) and obj.artistName == name:
artists.append(obj)
return artists
def create_album(self, album: Album) -> Album:
l.debug(f"Creating album {album.title}")
json_artist = album.artist.__dict__
album.artist = json_artist
data = self._post("/api/v1/album", json=album.__dict__)
return Album(**data)
def update_album(self, album_id: int, album: Album) -> Album:
l.debug(f"Updating album {album_id}")
json_artist = album.artist.__dict__
album.artist = json_artist
data = self._put(f"/api/v1/album/{album_id}", json=album.__dict__)
return Album(**data)
def create_artist(self, artist: Artist) -> Artist:
l.debug(f"Creating artist {artist.artistName}")
data = self._post("/api/v1/artist", json=artist.__dict__)
return Artist(**data)
def update_artist(self, artist_id: int, artist: Artist) -> Artist:
l.debug(f"Updating artist {artist_id}")
data = self._put(f"/api/v1/artist/{artist_id}", json=artist.__dict__)
return Artist(**data)
# shorthand method to set artist to monitored
def monitor_artist(self, artist: Artist):
artist.monitored = True
l.debug(f"Monitoring artist {artist.artistName}")
if artist.id == 0:
artist = self.create_artist(artist)
else:
self.update_artist(artist.id, artist)
# shorthand method to set album to monitored
def monitor_album(self, album: Album):
album.monitored = True
l.debug(f"Monitoring album {album.title}")
if album.id == 0:
album = self.create_album(album)
else:
self.update_album(album.id, album)
# a method to query /api/v1/rootfolder and return a List[RootFolder]
def get_root_folders(self) -> List[RootFolder]:
l.debug("Getting root folders")
data = self._get("/api/v1/rootfolder")
return [RootFolder(**folder) for folder in data]
# a method to query /api/v1/qualityprofile and return a List[QualityProfile]
def get_quality_profiles(self) -> List[QualityProfile]:
l.debug("Getting quality profiles")
data = self._get("/api/v1/qualityprofile")
return [QualityProfile(**profile) for profile in data]

View File

@@ -0,0 +1,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 ###

View File

@@ -0,0 +1,32 @@
"""Add lidarr_processed flag
Revision ID: d13088ebddc5
Revises: 18d056f49f59
Create Date: 2024-12-03 22:44:21.287754
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd13088ebddc5'
down_revision = '18d056f49f59'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.add_column(sa.Column('lidarr_processed', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('track', schema=None) as batch_op:
batch_op.drop_column('lidarr_processed')
# ### end Alembic commands ###

View File

@@ -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. It´s definitely not a general Playlist Manager for Jellyfin.
## Features ## Features
- **Discover Playlists**: Use well-known *Featured Playlists* listings. - **Discover Playlists**: Browse playlists like its nothing.
- **Categories**: Browse playlists by categories
- **View Monitored Playlists**: View playlists which are already synced by the server, adding these to your Jellyfin account will make them available immediately - **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 - **Search Playlist**: Search for playlists
- **No Sign-Up or User-Accounts**: Jellyplist uses your local Jellyfin server for authentication - **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. - **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 - **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 ## Getting Started
The easiest way to start is by using docker and compose. 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 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) 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
> [!IMPORTANT] 3. Get your cookie-file from open.spotify.com , this works the same way as in step 2.
> Currently a [youtube premium account](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium) is required, the next release will mitigate this. 4. Prepare a `.env` File
3. Prepare a `.env` File
``` ```
POSTGRES_USER = jellyplist POSTGRES_USER = jellyplist
POSTGRES_PASSWORD = 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_HOST = postgres-jellyplist #Hostname of the db Container
JELLYPLIST_DB_USER = jellyplist JELLYPLIST_DB_USER = jellyplist
JELLYPLIST_DB_PASSWORD = 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 # 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 # 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 - jellyplist-network
volumes: volumes:
# Map Your cookies.txt file to exac # Map Your cookies.txt file to exac
- /your/local/path/cookies.txt:/jellyplist/cookies.txt - /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_file:
- .env - .env
@@ -95,7 +113,8 @@ services:
volumes: volumes:
# Map Your cookies.txt file to exac # Map Your cookies.txt file to exac
- /your/local/path/cookies.txt:/jellyplist/cookies.txt - /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_file:
- .env - .env
depends_on: depends_on:

View File

@@ -9,10 +9,12 @@ body {
padding-left: 10px; padding-left: 10px;
color: white; color: white;
} }
.top-bar { .top-bar {
background-color: #1a1d21; background-color: #1a1d21;
} }
.sidebar h3 { .sidebar h3 {
color: white; color: white;
padding-left: 15px; padding-left: 15px;
@@ -50,6 +52,76 @@ body {
width: 140px; width: 140px;
margin-right: 10px; margin-right: 10px;
} }
.logo img{
.logo img {
width: 100%; 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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -5,14 +5,14 @@ var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
}) })
// Function to open the search modal and trigger the search automatically // 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 modal = new bootstrap.Modal(document.getElementById('searchModal'));
const searchQueryInput = document.getElementById('search-query'); const searchQueryInput = document.getElementById('search-query');
const spotifyIdInput = document.getElementById('spotify-id'); const providerTrackIdInput = document.getElementById('provider-track-id');
// Pre-fill the input fields // Pre-fill the input fields
searchQueryInput.value = trackTitle; searchQueryInput.value = trackTitle;
spotifyIdInput.value = spotify_id; providerTrackIdInput.value = provider_track_id;
// Show the modal // Show the modal
modal.show(); modal.show();
@@ -85,10 +85,10 @@ function playJellyfinTrack(button, jellyfinId) {
.catch(error => console.error('Error fetching Jellyfin stream URL:', error)); .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) { if (event.ctrlKey) {
// CTRL key is pressed, open the search modal // CTRL key is pressed, open the search modal
openSearchModal(trackTitle, spotifyId); openSearchModal(trackTitle, providerTrackId);
} else { } else {
// CTRL key is not pressed, play the track // CTRL key is not pressed, play the track
playJellyfinTrack(event.target, jellyfinId); playJellyfinTrack(event.target, jellyfinId);

View File

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

View File

@@ -0,0 +1,32 @@
{% extends "admin.html" %}
{% block admin_content %}
<div class="container mt-5">
<h1>Lidarr Configuration</h1>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% else %}
<form id="lidarrConfigForm" method="POST" action="{{ url_for('save_lidarr_config') }}">
<div class="mb-3">
<label for="qualityProfile" class="form-label">Default Quality Profile</label>
<select class="form-select" id="qualityProfile" name="qualityProfile" required>
{% for profile in quality_profiles %}
<option value="{{ profile.id }}" {% if profile.id == current_quality_profile|int %}selected{% endif %}>{{ profile.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="rootFolder" class="form-label">Default Root Folder</label>
<select class="form-select" id="rootFolder" name="rootFolder" required>
{% for folder in root_folders %}
<option value="{{ folder.path }}" {% if folder.path == current_root_folder %}selected{% endif %}>{{ folder.path }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,9 +1,10 @@
{% extends "admin.html" %} {% extends "admin.html" %}
{% block admin_content %} {% block admin_content %}
<div class="container mt-5"> <div class="">
<table class="table"> <table class="table ">
<thead> <thead>
<tr> <tr>
<th>Locked</th>
<th>Task Name</th> <th>Task Name</th>
<th>Status</th> <th>Status</th>
<th>Progress</th> <th>Progress</th>
@@ -15,6 +16,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<hr>
<h4>Unlock blocked tasks</h4>
<div> <div>
<form hx-post="/unlock_key" hx-swap="outerHTML" hx-target="#empty"> <form hx-post="/unlock_key" hx-swap="outerHTML" hx-target="#empty">
<div class="mb-3"> <div class="mb-3">

View File

@@ -34,12 +34,22 @@
<nav> <nav>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item"> <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> <a class="nav-link" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/categories"><i class="fa-solid fa-layer-group"></i> <a class="nav-link" href="/categories"><i class="fa-solid fa-layer-group"></i>
Categories</a> Categories</a>
</li> </li> -->
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/playlists/monitored"><i <a class="nav-link" href="/playlists/monitored"><i
class="fa-solid fa-tower-observation"></i> Monitored</a> class="fa-solid fa-tower-observation"></i> Monitored</a>
@@ -69,12 +79,22 @@
</div> </div>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item"> <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> <a class="nav-link text-white" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-white" href="/categories"><i class="fa-solid fa-layer-group"></i> <a class="nav-link text-white" href="/categories"><i class="fa-solid fa-layer-group"></i>
Categories</a> Categories</a>
</li> </li> -->
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-white" href="/playlists/monitored"><i <a class="nav-link text-white" href="/playlists/monitored"><i
class="fa-solid fa-tower-observation"></i> Monitored </a> class="fa-solid fa-tower-observation"></i> Monitored </a>
@@ -96,7 +116,7 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<span class="fixed-bottom m-3">{{version}}</span> <span class="fixed-bottom m-3 ms-5">{{version | version_check}}</span>
</div> </div>
<!-- Main content with toggle button for mobile sidebar --> <!-- Main content with toggle button for mobile sidebar -->
@@ -108,29 +128,23 @@
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<h1 class="mb-4 ms-3">{{ title }}</h1> <!-- Search Form -->
<form action="/search" method="GET" class="d-flex flex-grow-1 mb-1 me-2">
<div class="d-flex align-items-center "> <input
<form action="/search" method="GET" class="w-100"> type="search"
<div class="input-group"> class="form-control me-2"
<input name="query"
type="search" placeholder="Search ..."
class="form-control" aria-label="Search"
name="query" >
placeholder="Search Spotify..." <button class="btn btn-primary" type="submit">Search</button>
aria-label="Search" </form>
>
<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>
<!-- Display Initials Badge -->
<span>{{ session.get('jellyfin_user_name') }}</span>
</div> </div>
<h1 class="mb-1 ">{{ title }}</h1>
<h3 class="mb-4 ">{{ subtitle }}</h3>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</div> </div>

61
templates/browse.html Normal file
View 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>

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

View File

@@ -2,5 +2,36 @@
{% block content %} {% 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 %} {% endblock %}

View File

@@ -1,6 +1,16 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% 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> <h1 class="mb-4">{{ items_title }}</h1>
<h6 class="mb-4">{{ items_subtitle }}</h6> <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"> <div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
@@ -8,5 +18,6 @@
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,16 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% 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> <h1 >Your subscribed Jellyfin Playlists</h1>
<h6 ></h6> <h6 ></h6>
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container"> <div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
@@ -10,6 +20,6 @@
{% endfor %} {% endfor %}
</div> </div>
{%endif%}
{% endblock %} {% endblock %}

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

View File

@@ -1,5 +1,5 @@
{% if item.can_add %} {% 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" data-bs-toggle="tooltip" title="Add to my Jellyfin"
hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'> hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'>
<i class="fa-solid fa-circle-plus"> </i> <i class="fa-solid fa-circle-plus"> </i>

View File

@@ -7,6 +7,10 @@
<th>Name</th> <th>Name</th>
<th>Artist(s)</th> <th>Artist(s)</th>
<th>Path</th> <th>Path</th>
<th>Container</th>
{% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %}
<th></th>
{% endif %}
<th></th> <th></th>
<th></th> <th></th>
</tr> </tr>
@@ -14,17 +18,21 @@
<tbody> <tbody>
{% for track in results %} {% for track in results %}
<tr> <tr>
<td>{{ track.Name }}</td> <td>{{ track.Name | highlight(search_query) }}</td>
<td>{{ ', '.join(track.Artists) }}</td> <td>{{ ', '.join(track.Artists) }}</td>
<td>{{ track.Path}}</td> <td>{{ track.Path}}</td>
<td>{{ track.Container }}</td>
{% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %}
<td> {{track.Path | audioprofile(track.Path) }}</td>
{% endif %}
<td> <td>
<button class="btn btn-sm btn-primary" onclick="playJellyfinTrack(this, '{{ track.Id }}')"> <button class="btn btn-sm btn-primary" onclick="playJellyfinTrack(this, '{{ track.Id }}')">
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
</button> </button>
</td> </td>
<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}}"}'> <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 }}">
Associate Track Link Track
</button> </button>
</td> </td>
</tr> </tr>

View File

@@ -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="d-flex align-items-center row sticky-top py-3 mb-3 bg-dark" style="top: 0; z-index: 1000;">
<div class="col-6"> <div class="col-6">
<img src="{{ playlist_cover }}" class="img-fluid"> <img src="{{ item.image }}" class="img-fluid">
</div> </div>
<div class="col-6"> <div class="col-6">
<div class="playlist-info"> <div class="playlist-info">
<h1>{{ playlist_name }}</h1> <h1>{{ item.name }}</h1>
<p>{{ playlist_description }}</p> <p>{{ item.description }}</p>
<p>{{ track_count }} songs, {{ total_duration }}</p> <p>{{ item.track_count }} songs, {{ total_duration }}</p>
<p>Last Updated: {{ last_updated}} | Last Change: {{ last_changed}}</p> <p>Last Updated: {{ item.last_updated}} | Last Change: {{ item.last_changed}}</p>
{% include 'partials/_add_remove_button.html' %} {% include 'partials/_add_remove_button.html' %}
</div> </div>
</div> </div>

View File

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

View File

@@ -1,7 +1,14 @@
{% for task_name, task in tasks.items() %} {% for task_name, task in tasks.items() %}
<tr id="task-row-{{ task_name }}"> <tr id="task-row-{{ task_name }}">
<td>{{ task_name }}</td> <td class="w-auto">
<td>{{ task.state }}</td> {% 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> <td>
{% if task.info.percent %} {% if task.info.percent %}
<div class="progress" style="height: 20px;"> <div class="progress" style="height: 20px;">

View File

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

View File

@@ -1,77 +1,100 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th scope="col">#</th> <th scope="col">#</th>
<th scope="col">Title</th> <th scope="col">Title</th>
<th scope="col">Artist</th> <th scope="col">Artist</th>
<th scope="col">Duration</th> <th scope="col">Duration</th>
<th scope="col">Spotify</th> <th scope="col">{{provider_id}}</th>
<th scope="col">Preview</th> <th scope="col">Preview</th>
<th scope="col">Status</th> <th scope="col">Status</th>
<th scope="col">Jellyfin</th> <th scope="col">Jellyfin</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for track in tracks %} {% for track in tracks %}
<tr> <tr hx-get="/track_details/{{track.provider_track_id}}?provider={{ provider_id }}"
<th scope="row">{{ loop.index }}</th> hx-target="#trackDetailsModalcontent" hx-trigger="dblclick" hx-on="htmx:afterOnLoad:showModal">
<td>{{ track.title }}</td> <th scope="row">{{ loop.index }}</th>
<td>{{ track.artist }}</td> <td>{{ track.title }}</td>
<td>{{ track.duration }}</td> <td>{{ track.artist }}</td>
<td> <td>{{ track.duration }}</td>
<a href="{{ track.url }}" target="_blank" class="text-success" data-bs-toggle="tooltip" title="Open in Spotify"> <td>
<i class="fab fa-spotify fa-lg"></i> <a href="{{ track.url[0] }}" target="_blank" class="text-success" data-bs-toggle="tooltip"
</a> title="Open in {{ track.provider_id }}">
</td> <i class="fab fa-{{ track.provider_id.lower() }} fa-lg"></i>
<td> </a>
{% if track.preview_url %} </td>
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')" data-bs-toggle="tooltip" title="Play Preview"> <td>
<i class="fas fa-play"></i> {% if track.preview_url %}
</button> <button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')"
{% else %} data-bs-toggle="tooltip" title="Play Preview">
<span data-bs-toggle="tooltip" title="No Preview Available"> <i class="fas fa-play"></i>
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button> </button>
</span> {% else %}
{% endif %} <span data-bs-toggle="tooltip" title="No Preview Available">
</td> <button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
<td> </span>
{% if not track.downloaded %} {% endif %}
<button class="btn btn-sm btn-danger" </td>
data-bs-toggle="tooltip" title="{{ track.download_status if track.download_status else 'Not downloaded'}}"> <td>
<i class="fa-solid fa-triangle-exclamation"></i> {% if not track.downloaded %}
</button> <button class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
{% else %} title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Track downloaded"> <i class="fa-solid fa-triangle-exclamation"></i>
<i class="fa-solid fa-circle-check"></i> </button>
</button> {% else %}
{% endif %} <button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Downloaded">
</td> <i class="fa-solid fa-check"></i>
<td> </button>
{% set title = track.title | replace("'","") %} {% endif %}
</td>
<td>
{% set title = track.title | replace("'","") %}
{% if track.jellyfin_id %} {% 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)"> <button class="btn btn-sm btn-success"
<i class="fas fa-play"></i> 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> </button>
{% elif track.downloaded %} </span>
<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"> {% else %}
<button class="btn btn-sm btn-warning" onclick="openSearchModal('{{ title }}','{{track.spotify_id}}')"> <span>
<i class="fas fa-triangle-exclamation"></i> <button class="btn btn-sm" onclick="openSearchModal('{{ title }}','{{track.provider_track_id}}')"
</button> data-bs-toggle="tooltip" title="Click to assign a track"><i class="fas fa-ban"></i></button>
</span> </span>
{% else %} {% endif %}
<span data-bs-toggle="tooltip" title="Not Available"> </td>
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button> </tr>
</span> {% endfor %}
{% endif %} </tbody>
</td> </table>
</tr>
{% endfor %} <div class="modal fade" id="trackDetailsModal" tabindex="-1" aria-labelledby="trackDetailsModalLabel"
</tbody> aria-hidden="true">
</table> <div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content" id="trackDetailsModalcontent">
</div>
</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 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-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="searchModalLabel">Search Jellyfin for Track</h5> <h5 class="modal-title" id="searchModalLabel">Search Jellyfin for Track</h5>
@@ -81,14 +104,20 @@
<!-- htmx-enabled form --> <!-- htmx-enabled form -->
<form id="search-form" hx-get="/search_jellyfin" hx-target="#search-results" hx-trigger="submit"> <form id="search-form" hx-get="/search_jellyfin" hx-target="#search-results" hx-trigger="submit">
<div class="input-group mb-3"> <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="text" class="form-control" id="search-query" name="search_query"
<input type="hidden" class="form-control" id="spotify-id" name="spotify_id" > 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> <button class="btn btn-primary" type="submit">Search</button>
</div> </div>
</form> </form>
<div id="search-results"> <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>
</div> </div>

View File

@@ -31,12 +31,12 @@
</div> </div>
<div class="mt-auto pt-3"> <div class="mt-auto pt-3">
{% if item.type == 'category'%} {% 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> <i class="fa-solid fa-eye"></i>
</a> </a>
{%else%} {%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"> title="View Playlist details">
<i class="fa-solid fa-eye"></i> <i class="fa-solid fa-eye"></i>
</a> </a>

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

View File

@@ -1 +1 @@
__version__ = "0.1.6" __version__ = "0.1.8"