Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7b58d7b2 | ||
|
|
68f17e84ed | ||
|
|
f48186bcf6 | ||
|
|
ed8fb70500 | ||
|
|
96b5cd5928 | ||
|
|
006a3ce32e | ||
|
|
a08c9c7800 | ||
|
|
e00513ba52 | ||
|
|
f04657a86c | ||
|
|
7a6d238610 | ||
|
|
ce50e1a8f8 | ||
|
|
32c860fbb9 | ||
|
|
bf725c6b24 | ||
|
|
bb195b1c77 | ||
|
|
e66208b49e | ||
|
|
9e675f8cf4 | ||
|
|
8a883edf07 | ||
|
|
e9fa5f8994 | ||
|
|
1b91110768 | ||
|
|
61604950c3 | ||
|
|
58674d4c26 | ||
|
|
d146e78132 | ||
|
|
3ceae962b1 | ||
|
|
6f051cb167 | ||
|
|
42325742f0 | ||
|
|
8ad5ff0860 | ||
|
|
92407a2ee0 | ||
|
|
7af86c926f | ||
|
|
580906dc78 | ||
|
|
917ec9542f | ||
|
|
b9530a159c | ||
|
|
fffeac8c74 | ||
|
|
4d06b257cb | ||
|
|
8c9fb43f01 | ||
|
|
69c5f7093e | ||
|
|
34ae3c5680 | ||
|
|
56d937a21f | ||
|
|
3a78e710ae | ||
|
|
1b95f201be | ||
|
|
b7de39e501 | ||
|
|
9c46be1701 | ||
|
|
43adf12755 | ||
|
|
9de58731c0 | ||
|
|
969eca4a04 | ||
|
|
9667b71d24 | ||
|
|
4d9e6162fc | ||
|
|
b877ee04e3 | ||
|
|
d6a702b606 | ||
|
|
d615bafd1f | ||
|
|
a44c5b5209 | ||
|
|
67d2b3cb9e | ||
|
|
6248c54829 | ||
|
|
2da69fc330 | ||
|
|
0c57912053 | ||
|
|
423ffbb608 | ||
|
|
d9302434c2 | ||
|
|
debe273cfb | ||
|
|
f9e8be1824 | ||
|
|
cdf7d8ffe9 | ||
|
|
41c62a5376 | ||
|
|
4f06f81e93 | ||
|
|
754f7f9204 | ||
|
|
01cc78eb93 | ||
|
|
79c9554ce2 | ||
|
|
500a049976 | ||
|
|
477c869107 | ||
|
|
731d2db083 | ||
|
|
3862730203 | ||
|
|
11bd25e5be | ||
|
|
c78ceef508 | ||
|
|
aa201c3be2 | ||
|
|
6f3f5b9623 | ||
|
|
4106524710 | ||
|
|
be37d4cffe | ||
|
|
4deb7387aa | ||
|
|
6129bee98c | ||
|
|
23d121e58f | ||
|
|
7676189625 | ||
|
|
798c4ae28d | ||
|
|
eeb6ad9172 | ||
|
|
92e8963727 | ||
|
|
d54100cbc4 | ||
|
|
e559b1cf11 | ||
|
|
631b2a35f7 | ||
|
|
ad5957b539 | ||
|
|
e2bea2c151 | ||
|
|
181eff22ef | ||
|
|
a20f1733f1 | ||
|
|
0f4d599308 | ||
|
|
9acf3bde84 |
@@ -1,12 +1,12 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.8
|
||||
current_version = 0.1.10
|
||||
commit = True
|
||||
tag = True
|
||||
|
||||
[bumpversion:file:app/version.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
search = __version__ = "v{current_version}"
|
||||
replace = __version__ = "v{new_version}"
|
||||
|
||||
[bumpversion:file:version.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
search = __version__ = "v{current_version}"
|
||||
replace = __version__ = "v{new_version}"
|
||||
|
||||
@@ -11,7 +11,12 @@ __pycache__/
|
||||
.DS_Store
|
||||
|
||||
# Ignore Git files
|
||||
.git
|
||||
cookies*
|
||||
.git*
|
||||
*cookies*
|
||||
set_env.sh
|
||||
jellyplist.code-workspace
|
||||
|
||||
# Ignore GitHub page related files
|
||||
changelogs
|
||||
readme.md
|
||||
screenshots
|
||||
11
.github/workflows/main.yml
vendored
11
.github/workflows/main.yml
vendored
@@ -1,9 +1,12 @@
|
||||
name: Build and Release on Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to build the Docker image from'
|
||||
required: true
|
||||
default: 'main'
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
@@ -73,5 +76,7 @@ jobs:
|
||||
name: Release ${{ env.VERSION }}
|
||||
body: |
|
||||
${{ env.CHANGELOG_CONTENT }}
|
||||
generate_release_notes: true
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
26
.github/workflows/manual-build.yml
vendored
26
.github/workflows/manual-build.yml
vendored
@@ -32,9 +32,21 @@ jobs:
|
||||
- name: Extract Version
|
||||
id: extract_version
|
||||
run: |
|
||||
version=$(python3 -c "import version; print(f'${{ env.BRANCH_NAME}}-{version.__version__}')")
|
||||
version=$(python3 -c "import version; print(f'{version.__version__}')")
|
||||
echo "VERSION=$version" >> $GITHUB_ENV
|
||||
|
||||
- name: Read Changelog
|
||||
id: changelog
|
||||
run: |
|
||||
if [ -f changelogs/${{ env.VERSION }}.md ]; then
|
||||
changelog_content=$(cat changelogs/${{ env.VERSION }}.md)
|
||||
echo "CHANGELOG_CONTENT<<EOF" >> $GITHUB_ENV
|
||||
echo "$changelog_content" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CHANGELOG_CONTENT=No changelog available for this release." >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Set up Docker
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
@@ -56,13 +68,17 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }}
|
||||
ghcr.io/${{ github.repository }}:dev
|
||||
ghcr.io/${{ github.repository }}:${{ env.VERSION }}
|
||||
ghcr.io/${{ github.repository }}:${{ env.BRANCH_NAME }}
|
||||
ghcr.io/${{ github.repository }}:${{ env.VERSION }}-${{ env.BRANCH_NAME}}
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.VERSION }}
|
||||
name: Dev Release ${{ env.VERSION }}
|
||||
tag_name: |
|
||||
${{ env.VERSION }}-${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}
|
||||
name: |
|
||||
${{ env.BRANCH_NAME }} Release ${{ env.VERSION }}
|
||||
body: |
|
||||
${{ env.CHANGELOG_CONTENT }}
|
||||
generate_release_notes: true
|
||||
make_latest: false
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -79,3 +79,4 @@ set_env.sh
|
||||
notes.md
|
||||
DEV_BUILD
|
||||
payload.json
|
||||
settings.yaml
|
||||
|
||||
2
.pylintrc
Normal file
2
.pylintrc
Normal file
@@ -0,0 +1,2 @@
|
||||
[MESSAGES CONTROL]
|
||||
disable=logging-fstring-interpolation,broad-exception-raised
|
||||
@@ -8,7 +8,7 @@ WORKDIR /jellyplist
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN apt update
|
||||
RUN apt install ffmpeg netcat-openbsd -y
|
||||
RUN apt install ffmpeg netcat-openbsd supervisor -y
|
||||
# Copy the application code
|
||||
COPY . .
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
@@ -16,6 +16,7 @@ RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 5055
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
|
||||
# Set the entrypoint
|
||||
@@ -23,4 +24,4 @@ ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "run.py"]
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
@@ -1,6 +1,8 @@
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import yaml
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
import sys
|
||||
@@ -97,15 +99,38 @@ app = Flask(__name__, template_folder="../templates", static_folder='../static')
|
||||
|
||||
|
||||
app.config.from_object(Config)
|
||||
app.config['runtime_settings'] = {}
|
||||
yaml_file = 'settings.yaml'
|
||||
def load_yaml_settings():
|
||||
with open(yaml_file, 'r') as f:
|
||||
app.config['runtime_settings'] = yaml.safe_load(f)
|
||||
def save_yaml_settings():
|
||||
with open(yaml_file, 'w') as f:
|
||||
yaml.dump(app.config['runtime_settings'], f)
|
||||
|
||||
|
||||
for handler in app.logger.handlers:
|
||||
app.logger.removeHandler(handler)
|
||||
|
||||
|
||||
log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO) # Default to DEBUG if invalid
|
||||
app.logger.setLevel(log_level)
|
||||
|
||||
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(levelname)7s - %(message)s"
|
||||
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)36s() ] %(levelname)7s - %(message)s"
|
||||
logging.basicConfig(format=FORMAT)
|
||||
|
||||
# Add RotatingFileHandler to log to a file
|
||||
# if worker is in sys.argv, we are running a celery worker, so we log to a different file
|
||||
if 'worker' in sys.argv:
|
||||
log_file = os.path.join("/var/log/", 'jellyplist_worker.log')
|
||||
elif 'beat' in sys.argv:
|
||||
log_file = os.path.join("/var/log/", 'jellyplist_beat.log')
|
||||
else:
|
||||
log_file = os.path.join("/var/log/", 'jellyplist.log')
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=2 * 1024 * 1024, backupCount=10)
|
||||
file_handler.setFormatter(logging.Formatter(FORMAT))
|
||||
app.logger.addHandler(file_handler)
|
||||
|
||||
Config.validate_env_vars()
|
||||
cache = Cache(app)
|
||||
redis_client = redis.StrictRedis(host=app.config['CACHE_REDIS_HOST'], port=app.config['CACHE_REDIS_PORT'], db=0, decode_responses=True)
|
||||
@@ -181,7 +206,38 @@ spotify_client.authenticate()
|
||||
from .registry import MusicProviderRegistry
|
||||
MusicProviderRegistry.register_provider(spotify_client)
|
||||
|
||||
if app.config['ENABLE_DEEZER']:
|
||||
from .providers import DeezerClient
|
||||
deezer_client = DeezerClient()
|
||||
deezer_client.authenticate()
|
||||
MusicProviderRegistry.register_provider(deezer_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'])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if os.path.exists(yaml_file):
|
||||
app.logger.info('Loading runtime settings from settings.yaml')
|
||||
load_yaml_settings()
|
||||
# def watch_yaml_file(yaml_file, interval=30):
|
||||
# last_mtime = os.path.getmtime(yaml_file)
|
||||
# while True:
|
||||
# time.sleep(interval)
|
||||
# current_mtime = os.path.getmtime(yaml_file)
|
||||
# if current_mtime != last_mtime:
|
||||
# last_mtime = current_mtime
|
||||
# yaml_settings = load_yaml_settings(yaml_file)
|
||||
# app.config.update(yaml_settings)
|
||||
# app.logger.info(f"Reloaded YAML settings from {yaml_file}")
|
||||
|
||||
# watcher_thread = threading.Thread(
|
||||
# target=watch_yaml_file,
|
||||
# args=('settings.yaml',),
|
||||
# daemon=True
|
||||
# )
|
||||
# watcher_thread.start()
|
||||
@@ -97,3 +97,21 @@ def jellyfin_link(jellyfin_id: str) -> Markup:
|
||||
|
||||
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
|
||||
return Markup(f'<a href="{link}" target="_blank">{jellyfin_id}</a>')
|
||||
|
||||
@template_filter('jellyfin_link_button')
|
||||
def jellyfin_link_btn(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}" class="btn btn-primary mt-2" target="_blank">Open in Jellyfin</a>')
|
||||
|
||||
|
||||
# A template filter for displaying a datetime in a human-readable format
|
||||
@template_filter('human_datetime')
|
||||
def human_datetime(dt) -> str:
|
||||
if not dt:
|
||||
return 'No date provided'
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
@@ -112,6 +112,22 @@ def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track:
|
||||
app.logger.error(f"Error fetching track {track_id} from {provider_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
@cache.memoize(timeout=3600)
|
||||
def get_cached_provider_playlist(playlist_id : str,provider_id : str)-> base.Playlist:
|
||||
"""
|
||||
Fetches a playlist by its ID, utilizing caching to minimize API calls.
|
||||
|
||||
:param playlist_id: The playlist ID.
|
||||
:return: Playlist data as a dictionary, or None if an error occurs.
|
||||
"""
|
||||
try:
|
||||
# get the provider from the registry
|
||||
provider = MusicProviderRegistry.get_provider(provider_id)
|
||||
playlist_data = provider.get_playlist(playlist_id)
|
||||
return playlist_data
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching playlist {playlist_id} from {provider_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]:
|
||||
is_admin = session.get('is_admin', False)
|
||||
@@ -245,3 +261,7 @@ def get_latest_release(tag_name :str):
|
||||
except requests.exceptions.RequestException as e:
|
||||
app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||
return False,''
|
||||
|
||||
def set_log_level(level):
|
||||
app.logger.setLevel(level)
|
||||
app.logger.info(f"Log level set to {level}")
|
||||
@@ -50,7 +50,7 @@ playlist_tracks = db.Table('playlist_tracks',
|
||||
|
||||
class Track(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
provider_track_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
provider_uri = db.Column(db.String(120), unique=True, nullable=False)
|
||||
downloaded = db.Column(db.Boolean())
|
||||
@@ -59,8 +59,11 @@ class Track(db.Model):
|
||||
download_status = db.Column(db.String(2048), nullable=True)
|
||||
provider_id = db.Column(db.String(20))
|
||||
|
||||
|
||||
# Many-to-Many relationship with Playlists
|
||||
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
|
||||
|
||||
lidarr_processed = db.Column(db.Boolean(), default=False)
|
||||
quality_score = db.Column(db.Float(), default=0)
|
||||
def __repr__(self):
|
||||
return f'<Track {self.name}:{self.provider_track_id}>'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .spotify import SpotifyClient
|
||||
#from .deezer import DeezerClient
|
||||
|
||||
__all__ = ["SpotifyClient"]
|
||||
320
app/providers/deezer.py
Normal file
320
app/providers/deezer.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import time
|
||||
from bs4 import BeautifulSoup
|
||||
import deezer
|
||||
import deezer.resources
|
||||
import deezer.exceptions
|
||||
import json
|
||||
import requests
|
||||
from typing import List, Optional, Dict
|
||||
import logging
|
||||
from deezer import Client
|
||||
|
||||
from app.providers.base import (
|
||||
MusicProviderClient,
|
||||
AccountAttributes,
|
||||
Album,
|
||||
Artist,
|
||||
BrowseCard,
|
||||
BrowseSection,
|
||||
Image,
|
||||
Owner,
|
||||
Playlist,
|
||||
PlaylistTrack,
|
||||
Profile,
|
||||
Track,
|
||||
ExternalUrl,
|
||||
Category,
|
||||
)
|
||||
|
||||
l = logging.getLogger(__name__)
|
||||
|
||||
class DeezerClient(MusicProviderClient):
|
||||
"""
|
||||
Deezer implementation of the MusicProviderClient.
|
||||
An abstraction layer of deezer-python
|
||||
https://github.com/browniebroke/deezer-python library to work with Jellyplist.
|
||||
"""
|
||||
|
||||
@property
|
||||
def _identifier(self) -> str:
|
||||
return "Deezer"
|
||||
|
||||
|
||||
|
||||
def __init__(self, access_token: Optional[str] = None):
|
||||
"""
|
||||
Initialize the Deezer client.
|
||||
:param access_token: Optional access token for authentication.
|
||||
"""
|
||||
self._client = deezer.Client(access_token=access_token)
|
||||
|
||||
#region Helper methods for parsing Deezer API responses
|
||||
def _parse_track(self, track: deezer.resources.Track) -> Track:
|
||||
"""
|
||||
Parse a track object.
|
||||
:param track: The track object from the Deezer API.
|
||||
:return: A Track object.
|
||||
"""
|
||||
|
||||
l.debug(f"Track: {track}")
|
||||
retrycount= 0
|
||||
max_retries = 3
|
||||
wait = .8
|
||||
while True:
|
||||
try:
|
||||
artists = [self._parse_artist(track.artist)]
|
||||
if hasattr(track, 'contributors'):
|
||||
artists = [self._parse_artist(artist) for artist in track.contributors]
|
||||
return Track(
|
||||
id=str(track.id),
|
||||
name=track.title,
|
||||
uri=f"deezer:track:{track.id}",
|
||||
duration_ms=track.duration * 1000,
|
||||
explicit=track.explicit_lyrics,
|
||||
album=self._parse_album(track.album),
|
||||
artists=artists,
|
||||
external_urls=[],
|
||||
)
|
||||
except deezer.exceptions.DeezerErrorResponse as e:
|
||||
if e.json_data['error']['code'] == 4:
|
||||
l.warning(f"Quota limit exceeded. Waiting for {wait} seconds before retrying...")
|
||||
retrycount += 1
|
||||
if retrycount >= max_retries:
|
||||
l.error("Maximum retries reached. Aborting.")
|
||||
raise
|
||||
time.sleep(wait)
|
||||
else:
|
||||
raise
|
||||
def _parse_artist(self, artist: deezer.resources.Artist) -> Artist:
|
||||
"""
|
||||
Parse an artist object.
|
||||
:param artist: The artist object from the Deezer API.
|
||||
:return: An Artist object.
|
||||
"""
|
||||
return Artist(
|
||||
id=str(artist.id),
|
||||
name=artist.name,
|
||||
uri=f"deezer:artist:{artist.id}",
|
||||
external_urls=[],
|
||||
)
|
||||
|
||||
def _parse_album(self, album: deezer.resources.Album) -> Album:
|
||||
"""
|
||||
Parse an album object.
|
||||
:param album: The album object from the Deezer API.
|
||||
:return: An Album object.
|
||||
"""
|
||||
#artists = [self._parse_artist(artist) for artist in album.contributors]
|
||||
artists = []
|
||||
images = [Image(url=album.cover_xl, height=None, width=None)]
|
||||
return Album(
|
||||
id=str(album.id),
|
||||
name=album.title,
|
||||
uri=f"deezer:album:{album.id}",
|
||||
external_urls=[],
|
||||
artists=artists,
|
||||
images=images
|
||||
)
|
||||
def _parse_playlist(self, playlist: deezer.resources.Playlist) -> Playlist:
|
||||
"""
|
||||
Parse a playlist object.
|
||||
:param playlist: The playlist object from the Deezer API.
|
||||
:return: A Playlist object.
|
||||
"""
|
||||
images = [Image(url=playlist.picture_medium, height=None, width=None)]
|
||||
tracks = []
|
||||
tracks = [PlaylistTrack(is_local=False, track=self._parse_track(playlist_track), added_at='', added_by='') for playlist_track in playlist.get_tracks()]
|
||||
|
||||
|
||||
return Playlist(
|
||||
id=str(playlist.id),
|
||||
name=playlist.title,
|
||||
uri=f"deezer:playlist:{playlist.id}",
|
||||
external_urls=[ExternalUrl(url=playlist.link)],
|
||||
description=playlist.description,
|
||||
public=playlist.public,
|
||||
collaborative=playlist.collaborative,
|
||||
followers=playlist.fans,
|
||||
images=images,
|
||||
owner=Owner(
|
||||
id=str(playlist.creator.id),
|
||||
name=playlist.creator.name,
|
||||
uri=f"deezer:user:{playlist.creator.id}",
|
||||
external_urls=[ExternalUrl(url=playlist.creator.link)]
|
||||
),
|
||||
tracks=tracks
|
||||
)
|
||||
|
||||
#endregion
|
||||
def authenticate(self, credentials: Optional[dict] = None) -> None:
|
||||
"""
|
||||
Authenticate with Deezer using an access token.
|
||||
:param credentials: Optional dictionary containing 'access_token'.
|
||||
"""
|
||||
l.info("Authentication is handled by deezer-python.")
|
||||
pass
|
||||
|
||||
def extract_playlist_id(self, uri: str) -> str:
|
||||
"""
|
||||
Extract the playlist ID from a Deezer playlist URL or URI.
|
||||
:param uri: The playlist URL or URI.
|
||||
:return: The playlist ID.
|
||||
"""
|
||||
# TODO: Implement this method
|
||||
return ''
|
||||
|
||||
def get_playlist(self, playlist_id: str) -> Playlist:
|
||||
"""
|
||||
Fetch a playlist by its ID.
|
||||
:param playlist_id: The ID of the playlist to fetch.
|
||||
:return: A Playlist object.
|
||||
"""
|
||||
data = self._client.get_playlist(int(playlist_id))
|
||||
return self._parse_playlist(data)
|
||||
|
||||
def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]:
|
||||
"""
|
||||
Search for playlists matching a query.
|
||||
:param query: The search query.
|
||||
:param limit: Maximum number of results to return.
|
||||
:return: A list of Playlist objects.
|
||||
"""
|
||||
playlists = []
|
||||
search_results = self._client.search_playlists(query, strict=None, ordering=None)
|
||||
for item in search_results:
|
||||
images = [Image(url=item.picture_xl, height=None, width=None)]
|
||||
tracks = [PlaylistTrack(is_local=False, track=self._parse_track(playlist_track), added_at='', added_by='') for playlist_track in item.tracks]
|
||||
playlist = Playlist(
|
||||
id=str(item.id),
|
||||
name=item.title,
|
||||
uri=f"deezer:playlist:{item.id}",
|
||||
external_urls=[ExternalUrl(url=item.link)],
|
||||
description=item.description,
|
||||
public=item.public,
|
||||
collaborative=item.collaborative,
|
||||
followers=item.fans,
|
||||
images=images,
|
||||
owner=Owner(
|
||||
id=str(item.creator.id),
|
||||
name=item.create.name,
|
||||
uri=f"deezer:user:{item.creator.id}",
|
||||
external_urls=[ExternalUrl(url=item.creator.link)]
|
||||
),
|
||||
tracks=tracks
|
||||
)
|
||||
playlists.append(playlist)
|
||||
return playlists
|
||||
|
||||
|
||||
def get_track(self, track_id: str) -> Track:
|
||||
"""
|
||||
Fetch a track by its ID.
|
||||
:param track_id: The ID of the track to fetch.
|
||||
:return: A Track object.
|
||||
"""
|
||||
track = self._client.get_track(int(track_id))
|
||||
return self._parse_track(track)
|
||||
|
||||
|
||||
def browse(self, **kwargs) -> List[BrowseSection]:
|
||||
"""
|
||||
Browse featured content.
|
||||
:param kwargs: Additional parameters.
|
||||
:return: A list of BrowseSection objects.
|
||||
"""
|
||||
# Deezer does not have a direct equivalent, but we can fetch charts
|
||||
url = 'https://www.deezer.com/de/channels/explore/explore-tab'
|
||||
headers = {
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'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 Edg/131.0.0.0',
|
||||
'sec-ch-ua': '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0'
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
dzr_app_div = soup.find('div', id='dzr-app')
|
||||
script_tag = dzr_app_div.find('script')
|
||||
|
||||
script_content = script_tag.string.strip()
|
||||
json_content = script_content.replace('window.__DZR_APP_STATE__ = ', '', 1)
|
||||
data = json.loads(json_content)
|
||||
|
||||
sections = []
|
||||
for section in data['sections']:
|
||||
browse_section = None
|
||||
if 'module_type=channel' in section['section_id']:
|
||||
cards = []
|
||||
for item in section['items']:
|
||||
if item['type'] == 'channel':
|
||||
image_url = f"https://cdn-images.dzcdn.net/images/{item['image_linked_item']['type']}/{item['image_linked_item']['md5']}/256x256-000000-80-0-0.jpg"
|
||||
card = BrowseCard(
|
||||
title=item['title'],
|
||||
uri=f"deezer:channel:{item['data']['slug']}",
|
||||
artwork=[Image(url=image_url, height=None, width=None)],
|
||||
background_color=item['data']['background_color']
|
||||
)
|
||||
cards.append(card)
|
||||
browse_section = BrowseSection(
|
||||
title=section['title'],
|
||||
uri=f"deezer:section:{section['group_id']}",
|
||||
items=cards
|
||||
)
|
||||
if browse_section:
|
||||
sections.append(browse_section)
|
||||
return sections
|
||||
|
||||
def browse_page(self, uri: str) -> List[Playlist]:
|
||||
"""
|
||||
Fetch playlists for a given browse page.
|
||||
:param uri: The uri to query.
|
||||
:return: A list of Playlist objects.
|
||||
"""
|
||||
# Deezer does not have a direct equivalent, but we can fetch charts
|
||||
playlists = []
|
||||
slug = uri.split(':')[-1]
|
||||
url = f'https://www.deezer.com/de/channels/{slug}'
|
||||
headers = {
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'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 Edg/131.0.0.0',
|
||||
'sec-ch-ua': '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0'
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
dzr_app_div = soup.find('div', id='dzr-app')
|
||||
script_tag = dzr_app_div.find('script')
|
||||
|
||||
script_content = script_tag.string.strip()
|
||||
json_content = script_content.replace('window.__DZR_APP_STATE__ = ', '', 1)
|
||||
data = json.loads(json_content)
|
||||
for section in data['sections']:
|
||||
for item in section['items']:
|
||||
if item['type'] == 'playlist':
|
||||
#playlist = self.get_playlist(item['data']['slug'])
|
||||
image_url = f"https://cdn-images.dzcdn.net/images/{item['type']}/{item['data']['PLAYLIST_PICTURE']}/256x256-000000-80-0-0.jpg"
|
||||
playlist = Playlist(
|
||||
id=str(item['id']),
|
||||
name=item['title'],
|
||||
uri=f"deezer:playlist:{item['id']}",
|
||||
external_urls=[ExternalUrl(url=f"https://www.deezer.com/playlist/{item['target']}")],
|
||||
description=item.get('catption',''),
|
||||
public=True, # TODO: Check if this is correct
|
||||
collaborative=False, # TODO: Check if this is correct
|
||||
followers=item['data']['NB_FAN'],
|
||||
images=[Image(url=image_url, height=None, width=None)],
|
||||
owner=Owner(
|
||||
id=item['data'].get('PARENT_USERNAME',''),
|
||||
name=item['data'].get('PARENT_USERNAME',''),
|
||||
uri=f"deezer:user:{item['data'].get('PARENT_USERNAME','')}",
|
||||
external_urls=[ExternalUrl(url='')]
|
||||
)
|
||||
)
|
||||
playlists.append(playlist)
|
||||
return playlists
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.tasks import task_manager
|
||||
|
||||
from app.registry.music_provider_registry import MusicProviderRegistry
|
||||
from jellyfin.objects import PlaylistMetadata
|
||||
from app.routes import pl_bp
|
||||
from app.routes import pl_bp, routes
|
||||
|
||||
@app.route('/jellyfin_playlists')
|
||||
@functions.jellyfin_login_required
|
||||
@@ -34,7 +34,10 @@ def jellyfin_playlists():
|
||||
|
||||
combined_playlists = []
|
||||
for pl in playlists:
|
||||
provider_playlist = provider_client.get_playlist(pl.provider_playlist_id)
|
||||
# Use the cached provider_playlist_id to fetch the playlist from the provider
|
||||
provider_playlist = functions.get_cached_provider_playlist(pl.provider_playlist_id,pl.provider_id)
|
||||
#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:
|
||||
@@ -50,6 +53,13 @@ def jellyfin_playlists():
|
||||
def add_playlist():
|
||||
playlist_id = request.form.get('item_id')
|
||||
playlist_name = request.form.get('item_name')
|
||||
additional_users = None
|
||||
if not playlist_id and request.data:
|
||||
# get data convert from json to dict
|
||||
data = request.get_json()
|
||||
playlist_id = data.get('item_id')
|
||||
playlist_name = data.get('item_name')
|
||||
additional_users = data.get('additional_users')
|
||||
# also get the provider id from the query params
|
||||
provider_id = request.args.get('provider')
|
||||
if not playlist_id:
|
||||
@@ -119,6 +129,13 @@ def add_playlist():
|
||||
"can_remove":True,
|
||||
"jellyfin_id" : playlist.jellyfin_id
|
||||
}
|
||||
if additional_users and session['is_admin']:
|
||||
db.session.commit()
|
||||
app.logger.debug(f"Additional users: {additional_users}")
|
||||
for user_id in additional_users:
|
||||
routes.add_jellyfin_user_to_playlist_internal(user_id,playlist.jellyfin_id)
|
||||
|
||||
|
||||
return render_template('partials/_add_remove_button.html',item= item)
|
||||
|
||||
|
||||
@@ -153,6 +170,37 @@ def delete_playlist(playlist_id):
|
||||
except Exception as e:
|
||||
flash(f'Failed to remove item: {str(e)}')
|
||||
|
||||
@app.route('/refresh_playlist/<playlist_id>', methods=['GET'])
|
||||
@functions.jellyfin_admin_required
|
||||
def refresh_playlist(playlist_id):
|
||||
# get the playlist from the database using the playlist_id
|
||||
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
|
||||
# if the playlist has a jellyfin_id, then fetch the playlist from Jellyfin
|
||||
if playlist.jellyfin_id:
|
||||
try:
|
||||
app.logger.debug(f"removing all tracks from playlist {playlist.jellyfin_id}")
|
||||
jellyfin_playlist = jellyfin.get_music_playlist(session_token=functions._get_api_token(), playlist_id=playlist.jellyfin_id)
|
||||
jellyfin.remove_songs_from_playlist(session_token=functions._get_token_from_sessioncookie(), playlist_id=playlist.jellyfin_id, song_ids=[track for track in jellyfin_playlist['ItemIds']])
|
||||
ordered_tracks = db.session.execute(
|
||||
db.select(Track, playlist_tracks.c.track_order)
|
||||
.join(playlist_tracks, playlist_tracks.c.track_id == Track.id)
|
||||
.where(playlist_tracks.c.playlist_id == playlist.id)
|
||||
.order_by(playlist_tracks.c.track_order)
|
||||
).all()
|
||||
|
||||
tracks = [track.jellyfin_id for track, idx in ordered_tracks if track.jellyfin_id is not None]
|
||||
#jellyfin.remove_songs_from_playlist(session_token=jellyfin_admin_token, playlist_id=playlist.jellyfin_id, song_ids=tracks)
|
||||
jellyfin.add_songs_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(), playlist_id=playlist.jellyfin_id, song_ids=tracks)
|
||||
# if the playlist is found, then update the playlist metadata
|
||||
provider_playlist = MusicProviderRegistry.get_provider(playlist.provider_id).get_playlist(playlist.provider_playlist_id)
|
||||
functions.update_playlist_metadata(playlist, provider_playlist)
|
||||
flash('Playlist refreshed')
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Failed to refresh playlist: {str(e)}')
|
||||
return jsonify({'success': False})
|
||||
|
||||
|
||||
@app.route('/wipe_playlist/<playlist_id>', methods=['DELETE'])
|
||||
@functions.jellyfin_admin_required
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 import app, db, functions, jellyfin, read_dev_build_file, tasks, save_yaml_settings
|
||||
from app.classes import AudioProfile, CombinedPlaylistData
|
||||
from app.models import JellyfinUser,Playlist,Track
|
||||
from celery.result import AsyncResult
|
||||
@@ -68,13 +68,13 @@ def save_lidarr_config():
|
||||
@functions.jellyfin_admin_required
|
||||
def task_manager():
|
||||
statuses = {}
|
||||
lock_keys = []
|
||||
for task_name, task_id in tasks.task_manager.tasks.items():
|
||||
statuses[task_name] = tasks.task_manager.get_task_status(task_name)
|
||||
lock_keys.append(f"{task_name}_lock")
|
||||
lock_keys.append('full_update_jellyfin_ids_lock')
|
||||
return render_template('admin/tasks.html', tasks=statuses,lock_keys = lock_keys)
|
||||
|
||||
|
||||
return render_template('admin/tasks.html', tasks=statuses)
|
||||
|
||||
@app.route('/admin')
|
||||
@app.route('/admin/link_issues')
|
||||
@functions.jellyfin_admin_required
|
||||
def link_issues():
|
||||
@@ -107,6 +107,81 @@ def link_issues():
|
||||
|
||||
return render_template('admin/link_issues.html' , tracks = tracks )
|
||||
|
||||
@app.route('/admin/logs')
|
||||
@functions.jellyfin_admin_required
|
||||
def view_logs():
|
||||
# parse the query parameter
|
||||
log_name = request.args.get('name')
|
||||
logs = []
|
||||
if log_name == 'logs' or not log_name and os.path.exists('/var/log/jellyplist.log'):
|
||||
with open('/var/log/jellyplist.log', 'r',encoding='utf-8') as f:
|
||||
logs = f.readlines()
|
||||
if log_name == 'worker' and os.path.exists('/var/log/jellyplist_worker.log'):
|
||||
with open('/var/log/jellyplist_worker.log', 'r', encoding='utf-8') as f:
|
||||
logs = f.readlines()
|
||||
if log_name == 'beat' and os.path.exists('/var/log/jellyplist_beat.log'):
|
||||
with open('/var/log/jellyplist_beat.log', 'r',encoding='utf-8') as f:
|
||||
logs = f.readlines()
|
||||
return render_template('admin/logview.html', logs=str.join('',logs),name=log_name)
|
||||
|
||||
@app.route('/admin/setloglevel', methods=['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def set_log_level():
|
||||
loglevel = request.form.get('logLevel')
|
||||
if loglevel:
|
||||
if loglevel in ['DEBUG','INFO','WARNING','ERROR','CRITICAL']:
|
||||
functions.set_log_level(loglevel)
|
||||
flash(f'Log level set to {loglevel}', category='success')
|
||||
return redirect(url_for('view_logs'))
|
||||
|
||||
@app.route('/admin/logs/getLogsForIssue')
|
||||
@functions.jellyfin_admin_required
|
||||
def get_logs_for_issue():
|
||||
# get the last 200 lines of all log files
|
||||
last_lines = -300
|
||||
logs = []
|
||||
logs += f'## Logs and Details for Issue ##\n'
|
||||
logs += f'Version: *{__version__}{read_dev_build_file()}*\n'
|
||||
if os.path.exists('/var/log/jellyplist.log'):
|
||||
with open('/var/log/jellyplist.log', 'r',encoding='utf-8') as f:
|
||||
logs += f'### jellyfin.log\n'
|
||||
logs += f'```log\n'
|
||||
logs += f.readlines()[last_lines:]
|
||||
logs += f'```\n'
|
||||
|
||||
if os.path.exists('/var/log/jellyplist_worker.log'):
|
||||
with open('/var/log/jellyplist_worker.log', 'r', encoding='utf-8') as f:
|
||||
logs += f'### jellyfin_worker.log\n'
|
||||
logs += f'```log\n'
|
||||
logs += f.readlines()[last_lines:]
|
||||
logs += f'```\n'
|
||||
|
||||
if os.path.exists('/var/log/jellyplist_beat.log'):
|
||||
with open('/var/log/jellyplist_beat.log', 'r',encoding='utf-8') as f:
|
||||
logs += f'### jellyplist_beat.log\n'
|
||||
logs += f'```log\n'
|
||||
logs += f.readlines()[last_lines:]
|
||||
logs += f'```\n'
|
||||
# in the logs array, anonymize IP addresses
|
||||
logs = [re.sub(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', 'xxx.xxx.xxx.xxx', log) for log in logs]
|
||||
|
||||
return jsonify({'logs': logs})
|
||||
|
||||
@app.route('/admin')
|
||||
@app.route('/admin/settings')
|
||||
@app.route('/admin/settings/save' , methods=['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def admin_settings():
|
||||
# if the request is a POST request, save the settings
|
||||
if request.method == 'POST':
|
||||
# from the form, get all values from default_playlist_users and join them to one array of strings
|
||||
|
||||
app.config['runtime_settings']['default_playlist_users'] = request.form.getlist('default_playlist_users')
|
||||
save_yaml_settings()
|
||||
flash('Settings saved', category='success')
|
||||
return redirect('/admin/settings')
|
||||
return render_template('admin/settings.html',jellyfin_users = jellyfin.get_users(session_token=functions._get_api_token()))
|
||||
|
||||
|
||||
|
||||
@app.route('/run_task/<task_name>', methods=['POST'])
|
||||
@@ -116,6 +191,7 @@ def run_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)
|
||||
|
||||
|
||||
@@ -123,12 +199,15 @@ def run_task(task_name):
|
||||
@functions.jellyfin_admin_required
|
||||
def task_status():
|
||||
statuses = {}
|
||||
lock_keys = []
|
||||
for task_name, task_id in tasks.task_manager.tasks.items():
|
||||
statuses[task_name] = tasks.task_manager.get_task_status(task_name)
|
||||
lock_keys.append(f"{task_name}_lock")
|
||||
|
||||
lock_keys.append('full_update_jellyfin_ids_lock')
|
||||
|
||||
# Render the HTML partial template instead of returning JSON
|
||||
return render_template('partials/_task_status.html', tasks=statuses)
|
||||
return render_template('partials/_task_status.html', tasks=statuses, lock_keys = lock_keys)
|
||||
|
||||
|
||||
|
||||
@@ -180,7 +259,7 @@ def openPlaylist():
|
||||
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)
|
||||
provider_playlist = functions.get_cached_provider_playlist(extracted_playlist_id, provider_id)
|
||||
|
||||
combined_data = functions.prepPlaylistData(provider_playlist)
|
||||
if combined_data:
|
||||
@@ -217,8 +296,8 @@ def browse_page(page_id):
|
||||
@functions.jellyfin_login_required
|
||||
def monitored_playlists():
|
||||
|
||||
# 1. Get all Playlists from the Database.
|
||||
all_playlists = Playlist.query.all()
|
||||
# 1. Get all Playlists from the Database and order them by Id
|
||||
all_playlists = Playlist.query.order_by(Playlist.id).all()
|
||||
|
||||
# 2. Group them by provider
|
||||
playlists_by_provider = defaultdict(list)
|
||||
@@ -236,7 +315,7 @@ def monitored_playlists():
|
||||
|
||||
combined_playlists = []
|
||||
for pl in playlists:
|
||||
provider_playlist = provider_client.get_playlist(pl.provider_playlist_id)
|
||||
provider_playlist = functions.get_cached_provider_playlist(pl.provider_playlist_id,pl.provider_id)
|
||||
# 4. Convert the playlists to CombinedPlaylistData
|
||||
combined_data = functions.prepPlaylistData(provider_playlist)
|
||||
if combined_data:
|
||||
@@ -380,13 +459,95 @@ def associate_track():
|
||||
@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)
|
||||
tasks.task_manager.release_lock(key_name)
|
||||
flash(f'Lock {key_name} released', category='success')
|
||||
return ''
|
||||
|
||||
@app.route("/admin/getJellyfinUsers",methods = ['GET'])
|
||||
@functions.jellyfin_admin_required
|
||||
def get_jellyfin_users():
|
||||
users = jellyfin.get_users(session_token=functions._get_api_token())
|
||||
return jsonify({'users': users})
|
||||
|
||||
|
||||
@app.route("/admin/getJellyfinPlaylistUsers",methods = ['GET'])
|
||||
@functions.jellyfin_admin_required
|
||||
def get_jellyfin_playlist_users():
|
||||
playlist_id = request.args.get('playlist')
|
||||
if not playlist_id:
|
||||
return jsonify({'error': 'Playlist not specified'}), 400
|
||||
users = jellyfin.get_playlist_users(session_token=functions._get_api_token(), playlist_id=playlist_id)
|
||||
all_users = jellyfin.get_users(session_token=functions._get_api_token())
|
||||
# extend users with the username from all_users
|
||||
for user in users:
|
||||
user['Name'] = next((u['Name'] for u in all_users if u['Id'] == user['UserId']), None)
|
||||
|
||||
# from all_users remove the users that are already in the playlist
|
||||
all_users = [u for u in all_users if u['Id'] not in [user['UserId'] for user in users]]
|
||||
|
||||
|
||||
return jsonify({'assigned_users': users, 'remaining_users': all_users})
|
||||
|
||||
@app.route("/admin/removeJellyfinUserFromPlaylist", methods= ['GET'])
|
||||
@functions.jellyfin_admin_required
|
||||
def remove_jellyfin_user_from_playlist():
|
||||
playlist_id = request.args.get('playlist')
|
||||
user_id = request.args.get('user')
|
||||
if not playlist_id or not user_id:
|
||||
return jsonify({'error': 'Playlist or User not specified'}), 400
|
||||
# remove this playlist also from the user in the database
|
||||
# get the playlist from the db
|
||||
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
|
||||
user = JellyfinUser.query.filter_by(jellyfin_user_id=user_id).first()
|
||||
if not user:
|
||||
# Add the user to the database if they don't exist
|
||||
jellyfin_user = jellyfin.get_users(session_token=functions._get_api_token(), user_id=user_id)
|
||||
user = JellyfinUser(name=jellyfin_user['Name'], jellyfin_user_id=jellyfin_user['Id'], is_admin = jellyfin_user['Policy']['IsAdministrator'])
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
if not playlist or not user:
|
||||
return jsonify({'error': 'Playlist or User not found'}), 400
|
||||
if playlist in user.playlists:
|
||||
user.playlists.remove(playlist)
|
||||
db.session.commit()
|
||||
|
||||
jellyfin.remove_user_from_playlist2(session_token=functions._get_api_token(), playlist_id=playlist_id, user_id=user_id, admin_user_id=functions._get_admin_id())
|
||||
return jsonify({'success': True})
|
||||
|
||||
@app.route('/admin/addJellyfinUserToPlaylist')
|
||||
@functions.jellyfin_admin_required
|
||||
def add_jellyfin_user_to_playlist():
|
||||
playlist_id = request.args.get('playlist')
|
||||
user_id = request.args.get('user')
|
||||
return add_jellyfin_user_to_playlist_internal(user_id, playlist_id)
|
||||
|
||||
|
||||
def add_jellyfin_user_to_playlist_internal(user_id, playlist_id):
|
||||
# assign this playlist also to the user in the database
|
||||
# get the playlist from the db
|
||||
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
|
||||
user = JellyfinUser.query.filter_by(jellyfin_user_id=user_id).first()
|
||||
if not user:
|
||||
# Add the user to the database if they don't exist
|
||||
jellyfin_user = jellyfin.get_users(session_token=functions._get_api_token(), user_id=user_id)
|
||||
user = JellyfinUser(name=jellyfin_user['Name'], jellyfin_user_id=jellyfin_user['Id'], is_admin = jellyfin_user['Policy']['IsAdministrator'])
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
if not playlist or not user:
|
||||
return jsonify({'error': 'Playlist or User not found'}), 400
|
||||
if playlist not in user.playlists:
|
||||
user.playlists.append(playlist)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
if not playlist_id or not user_id:
|
||||
return jsonify({'error': 'Playlist or User not specified'}), 400
|
||||
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), playlist_id=playlist_id, user_id=functions._get_admin_id(), user_ids=[user_id])
|
||||
return jsonify({'success': True})
|
||||
|
||||
@pl_bp.route('/test')
|
||||
def test():
|
||||
|
||||
102
app/tasks.py
102
app/tasks.py
@@ -21,7 +21,7 @@ from lidarr.classes import Artist
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def setup_log_format(sender, conf, **kwargs):
|
||||
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)23s() ] %(levelname)7s - %(message)s"
|
||||
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)42s() ] %(levelname)7s - %(message)s"
|
||||
|
||||
conf.worker_log_format = FORMAT.strip().format(sender)
|
||||
conf.worker_task_log_format = FORMAT.format(sender)
|
||||
@@ -94,6 +94,9 @@ def update_all_playlists_track_status(self):
|
||||
|
||||
app.logger.info("All playlists' track statuses updated.")
|
||||
return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists}
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
|
||||
return {'status': 'Error downloading tracks'}
|
||||
finally:
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
@@ -110,8 +113,8 @@ def download_missing_tracks(self):
|
||||
app.logger.info("Starting track download job...")
|
||||
|
||||
with app.app_context():
|
||||
spotdl_config = app.config['SPOTDL_CONFIG']
|
||||
cookie_file = spotdl_config['cookie_file']
|
||||
spotdl_config: dict = app.config['SPOTDL_CONFIG']
|
||||
cookie_file = spotdl_config.get('cookie_file', None)
|
||||
output_dir = spotdl_config['output']
|
||||
client_id = app.config['SPOTIFY_CLIENT_ID']
|
||||
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
|
||||
@@ -125,6 +128,7 @@ def download_missing_tracks(self):
|
||||
return {'status': 'No undownloaded tracks found'}
|
||||
|
||||
app.logger.info(f"Found {total_tracks} tracks to download.")
|
||||
app.logger.debug(f"output_dir: {output_dir}")
|
||||
processed_tracks = 0
|
||||
failed_downloads = 0
|
||||
for track in undownloaded_tracks:
|
||||
@@ -136,11 +140,39 @@ def download_missing_tracks(self):
|
||||
'failed': failed_downloads
|
||||
})
|
||||
# Check if the track already exists in the output directory
|
||||
file_path = f"{output_dir.replace('{track-id}', track.provider_track_id)}.mp3"
|
||||
if os.getenv('SPOTDL_OUTPUT_FORMAT') == '__jellyplist/{track-id}':
|
||||
file_path = f"{output_dir.replace('{track-id}', track.provider_track_id)}"
|
||||
else:
|
||||
# if the output format is other than the default, we need to fetch the track first!
|
||||
spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify")
|
||||
# spotify_track has name, artists, album and id
|
||||
# name needs to be mapped to {title}
|
||||
# artist[0] needs to be mapped to {artist}
|
||||
# artists needs to be mapped to {artists}
|
||||
# album needs to be mapped to {album} , but needs to be checked if it is set or not, because it is Optional
|
||||
# id needs to be mapped to {track-id}
|
||||
# the output format is then used to create the file path
|
||||
if spotify_track:
|
||||
|
||||
file_path = output_dir.replace("{title}",spotify_track.name)
|
||||
file_path = file_path.replace("{artist}",spotify_track.artists[0].name)
|
||||
file_path = file_path.replace("{artists}",",".join([artist.name for artist in spotify_track.artists]))
|
||||
file_path = file_path.replace("{album}",spotify_track.album.name if spotify_track.album else "")
|
||||
file_path = file_path.replace("{track-id}",spotify_track.id)
|
||||
app.logger.debug(f"File path: {file_path}")
|
||||
|
||||
if not file_path:
|
||||
app.logger.error(f"Error creating file path for track {track.name}.")
|
||||
failed_downloads += 1
|
||||
track.download_status = "Error creating file path"
|
||||
db.session.commit()
|
||||
continue
|
||||
|
||||
|
||||
|
||||
# region search before download
|
||||
if search_before_download:
|
||||
app.logger.info(f"Searching for track in Jellyfin: {track.name}")
|
||||
spotify_track = functions.get_cached_provider_track(track.provider_track_id, provider_id="Spotify")
|
||||
# at first try to find the track without fingerprinting it
|
||||
best_match = find_best_match_from_jellyfin(track)
|
||||
if best_match:
|
||||
@@ -186,7 +218,7 @@ def download_missing_tracks(self):
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
|
||||
if file_path:
|
||||
if os.path.exists(file_path):
|
||||
app.logger.info(f"Track {track.name} is already downloaded at {file_path}. Marking as downloaded.")
|
||||
track.downloaded = True
|
||||
@@ -207,14 +239,20 @@ def download_missing_tracks(self):
|
||||
"--client-id", client_id,
|
||||
"--client-secret", client_secret
|
||||
]
|
||||
if os.path.exists(cookie_file):
|
||||
if cookie_file and os.path.exists(cookie_file):
|
||||
app.logger.debug(f"Found {cookie_file}, using it for spotDL")
|
||||
command.append("--cookie-file")
|
||||
command.append(cookie_file)
|
||||
if app.config['SPOTDL_PROXY']:
|
||||
app.logger.debug(f"Using proxy: {app.config['SPOTDL_PROXY']}")
|
||||
command.append("--proxy")
|
||||
command.append(app.config['SPOTDL_PROXY'])
|
||||
|
||||
app.logger.info(f"Executing the spotDL command: {' '.join(command)}")
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=90)
|
||||
if result.returncode == 0 and os.path.exists(file_path):
|
||||
if result.returncode == 0:
|
||||
track.downloaded = True
|
||||
if file_path:
|
||||
track.filesystem_path = file_path
|
||||
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
|
||||
else:
|
||||
@@ -248,6 +286,9 @@ def download_missing_tracks(self):
|
||||
'processed': processed_tracks,
|
||||
'failed': failed_downloads
|
||||
}
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
|
||||
return {'status': 'Error downloading tracks'}
|
||||
finally:
|
||||
task_manager.release_lock(lock_key)
|
||||
if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']:
|
||||
@@ -304,6 +345,13 @@ def check_for_playlist_updates(self):
|
||||
db.session.commit()
|
||||
app.logger.info(f'Added new track: {track.name}')
|
||||
tracks_to_add.append((track, idx))
|
||||
# else check if the track is already in the playlist and change the track_order in the playlist_tracks table
|
||||
else:
|
||||
app.logger.debug(f"track {track_info.track.name} moved to position {idx}")
|
||||
track = existing_tracks[track_id]
|
||||
stmt = playlist_tracks.update().where(playlist_tracks.c.playlist_id == playlist.id).where(playlist_tracks.c.track_id == track.id).values(track_order=idx)
|
||||
db.session.execute(stmt)
|
||||
db.session.commit()
|
||||
|
||||
tracks_to_remove = [
|
||||
existing_tracks[track_id]
|
||||
@@ -345,6 +393,7 @@ def check_for_playlist_updates(self):
|
||||
).all()
|
||||
|
||||
tracks = [track.jellyfin_id for track, idx in ordered_tracks if track.jellyfin_id is not None]
|
||||
#jellyfin.remove_songs_from_playlist(session_token=jellyfin_admin_token, playlist_id=playlist.jellyfin_id, song_ids=tracks)
|
||||
jellyfin.add_songs_to_playlist(session_token=jellyfin_admin_token, user_id=jellyfin_admin_id, playlist_id=playlist.jellyfin_id, song_ids=tracks)
|
||||
#endregion
|
||||
except Exception as e:
|
||||
@@ -360,6 +409,9 @@ def check_for_playlist_updates(self):
|
||||
app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.")
|
||||
|
||||
return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists}
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
|
||||
return {'status': 'Error downloading tracks'}
|
||||
finally:
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
@@ -375,11 +427,18 @@ def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
app.logger.info("Starting Jellyfin ID update for tracks...")
|
||||
|
||||
with app.app_context():
|
||||
downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all()
|
||||
|
||||
downloaded_tracks = Track.query.filter(
|
||||
Track.downloaded == True,
|
||||
Track.jellyfin_id == None,
|
||||
(Track.quality_score < app.config['QUALITY_SCORE_THRESHOLD']) | (Track.quality_score == None)
|
||||
).all()
|
||||
if task_manager.acquire_lock(full_update_key, expiration=60*60*24):
|
||||
app.logger.info(f"performing full update on jellyfin track ids. (Update tracks and playlists if better quality will be found)")
|
||||
downloaded_tracks = Track.query.all()
|
||||
app.logger.info(f"\tQUALITY_SCORE_THRESHOLD = {app.config['QUALITY_SCORE_THRESHOLD']}")
|
||||
downloaded_tracks = Track.query.filter(
|
||||
(Track.quality_score < app.config['QUALITY_SCORE_THRESHOLD']) | (Track.quality_score == None)
|
||||
).all()
|
||||
else:
|
||||
app.logger.debug(f"doing update on tracks with downloaded = True and jellyfin_id = None")
|
||||
total_tracks = len(downloaded_tracks)
|
||||
@@ -393,6 +452,7 @@ def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
for track in downloaded_tracks:
|
||||
try:
|
||||
best_match = find_best_match_from_jellyfin(track)
|
||||
|
||||
if best_match:
|
||||
track.downloaded = True
|
||||
if track.jellyfin_id != best_match['Id']:
|
||||
@@ -402,10 +462,11 @@ def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
track.filesystem_path = best_match['Path']
|
||||
app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})")
|
||||
|
||||
|
||||
track.quality_score = best_match['quality_score']
|
||||
|
||||
db.session.commit()
|
||||
else:
|
||||
|
||||
app.logger.warning(f"No matching track found in Jellyfin for {track.name}.")
|
||||
|
||||
spotify_track = None
|
||||
@@ -415,11 +476,14 @@ def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
|
||||
processed_tracks += 1
|
||||
progress = (processed_tracks / total_tracks) * 100
|
||||
|
||||
self.update_state(state=f'{processed_tracks}/{total_tracks}: {track.name}', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
|
||||
|
||||
app.logger.info("Finished updating Jellyfin IDs for all tracks.")
|
||||
return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks}
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error updating jellyfin ids: {str(e)}", exc_info=True)
|
||||
return {'status': 'Error updating jellyfin ids '}
|
||||
finally:
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
@@ -502,6 +566,9 @@ def request_lidarr(self):
|
||||
|
||||
app.logger.info(f'Requests sent to Lidarr. Total items: {total_items}')
|
||||
return {'status': 'Request sent to Lidarr'}
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error downloading tracks: {str(e)}", exc_info=True)
|
||||
return {'status': 'Error downloading tracks'}
|
||||
finally:
|
||||
task_manager.release_lock(lock_key)
|
||||
|
||||
@@ -553,6 +620,7 @@ def find_best_match_from_jellyfin(track: Track):
|
||||
|
||||
app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
|
||||
best_match = result
|
||||
best_quality_score = quality_score
|
||||
break
|
||||
|
||||
|
||||
@@ -563,7 +631,9 @@ def find_best_match_from_jellyfin(track: Track):
|
||||
if quality_score > best_quality_score:
|
||||
best_match = result
|
||||
best_quality_score = quality_score
|
||||
|
||||
# attach the quality_score to the best_match
|
||||
if best_match:
|
||||
best_match['quality_score'] = best_quality_score
|
||||
return best_match
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}")
|
||||
@@ -587,8 +657,8 @@ def compute_quality_score(result, use_ffprobe=False) -> float:
|
||||
if result.get('HasLyrics'):
|
||||
score += 10
|
||||
|
||||
runtime_ticks = result.get('RunTimeTicks', 0)
|
||||
score += runtime_ticks / 1e6
|
||||
#runtime_ticks = result.get('RunTimeTicks', 0)
|
||||
#score += runtime_ticks / 1e6
|
||||
|
||||
if use_ffprobe:
|
||||
path = result.get('Path')
|
||||
@@ -619,7 +689,7 @@ class TaskManager:
|
||||
raise ValueError(f"Task {task_name} is not defined.")
|
||||
task = globals()[task_name].delay(*args, **kwargs)
|
||||
self.tasks[task_name] = task.id
|
||||
return task.id
|
||||
return task.id,'STARTED'
|
||||
|
||||
def get_task_status(self, task_name):
|
||||
if task_name not in self.tasks:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "v0.1.10"
|
||||
132
changelogs/v0.1.9.md
Normal file
132
changelogs/v0.1.9.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Whats up in Jellyplist v0.1.9?
|
||||
## ⚠️ BREAKING CHANGE: docker-compose.yml
|
||||
>[!WARNING]
|
||||
>In this release I´ve done some rework so now the setup is a bit easier, because you don´t have to spin up the -worker -beat container, these are now all in the default container and managed via supervisor. This means you have to update your `docker-compose.yml` when updating!
|
||||
|
||||
So now your compose file should look more or less like this
|
||||
|
||||
```yaml
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- jellyplist-network
|
||||
postgres:
|
||||
container_name: postgres-jellyplist
|
||||
image: postgres:17.2
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATA: /data/postgres
|
||||
volumes:
|
||||
- /jellyplist_pgdata/postgres:/data/postgres
|
||||
networks:
|
||||
- jellyplist-network
|
||||
restart: unless-stopped
|
||||
|
||||
jellyplist:
|
||||
container_name: jellyplist
|
||||
image: ${IMAGE}
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
ports:
|
||||
- "5055:5055"
|
||||
networks:
|
||||
- jellyplist-network
|
||||
volumes:
|
||||
- /jellyplist/cookies.txt:/jellyplist/cookies.txt
|
||||
- /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
|
||||
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH}
|
||||
- /my/super/cool/storage/jellyplist/settings.yaml:/jellyplist/settings.yaml
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
networks:
|
||||
jellyplist-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
redis_data:
|
||||
```
|
||||
And the `.env` File
|
||||
```env
|
||||
IMAGE = ghcr.io/kamilkosek/jellyplist:latest
|
||||
POSTGRES_USER = jellyplist
|
||||
POSTGRES_PASSWORD = jellyplist
|
||||
SECRET_KEY = supersecretkey # Secret key for session management
|
||||
JELLYFIN_SERVER_URL = http://<jellyfin_server>:8096 # Default to local Jellyfin server
|
||||
JELLYFIN_ACCESS_TOKEN = <jellyfin access token>
|
||||
JELLYFIN_ADMIN_USER = <jellyfin admin username>
|
||||
JELLYFIN_ADMIN_PASSWORD = <jellyfin admin password>
|
||||
SPOTIFY_CLIENT_ID = <spotify client id>
|
||||
SPOTIFY_CLIENT_SECRET = <spotify client secret>
|
||||
JELLYPLIST_DB_HOST = postgres-jellyplist
|
||||
JELLYPLIST_DB_USER = jellyplist
|
||||
JELLYPLIST_DB_PASSWORD = jellyplist
|
||||
LOG_LEVEL = INFO
|
||||
LIDARR_API_KEY = <lidarr api key>
|
||||
LIDARR_URL = http://<lidarr server>:8686
|
||||
LIDARR_MONITOR_ARTISTS = false
|
||||
SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt'
|
||||
MUSIC_STORAGE_BASE_PATH = '/storage/media/music'
|
||||
|
||||
```
|
||||
### 🆕 Log Viewer
|
||||
Under the `Admin` Page there is now a tab called `Logs` from where you can view the current logs, change the log-level on demand and copy a prepared markdown snippet ready to be pasted into a GitHub issue.
|
||||
|
||||
### 🆕 New env var´s, a bit more control over spotDL
|
||||
#### `SPOTDL_PROXY`
|
||||
Set a Proxy for spotDL. See [https://spotdl.readthedocs.io/en/latest/usage/#command-line-options](https://spotdl.readthedocs.io/en/latest/usage/#command-line-options)
|
||||
#### `SPOTDL_OUTPUT_FORMAT`
|
||||
Set the output folder and file name format for downloaded tracks via spotDL. Not all variables, which are supported by spotDL are supported by Jellyplist.
|
||||
- `{title}`
|
||||
- `{artist}`
|
||||
- `{artists}`
|
||||
- `{album}`
|
||||
|
||||
This way you will have a bit more controler over how the files are stored.
|
||||
The complete output path is joined from `MUSIC_STORAGE_BASE_PATH` and `SPOTDL_OUTPUT_FORMAT`
|
||||
|
||||
_*Example:*_
|
||||
|
||||
`MUSIC_STORAGE_BASE_PATH = /storage/media/music`
|
||||
|
||||
and
|
||||
|
||||
`SPOTDL_OUTPUT_FORMAT = /{artist}/{album}/{title}`
|
||||
|
||||
The Track is _All I Want for Christmas Is You by Mariah Carey_ this will result in the following folder structure:
|
||||
|
||||
`/storage/media/music/Mariah Carey/Merry Christmas/All I Want for Christmas Is You.mp3`
|
||||
|
||||
### 🆕 Admin Users can now add Playlists to multiple Users
|
||||
Sometimes I want to add a playlist to several users at once, because it´s either a _generic_ one or because my wife doesn´t want to bother with the technical stuff 😬
|
||||
So now, when logged in as an admin user, when adding a playlist you can select users from your Jellyfin server which will also receive it.
|
||||
Under `Admin` you can also select users which will be preselected by default. These will be stored in the file `settings.yaml`.
|
||||
You can or should map this file to a file outside the container, so it will persist accross image updates (see compose sample above)
|
||||
|
||||
### 🆕 New `env` var `QUALITY_SCORE_THRESHOLD`
|
||||
Get a better control over the `update_jellyfin_id_for_downloaded_tracks()` behaviour.
|
||||
Until now this tasks performed a __full update__ every 24h: This means, every track from every playlist was searched through the Jellyfin API with the hope of finding the same track but with a better quality. While this is ok and works fine for small libraries, this tasks eats a lot of power on large libraries and also takes time.
|
||||
So there is now the new `env` variable `QUALITY_SCORE_THRESHOLD` (default: `1000.0`). When a track was once found with a quality score above 1000.0, Jellyplist wont try to perform another `quality update` anymore on this track.
|
||||
In order to be able to classify it a little better, here are a few common quality scores:
|
||||
- spotDL downloaded track without yt-music premium: `< 300`
|
||||
- spotDL downloaded track **with** yt-music premium: `< 450`
|
||||
- flac `> 1000`
|
||||
|
||||
>[!TIP]
|
||||
>Want to know what quality score (and many other details) a track has ? Just double-click the table row in the playlist details view to get all the info´s!
|
||||
|
||||
### Other changes, improvements and fixes
|
||||
- Fix for #38 and #22 , where the manual task starting was missing a return value
|
||||
- Fixed an issue where the content-type of a playlist cover image, would cause the Jellyfin API Client to fail. Thanks @artyorsh
|
||||
- Fixed missing lock keys to task manager and task status rendering
|
||||
- Pinned postgres version to 17.2
|
||||
- Enhanced error logging in tasks
|
||||
- several fixes and improvements for the Jellyfin API Client
|
||||
|
||||
25
config.py
25
config.py
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import app
|
||||
|
||||
class Config:
|
||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
@@ -21,7 +22,7 @@ class Config:
|
||||
DISPLAY_EXTENDED_AUDIO_DATA = os.getenv('DISPLAY_EXTENDED_AUDIO_DATA',"false").lower() == 'true'
|
||||
CACHE_TYPE = 'redis'
|
||||
CACHE_REDIS_PORT = 6379
|
||||
CACHE_REDIS_HOST = 'redis'
|
||||
CACHE_REDIS_HOST = os.getenv('CACHE_REDIS_HOST','redis')
|
||||
CACHE_REDIS_DB = 0
|
||||
CACHE_DEFAULT_TIMEOUT = 3600
|
||||
REDIS_URL = os.getenv('REDIS_URL','redis://redis:6379/0')
|
||||
@@ -33,18 +34,32 @@ class Config:
|
||||
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_PROXY = os.getenv('SPOTDL_PROXY',None)
|
||||
SPOTDL_OUTPUT_FORMAT = os.getenv('SPOTDL_OUTPUT_FORMAT','__jellyplist/{artist}-{title}.mp3')
|
||||
QUALITY_SCORE_THRESHOLD = float(os.getenv('QUALITY_SCORE_THRESHOLD',1000.0))
|
||||
|
||||
ENABLE_DEEZER = os.getenv('ENABLE_DEEZER','false').lower() == 'true'
|
||||
# SpotDL specific configuration
|
||||
SPOTDL_CONFIG = {
|
||||
'cookie_file': '/jellyplist/cookies.txt',
|
||||
# combine the path provided in MUSIC_STORAGE_BASE_PATH with the following path __jellyplist/{track-id} to get the value for output
|
||||
|
||||
'threads': 12
|
||||
}
|
||||
# combine the path provided in MUSIC_STORAGE_BASE_PATH with the SPOTDL_OUTPUT_FORMAT to get the value for output
|
||||
if os.getenv('MUSIC_STORAGE_BASE_PATH'):
|
||||
# Ensure MUSIC_STORAGE_BASE_PATH ends with "__jellyplist"
|
||||
if not MUSIC_STORAGE_BASE_PATH.endswith("__jellyplist"):
|
||||
MUSIC_STORAGE_BASE_PATH += "__jellyplist"
|
||||
|
||||
# Ensure SPOTDL_OUTPUT_FORMAT does not start with "/"
|
||||
normalized_spotdl_output_format = SPOTDL_OUTPUT_FORMAT.lstrip("/").replace(" ", "_")
|
||||
|
||||
# Join the paths
|
||||
output_path = os.path.join(MUSIC_STORAGE_BASE_PATH, normalized_spotdl_output_format)
|
||||
|
||||
output_path = os.path.join(MUSIC_STORAGE_BASE_PATH,'__jellyplist/{track-id}')
|
||||
SPOTDL_CONFIG.update({'output': output_path})
|
||||
|
||||
if SPOTIFY_COOKIE_FILE:
|
||||
SPOTDL_CONFIG.update({'cookie_file': SPOTIFY_COOKIE_FILE})
|
||||
|
||||
@classmethod
|
||||
def validate_env_vars(cls):
|
||||
required_vars = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import requests
|
||||
import base64
|
||||
@@ -118,6 +119,23 @@ class JellyfinClient:
|
||||
else:
|
||||
raise Exception(f"Failed to update playlist: {response.content}")
|
||||
|
||||
def get_music_playlist(self, session_token : str, playlist_id: str):
|
||||
"""
|
||||
Get a music playlist by its ID.
|
||||
:param playlist_id: The ID of the playlist to fetch.
|
||||
:return: The playlist object
|
||||
"""
|
||||
url = f'{self.base_url}/Playlists/{playlist_id}'
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get playlist: {response.content}")
|
||||
|
||||
def get_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata:
|
||||
url = f'{self.base_url}/Items/{playlist_id}'
|
||||
params = {
|
||||
@@ -239,7 +257,8 @@ class JellyfinClient:
|
||||
|
||||
'IncludeItemTypes': 'Audio', # Search only for audio items
|
||||
'Recursive': 'true', # Search within all folders
|
||||
'Fields': 'Name,Id,Album,Artists,Path' # Retrieve the name and ID of the song
|
||||
'Fields': 'Name,Id,Album,Artists,Path', # Retrieve the name and ID of the song
|
||||
'Limit': 100
|
||||
}
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
@@ -254,30 +273,38 @@ class JellyfinClient:
|
||||
|
||||
def add_songs_to_playlist(self, session_token: str, user_id: str, playlist_id: str, song_ids: list[str]):
|
||||
"""
|
||||
Add songs to an existing playlist.
|
||||
Add songs to an existing playlist in batches to prevent URL length issues.
|
||||
:param playlist_id: The ID of the playlist to update.
|
||||
:param song_ids: A list of song IDs to add.
|
||||
:return: A success message.
|
||||
"""
|
||||
# Construct the API URL with query parameters
|
||||
# Construct the API URL without query parameters
|
||||
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
|
||||
batch_size = 50
|
||||
total_songs = len(song_ids)
|
||||
self.logger.debug(f"Total songs to add: {total_songs}")
|
||||
|
||||
for i in range(0, total_songs, batch_size):
|
||||
batch = song_ids[i:i + batch_size]
|
||||
params = {
|
||||
'ids': ','.join(song_ids), # Comma-separated song IDs
|
||||
'ids': ','.join(batch), # Comma-separated song IDs
|
||||
'userId': user_id
|
||||
}
|
||||
self.logger.debug(f"Url={url} - Adding batch: {batch}")
|
||||
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
# Send the request to Jellyfin API with query parameters
|
||||
response = requests.post(url, headers=self._get_headers(session_token=session_token), params=params, timeout = self.timeout)
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(session_token=session_token),
|
||||
params=params,
|
||||
timeout=self.timeout
|
||||
)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
|
||||
# Check for success
|
||||
if response.status_code == 204: # 204 No Content indicates success
|
||||
return {"status": "success", "message": "Songs added to playlist successfully"}
|
||||
else:
|
||||
if response.status_code != 204: # 204 No Content indicates success
|
||||
raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}")
|
||||
|
||||
return {"status": "success", "message": "Songs added to playlist successfully"}
|
||||
|
||||
def remove_songs_from_playlist(self, session_token: str, playlist_id: str, song_ids):
|
||||
"""
|
||||
Remove songs from an existing playlist.
|
||||
@@ -285,20 +312,26 @@ class JellyfinClient:
|
||||
:param song_ids: A list of song IDs to remove.
|
||||
:return: A success message.
|
||||
"""
|
||||
batch_size = 50
|
||||
total_songs = len(song_ids)
|
||||
self.logger.debug(f"Total songs to remove: {total_songs}")
|
||||
|
||||
for i in range(0, total_songs, batch_size):
|
||||
batch = song_ids[i:i + batch_size]
|
||||
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
|
||||
params = {
|
||||
'EntryIds': ','.join(song_ids) # Join song IDs with commas
|
||||
'EntryIds': ','.join(batch) # Join song IDs with commas
|
||||
}
|
||||
self.logger.debug(f"Url={url}")
|
||||
self.logger.debug(f"Url={url} - Removing batch: {batch}")
|
||||
|
||||
response = requests.delete(url, headers=self._get_headers(session_token=session_token), params=params, timeout=self.timeout)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
|
||||
if response.status_code == 204: # 204 No Content indicates success for updating
|
||||
return {"status": "success", "message": "Songs removed from playlist successfully"}
|
||||
else:
|
||||
if response.status_code != 204: # 204 No Content indicates success for updating
|
||||
raise Exception(f"Failed to remove songs from playlist: {response.content}")
|
||||
|
||||
return {"status": "success", "message": "Songs removed from playlist successfully"}
|
||||
|
||||
def remove_item(self, session_token: str, playlist_id: str):
|
||||
"""
|
||||
Remove an existing playlist by its ID.
|
||||
@@ -350,6 +383,34 @@ class JellyfinClient:
|
||||
# Raise an exception if the request failed
|
||||
raise Exception(f"Failed to remove user from playlist: {response.content}")
|
||||
|
||||
def remove_user_from_playlist2(self, session_token: str, playlist_id: str, user_id: str, admin_user_id : str):
|
||||
#TODO: This is a workaround for the issue where the above method does not work
|
||||
metadata = self.get_playlist_metadata(session_token= session_token, user_id= admin_user_id, playlist_id= playlist_id)
|
||||
# Construct the API URL
|
||||
url = f'{self.base_url}/Playlists/{playlist_id}'
|
||||
users_data = []
|
||||
current_users = self.get_playlist_users(session_token=session_token, playlist_id= playlist_id)
|
||||
for cu in current_users:
|
||||
# This way we remove the user
|
||||
if cu['UserId'] != user_id:
|
||||
users_data.append({'UserId': cu['UserId'], 'CanEdit': cu['CanEdit']})
|
||||
|
||||
data = {
|
||||
'Users' : users_data
|
||||
}
|
||||
# Prepare the headers
|
||||
headers = self._get_headers(session_token=session_token)
|
||||
|
||||
# Send the request to Jellyfin API
|
||||
response = requests.post(url, headers=headers, json=data,timeout = self.timeout)
|
||||
|
||||
# Check for success
|
||||
if response.status_code == 204:
|
||||
self.update_playlist_metadata(session_token= session_token, user_id= admin_user_id, playlist_id= playlist_id , updates= metadata)
|
||||
return {"status": "success", "message": f"Users added to playlist {playlist_id}."}
|
||||
else:
|
||||
raise Exception(f"Failed to add users to playlist: {response.status_code} - {response.content}")
|
||||
|
||||
|
||||
def set_playlist_cover_image(self, session_token: str, playlist_id: str, provider_image_url: str):
|
||||
"""
|
||||
@@ -367,8 +428,8 @@ class JellyfinClient:
|
||||
raise Exception(f"Failed to download image from Spotify: {response.content}")
|
||||
|
||||
# Step 2: Check the image content type (assume it's JPEG or PNG based on the content type from the response)
|
||||
content_type = response.headers.get('Content-Type')
|
||||
if content_type not in ['image/jpeg', 'image/png', 'application/octet-stream']:
|
||||
content_type = response.headers.get('Content-Type').lower()
|
||||
if content_type not in ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream']:
|
||||
raise Exception(f"Unsupported image format: {content_type}")
|
||||
# Todo:
|
||||
if content_type == 'application/octet-stream':
|
||||
@@ -454,6 +515,18 @@ class JellyfinClient:
|
||||
|
||||
return response.json()
|
||||
|
||||
def get_users(self, session_token: str, user_id: Optional[str] = None):
|
||||
url = f'{self.base_url}/Users'
|
||||
if user_id:
|
||||
url = f'{url}/{user_id}'
|
||||
|
||||
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Failed to fetch users: {response.content}")
|
||||
|
||||
return response.json()
|
||||
|
||||
def search_track_in_jellyfin(self, session_token: str, preview_url: str, song_name: str, artist_names: list):
|
||||
"""
|
||||
Search for a track in Jellyfin by comparing the preview audio to tracks in the library.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add quality score to Track
|
||||
|
||||
Revision ID: 2777a1885a6b
|
||||
Revises: 46a65ecc9904
|
||||
Create Date: 2024-12-11 20:02:00.303765
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2777a1885a6b'
|
||||
down_revision = '46a65ecc9904'
|
||||
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('quality_score', sa.Float(), 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('quality_score')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Change track name lenght maximum to 200
|
||||
|
||||
Revision ID: 46a65ecc9904
|
||||
Revises: d13088ebddc5
|
||||
Create Date: 2024-12-11 19:35:47.617811
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '46a65ecc9904'
|
||||
down_revision = 'd13088ebddc5'
|
||||
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.alter_column('name',
|
||||
existing_type=sa.VARCHAR(length=150),
|
||||
type_=sa.String(length=200),
|
||||
existing_nullable=False)
|
||||
|
||||
# ### 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.alter_column('name',
|
||||
existing_type=sa.String(length=200),
|
||||
type_=sa.VARCHAR(length=150),
|
||||
existing_nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
52
readme.md
52
readme.md
@@ -26,6 +26,7 @@ The easiest way to start is by using docker and compose.
|
||||
3. Get your cookie-file from open.spotify.com , this works the same way as in step 2.
|
||||
4. Prepare a `.env` File
|
||||
```
|
||||
IMAGE = ghcr.io/kamilkosek/jellyplist:latest
|
||||
POSTGRES_USER = jellyplist
|
||||
POSTGRES_PASSWORD = jellyplist
|
||||
SECRET_KEY = Keykeykesykykesky # Secret key for session management
|
||||
@@ -40,14 +41,16 @@ JELLYPLIST_DB_PASSWORD = jellyplist
|
||||
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:
|
||||
# SPOTDL_PROXY = http://proxy:8080
|
||||
# SPOTDL_OUTPUT_FORMAT = "/{artist}/{artists} - {title}" # Supported variables: {title}, {artist},{artists}, {album}, Will be joined with to get a complete path
|
||||
|
||||
# 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 ("true" MAY INCURE PERFORMENCE ISSUES)
|
||||
|
||||
# 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.
|
||||
#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. ("true" MAY INCURE PERFORMENCE ISSUES)
|
||||
|
||||
# LOG_LEVEL = DEBUG # Defaults to INFO
|
||||
|
||||
@@ -73,13 +76,13 @@ services:
|
||||
- jellyplist-network
|
||||
postgres:
|
||||
container_name: postgres-jellyplist
|
||||
image: postgres
|
||||
image: postgres:17.2
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATA: /data/postgres
|
||||
volumes:
|
||||
- postgres:/data/postgres
|
||||
- /jellyplist_pgdata/postgres:/data/postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
@@ -88,7 +91,7 @@ services:
|
||||
|
||||
jellyplist:
|
||||
container_name: jellyplist
|
||||
image: ghcr.io/kamilkosek/jellyplist:latest
|
||||
image: ${IMAGE}
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
@@ -97,51 +100,18 @@ services:
|
||||
networks:
|
||||
- jellyplist-network
|
||||
volumes:
|
||||
# Map Your cookies.txt file to exac
|
||||
- /your/local/path/cookies.txt:/jellyplist/cookies.txt #
|
||||
- /your/local/path/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
|
||||
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} # Jellyplist must be able to access the file paths like they are stored in Jellyfin
|
||||
- /jellyplist/cookies.txt:/jellyplist/cookies.txt
|
||||
- /jellyplist/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
|
||||
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH}
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# The jellyplist-worker is used to perform background tasks, such as downloads and playlist updates.
|
||||
# It is the same container, but with a different command
|
||||
jellyplist-worker:
|
||||
container_name: jellyplist-worker
|
||||
image: ghcr.io/kamilkosek/jellyplist:latest
|
||||
command: ["celery", "-A", "app.celery", "worker", "--loglevel=info"]
|
||||
volumes:
|
||||
# Map Your cookies.txt file to exac
|
||||
- /your/local/path/cookies.txt:/jellyplist/cookies.txt
|
||||
- /your/local/path/open.spotify.com_cookies.txt:/jellyplist/spotify-cookie.txt
|
||||
- ${MUSIC_STORAGE_BASE_PATH}:${MUSIC_STORAGE_BASE_PATH} # Jellyplist must be able to access the file paths like they are stored in Jellyfin
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
networks:
|
||||
- jellyplist-network
|
||||
# jellyplist-beat is used to schedule the background tasks
|
||||
jellyplist-beat:
|
||||
container_name: jellyplist-beat
|
||||
image: ghcr.io/kamilkosek/jellyplist:latest
|
||||
command: ["celery", "-A", "app.celery", "beat", "--loglevel=info"]
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
networks:
|
||||
- jellyplist-network
|
||||
|
||||
networks:
|
||||
jellyplist-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
pgadmin:
|
||||
redis_data:
|
||||
```
|
||||
5. Start your stack with `docker compose up -d`
|
||||
|
||||
@@ -9,7 +9,7 @@ numpy==2.1.3
|
||||
pyacoustid==1.3.0
|
||||
redis==5.1.1
|
||||
Requests==2.32.3
|
||||
spotdl==4.2.10
|
||||
spotdl==4.2.11
|
||||
spotipy==2.24.0
|
||||
SQLAlchemy==2.0.35
|
||||
Unidecode==1.3.8
|
||||
@@ -17,3 +17,9 @@ psycopg2-binary
|
||||
eventlet
|
||||
pydub
|
||||
fuzzywuzzy
|
||||
pyyaml
|
||||
click
|
||||
pycryptodomex
|
||||
mutagen
|
||||
requests
|
||||
deezer-py
|
||||
@@ -58,7 +58,7 @@ body {
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1600px) {
|
||||
.modal-dialog {
|
||||
.modal-xl {
|
||||
max-width: 90%;
|
||||
/* New width for default modal */
|
||||
}
|
||||
|
||||
35
supervisord.conf
Normal file
35
supervisord.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
|
||||
[program:jellyplist]
|
||||
command=python run.py
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_events_enabled=true
|
||||
stderr_events_enabled=true
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile_maxbytes=0
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
|
||||
[program:celery_worker]
|
||||
command=celery -A app.celery worker
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_events_enabled=true
|
||||
stderr_events_enabled=true
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile_maxbytes=0
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
|
||||
[program:celery_beat]
|
||||
command=celery -A app.celery beat
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_events_enabled=true
|
||||
stderr_events_enabled=true
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile_maxbytes=0
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
@@ -4,7 +4,11 @@
|
||||
<nav class="navbar navbar-expand-lg navbar-dark border-bottom mb-2">
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item active">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/settings">Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/link_issues">Link Issues
|
||||
{% include 'partials/_unlinked_tracks_badge.html' %}</a>
|
||||
</li>
|
||||
@@ -14,6 +18,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/lidarr">Lidarr</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/logs?name=logs">Logs</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
163
templates/admin/logview.html
Normal file
163
templates/admin/logview.html
Normal file
@@ -0,0 +1,163 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin_content %}
|
||||
{% if not logs %}
|
||||
{% set logs = "Logfile empty or not found" %}
|
||||
{% endif %}
|
||||
{% set log_level = config['LOG_LEVEL'] %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.0/min/vs/loader.js"></script>
|
||||
|
||||
<div class="container-fluid mt-5">
|
||||
<h1>Log Viewer</h1>
|
||||
<div class="mb-3 row">
|
||||
|
||||
<form action="/admin/setloglevel" method="post" class="d-inline">
|
||||
<label for="logLevel" class="form-label">Log Level</label>
|
||||
<select class="form-select" id="logLevel" name="logLevel" required aria-describedby="loglevelHelp">
|
||||
<option value="DEBUG" {% if log_level=="DEBUG" %}selected{% endif %}>DEBUG</option>
|
||||
<option value="INFO" {% if log_level=="INFO" %}selected{% endif %}>INFO</option>
|
||||
<option value="WARNING" {% if log_level=="WARNING" %}selected{% endif %}>WARNING</option>
|
||||
<option value="ERROR" {% if log_level=="ERROR" %}selected{% endif %}>ERROR</option>
|
||||
<option value="CRITICAL" {% if log_level=="CRITICAL" %}selected{% endif %}>CRITICAL</option>
|
||||
</select>
|
||||
<div id="loglevelHelp" class="form-text">Set the log level on demand.</div>
|
||||
<button type="submit" class="btn btn-primary mt-2">Set Log Level</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="mb-5 mt-3 row">
|
||||
<button type="button" class="btn btn-warning" onclick="openCreateIssueModal()">Get Logs for a new Issue</button>
|
||||
|
||||
<!-- Modal HTML -->
|
||||
<div class="modal fade" id="createIssueModal" tabindex="-1" aria-labelledby="createIssueModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="createIssueModalLabel">Create Issue</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span class="m-2">Hit the copy button or copy this text manually and paste it to your GitHub
|
||||
Issue.</span>
|
||||
<div id="issue-text" style="height: 400px;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" id="copyText" class="btn btn-primary">Copy</button>
|
||||
</div>
|
||||
<script>
|
||||
async function setClipboard(text) {
|
||||
const type = "text/plain";
|
||||
const blob = new Blob([text], { type });
|
||||
const data = [new ClipboardItem({ [type]: blob })];
|
||||
await navigator.clipboard.write(data);
|
||||
}
|
||||
document.getElementById('copyText').addEventListener('click', function () {
|
||||
const issueEditor = monaco.editor.getModels()[1];
|
||||
const issueText = issueEditor.getValue();
|
||||
if(!window.isSecureContext){
|
||||
alert('Clipboard API is not available in insecure context. Please use a secure context (HTTPS) or just copy the text manually.');
|
||||
return;
|
||||
}
|
||||
setClipboard(issueText);
|
||||
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5 mt-3 row">
|
||||
<label for="logType" class="form-label">Select Logs</label>
|
||||
<select class="form-select" id="logType" name="logType" required
|
||||
onchange="location.href='/admin/logs?name=' + this.value;">
|
||||
<option value="logs" {% if name=="logs" %}selected{% endif %}>Logs</option>
|
||||
<option value="worker" {% if name=="worker" %}selected{% endif %}>Worker Logs</option>
|
||||
<option value="beat" {% if name=="beat" %}selected{% endif %}>Beat Logs</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3 row" id="editor" style="height: 700px;">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.0/min/vs' } });
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
monaco.languages.register({ id: "jellyplistLog" });
|
||||
|
||||
// Register a tokens provider for the language
|
||||
monaco.languages.setMonarchTokensProvider("jellyplistLog", {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/ERROR -.*/, "custom-error"],
|
||||
[/WARNING -/, "custom-notice"],
|
||||
[/INFO -/, "custom-info"],
|
||||
[/DEBUG -.*/, "custom-debug"],
|
||||
[/^\[\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d{3}\]/, "custom-date"],
|
||||
[/\s.*[a-zA-Z0-9_]+\.[a-z]{2,4}(?=:)/, "custom-filename"],
|
||||
[/\d+(?= -)/, "custom-lineno"]
|
||||
],
|
||||
},
|
||||
});
|
||||
monaco.editor.defineTheme("jellyplistLogTheme", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "custom-info", foreground: "808080" },
|
||||
{ token: "custom-error", foreground: "ff0000", fontStyle: "bold" },
|
||||
{ token: "custom-notice", foreground: "FFA500" },
|
||||
{ token: "custom-debug", foreground: "851ea5" },
|
||||
{ token: "custom-date", foreground: "90cc6f" },
|
||||
{ token: "custom-filename", foreground: "d9d04f", fontStyle: "italic" },
|
||||
{ token: "custom-lineno", foreground: "d9d04f", fontStyle: "light" },
|
||||
],
|
||||
colors: {
|
||||
"editor.foreground": "#ffffff",
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
let editor = monaco.editor.create(document.getElementById('editor'), {
|
||||
value: `{{logs | safe }}`,
|
||||
language: 'jellyplistLog',
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
theme: 'jellyplistLogTheme',
|
||||
automaticLayout: true
|
||||
|
||||
});
|
||||
editor.revealLine(editor.getModel().getLineCount())
|
||||
|
||||
|
||||
});
|
||||
function openCreateIssueModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('createIssueModal'));
|
||||
|
||||
fetch('/admin/logs/getLogsForIssue')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
|
||||
const issueText = data.logs;
|
||||
const issueTextInput = document.getElementById('issue-text');
|
||||
// before creating the new editor, remove the old one
|
||||
while (issueTextInput.firstChild) {
|
||||
issueTextInput.removeChild(issueTextInput.firstChild);
|
||||
}
|
||||
const issueEditor = monaco.editor.create(issueTextInput, {
|
||||
value: issueText.join(''),
|
||||
language: 'markdown',
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true
|
||||
|
||||
|
||||
});
|
||||
modal.show();
|
||||
|
||||
|
||||
})
|
||||
.catch(error => console.error('Error fetching issue logs:', error));
|
||||
}
|
||||
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
20
templates/admin/settings.html
Normal file
20
templates/admin/settings.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin_content %}
|
||||
<h2>Settings</h2>
|
||||
<form action="/admin/settings/save" method="post">
|
||||
<div class="mb-3">
|
||||
<h3>Default Playlist Users</h3>
|
||||
<div id="defaultPlaylistUsers">
|
||||
{% for user in jellyfin_users %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="default_playlist_users" value="{{ user.Id }}" id="user-{{ user.Id }}"
|
||||
{% if user.Id in config['runtime_settings']['default_playlist_users'] %}checked{% endif %}>
|
||||
<label class="form-check-label" for="user-{{ user.Id }}">{{ user.Name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<title>Jellyplist {{ title }}</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
@@ -17,7 +17,8 @@
|
||||
<script src="https://unpkg.com/htmx.org"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
|
||||
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -55,10 +56,9 @@
|
||||
class="fa-solid fa-tower-observation"></i> Monitored</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin
|
||||
Playlists</a>
|
||||
<a class="nav-link" href="/jellyfin_playlists"><i class="fas fa-list"></i> My Playlists</a>
|
||||
</li>
|
||||
{% if session.get('is_admin') and session.get('debug') %}
|
||||
{% if session.get('is_admin') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin"><i class="fas fa-flask"></i> Admin</a>
|
||||
</li>
|
||||
@@ -100,8 +100,7 @@
|
||||
class="fa-solid fa-tower-observation"></i> Monitored </a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> Jellyfin
|
||||
Playlists</a>
|
||||
<a class="nav-link text-white" href="/jellyfin_playlists"><i class="fas fa-list"></i> My Playlists</a>
|
||||
</li>
|
||||
{% if session.get('is_admin') %}
|
||||
<li class="nav-item">
|
||||
@@ -150,8 +149,7 @@
|
||||
</div>
|
||||
<div id="alerts"></div>
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener("showToastMessages", function () {
|
||||
console.log("showToastMessages")
|
||||
|
||||
@@ -1,9 +1,91 @@
|
||||
{% if item.can_add %}
|
||||
|
||||
|
||||
{% if session['is_admin'] %}
|
||||
<button class="btn btn-primary" id="add-playlist-admin-{{item['id']}}" data-bs-toggle="tooltip" title="Add Playlist for Users">
|
||||
<i class="fa-solid fa-users"> </i>
|
||||
</button>
|
||||
|
||||
<div class="modal fade" id="addPlaylistModal-{{item['id']}}" tabindex="-1" aria-labelledby="addPlaylistModal-{{item['id']}}Label" aria-hidden="true" data-bs-backdrop="false" >
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addPlaylistModal-{{item['id']}}Label">Select Additional Users</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="allUsers-{{item['id']}}">
|
||||
<!-- All users will be dynamically loaded here with checkboxes -->
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
<button class="btn btn-secondary" id="selectAllUsers-{{item['id']}}">Select All</button>
|
||||
<button class="btn btn-success" id="addPlaylistButton-{{item['id']}}">Add Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById("add-playlist-admin-{{item['id']}}").addEventListener('click', function() {
|
||||
var modal = new bootstrap.Modal(document.getElementById("addPlaylistModal-{{item['id']}}"));
|
||||
modal.show();
|
||||
loadAllUsers{{item['id']}}();
|
||||
});
|
||||
|
||||
function loadAllUsers{{item['id']}}() {
|
||||
fetch("/admin/getJellyfinUsers")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const allUsersDiv = document.getElementById("allUsers-{{item['id']}}");
|
||||
allUsersDiv.innerHTML = '';
|
||||
data.users.forEach(user => {
|
||||
const checkbox = document.createElement('div');
|
||||
checkbox.classList.add('form-check');
|
||||
const isChecked = {{ config['runtime_settings']['default_playlist_users']|safe }}.includes(user.Id) ? 'checked' : '';
|
||||
checkbox.innerHTML = `<input class="form-check-input" type="checkbox" value="${user.Id}" id="user-${user.Id}" ${isChecked}>
|
||||
<label class="form-check-label" for="user-${user.Id}">${user.Name}</label>`;
|
||||
allUsersDiv.appendChild(checkbox);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("selectAllUsers-{{item['id']}}").addEventListener('click', function() {
|
||||
document.querySelectorAll("#allUsers-{{item['id']}} .form-check-input").forEach(checkbox => {
|
||||
checkbox.checked = true;
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("addPlaylistButton-{{item['id']}}").addEventListener('click', function() {
|
||||
const selectedUsers = Array.from(document.querySelectorAll("#allUsers-{{item['id']}} .form-check-input:checked")).map(checkbox => checkbox.value);
|
||||
const hxVals = {
|
||||
item_id: "{{ item.id }}",
|
||||
item_name: "{{ item.name }}",
|
||||
additional_users: selectedUsers
|
||||
};
|
||||
fetch("/addplaylist?provider={{provider_id}}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'HX-Request': 'true'
|
||||
},
|
||||
body: JSON.stringify(hxVals)
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{%else%}
|
||||
<button class="btn btn-success" hx-post="/addplaylist?provider={{provider_id}}" hx-include="this" hx-swap="outerHTML" hx-target="this"
|
||||
data-bs-toggle="tooltip" title="Add to my Jellyfin"
|
||||
hx-vals='{"item_id": "{{ item.id }}", "item_name": "{{ item.name }}"}'>
|
||||
<i class="fa-solid fa-circle-plus"> </i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% elif item.can_remove %}
|
||||
<span id="item-can-remove-{{ item.id }}" >
|
||||
@@ -13,9 +95,110 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if session['is_admin'] and item.can_remove %}
|
||||
<button class="btn btn-danger " hx-delete="{{ url_for('wipe_playlist', playlist_id=item['jellyfin_id']) }}"
|
||||
hx-include="this" hx-swap="outerHTML" hx-target="#item-can-remove-{{ item.id }}" data-bs-toggle="tooltip" title="Delete playlist from monitoring and remove (DELETE FOR ALL USERS) from Jellyfin">
|
||||
<button class="btn btn-danger" id="confirm-delete-{{item['jellyfin_id']}}" data-bs-toggle="tooltip" title="Delete playlist from monitoring and remove (DELETE FOR ALL USERS) from Jellyfin">
|
||||
<i class="fa-solid fa-trash"> </i>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
document.getElementById("confirm-delete-{{item['jellyfin_id']}}").addEventListener('click', function() {
|
||||
const button = this;
|
||||
const icon = button.querySelector('i');
|
||||
if (icon.classList.contains('fa-trash')) {
|
||||
icon.classList.remove('fa-trash');
|
||||
icon.classList.add('fa-check');
|
||||
button.setAttribute('title', 'Click again to confirm deletion');
|
||||
} else {
|
||||
fetch("{{ url_for('wipe_playlist', playlist_id=item['jellyfin_id']) }}", {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'HX-Request': 'true'
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
button.closest('#item-can-remove-{{ item.id }}').outerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</span>
|
||||
{% endif%}
|
||||
{% if session['is_admin'] and item.can_remove %}
|
||||
<button class="btn btn-info" id="manage-users-{{item['jellyfin_id']}}" data-bs-toggle="tooltip" title="Manage Users">
|
||||
<i class="fa-solid fa-user"> </i>
|
||||
</button>
|
||||
<div class="modal fade" id="manageUsersModal-{{item['jellyfin_id']}}" tabindex="-1" aria-labelledby="manageUsersModal-{{item['jellyfin_id']}}Label" aria-hidden="true" data-bs-modal-backdrop="false">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="manageUsersModal-{{item['jellyfin_id']}}Label">Manage Users</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="assignedUsers-{{item['jellyfin_id']}}">
|
||||
<!-- Assigned users will be dynamically loaded here -->
|
||||
</div>
|
||||
<div class="input-group mt-3">
|
||||
<select class="form-select" id="availableUsers-{{item['jellyfin_id']}}">
|
||||
<!-- Available users will be dynamically loaded here -->
|
||||
</select>
|
||||
<button class="btn btn-primary" id="addUserButton-{{item['jellyfin_id']}}">Add User to Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("manage-users-{{item['jellyfin_id']}}").addEventListener('click', function() {
|
||||
var modal = new bootstrap.Modal(document.getElementById("manageUsersModal-{{item['jellyfin_id']}}"));
|
||||
modal.show();
|
||||
loadUsers{{item['jellyfin_id']}}();
|
||||
});
|
||||
|
||||
function loadUsers{{item['jellyfin_id']}}() {
|
||||
fetch("/admin/getJellyfinPlaylistUsers?playlist={{item['jellyfin_id']}}")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log("jellyfin playlist id: {{item['jellyfin_id']}}");
|
||||
const assignedUsersDiv = document.getElementById("assignedUsers-{{item['jellyfin_id']}}");
|
||||
console.log(assignedUsersDiv);
|
||||
assignedUsersDiv.innerHTML = '';
|
||||
data.assigned_users.forEach(user => {
|
||||
const badge = document.createElement('span');
|
||||
badge.classList.add('badge', 'bg-primary', 'me-1', 'mb-1');
|
||||
badge.innerHTML = `${user.Name} <button class="btn btn-danger btn-sm ms-1" onclick="removeUser{{item['jellyfin_id']}}('${user.UserId}')">×</button>`;
|
||||
assignedUsersDiv.appendChild(badge);
|
||||
});
|
||||
|
||||
const availableUsersSelect = document.getElementById("availableUsers-{{item['jellyfin_id']}}");
|
||||
availableUsersSelect.innerHTML = '';
|
||||
data.remaining_users.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.Id;
|
||||
option.textContent = user.Name;
|
||||
availableUsersSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeUser{{item['jellyfin_id']}}(userId) {
|
||||
fetch(`/admin/removeJellyfinUserFromPlaylist?user=${userId}&playlist={{item['jellyfin_id']}}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadUsers{{item['jellyfin_id']}}();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("addUserButton-{{item['jellyfin_id']}}").addEventListener('click', function() {
|
||||
const userId = document.getElementById("availableUsers-{{item['jellyfin_id']}}").value;
|
||||
fetch(`/admin/addJellyfinUserToPlaylist?user=${userId}&playlist={{item['jellyfin_id']}}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadUsers{{item['jellyfin_id']}}();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -7,8 +7,32 @@
|
||||
<h1>{{ item.name }}</h1>
|
||||
<p>{{ item.description }}</p>
|
||||
<p>{{ item.track_count }} songs, {{ total_duration }}</p>
|
||||
<p>Last Updated: {{ item.last_updated}} | Last Change: {{ item.last_changed}}</p>
|
||||
<p>Last Updated: {{ item.last_updated | human_datetime}} | Last Change: {{ item.last_changed | human_datetime}}</p>
|
||||
{% include 'partials/_add_remove_button.html' %}
|
||||
</div>
|
||||
<p>
|
||||
{{item.jellyfin_id | jellyfin_link_button}}
|
||||
{% if session['is_admin'] and item.jellyfin_id %}
|
||||
<button id="refresh-playlist-btn" class="btn btn-primary mt-2">Refresh Playlist in Jellyfin</button>
|
||||
<script>
|
||||
document.getElementById('refresh-playlist-btn').addEventListener('click', function() {
|
||||
fetch(`/refresh_playlist/{{item.jellyfin_id}}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Playlist refreshed successfully');
|
||||
} else {
|
||||
alert('Failed to refresh playlist');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while refreshing the playlist');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,7 +6,6 @@
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Duration</th>
|
||||
<th scope="col">{{provider_id}}</th>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Jellyfin</th>
|
||||
</tr>
|
||||
@@ -25,18 +24,7 @@
|
||||
<i class="fab fa-{{ track.provider_id.lower() }} fa-lg"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if track.preview_url %}
|
||||
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')"
|
||||
data-bs-toggle="tooltip" title="Play Preview">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span data-bs-toggle="tooltip" title="No Preview Available">
|
||||
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if not track.downloaded %}
|
||||
<button class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
|
||||
<!-- Card Image -->
|
||||
<div style="position: relative;">
|
||||
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}">
|
||||
|
||||
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Card Body -->
|
||||
@@ -30,17 +33,10 @@
|
||||
<p class="card-text">{{ item.description }}</p>
|
||||
</div>
|
||||
<div class="mt-auto pt-3">
|
||||
{% if item.type == 'category'%}
|
||||
<a href="{{ item.url }}" class="btn btn-primary" data-bs-toggle="tooltip" title="View Playlist">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</a>
|
||||
{%else%}
|
||||
|
||||
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}" class="btn btn-primary" data-bs-toggle="tooltip"
|
||||
title="View Playlist details">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</a>
|
||||
{%endif%}
|
||||
{% include 'partials/_add_remove_button.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "v0.1.10"
|
||||
|
||||
Reference in New Issue
Block a user