Compare commits
206 Commits
0.1.2
...
1e18576a88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e18576a88 | ||
|
|
ff99475fab | ||
|
|
54d012009e | ||
|
|
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 | ||
|
|
804b2bfe7e | ||
|
|
4c675e814c | ||
|
|
1509c37cd9 | ||
|
|
24ba4a0b70 | ||
|
|
7a7ef8d7bc | ||
|
|
be9a72701e | ||
|
|
c5de8d9841 | ||
|
|
671b813e6c | ||
|
|
b29a7bbbe3 | ||
|
|
d4c3a67249 | ||
|
|
4be027bb35 | ||
|
|
39e44e0606 | ||
|
|
8c30c6183d | ||
|
|
9e7f331c49 | ||
|
|
bb856c96a1 | ||
|
|
14a8fdc127 | ||
|
|
1d24972ea0 | ||
|
|
7b15a8b53d | ||
|
|
d41b901649 | ||
|
|
f7372fed38 | ||
|
|
89a1bc21be | ||
|
|
360c4e5b7a | ||
|
|
d69ac22998 | ||
|
|
d9dabd0a9c | ||
|
|
087d44836f | ||
|
|
1ee0087b8f | ||
|
|
e2d37b77b0 | ||
|
|
86f5bf118a | ||
|
|
e43d36dd24 | ||
|
|
30ea28ed6e | ||
|
|
07503a8003 | ||
|
|
9a5adfaa5b | ||
|
|
b9ad5be7bc | ||
|
|
b861a1a8f4 | ||
|
|
87791cf21d | ||
|
|
883294d74e | ||
|
|
2b3c400c10 | ||
|
|
00ba693fb9 | ||
|
|
cd498988ae | ||
|
|
3a26c054a0 | ||
|
|
d5aee793a0 | ||
|
|
6b78b90ee7 | ||
|
|
d70c3b3913 | ||
|
|
e428629928 | ||
|
|
94d401a99f | ||
|
|
56aaec603b | ||
|
|
aa718eb628 | ||
|
|
33ccbc470c | ||
|
|
3c25cd70ea | ||
|
|
25e51f1ef2 | ||
|
|
7232b3223d | ||
|
|
f81188f7e3 | ||
|
|
cbe172ff1f | ||
|
|
d8d677bc1b | ||
|
|
18dc6e18af | ||
|
|
3bcecfe6fd | ||
|
|
1867f982a0 | ||
|
|
b60a882dab | ||
|
|
c6eb95112e | ||
|
|
c9363104ec | ||
|
|
dc0165957a | ||
|
|
ddf73b77db | ||
|
|
6ec7e223ce | ||
|
|
da2b725b22 | ||
|
|
2a5d1dd425 | ||
|
|
4f82ba6aab | ||
|
|
b64d9bf8fc | ||
|
|
78d96c2ccc | ||
|
|
a436a0ad91 | ||
|
|
0ca91b7d7b | ||
|
|
af662df434 | ||
|
|
7e24016788 | ||
|
|
16e1a8a58d | ||
|
|
0fe45483dc | ||
|
|
b010c8950e | ||
|
|
bdab83e464 | ||
|
|
f0bffe92ae | ||
|
|
fce13015ea | ||
|
|
b49e7cc05c | ||
|
|
e497b33ccd | ||
|
|
543a1359f2 | ||
|
|
8392e37592 | ||
|
|
0401e6481e | ||
|
|
996daf700a | ||
|
|
4d9f9462f5 | ||
|
|
a84ae01e55 | ||
|
|
7de92f01ec | ||
|
|
3c006ed031 | ||
|
|
3e593bf475 | ||
|
|
3f5318a17b | ||
|
|
e5416ed800 | ||
|
|
961e175a7d | ||
|
|
810febbec2 | ||
|
|
82390455d0 | ||
|
|
e3d37576ed | ||
|
|
6fe5c0fae7 | ||
|
|
68ab47c443 | ||
|
|
e32bfb63e0 | ||
|
|
4a14e48938 | ||
|
|
d03ce01cb3 | ||
|
|
42ed70df3b | ||
|
|
1c4030a4c6 | ||
|
|
31556fd207 | ||
|
|
42c92caef7 | ||
|
|
7f075fb490 | ||
|
|
53e4cf0a8d | ||
|
|
7a4ef7f312 | ||
|
|
ef34aaa7a7 | ||
|
|
313db2b71a | ||
|
|
84891ef548 | ||
|
|
7177581a4c | ||
|
|
f9993959ed | ||
|
|
6f56f25384 |
@@ -1,12 +1,12 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.2
|
||||
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
|
||||
jellyplist.code-workspace
|
||||
|
||||
# Ignore GitHub page related files
|
||||
changelogs
|
||||
readme.md
|
||||
screenshots
|
||||
39
.github/workflows/main.yml
vendored
39
.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:
|
||||
@@ -37,8 +40,33 @@ jobs:
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository }}:${{ env.VERSION }}
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ env.VERSION }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
|
||||
# Read changelog for the current version
|
||||
- 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
|
||||
|
||||
# # Generate auto-generated release notes
|
||||
# - name: Generate Auto Release Notes
|
||||
# id: release_notes
|
||||
# run: |
|
||||
# auto_notes=$(gh release create ${{ env.VERSION }} --generate-notes --prerelease --json body --jq .body)
|
||||
# echo "AUTO_RELEASE_NOTES<<EOF" >> $GITHUB_ENV
|
||||
# echo "$auto_notes" >> $GITHUB_ENV
|
||||
# echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
# Create a release on GitHub
|
||||
- name: Create GitHub Release
|
||||
@@ -46,6 +74,9 @@ jobs:
|
||||
with:
|
||||
tag_name: ${{ env.VERSION }}
|
||||
name: Release ${{ env.VERSION }}
|
||||
body: |
|
||||
${{ env.CHANGELOG_CONTENT }}
|
||||
generate_release_notes: true
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
48
.github/workflows/manual-build.yml
vendored
48
.github/workflows/manual-build.yml
vendored
@@ -18,6 +18,34 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
# Extract branch name and latest commit SHA
|
||||
- name: Extract branch name and commit SHA
|
||||
id: branch_info
|
||||
run: |
|
||||
echo "BRANCH_NAME=${{ github.event.inputs.branch }}" >> $GITHUB_ENV
|
||||
echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
# Create a file indicating this is a dev build
|
||||
- name: Create DEV_BUILD file
|
||||
run: |
|
||||
echo "${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}" > DEV_BUILD
|
||||
- name: Extract Version
|
||||
id: extract_version
|
||||
run: |
|
||||
version=$(python3 -c "import version; print(f'{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
|
||||
@@ -36,5 +64,23 @@ jobs:
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ env.COMMIT_SHA }}
|
||||
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 }}-${{ env.BRANCH_NAME }}-${{ env.COMMIT_SHA }}
|
||||
name: |
|
||||
${{ env.BRANCH_NAME }} Release ${{ env.VERSION }}
|
||||
body: |
|
||||
${{ env.CHANGELOG_CONTENT }}
|
||||
generate_release_notes: true
|
||||
make_latest: false
|
||||
|
||||
|
||||
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -73,6 +73,10 @@ coverage/
|
||||
# macOS
|
||||
.DS_Store
|
||||
.cache
|
||||
cookies*.txt
|
||||
*cookies*.txt
|
||||
*.code-workspace
|
||||
set_env.sh
|
||||
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"]
|
||||
149
app/__init__.py
149
app/__init__.py
@@ -1,6 +1,8 @@
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import yaml
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
import sys
|
||||
@@ -8,6 +10,7 @@ from flask import Flask, has_request_context
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from psycopg2 import OperationalError
|
||||
import redis
|
||||
import spotipy
|
||||
from spotipy.oauth2 import SpotifyClientCredentials
|
||||
from celery import Celery
|
||||
@@ -21,6 +24,7 @@ from flask_caching import Cache
|
||||
from .version import __version__
|
||||
|
||||
|
||||
|
||||
def check_db_connection(db_uri, retries=5, delay=5):
|
||||
"""
|
||||
Check if the database is reachable.
|
||||
@@ -70,63 +74,88 @@ def make_celery(app):
|
||||
},
|
||||
'update_all_playlists_track_status-schedule': {
|
||||
'task': 'app.tasks.update_all_playlists_track_status',
|
||||
'schedule': crontab(minute='*/2'),
|
||||
'schedule': crontab(minute='*/5'),
|
||||
|
||||
},
|
||||
'update_jellyfin_id_for_downloaded_tracks-schedule': {
|
||||
'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks',
|
||||
'schedule': crontab(minute='*/10'),
|
||||
|
||||
}
|
||||
}
|
||||
if app.config['LIDARR_API_KEY']:
|
||||
celery.conf.beat_schedule['request-lidarr-schedule'] = {
|
||||
'task': 'app.tasks.request_lidarr',
|
||||
'schedule': crontab(minute='50')
|
||||
}
|
||||
|
||||
celery.conf.timezone = 'UTC'
|
||||
return celery
|
||||
|
||||
log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s : %(message)s')
|
||||
|
||||
# Why this ? Because we are using the same admin login for web, worker and beat we need to distinguish the device_id´s
|
||||
device_id = f'JellyPlist_{'_'.join(sys.argv)}'
|
||||
|
||||
# Initialize Flask app
|
||||
app = Flask(__name__, template_folder="../templates", static_folder='../static')
|
||||
# log_file = 'app.log'
|
||||
# handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3)
|
||||
# handler.setLevel(logging.DEBUG)
|
||||
# handler.setFormatter(log_formatter)
|
||||
# stream_handler = logging.StreamHandler(sys.stdout)
|
||||
# stream_handler.setLevel(logging.DEBUG)
|
||||
# stream_handler.setFormatter(log_formatter)
|
||||
|
||||
|
||||
# # app.logger.addHandler(handler)
|
||||
# app.logger.addHandler(stream_handler)
|
||||
|
||||
app.config.from_object(Config)
|
||||
app.logger.setLevel(logging.DEBUG)
|
||||
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)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)
|
||||
|
||||
|
||||
# Spotify, Jellyfin, and Spotdl setup
|
||||
app.logger.info(f"setting up spotipy")
|
||||
sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials(
|
||||
client_id=app.config['SPOTIPY_CLIENT_ID'],
|
||||
client_secret=app.config['SPOTIPY_CLIENT_SECRET']
|
||||
client_id=app.config['SPOTIFY_CLIENT_ID'],
|
||||
client_secret=app.config['SPOTIFY_CLIENT_SECRET']
|
||||
))
|
||||
|
||||
app.logger.info(f"setting up jellyfin client")
|
||||
app.logger.info(f"setting up jellyfin client, BaseUrl = {app.config['JELLYFIN_SERVER_URL']}, timeout = {app.config['JELLYFIN_REQUEST_TIMEOUT']}")
|
||||
|
||||
jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL'])
|
||||
jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL'], app.config['JELLYFIN_REQUEST_TIMEOUT'])
|
||||
jellyfin_admin_token, jellyfin_admin_id, jellyfin_admin_name, jellyfin_admin_is_admin = jellyfin.login_with_password(
|
||||
app.config['JELLYFIN_ADMIN_USER'],
|
||||
app.config['JELLYFIN_ADMIN_PASSWORD'], device_id= device_id
|
||||
)
|
||||
|
||||
# SQLAlchemy and Migrate setup
|
||||
app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}")
|
||||
check_db_connection(f'postgresql://{app.config["JELLYPLIST_DB_USER"]}:{app.config["JELLYPLIST_DB_PASSWORD"]}@{app.config["JELLYPLIST_DB_HOST"]}/jellyplist',retries=5,delay=2)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{app.config['JELLYPLIST_DB_USER']}:{app.config['JELLYPLIST_DB_PASSWORD']}@{app.config['JELLYPLIST_DB_HOST']}/jellyplist'
|
||||
app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}:{app.config['JELLYPLIST_DB_PORT']}")
|
||||
db_uri = f'postgresql://{app.config["JELLYPLIST_DB_USER"]}:{app.config["JELLYPLIST_DB_PASSWORD"]}@{app.config["JELLYPLIST_DB_HOST"]}:{app.config['JELLYPLIST_DB_PORT']}/jellyplist'
|
||||
check_db_connection(db_uri=db_uri,retries=5,delay=2)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = db_uri
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
db = SQLAlchemy(app)
|
||||
app.logger.info(f"applying db migrations")
|
||||
@@ -138,14 +167,78 @@ app.config.update(
|
||||
result_backend=app.config['REDIS_URL']
|
||||
)
|
||||
|
||||
|
||||
def read_dev_build_file(file_path="/jellyplist/DEV_BUILD"):
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, "r") as file:
|
||||
content = file.read().strip()
|
||||
return f"-{content}"
|
||||
else:
|
||||
return ''
|
||||
app.logger.info(f"initializing celery")
|
||||
celery = make_celery(app)
|
||||
socketio = SocketIO(app, message_queue=app.config['REDIS_URL'], async_mode='eventlet')
|
||||
celery.set_default()
|
||||
|
||||
app.logger.info(f'Jellyplist {__version__} started')
|
||||
from app import routes
|
||||
from app import jellyfin_routes, tasks
|
||||
if "worker" in sys.argv:
|
||||
tasks.release_lock("download_missing_tracks_lock")
|
||||
app.logger.info(f'Jellyplist {__version__}{read_dev_build_file()} started')
|
||||
app.logger.debug(f"Debug logging active")
|
||||
|
||||
from app.routes import pl_bp, routes, jellyfin_routes
|
||||
app.register_blueprint(pl_bp)
|
||||
|
||||
from app import filters # Import the filters dictionary
|
||||
|
||||
# Register all filters
|
||||
for name, func in filters.filters.items():
|
||||
app.jinja_env.filters[name] = func
|
||||
|
||||
|
||||
from .providers import SpotifyClient
|
||||
if app.config['SPOTIFY_COOKIE_FILE']:
|
||||
if os.path.exists(app.config['SPOTIFY_COOKIE_FILE']):
|
||||
spotify_client = SpotifyClient(app.config['SPOTIFY_COOKIE_FILE'])
|
||||
else:
|
||||
app.logger.error(f"Cookie file {app.config['SPOTIFY_COOKIE_FILE']} does not exist. Exiting.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
spotify_client = SpotifyClient()
|
||||
|
||||
spotify_client.authenticate()
|
||||
app.logger.info('spotify auth successful')
|
||||
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()
|
||||
116
app/classes.py
Normal file
116
app/classes.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import json
|
||||
from flask import current_app as app # Adjust this based on your app's structure
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class AudioProfile:
|
||||
def __init__(self, path: str, bitrate: int = 0, sample_rate: int = 0, channels: int = 0) -> None:
|
||||
"""
|
||||
Initialize an AudioProfile instance.
|
||||
|
||||
Args:
|
||||
path (str): The file path of the audio file.
|
||||
bitrate (int): The audio bitrate in kbps. Default is 0.
|
||||
sample_rate (int): The sample rate in Hz. Default is 0.
|
||||
channels (int): The number of audio channels. Default is 0.
|
||||
"""
|
||||
self.path: str = path
|
||||
self.bitrate: int = bitrate # in kbps
|
||||
self.sample_rate: int = sample_rate # in Hz
|
||||
self.channels: int = channels
|
||||
|
||||
@staticmethod
|
||||
def analyze_audio_quality_with_ffprobe(filepath: str) -> Optional['AudioProfile']:
|
||||
"""
|
||||
Static method to analyze audio quality using ffprobe and return an AudioProfile instance.
|
||||
|
||||
Args:
|
||||
filepath (str): Path to the audio file to analyze.
|
||||
|
||||
Returns:
|
||||
Optional[AudioProfile]: An instance of AudioProfile if analysis is successful, None otherwise.
|
||||
"""
|
||||
try:
|
||||
# ffprobe command to extract bitrate, sample rate, and channel count
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error', '-select_streams', 'a:0',
|
||||
'-show_entries', 'stream=bit_rate,sample_rate,channels',
|
||||
'-show_format',
|
||||
'-of', 'json', filepath
|
||||
]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode != 0:
|
||||
app.logger.error(f"ffprobe error for file {filepath}: {result.stderr}")
|
||||
return None
|
||||
|
||||
# Parse ffprobe output
|
||||
data = json.loads(result.stdout)
|
||||
stream = data.get('streams', [{}])[0]
|
||||
bitrate: int = int(stream.get('bit_rate', 0)) // 1000 # Convert to kbps
|
||||
if bitrate == 0: # Fallback if no bit_rate in stream
|
||||
bitrate = int(data.get('format').get('bit_rate', 0)) // 1000
|
||||
sample_rate: int = int(stream.get('sample_rate', 0)) # Hz
|
||||
channels: int = int(stream.get('channels', 0))
|
||||
|
||||
# Create an AudioProfile instance
|
||||
return AudioProfile(filepath, bitrate, sample_rate, channels)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error analyzing audio quality with ffprobe: {str(e)}")
|
||||
return None
|
||||
|
||||
def compute_quality_score(self) -> int:
|
||||
"""
|
||||
Compute a quality score based on bitrate, sample rate, and channels.
|
||||
|
||||
Returns:
|
||||
int: The computed quality score.
|
||||
"""
|
||||
return self.bitrate + (self.sample_rate // 1000) + (self.channels * 10)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Representation of the AudioProfile instance.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the AudioProfile instance.
|
||||
"""
|
||||
return (f"AudioProfile(path='{self.path}', bitrate={self.bitrate} kbps, "
|
||||
f"sample_rate={self.sample_rate} Hz, channels={self.channels})")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombinedTrackData():
|
||||
# Combines a track from a provider with a track from the db
|
||||
title: str
|
||||
artist: List[str]
|
||||
url: List[str]
|
||||
duration: str
|
||||
downloaded: bool
|
||||
filesystem_path: Optional[str]
|
||||
jellyfin_id: Optional[str]
|
||||
provider_id: str
|
||||
provider_track_id: str
|
||||
duration_ms: int
|
||||
download_status: Optional[str]
|
||||
provider: str
|
||||
|
||||
@dataclass
|
||||
class CombinedPlaylistData():
|
||||
name: str
|
||||
description: Optional[str]
|
||||
image: str
|
||||
url: str
|
||||
id: str
|
||||
jellyfin_id: Optional[str]
|
||||
can_add: bool
|
||||
can_remove: bool
|
||||
last_updated: Optional[datetime]
|
||||
last_changed: Optional[datetime]
|
||||
tracks_available: int
|
||||
track_count: int
|
||||
tracks_linked: int
|
||||
percent_available: float
|
||||
status: str
|
||||
117
app/filters.py
Normal file
117
app/filters.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import os
|
||||
import re
|
||||
from markupsafe import Markup
|
||||
|
||||
from app.classes import AudioProfile
|
||||
from app import app, functions, read_dev_build_file
|
||||
from .version import __version__
|
||||
|
||||
filters = {}
|
||||
|
||||
def template_filter(name):
|
||||
"""Decorator to register a Jinja2 filter."""
|
||||
def decorator(func):
|
||||
filters[name] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
@template_filter('highlight')
|
||||
def highlight_search(text: str, search_query: str) -> Markup:
|
||||
if not search_query:
|
||||
return text
|
||||
|
||||
search_query_escaped = re.escape(search_query)
|
||||
|
||||
# If the text matches the search query exactly, apply a different highlight
|
||||
if text.strip().lower() == search_query.strip().lower():
|
||||
return Markup(f'<mark style="background-color: lightgreen; color: black;">{text}</mark>')
|
||||
|
||||
# Highlight partial matches in the text
|
||||
highlighted_text = re.sub(
|
||||
f"({search_query_escaped})",
|
||||
r'<mark>\1</mark>',
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
return Markup(highlighted_text)
|
||||
|
||||
|
||||
@template_filter('audioprofile')
|
||||
def audioprofile(text: str, path: str) -> Markup:
|
||||
if not path or not os.path.exists(path):
|
||||
return Markup() # Return the original text if the file does not exist
|
||||
|
||||
# Create the AudioProfile instance using the static method
|
||||
audio_profile = AudioProfile.analyze_audio_quality_with_ffprobe(path)
|
||||
if not audio_profile:
|
||||
return Markup(f"<span style='color: red;'>ERROR</span>")
|
||||
|
||||
# Create a nicely formatted HTML representation
|
||||
audio_profile_html = (
|
||||
f"<strong>Bitrate:</strong> {audio_profile.bitrate} kbps<br>"
|
||||
f"<strong>Sample Rate:</strong> {audio_profile.sample_rate} Hz<br>"
|
||||
f"<strong>Channels:</strong> {audio_profile.channels}<br>"
|
||||
f"<strong>Quality Score:</strong> {audio_profile.compute_quality_score()}"
|
||||
|
||||
)
|
||||
return Markup(audio_profile_html)
|
||||
|
||||
@template_filter('version_check')
|
||||
def version_check(version: str) -> Markup:
|
||||
version = f"{__version__}{read_dev_build_file()}"
|
||||
# if version contains a dash and the text after the dash is LOCAL, return version as a blue badge
|
||||
if app.config['CHECK_FOR_UPDATES']:
|
||||
if '-' in version and version.split('-')[1] == 'LOCAL':
|
||||
return Markup(f"<span class='badge rounded-pill bg-primary'>{version}</span>")
|
||||
# else if the version string contains a dash and the text after the dash is not LOCAL, check whether it contains another dash (like in e.g. v0.1.7-dev-89a1bc2) and split both parts
|
||||
elif '-' in version and version.split('-')[1] != 'LOCAL' :
|
||||
branch, commit_sha = version.split('-')[1], version.split('-')[2]
|
||||
nra,url = functions.get_latest_dev_releases(branch_name = branch, commit_sha = commit_sha)
|
||||
if nra:
|
||||
return Markup(f"<a href='{url}' target='_blank'><span class='badge rounded-pill text-bg-warning btn-pulsing' data-bs-toggle='tooltip' title='An update for the {branch} branch is available.'>{version}</span></a>")
|
||||
else:
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-secondary'>{version}</span>")
|
||||
else:
|
||||
nra,url = functions.get_latest_release(version)
|
||||
if nra:
|
||||
return Markup(f"<a href='{url}' target='_blank'><span class='badge rounded-pill text-bg-warning btn-pulsing' data-bs-toggle='tooltip' title='An update is available.'>{version}</span></a>")
|
||||
|
||||
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-primary'>{version}</span>")
|
||||
else:
|
||||
return Markup(f"<span class='badge rounded-pill text-bg-info'>{version}</span>")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@template_filter('jellyfin_link')
|
||||
def jellyfin_link(jellyfin_id: str) -> Markup:
|
||||
|
||||
jellyfin_server_url = app.config.get('JELLYFIN_SERVER_URL')
|
||||
if not jellyfin_server_url:
|
||||
return Markup(f"<span style='color: red;'>JELLYFIN_SERVER_URL not configured</span>")
|
||||
|
||||
link = f"{jellyfin_server_url}/web/#/details?id={jellyfin_id}"
|
||||
return Markup(f'<a href="{link}" target="_blank">{jellyfin_id}</a>')
|
||||
|
||||
@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')
|
||||
410
app/functions.py
410
app/functions.py
@@ -1,130 +1,102 @@
|
||||
from flask import flash, redirect, session, url_for
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from flask import flash, redirect, session, url_for,g
|
||||
import requests
|
||||
from app.classes import CombinedPlaylistData, CombinedTrackData
|
||||
from app.models import JellyfinUser, Playlist,Track
|
||||
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache
|
||||
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache, redis_client
|
||||
from functools import wraps
|
||||
from celery.result import AsyncResult
|
||||
from app.tasks import download_missing_tracks,check_for_playlist_updates, update_all_playlists_track_status, update_jellyfin_id_for_downloaded_tracks
|
||||
from app.providers import base
|
||||
from app.providers.base import PlaylistTrack
|
||||
from app.registry.music_provider_registry import MusicProviderRegistry
|
||||
from lidarr.classes import Album, Artist
|
||||
from . import tasks
|
||||
from jellyfin.objects import PlaylistMetadata
|
||||
from spotipy.exceptions import SpotifyException
|
||||
|
||||
TASK_STATUS = {
|
||||
'update_all_playlists_track_status': None,
|
||||
'download_missing_tracks': None,
|
||||
'check_for_playlist_updates': None,
|
||||
'update_jellyfin_id_for_downloaded_tracks' : None
|
||||
}
|
||||
import re
|
||||
|
||||
def manage_task(task_name):
|
||||
task_id = TASK_STATUS.get(task_name)
|
||||
|
||||
if task_id:
|
||||
result = AsyncResult(task_id)
|
||||
if result.state in ['PENDING', 'STARTED']:
|
||||
return result.state, result.info if result.info else {}
|
||||
if task_name == 'update_all_playlists_track_status':
|
||||
result = update_all_playlists_track_status.delay()
|
||||
elif task_name == 'download_missing_tracks':
|
||||
result = download_missing_tracks.delay()
|
||||
elif task_name == 'check_for_playlist_updates':
|
||||
result = check_for_playlist_updates.delay()
|
||||
elif task_name == 'update_jellyfin_id_for_downloaded_tracks':
|
||||
result = update_jellyfin_id_for_downloaded_tracks.delay()
|
||||
|
||||
TASK_STATUS[task_name] = result.id
|
||||
return result.state, result.info if result.info else {}
|
||||
|
||||
|
||||
def prepPlaylistData(data):
|
||||
playlists = []
|
||||
def prepPlaylistData(playlist: base.Playlist) -> Optional[CombinedPlaylistData]:
|
||||
jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||
if not data.get('playlists'):
|
||||
|
||||
data['playlists']= {}
|
||||
data['playlists']['items'] = [data]
|
||||
|
||||
for playlist_data in data['playlists']['items']:
|
||||
# Fetch the playlist from the database if it exists
|
||||
db_playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_data['id']).first()
|
||||
|
||||
if db_playlist:
|
||||
# If the playlist is in the database, use the stored values
|
||||
if isinstance(playlist_data['tracks'],list):
|
||||
track_count = len(playlist_data['tracks'] )
|
||||
else:
|
||||
track_count = playlist_data['tracks']['total'] or 0
|
||||
tracks_available = db_playlist.tracks_available or 0
|
||||
tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) or 0
|
||||
percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0
|
||||
|
||||
# Determine playlist status
|
||||
if tracks_available == track_count and track_count > 0:
|
||||
status = 'green' # Fully available
|
||||
elif tracks_available > 0:
|
||||
status = 'yellow' # Partially available
|
||||
else:
|
||||
status = 'red' # Not available
|
||||
else:
|
||||
# If the playlist is not in the database, initialize with 0
|
||||
track_count = 0
|
||||
tracks_available = 0
|
||||
tracks_linked = 0
|
||||
percent_available = 0
|
||||
status = 'red' # Not requested yet
|
||||
|
||||
# Append playlist data to the list
|
||||
playlists.append({
|
||||
'name': playlist_data['name'],
|
||||
'description': playlist_data['description'],
|
||||
'image': playlist_data['images'][0]['url'] if playlist_data['images'] else 'default-image.jpg',
|
||||
'url': playlist_data['external_urls']['spotify'],
|
||||
'id': playlist_data['id'],
|
||||
'jellyfin_id': db_playlist.jellyfin_id if db_playlist else '',
|
||||
'can_add': (db_playlist not in jellyfin_user.playlists) if db_playlist else True,
|
||||
'can_remove' : (db_playlist in jellyfin_user.playlists) if db_playlist else False,
|
||||
'last_updated':db_playlist.last_updated if db_playlist else '',
|
||||
'last_changed':db_playlist.last_changed if db_playlist else '',
|
||||
'tracks_available': tracks_available,
|
||||
'track_count': track_count,
|
||||
'tracks_linked': tracks_linked,
|
||||
'percent_available': percent_available,
|
||||
'status': status # Red, yellow, or green based on availability
|
||||
})
|
||||
|
||||
return playlists
|
||||
|
||||
def get_cached_spotify_playlists(playlist_ids):
|
||||
"""
|
||||
Fetches multiple Spotify playlists by their IDs, utilizing individual caching.
|
||||
|
||||
:param playlist_ids: A list of Spotify playlist IDs.
|
||||
:return: A dictionary containing the fetched playlists.
|
||||
"""
|
||||
spotify_data = {'playlists': {'items': []}}
|
||||
|
||||
for playlist_id in playlist_ids:
|
||||
playlist_data = get_cached_spotify_playlist(playlist_id)
|
||||
if playlist_data:
|
||||
spotify_data['playlists']['items'].append(playlist_data)
|
||||
else:
|
||||
app.logger.warning(f"Playlist data for ID {playlist_id} could not be retrieved.")
|
||||
|
||||
return spotify_data
|
||||
|
||||
@cache.memoize(timeout=3600)
|
||||
def get_cached_spotify_playlist(playlist_id):
|
||||
"""
|
||||
Fetches a Spotify playlist by its ID, utilizing caching to minimize API calls.
|
||||
|
||||
:param playlist_id: The Spotify playlist ID.
|
||||
:return: Playlist data as a dictionary, or None if an error occurs.
|
||||
"""
|
||||
try:
|
||||
playlist_data = sp.playlist(playlist_id) # Fetch data from Spotify API
|
||||
return playlist_data
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching playlist {playlist_id} from Spotify: {str(e)}")
|
||||
if not jellyfin_user:
|
||||
app.logger.error(f"jellyfin_user not set: session user id: {session['jellyfin_user_id']}. Logout and Login again")
|
||||
return None
|
||||
@cache.memoize(timeout=3600*24*10)
|
||||
def get_cached_spotify_track(track_id):
|
||||
|
||||
# Fetch the playlist from the database if it exists
|
||||
db_playlist : Playlist = Playlist.query.filter_by(provider_playlist_id=playlist.id).first() if playlist else None
|
||||
|
||||
# Initialize default values
|
||||
track_count = db_playlist.track_count if db_playlist else 0
|
||||
tracks_available = db_playlist.tracks_available if db_playlist else 0
|
||||
tracks_linked = len([track for track in db_playlist.tracks if track.jellyfin_id]) if db_playlist else 0
|
||||
percent_available = (tracks_available / track_count * 100) if track_count > 0 else 0
|
||||
|
||||
# Determine playlist status
|
||||
if tracks_available == track_count and track_count > 0:
|
||||
status = 'green' # Fully available
|
||||
elif tracks_available > 0:
|
||||
status = 'yellow' # Partially available
|
||||
else:
|
||||
status = 'red' # Not available
|
||||
|
||||
# Build and return the PlaylistResponse object
|
||||
return CombinedPlaylistData(
|
||||
name=playlist.name,
|
||||
description=playlist.description,
|
||||
image=playlist.images[0].url if playlist.images else '/static/images/placeholder.png',
|
||||
url=playlist.external_urls[0].url if playlist.external_urls else '',
|
||||
id=playlist.id,
|
||||
jellyfin_id=db_playlist.jellyfin_id if db_playlist else '',
|
||||
can_add=(db_playlist not in jellyfin_user.playlists) if db_playlist else True,
|
||||
can_remove=(db_playlist in jellyfin_user.playlists) if db_playlist else False,
|
||||
last_updated=db_playlist.last_updated if db_playlist else None,
|
||||
last_changed=db_playlist.last_changed if db_playlist else None,
|
||||
tracks_available=tracks_available,
|
||||
track_count=track_count,
|
||||
tracks_linked=tracks_linked,
|
||||
percent_available=percent_available,
|
||||
status=status
|
||||
)
|
||||
|
||||
def lidarr_quality_profile_id(profile_id=None):
|
||||
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
|
||||
from app import lidarr_client
|
||||
if profile_id:
|
||||
redis_client.set('lidarr_quality_profile_id', profile_id)
|
||||
else:
|
||||
value = redis_client.get('lidarr_quality_profile_id')
|
||||
if not value:
|
||||
value = lidarr_client.get_quality_profiles()[0]
|
||||
lidarr_quality_profile_id(value.id)
|
||||
return value
|
||||
return value
|
||||
|
||||
def lidarr_root_folder_path(folder_path=None):
|
||||
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
|
||||
from app import lidarr_client
|
||||
if folder_path:
|
||||
redis_client.set('lidarr_root_folder_path', folder_path)
|
||||
else:
|
||||
value = redis_client.get('lidarr_root_folder_path')
|
||||
if not value:
|
||||
value = lidarr_client.get_root_folders()[0]
|
||||
lidarr_root_folder_path(value.path)
|
||||
return value.path
|
||||
return value
|
||||
|
||||
# a function which takes a lidarr.class.Artist object as a parameter, and applies the lidarr_quality_profile_id to the artist if its 0
|
||||
def apply_default_profile_and_root_folder(object : Artist ) -> Artist:
|
||||
if object.qualityProfileId == 0:
|
||||
object.qualityProfileId = int(lidarr_quality_profile_id())
|
||||
if object.rootFolderPath == '' or object.rootFolderPath == None:
|
||||
object.rootFolderPath = str(lidarr_root_folder_path())
|
||||
if object.metadataProfileId == 0:
|
||||
object.metadataProfileId = 1
|
||||
return object
|
||||
|
||||
@cache.memoize(timeout=3600*24*10)
|
||||
def get_cached_provider_track(track_id : str,provider_id : str)-> base.Track:
|
||||
"""
|
||||
Fetches a Spotify track by its ID, utilizing caching to minimize API calls.
|
||||
|
||||
@@ -132,80 +104,44 @@ def get_cached_spotify_track(track_id):
|
||||
:return: Track data as a dictionary, or None if an error occurs.
|
||||
"""
|
||||
try:
|
||||
track_data = sp.track(track_id=track_id) # Fetch data from Spotify API
|
||||
# get the provider from the registry
|
||||
provider = MusicProviderRegistry.get_provider(provider_id)
|
||||
track_data = provider.get_track(track_id)
|
||||
return track_data
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching track {track_id} from Spotify: {str(e)}")
|
||||
app.logger.error(f"Error fetching track {track_id} from {provider_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
@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.
|
||||
|
||||
def prepAlbumData(data):
|
||||
items = []
|
||||
for item in data['albums']['items']:
|
||||
items.append({
|
||||
'name': item['name'],
|
||||
'description': f"Released: {item['release_date']}",
|
||||
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
|
||||
'url': item['external_urls']['spotify'],
|
||||
'id' : item['id'],
|
||||
'can_add' : False
|
||||
})
|
||||
return items
|
||||
: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 prepArtistData(data):
|
||||
items = []
|
||||
for item in data['artists']['items']:
|
||||
items.append({
|
||||
'name': item['name'],
|
||||
'description': f"Popularity: {item['popularity']}",
|
||||
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
|
||||
'url': item['external_urls']['spotify'],
|
||||
'id' : item['id'],
|
||||
'can_add' : False
|
||||
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
|
||||
def getFeaturedPlaylists(country,offset):
|
||||
playlists_data = sp.featured_playlists(country=country, limit=16, offset=offset)
|
||||
|
||||
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],'Featured Playlists'
|
||||
|
||||
def getCategoryPlaylists(category,offset):
|
||||
playlists_data = sp.category_playlists(category_id=category, limit=16, offset=offset)
|
||||
|
||||
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],f"Category {playlists_data['message']}"
|
||||
|
||||
def getCategories(country,offset):
|
||||
categories_data = sp.categories(limit=16, offset= offset)
|
||||
categories = []
|
||||
|
||||
for cat in categories_data['categories']['items']:
|
||||
categories.append({
|
||||
'name': cat['name'],
|
||||
'description': '',
|
||||
'image': cat['icons'][0]['url'] if cat['icons'] else 'default-image.jpg',
|
||||
'url': f"/playlists?cat={cat['id']}",
|
||||
'id' : cat['id'],
|
||||
'type':'category'
|
||||
})
|
||||
return categories, categories_data['categories']['total'],'Browse Categories'
|
||||
|
||||
def get_tracks_for_playlist(data):
|
||||
results = data
|
||||
tracks = []
|
||||
def get_tracks_for_playlist(data: List[PlaylistTrack], provider_id : str ) -> List[CombinedTrackData]:
|
||||
is_admin = session.get('is_admin', False)
|
||||
tracks = []
|
||||
|
||||
for idx, item in enumerate(results['tracks']):
|
||||
track_data = item['track']
|
||||
for idx, item in enumerate(data):
|
||||
track_data = item.track
|
||||
if track_data:
|
||||
duration_ms = track_data['duration_ms']
|
||||
duration_ms = track_data.duration_ms
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
|
||||
track_db = Track.query.filter_by(spotify_track_id=track_data['id']).first()
|
||||
# Query track from the database
|
||||
track_db = Track.query.filter_by(provider_track_id=track_data.id).first()
|
||||
|
||||
if track_db:
|
||||
downloaded = track_db.downloaded
|
||||
@@ -218,40 +154,26 @@ def get_tracks_for_playlist(data):
|
||||
jellyfin_id = None
|
||||
download_status = None
|
||||
|
||||
tracks.append({
|
||||
'title': track_data['name'],
|
||||
'artist': ', '.join([artist['name'] for artist in track_data['artists']]),
|
||||
'url': track_data['external_urls']['spotify'],
|
||||
'duration': f'{minutes}:{seconds:02d}',
|
||||
'preview_url': track_data['preview_url'],
|
||||
'downloaded': downloaded,
|
||||
'filesystem_path': filesystem_path,
|
||||
'jellyfin_id': jellyfin_id,
|
||||
'spotify_id': track_data['id'],
|
||||
'duration_ms': duration_ms,
|
||||
'download_status' : download_status
|
||||
})
|
||||
|
||||
# Append a TrackResponse object
|
||||
tracks.append(
|
||||
CombinedTrackData(
|
||||
title=track_data.name,
|
||||
artist=[a.name for a in track_data.artists],
|
||||
url=[url.url for url in track_data.external_urls],
|
||||
duration=f'{minutes}:{seconds:02d}',
|
||||
downloaded=downloaded,
|
||||
filesystem_path=filesystem_path,
|
||||
jellyfin_id=jellyfin_id,
|
||||
provider_track_id=track_data.id,
|
||||
provider_id = provider_id,
|
||||
duration_ms=duration_ms,
|
||||
download_status=download_status,
|
||||
provider=provider_id
|
||||
)
|
||||
)
|
||||
|
||||
return tracks
|
||||
|
||||
def get_full_playlist_data(playlist_id):
|
||||
playlist_data = get_cached_spotify_playlist(playlist_id)
|
||||
all_tracks = []
|
||||
|
||||
offset = 0
|
||||
while True:
|
||||
response = sp.playlist_items(playlist_id, offset=offset, limit=100)
|
||||
items = response['items']
|
||||
all_tracks.extend(items)
|
||||
|
||||
if len(items) < 100:
|
||||
break
|
||||
offset += 100
|
||||
|
||||
playlist_data['tracks'] = all_tracks
|
||||
playlist_data['prepped_data'] = prepPlaylistData(playlist_data)
|
||||
return playlist_data
|
||||
|
||||
def jellyfin_login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
@@ -273,13 +195,13 @@ def jellyfin_admin_required(f):
|
||||
|
||||
|
||||
|
||||
def update_playlist_metadata(playlist,spotify_playlist_data):
|
||||
def update_playlist_metadata(playlist,provider_playlist_data : base.Playlist):
|
||||
metadata = PlaylistMetadata()
|
||||
metadata.Tags = [f'jellyplist:playlist:{playlist.id}',f'{playlist.tracks_available} of {playlist.track_count} Tracks available']
|
||||
metadata.Overview = spotify_playlist_data['description']
|
||||
metadata.Overview = provider_playlist_data.description
|
||||
jellyfin.update_playlist_metadata(session_token=_get_api_token(),playlist_id=playlist.jellyfin_id,updates= metadata , user_id= _get_admin_id())
|
||||
if spotify_playlist_data['images'] != None:
|
||||
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,spotify_image_url= spotify_playlist_data['images'][0]['url'])
|
||||
if provider_playlist_data.images:
|
||||
jellyfin.set_playlist_cover_image(session_token= _get_api_token(),playlist_id= playlist.jellyfin_id,provider_image_url= provider_playlist_data.images[0].url)
|
||||
|
||||
|
||||
|
||||
@@ -288,8 +210,58 @@ def _get_token_from_sessioncookie() -> str:
|
||||
def _get_api_token() -> str:
|
||||
#return app.config['JELLYFIN_ACCESS_TOKEN']
|
||||
return jellyfin_admin_token
|
||||
def _get_logged_in_user():
|
||||
def _get_logged_in_user() -> JellyfinUser:
|
||||
return JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||
def _get_admin_id():
|
||||
#return JellyfinUser.query.filter_by(is_admin=True).first().jellyfin_user_id
|
||||
return jellyfin_admin_id
|
||||
return jellyfin_admin_id
|
||||
|
||||
|
||||
def get_longest_substring(input_string):
|
||||
special_chars = ["'", "’", "‘", "‛", "`", "´", "‘"]
|
||||
pattern = "[" + re.escape("".join(special_chars)) + "]"
|
||||
substrings = re.split(pattern, input_string)
|
||||
longest_substring = max(substrings, key=len, default="")
|
||||
return longest_substring
|
||||
|
||||
@cache.memoize(timeout=3600*2)
|
||||
def get_latest_dev_releases(branch_name :str, commit_sha : str):
|
||||
try:
|
||||
response = requests.get('https://api.github.com/repos/kamilkosek/jellyplist/releases')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
latest_release = None
|
||||
for release in data:
|
||||
if branch_name in release['tag_name']:
|
||||
if latest_release is None or release['published_at'] > latest_release['published_at']:
|
||||
latest_release = release
|
||||
|
||||
if latest_release:
|
||||
response = requests.get(f'https://api.github.com/repos/kamilkosek/jellyplist/git/ref/tags/{latest_release["tag_name"]}')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if commit_sha != data['object']['sha'][:7]:
|
||||
return True, latest_release['html_url']
|
||||
|
||||
|
||||
return False, ''
|
||||
except requests.exceptions.RequestException as e:
|
||||
app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||
return False, ''
|
||||
|
||||
@cache.memoize(timeout=3600*2)
|
||||
def get_latest_release(tag_name :str):
|
||||
try:
|
||||
response = requests.get('https://api.github.com/repos/kamilkosek/jellyplist/releases/latest')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data['tag_name'] != tag_name:
|
||||
return True, data['html_url']
|
||||
return False, ''
|
||||
except requests.exceptions.RequestException as e:
|
||||
app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||
return False,''
|
||||
|
||||
def set_log_level(level):
|
||||
app.logger.setLevel(level)
|
||||
app.logger.info(f"Log level set to {level}")
|
||||
@@ -1,176 +0,0 @@
|
||||
import time
|
||||
from flask import Flask, jsonify, render_template, request, redirect, url_for, session, flash
|
||||
from sqlalchemy import insert
|
||||
from app import app, db, jellyfin, functions, device_id
|
||||
from app.models import Playlist,Track, playlist_tracks
|
||||
|
||||
|
||||
|
||||
from jellyfin.objects import PlaylistMetadata
|
||||
|
||||
|
||||
|
||||
@app.route('/jellyfin_playlists')
|
||||
@functions.jellyfin_login_required
|
||||
def jellyfin_playlists():
|
||||
try:
|
||||
# Fetch playlists from Jellyfin
|
||||
playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie())
|
||||
|
||||
# Extract Spotify playlist IDs from the database
|
||||
spotify_playlist_ids = []
|
||||
for pl in playlists:
|
||||
# Retrieve the playlist from the database using Jellyfin ID
|
||||
from_db = Playlist.query.filter_by(jellyfin_id=pl['Id']).first()
|
||||
if from_db and from_db.spotify_playlist_id:
|
||||
spotify_playlist_ids.append(from_db.spotify_playlist_id)
|
||||
else:
|
||||
app.logger.warning(f"No database entry found for Jellyfin playlist ID: {pl['Id']}")
|
||||
|
||||
if not spotify_playlist_ids:
|
||||
flash('No Spotify playlists found to display.', 'warning')
|
||||
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}}))
|
||||
|
||||
# Use the cached function to fetch Spotify playlists
|
||||
spotify_data = functions.get_cached_spotify_playlists(spotify_playlist_ids)
|
||||
|
||||
# Prepare the data for the template
|
||||
prepared_data = functions.prepPlaylistData(spotify_data)
|
||||
|
||||
return render_template('jellyfin_playlists.html', playlists=prepared_data)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error in /jellyfin_playlists route: {str(e)}")
|
||||
flash('An error occurred while fetching playlists.', 'danger')
|
||||
return render_template('jellyfin_playlists.html', playlists=functions.prepPlaylistData({'playlists': {'items': []}}))
|
||||
|
||||
|
||||
@app.route('/addplaylist', methods=['POST'])
|
||||
@functions.jellyfin_login_required
|
||||
def add_playlist():
|
||||
playlist_id = request.form.get('item_id') # HTMX sends the form data
|
||||
playlist_name = request.form.get('item_name') # Optionally retrieve playlist name from the form
|
||||
if not playlist_id:
|
||||
flash('No playlist ID provided')
|
||||
return ''
|
||||
|
||||
try:
|
||||
# Fetch playlist from Spotify API (or any relevant API)
|
||||
playlist_data = functions.get_cached_spotify_playlist(playlist_id)
|
||||
|
||||
# Check if playlist already exists in the database
|
||||
playlist = Playlist.query.filter_by(spotify_playlist_id=playlist_id).first()
|
||||
|
||||
if not playlist:
|
||||
# Add new playlist if it doesn't exist
|
||||
# create the playlist via api key, with the first admin as 'owner'
|
||||
fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data['name'],[],functions._get_admin_id())['Id']
|
||||
playlist = Playlist(name=playlist_data['name'], spotify_playlist_id=playlist_id,spotify_uri=playlist_data['uri'],track_count = playlist_data['tracks']['total'], tracks_available=0, jellyfin_id = fromJellyfin)
|
||||
db.session.add(playlist)
|
||||
db.session.commit()
|
||||
if app.config['START_DOWNLOAD_AFTER_PLAYLIST_ADD']:
|
||||
functions.manage_task('download_missing_tracks')
|
||||
|
||||
|
||||
# Get the logged-in user
|
||||
user = functions._get_logged_in_user()
|
||||
playlist.tracks_available = 0
|
||||
|
||||
# Add tracks to the playlist with track order
|
||||
for idx, track_data in enumerate(playlist_data['tracks']['items']):
|
||||
track_info = track_data['track']
|
||||
if not track_info:
|
||||
continue
|
||||
track = Track.query.filter_by(spotify_track_id=track_info['id']).first()
|
||||
|
||||
if not track:
|
||||
# Add new track if it doesn't exist
|
||||
track = Track(name=track_info['name'], spotify_track_id=track_info['id'], spotify_uri=track_info['uri'], downloaded=False)
|
||||
db.session.add(track)
|
||||
db.session.commit()
|
||||
elif track.downloaded:
|
||||
playlist.tracks_available += 1
|
||||
db.session.commit()
|
||||
|
||||
# Add track to playlist with order if it's not already associated
|
||||
if track not in playlist.tracks:
|
||||
# Insert into playlist_tracks with track order
|
||||
stmt = insert(playlist_tracks).values(
|
||||
playlist_id=playlist.id,
|
||||
track_id=track.id,
|
||||
track_order=idx # Maintain the order of tracks
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
db.session.commit()
|
||||
|
||||
update_playlist_metadata(playlist,playlist_data)
|
||||
|
||||
if playlist not in user.playlists:
|
||||
user.playlists.append(playlist)
|
||||
db.session.commit()
|
||||
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(),playlist_id = playlist.jellyfin_id,user_ids= [user.jellyfin_user_id])
|
||||
flash(f'Playlist "{playlist_data["name"]}" successfully added','success')
|
||||
|
||||
else:
|
||||
flash(f'Playlist "{playlist_data["name"]}" already in your list')
|
||||
item = {
|
||||
"name" : playlist_data["name"],
|
||||
"id" : playlist_id,
|
||||
"can_add":False,
|
||||
"can_remove":True,
|
||||
"jellyfin_id" : playlist.jellyfin_id
|
||||
}
|
||||
return render_template('partials/_add_remove_button.html',item= item)
|
||||
|
||||
|
||||
|
||||
|
||||
except Exception as e:
|
||||
flash(str(e))
|
||||
|
||||
|
||||
@app.route('/delete_playlist/<playlist_id>', methods=['DELETE'])
|
||||
@functions.jellyfin_login_required
|
||||
def delete_playlist(playlist_id):
|
||||
# Logic to delete the playlist using JellyfinClient
|
||||
try:
|
||||
user = functions._get_logged_in_user()
|
||||
for pl in user.playlists:
|
||||
if pl.jellyfin_id == playlist_id:
|
||||
user.playlists.remove(pl)
|
||||
playlist = pl
|
||||
jellyfin.remove_user_from_playlist(session_token= functions._get_api_token(), playlist_id= playlist_id, user_id=user.jellyfin_user_id)
|
||||
db.session.commit()
|
||||
flash('Playlist removed')
|
||||
item = {
|
||||
"name" : playlist.name,
|
||||
"id" : playlist.spotify_playlist_id,
|
||||
"can_add":True,
|
||||
"can_remove":False,
|
||||
"jellyfin_id" : playlist.jellyfin_id
|
||||
}
|
||||
return render_template('partials/_add_remove_button.html',item= item)
|
||||
except Exception as e:
|
||||
flash(f'Failed to remove item: {str(e)}')
|
||||
|
||||
|
||||
|
||||
|
||||
@functions.jellyfin_login_required
|
||||
@app.route('/get_jellyfin_stream/<string:jellyfin_id>')
|
||||
def get_jellyfin_stream(jellyfin_id):
|
||||
user_id = session['jellyfin_user_id'] # Beispiel: dynamischer Benutzer
|
||||
api_key = functions._get_token_from_sessioncookie() # Beispiel: dynamischer API-Schlüssel
|
||||
stream_url = f"{app.config['JELLYFIN_SERVER_URL']}/Audio/{jellyfin_id}/universal?UserId={user_id}&DeviceId={device_id}&MaxStreamingBitrate=140000000&Container=opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg&TranscodingContainer=mp4&TranscodingProtocol=hls&AudioCodec=aac&api_key={api_key}&PlaySessionId={int(time.time())}&StartTimeTicks=0&EnableRedirection=true&EnableRemoteMedia=false"
|
||||
return jsonify({'stream_url': stream_url})
|
||||
|
||||
@app.route('/search_jellyfin', methods=['GET'])
|
||||
@functions.jellyfin_login_required
|
||||
def search_jellyfin():
|
||||
search_query = request.args.get('search_query')
|
||||
spotify_id = request.args.get('spotify_id')
|
||||
if search_query:
|
||||
results = jellyfin.search_music_tracks(functions._get_token_from_sessioncookie(), search_query)
|
||||
# Render only the search results section as response
|
||||
return render_template('partials/_jf_search_results.html', results=results,spotify_id= spotify_id)
|
||||
return jsonify({'error': 'No search query provided'}), 400
|
||||
@@ -22,8 +22,8 @@ user_playlists = db.Table('user_playlists',
|
||||
class Playlist(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
spotify_playlist_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
spotify_uri = db.Column(db.String(120), unique=True, nullable=False)
|
||||
provider_playlist_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
provider_uri = db.Column(db.String(120), unique=True, nullable=False)
|
||||
|
||||
# Relationship with Tracks
|
||||
tracks = db.relationship('Track', secondary='playlist_tracks', back_populates='playlists')
|
||||
@@ -35,9 +35,10 @@ class Playlist(db.Model):
|
||||
snapshot_id = db.Column(db.String(120), nullable=True)
|
||||
# Many-to-Many relationship with JellyfinUser
|
||||
users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists')
|
||||
provider_id = db.Column(db.String(20))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Playlist {self.name}:{self.spotify_playlist_id}>'
|
||||
return f'<Playlist {self.name}:{self.provider_playlist_id}>'
|
||||
|
||||
# Association table between Playlists and Tracks
|
||||
playlist_tracks = db.Table('playlist_tracks',
|
||||
@@ -49,15 +50,20 @@ playlist_tracks = db.Table('playlist_tracks',
|
||||
|
||||
class Track(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
spotify_track_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
spotify_uri = db.Column(db.String(120), unique=True, nullable=False)
|
||||
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())
|
||||
filesystem_path = db.Column(db.String(), nullable=True)
|
||||
jellyfin_id = db.Column(db.String(120), nullable=True) # Add Jellyfin track ID field
|
||||
download_status = db.Column(db.String(2048), nullable=True)
|
||||
provider_id = db.Column(db.String(20))
|
||||
|
||||
|
||||
# Many-to-Many relationship with Playlists
|
||||
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
|
||||
|
||||
lidarr_processed = db.Column(db.Boolean(), default=False)
|
||||
quality_score = db.Column(db.Float(), default=0)
|
||||
def __repr__(self):
|
||||
return f'<Track {self.name}:{self.spotify_track_id}>'
|
||||
return f'<Track {self.name}:{self.provider_track_id}>'
|
||||
|
||||
4
app/providers/__init__.py
Normal file
4
app/providers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .spotify import SpotifyClient
|
||||
#from .deezer import DeezerClient
|
||||
|
||||
__all__ = ["SpotifyClient"]
|
||||
173
app/providers/base.py
Normal file
173
app/providers/base.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalUrl:
|
||||
url: str
|
||||
|
||||
@dataclass
|
||||
class ItemBase:
|
||||
id: str
|
||||
name: str
|
||||
uri: str
|
||||
external_urls: Optional[List[ExternalUrl]]
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
avatar: Optional[str] # Avatar URL or None
|
||||
avatar_background_color: Optional[int]
|
||||
name: str
|
||||
uri: str
|
||||
username: str
|
||||
|
||||
@dataclass
|
||||
class AccountAttributes:
|
||||
catalogue: str
|
||||
dsa_mode_available: bool
|
||||
dsa_mode_enabled: bool
|
||||
multi_user_plan_current_size: Optional[int]
|
||||
multi_user_plan_member_type: Optional[str]
|
||||
on_demand: bool
|
||||
opt_in_trial_premium_only_market: bool
|
||||
country: str
|
||||
product: str
|
||||
|
||||
@dataclass
|
||||
class Image:
|
||||
url: str
|
||||
height: Optional[int]
|
||||
width: Optional[int]
|
||||
|
||||
@dataclass
|
||||
class Artist(ItemBase):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class Album(ItemBase):
|
||||
artists: List[Artist]
|
||||
images: List[Image]
|
||||
|
||||
@dataclass
|
||||
class Track(ItemBase):
|
||||
duration_ms: int
|
||||
explicit: Optional[bool]
|
||||
album: Optional[Album]
|
||||
artists: List[Artist]
|
||||
|
||||
@dataclass
|
||||
class PlaylistTrack:
|
||||
added_at: Optional[str]
|
||||
added_by: Optional[str]
|
||||
is_local: bool
|
||||
track: Track
|
||||
|
||||
@dataclass
|
||||
class Owner(ItemBase):
|
||||
pass
|
||||
|
||||
@dataclass #tbc
|
||||
class Category(ItemBase):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class Playlist(ItemBase):
|
||||
|
||||
description: Optional[str]
|
||||
public: Optional[bool]
|
||||
collaborative: Optional[bool]
|
||||
followers: Optional[int]
|
||||
images: Optional[List[Image]]
|
||||
owner: Optional[Owner]
|
||||
tracks: List[PlaylistTrack] = field(default_factory=list)
|
||||
@dataclass
|
||||
class BrowseCard:
|
||||
title: str
|
||||
uri: str
|
||||
background_color: str
|
||||
artwork: List[Image]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowseSection:
|
||||
title: str
|
||||
items: List[BrowseCard]
|
||||
uri: str
|
||||
|
||||
# Abstract base class for music providers
|
||||
class MusicProviderClient(ABC):
|
||||
"""
|
||||
Abstract base class defining the interface for music provider clients.
|
||||
"""
|
||||
@property
|
||||
@abstractmethod
|
||||
def _identifier(self) -> str:
|
||||
"""
|
||||
A unique identifier for the music provider.
|
||||
Must be implemented by all subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def authenticate(self, credentials: dict) -> None:
|
||||
"""
|
||||
Authenticates the client with the provider using the provided credentials.
|
||||
:param credentials: A dictionary containing credentials (e.g., API keys, tokens).
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_playlist(self, playlist_id: str) -> Playlist:
|
||||
"""
|
||||
Fetches a playlist by its ID.
|
||||
:param playlist_id: The ID of the playlist to fetch.
|
||||
:return: A Playlist object.
|
||||
"""
|
||||
pass
|
||||
@abstractmethod
|
||||
def extract_playlist_id(self, uri: str) -> str:
|
||||
"""
|
||||
Extracts the playlist ID from a playlist URI.
|
||||
:param uri: The playlist URI.
|
||||
:return: The playlist ID.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]:
|
||||
"""
|
||||
Searches for tracks based on a query string.
|
||||
:param query: The search query.
|
||||
:param limit: Maximum number of results to return.
|
||||
:return: A list of Track objects.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_track(self, track_id: str) -> Track:
|
||||
"""
|
||||
Fetches details for a specific track.
|
||||
:param track_id: The ID of the track to fetch.
|
||||
:return: A Track object.
|
||||
"""
|
||||
pass
|
||||
@abstractmethod
|
||||
def browse(self, **kwargs) -> List[BrowseSection]:
|
||||
"""
|
||||
Generic browse method for the music provider.
|
||||
:param kwargs: Variable keyword arguments to support different browse parameters
|
||||
:return: A dictionary containing browse results
|
||||
"""
|
||||
pass
|
||||
@abstractmethod
|
||||
def browse_page(self, uri: str) -> List[Playlist]:
|
||||
"""
|
||||
Fetches a specific page of browse results.
|
||||
:param uri: The uri to query.
|
||||
:return: A list of Playlist objects.
|
||||
"""
|
||||
pass
|
||||
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
|
||||
|
||||
708
app/providers/spotify.py
Normal file
708
app/providers/spotify.py
Normal file
@@ -0,0 +1,708 @@
|
||||
import os
|
||||
from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category
|
||||
import base64
|
||||
|
||||
import json
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlencode
|
||||
from typing import List, Dict, Optional
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
import logging
|
||||
|
||||
from typing import Callable, Tuple
|
||||
import hmac
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
l = logging.getLogger(__name__)
|
||||
|
||||
_TOTP_SECRET = bytearray([53,53,48,55,49,52,53,56,53,51,52,56,55,52,57,57,53,57,50,50,52,56,54,51,48,51,50,57,51,52,55])
|
||||
|
||||
def generate_totp(
|
||||
secret: bytes = _TOTP_SECRET,
|
||||
algorithm: Callable[[], object] = hashlib.sha1,
|
||||
digits: int = 6,
|
||||
counter_factory: Callable[[], int] = lambda: int(time.time()) // 30,
|
||||
) -> Tuple[str, int]:
|
||||
counter = counter_factory()
|
||||
hmac_result = hmac.new(
|
||||
secret, counter.to_bytes(8, byteorder="big"), algorithm # type: ignore
|
||||
).digest()
|
||||
|
||||
offset = hmac_result[-1] & 15
|
||||
truncated_value = (
|
||||
(hmac_result[offset] & 127) << 24
|
||||
| (hmac_result[offset + 1] & 255) << 16
|
||||
| (hmac_result[offset + 2] & 255) << 8
|
||||
| (hmac_result[offset + 3] & 255)
|
||||
)
|
||||
return (
|
||||
str(truncated_value % (10**digits)).zfill(digits),
|
||||
counter * 30_000,
|
||||
) # (30 * 1000)
|
||||
|
||||
class SpotifyClient(MusicProviderClient):
|
||||
"""
|
||||
Spotify implementation of the MusicProviderClient.
|
||||
"""
|
||||
@property
|
||||
def _identifier(self) -> str:
|
||||
return "Spotify"
|
||||
|
||||
def __init__(self, cookie_file: Optional[str] = None):
|
||||
self.base_url = "https://api-partner.spotify.com"
|
||||
self.app_server_config_data = None
|
||||
self.client_token = None
|
||||
self.access_token = None
|
||||
self.client_id = None
|
||||
self.cookies = None
|
||||
if cookie_file:
|
||||
self._load_cookies(cookie_file)
|
||||
|
||||
def _load_cookies(self, cookie_file: str) -> None:
|
||||
"""
|
||||
Load cookies from a file.
|
||||
|
||||
:param cookie_file: Path to the cookie file.
|
||||
"""
|
||||
if not os.path.exists(cookie_file):
|
||||
l.error(f"Cookie file not found: {cookie_file}")
|
||||
raise FileNotFoundError(f"Cookie file not found: {cookie_file}")
|
||||
|
||||
cookie_jar = MozillaCookieJar(cookie_file)
|
||||
cookie_jar.load(ignore_discard=True, ignore_expires=True)
|
||||
self.cookies = requests.utils.dict_from_cookiejar(cookie_jar)
|
||||
|
||||
def authenticate(self, credentials: Optional[dict] = None) -> None:
|
||||
"""
|
||||
Authenticate with Spotify using cookies if available, or fetch session and config data.
|
||||
|
||||
:param credentials: Optional dictionary of credentials.
|
||||
"""
|
||||
if self.cookies:
|
||||
l.debug("Authenticating using cookies.")
|
||||
self.app_server_config_data = self._fetch_app_server_config_data()
|
||||
else:
|
||||
l.debug("Authenticating without cookies.")
|
||||
self.app_server_config_data = self._fetch_app_server_config_data(fetch_with_cookies=False)
|
||||
|
||||
self._get_access_token_and_client_id()
|
||||
self.client_token = self._fetch_client_token()
|
||||
|
||||
def _fetch_app_server_config_data(self, fetch_with_cookies: bool = True):
|
||||
"""
|
||||
Fetch app_server_config data from Spotify.
|
||||
|
||||
We will use the correlationId from app_server_config_data for a successful _fetch_client_token call
|
||||
|
||||
:param fetch_with_cookies: Whether to include cookies in the request.
|
||||
:return: Tuple containing session and config data.
|
||||
"""
|
||||
url = 'https://open.spotify.com/'
|
||||
headers = {
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
}
|
||||
cookies = self.cookies if fetch_with_cookies else None
|
||||
response = requests.get(url, headers=headers, cookies=cookies)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
app_server_config_script = soup.find('script', {'id': 'appServerConfig'}).text # decode JWT to obtain correlation required for obtain Bearer Token
|
||||
decoded_app_server_config_script = base64.b64decode(app_server_config_script) # base64 decode
|
||||
decoded_app_server_config_script = decoded_app_server_config_script.decode().strip("b'") # decode from byte object to string object
|
||||
if decoded_app_server_config_script:
|
||||
l.debug("fetched app_server_config_script scripts")
|
||||
return json.loads(decoded_app_server_config_script)
|
||||
else:
|
||||
raise ValueError("Failed to fetch app_server_config data.")
|
||||
|
||||
def _fetch_client_token(self):
|
||||
"""
|
||||
Fetch the client token using session data and cookies.
|
||||
|
||||
:return: The client token as a string.
|
||||
"""
|
||||
url = f'https://clienttoken.spotify.com/v1/clienttoken'
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'origin': 'https://open.spotify.com',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
}
|
||||
payload = {
|
||||
"client_data": {
|
||||
"client_version": "1.2.52.404.gcb99a997",
|
||||
"client_id": self.client_id,
|
||||
"js_sdk_data": {
|
||||
"device_brand": "unknown",
|
||||
"device_model": "unknown",
|
||||
"os": "windows",
|
||||
"os_version": "NT 10.0",
|
||||
"device_id": self.app_server_config_data.get("correlationId", ""),
|
||||
"device_type": "computer"
|
||||
}
|
||||
}
|
||||
}
|
||||
response = requests.post(url, headers=headers, json=payload, cookies=self.cookies)
|
||||
response.raise_for_status()
|
||||
l.debug("fetched client_token (granted_token)")
|
||||
return response.json().get("granted_token", "")
|
||||
|
||||
def _get_access_token_and_client_id(self, fetch_with_cookies: bool = True):
|
||||
"""
|
||||
Fetch the Access Token and Client ID by making GET call to open.spotify.com/get_access_token
|
||||
"""
|
||||
url = f'https://open.spotify.com/get_access_token'
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'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',
|
||||
}
|
||||
|
||||
totp, timestamp = generate_totp()
|
||||
|
||||
query_params = {
|
||||
"reason": "init",
|
||||
"productType": "web-player",
|
||||
"totp": totp,
|
||||
"totpVer": 5,
|
||||
"ts": timestamp,
|
||||
}
|
||||
cookies = self.cookies if fetch_with_cookies else None
|
||||
|
||||
response = requests.get(url, params=query_params, headers=headers, cookies=self.cookies)
|
||||
response.raise_for_status()
|
||||
self.client_id = response.json().get("clientId", "")
|
||||
self.access_token = response.json().get("accessToken", "")
|
||||
l.debug("fetched access_token and client_id")
|
||||
|
||||
def _make_request(self, endpoint: str, params: dict = None) -> dict:
|
||||
"""
|
||||
Helper method to make authenticated requests to Spotify APIs.
|
||||
"""
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'app-platform': 'WebPlayer',
|
||||
'authorization': f'Bearer {self.access_token}',
|
||||
'client-token': self.client_token.get('token','')
|
||||
}
|
||||
l.debug(f"starting request: {self.base_url}/{endpoint}")
|
||||
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
|
||||
# if the response is unauthorized, we need to reauthenticate
|
||||
if response.status_code == 401:
|
||||
l.debug("reauthenticating")
|
||||
self.authenticate()
|
||||
headers['authorization'] = f'Bearer {self.access_token}'
|
||||
headers['client-token'] = self.client_token.get('token','')
|
||||
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
#region utility functions to help parsing objects
|
||||
def _parse_external_urls(self, uri: str, entity_type: str) -> List[ExternalUrl]:
|
||||
"""
|
||||
Create ExternalUrl instances for an entity.
|
||||
|
||||
:param uri: The URI of the entity.
|
||||
:param entity_type: The type of entity ('track', 'album', 'artist', 'playlist', etc.).
|
||||
:return: A list of ExternalUrl instances.
|
||||
"""
|
||||
return [ExternalUrl(url=f"https://open.spotify.com/{entity_type}/{uri.split(':')[-1]}")]
|
||||
|
||||
|
||||
def _parse_images(self, image_data: List[Dict]) -> List[Image]:
|
||||
"""
|
||||
Parse images from the API response.
|
||||
|
||||
:param image_data: List of dictionaries containing image data.
|
||||
:return: A list of Image objects.
|
||||
"""
|
||||
images = []
|
||||
for img in image_data:
|
||||
# Extract the first source if available
|
||||
sources = img.get("sources", [])
|
||||
if sources:
|
||||
source = sources[0] # Take the first source as the default
|
||||
images.append(Image(
|
||||
url=source.get("url"),
|
||||
height=source.get("height"),
|
||||
width=source.get("width")
|
||||
))
|
||||
return images
|
||||
|
||||
|
||||
def _parse_artist(self, artist_data: Dict) -> Artist:
|
||||
"""
|
||||
Parse an artist object from API data.
|
||||
|
||||
:param artist_data: Dictionary representing an artist.
|
||||
:return: An Artist instance.
|
||||
"""
|
||||
return Artist(
|
||||
id=artist_data["uri"].split(":")[-1],
|
||||
name=artist_data["profile"]["name"],
|
||||
uri=artist_data["uri"],
|
||||
external_urls=self._parse_external_urls(artist_data["uri"], "artist")
|
||||
)
|
||||
|
||||
|
||||
def _parse_album(self, album_data: Dict) -> Album:
|
||||
"""
|
||||
Parse an album object from API data.
|
||||
|
||||
:param album_data: Dictionary representing an album.
|
||||
:return: An Album instance.
|
||||
"""
|
||||
artists = []
|
||||
if album_data.get("artists"):
|
||||
artists = [self._parse_artist(artist) for artist in album_data.get("artists").get('items', [])]
|
||||
return Album(
|
||||
id=album_data["uri"].split(":")[-1],
|
||||
name=album_data["name"],
|
||||
uri=album_data["uri"],
|
||||
external_urls=self._parse_external_urls(album_data["uri"], "album"),
|
||||
artists=artists,
|
||||
images=self._parse_images(album_data["coverArt"]["sources"])
|
||||
)
|
||||
|
||||
|
||||
def _parse_track(self, track_data: Dict) -> Track:
|
||||
"""
|
||||
Parse a track object from API data.
|
||||
|
||||
:param track_data: Dictionary representing a track.
|
||||
:return: A Track instance.
|
||||
"""
|
||||
duration_ms = 0
|
||||
aritsts = []
|
||||
if track_data.get("duration"):
|
||||
duration_ms = int(track_data.get("duration", 0).get("totalMilliseconds", 0))
|
||||
elif track_data.get("trackDuration"):
|
||||
duration_ms = track_data["trackDuration"]["totalMilliseconds"]
|
||||
|
||||
if track_data.get("firstArtist"):
|
||||
for artist in track_data.get("firstArtist").get('items', []):
|
||||
aritsts.append(self._parse_artist(artist))
|
||||
elif track_data.get("artists"):
|
||||
for artist in track_data.get("artists").get('items', []):
|
||||
aritsts.append(self._parse_artist(artist))
|
||||
|
||||
if track_data.get("albumOfTrack"):
|
||||
album = self._parse_album(track_data["albumOfTrack"])
|
||||
|
||||
|
||||
return Track(
|
||||
id=track_data["uri"].split(":")[-1],
|
||||
name=track_data["name"],
|
||||
uri=track_data["uri"],
|
||||
external_urls=self._parse_external_urls(track_data["uri"], "track"),
|
||||
duration_ms=duration_ms,
|
||||
explicit=track_data.get("explicit", False),
|
||||
album=self._parse_album(track_data["albumOfTrack"]),
|
||||
artists=aritsts
|
||||
)
|
||||
def _parse_owner(self, owner_data: Dict) -> Optional[Owner]:
|
||||
"""
|
||||
Parse an owner object from API data.
|
||||
|
||||
:param owner_data: Dictionary representing an owner.
|
||||
:return: An Owner instance or None if the owner data is empty.
|
||||
"""
|
||||
if not owner_data:
|
||||
return None
|
||||
|
||||
return Owner(
|
||||
id=owner_data.get("uri", "").split(":")[-1],
|
||||
name=owner_data.get("name", ""),
|
||||
uri=owner_data.get("uri", ""),
|
||||
external_urls=self._parse_external_urls(owner_data.get("uri", ""), "user")
|
||||
)
|
||||
def _parse_card_artwork(self, sources: List[Dict]) -> List[Image]:
|
||||
"""
|
||||
Parse artwork for a browse card.
|
||||
|
||||
:param sources: List of artwork source dictionaries.
|
||||
:return: A list of CardArtwork instances.
|
||||
"""
|
||||
return [Image(url=source["url"], height=source.get("height"), width=source.get("width")) for source in sources]
|
||||
|
||||
|
||||
def _parse_browse_card(self, card_data: Dict) -> BrowseCard:
|
||||
"""
|
||||
Parse a single browse card.
|
||||
|
||||
:param card_data: Dictionary containing card data.
|
||||
:return: A BrowseCard instance.
|
||||
"""
|
||||
card_content = card_data["content"]["data"]["data"]["cardRepresentation"]
|
||||
artwork_sources = card_content["artwork"]["sources"]
|
||||
|
||||
return BrowseCard(
|
||||
title=card_content["title"]["transformedLabel"],
|
||||
uri=card_data["uri"],
|
||||
background_color=card_content["backgroundColor"]["hex"],
|
||||
artwork=self._parse_card_artwork(artwork_sources)
|
||||
)
|
||||
|
||||
def _parse_playlist(self, playlist_data: Dict) -> Playlist:
|
||||
"""
|
||||
Parse a playlist object from API response data.
|
||||
|
||||
:param playlist_data: Dictionary containing playlist data.
|
||||
:return: A Playlist object.
|
||||
"""
|
||||
images = self._parse_images(playlist_data.get("images", {}).get("items", []))
|
||||
|
||||
owner_data = playlist_data.get("ownerV2", {}).get("data", {})
|
||||
owner = self._parse_owner(owner_data)
|
||||
|
||||
|
||||
valid_tracks = []
|
||||
for item in playlist_data.get("content", {}).get("items", []):
|
||||
data = item.get("itemV2", {}).get("data", {})
|
||||
uri = data.get("uri", "")
|
||||
if uri.startswith("spotify:track"):
|
||||
valid_tracks.append(self._parse_track(data))
|
||||
tracks = valid_tracks
|
||||
|
||||
return Playlist(
|
||||
id=playlist_data.get("uri", "").split(":")[-1],
|
||||
name=playlist_data.get("name", ""),
|
||||
uri=playlist_data.get("uri", ""),
|
||||
external_urls=self._parse_external_urls(playlist_data.get("uri", "").split(":")[-1], "playlist"),
|
||||
description=playlist_data.get("description", ""),
|
||||
public=playlist_data.get("public", None),
|
||||
collaborative=playlist_data.get("collaborative", None),
|
||||
followers=playlist_data.get("followers", 0),
|
||||
images=images,
|
||||
owner=owner,
|
||||
tracks=[
|
||||
PlaylistTrack(
|
||||
added_at=item.get("addedAt", {}).get("isoString", ""),
|
||||
added_by=None,
|
||||
is_local=False,
|
||||
track=track
|
||||
)
|
||||
for item, track in zip(
|
||||
playlist_data.get("content", {}).get("items", []),
|
||||
tracks
|
||||
)
|
||||
]
|
||||
)
|
||||
def _parse_browse_section(self, section_data: Dict) -> BrowseSection:
|
||||
"""
|
||||
Parse a single browse section.
|
||||
|
||||
:param section_data: Dictionary containing section data.
|
||||
:return: A BrowseSection instance.
|
||||
"""
|
||||
section_title = section_data["data"]["title"]["transformedLabel"]
|
||||
section_items = [
|
||||
item for item in section_data["sectionItems"]["items"]
|
||||
if not item["uri"].startswith("spotify:xlink")
|
||||
]
|
||||
return BrowseSection(
|
||||
title=section_title,
|
||||
items=[self._parse_browse_card(item) for item in section_items],
|
||||
uri=section_data["uri"]
|
||||
)
|
||||
|
||||
#endregion
|
||||
|
||||
def get_playlist(self, playlist_id: str) -> Playlist:
|
||||
"""
|
||||
Fetch a playlist by ID with all tracks.
|
||||
"""
|
||||
limit = 50
|
||||
offset = 0
|
||||
all_items = []
|
||||
|
||||
while True:
|
||||
query_parameters = {
|
||||
"operationName": "fetchPlaylist",
|
||||
"variables": json.dumps({
|
||||
"uri": f"spotify:playlist:{playlist_id}",
|
||||
"offset": offset,
|
||||
"limit": limit
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "19ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
data = self._make_request(f"pathfinder/v1/query?{encoded_query}")
|
||||
playlist_data = data.get('data', {}).get('playlistV2', {})
|
||||
content = playlist_data.get('content', {})
|
||||
items = content.get('items', [])
|
||||
all_items.extend(items)
|
||||
|
||||
if len(all_items) >= content.get('totalCount', 0):
|
||||
break
|
||||
|
||||
offset += limit
|
||||
|
||||
playlist_data["content"]["items"] = all_items
|
||||
return self._parse_playlist(playlist_data)
|
||||
|
||||
def extract_playlist_id(self, uri: str) -> str:
|
||||
"""
|
||||
Extract the playlist ID from a Spotify URI.
|
||||
"""
|
||||
# check whether the uri is a full url with https or just a uri
|
||||
if uri.startswith("https://open.spotify.com/"):
|
||||
#if it starts with https, we need to extract the playlist id from the url
|
||||
return uri.split('/')[-1]
|
||||
elif uri.startswith("spotify:playlist:"):
|
||||
return uri.split(':')[-1]
|
||||
else :
|
||||
raise ValueError("Invalid Spotify URI.")
|
||||
|
||||
def search_playlist(self, query: str, limit: int = 50) -> List[Playlist]:
|
||||
"""
|
||||
Searches for playlists on Spotify.
|
||||
:param query: Search query.
|
||||
:param limit: Maximum number of results.
|
||||
:return: A list of Playlist objects.
|
||||
"""
|
||||
query_parameters = {
|
||||
"operationName": "searchDesktop",
|
||||
"variables": json.dumps({
|
||||
"searchTerm": query,
|
||||
"offset": 0,
|
||||
"limit": limit,
|
||||
"numberOfTopResults": 5,
|
||||
"includeAudiobooks": False,
|
||||
"includeArtistHasConcertsField": False,
|
||||
"includePreReleases": False,
|
||||
"includeLocalConcertsField": False
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "f1f1c151cd392433ef4d2683a10deb9adeefd660f29692d8539ce450d2dfdb96"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
search_data = response.get("data", {}).get("searchV2", {})
|
||||
playlists_data = search_data.get("playlists", {}).get("items", [])
|
||||
|
||||
playlists = [self._parse_playlist(item["data"]) for item in playlists_data]
|
||||
return playlists
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while searching for playlists: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_track(self, track_id: str) -> Track:
|
||||
"""
|
||||
Fetches details for a specific track.
|
||||
:param track_id: The ID of the track.
|
||||
:return: A Track object.
|
||||
"""
|
||||
query_parameters = {
|
||||
"operationName": "getTrack",
|
||||
"variables": json.dumps({
|
||||
"uri": f"spotify:track:{track_id}"
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "5c5ec8c973a0ac2d5b38d7064056c45103c5a062ee12b62ce683ab397b5fbe7d"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
track_data = response.get("data", {}).get("trackUnion", {})
|
||||
return self._parse_track(track_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching the track: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# non generic method implementations:
|
||||
def get_profile(self) -> Optional[Profile]:
|
||||
"""
|
||||
Fetch the profile attributes of the authenticated Spotify user.
|
||||
|
||||
:return: A Profile object containing the user's profile information or None if an error occurs.
|
||||
"""
|
||||
query_parameters = {
|
||||
"operationName": "profileAttributes",
|
||||
"variables": json.dumps({}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "53bcb064f6cd18c23f752bc324a791194d20df612d8e1239c735144ab0399ced"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
encoded_query = urlencode(query_parameters)
|
||||
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
profile_data = response.get('data', {}).get('me', {}).get('profile', {})
|
||||
if not profile_data:
|
||||
raise ValueError("Invalid profile data received.")
|
||||
return Profile(
|
||||
avatar=profile_data.get("avatar"),
|
||||
avatar_background_color=profile_data.get("avatarBackgroundColor"),
|
||||
name=profile_data.get("name", ""),
|
||||
uri=profile_data.get("uri", ""),
|
||||
username=profile_data.get("username", "")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching profile attributes: {e}")
|
||||
return None
|
||||
def get_account_attributes(self) -> Optional[AccountAttributes]:
|
||||
"""
|
||||
Fetch the account attributes of the authenticated Spotify user.
|
||||
|
||||
:return: An AccountAttributes object containing the user's account information or None if an error occurs.
|
||||
"""
|
||||
# Define the query parameters
|
||||
query_parameters = {
|
||||
"operationName": "accountAttributes",
|
||||
"variables": json.dumps({}), # Empty variables for this query
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "4fbd57be3c6ec2157adcc5b8573ec571f61412de23bbb798d8f6a156b7d34cdf"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Encode the query parameters
|
||||
encoded_query = urlencode(query_parameters)
|
||||
|
||||
# API endpoint
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
# Perform the request
|
||||
response = self._make_request(url)
|
||||
|
||||
# Extract and validate the account data
|
||||
account_data = response.get('data', {}).get('me', {}).get('account', {})
|
||||
attributes = account_data.get("attributes", {})
|
||||
if not attributes or not account_data.get("country") or not account_data.get("product"):
|
||||
raise ValueError("Invalid account data received.")
|
||||
|
||||
# Map the response to the AccountAttributes class
|
||||
return AccountAttributes(
|
||||
catalogue=attributes.get("catalogue", ""),
|
||||
dsa_mode_available=attributes.get("dsaModeAvailable", False),
|
||||
dsa_mode_enabled=attributes.get("dsaModeEnabled", False),
|
||||
multi_user_plan_current_size=attributes.get("multiUserPlanCurrentSize"),
|
||||
multi_user_plan_member_type=attributes.get("multiUserPlanMemberType"),
|
||||
on_demand=attributes.get("onDemand", False),
|
||||
opt_in_trial_premium_only_market=attributes.get("optInTrialPremiumOnlyMarket", False),
|
||||
country=account_data.get("country", ""),
|
||||
product=account_data.get("product", "")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching account attributes: {e}")
|
||||
return None
|
||||
def browse(self, **kwargs) -> List[BrowseSection]:
|
||||
"""
|
||||
Fetch all browse sections with cards.
|
||||
|
||||
:param kwargs: Keyword arguments. Supported:
|
||||
- page_limit: Maximum number of pages to fetch (default: 50)
|
||||
- section_limit: Maximum number of sections per page (default: 99)
|
||||
:return: A list of BrowseSection objects.
|
||||
"""
|
||||
page_limit = kwargs.get('page_limit', 50)
|
||||
section_limit = kwargs.get('section_limit', 99)
|
||||
query_parameters = {
|
||||
"operationName": "browseAll",
|
||||
"variables": json.dumps({
|
||||
"pagePagination": {"offset": 0, "limit": page_limit},
|
||||
"sectionPagination": {"offset": 0, "limit": section_limit}
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "cd6fcd0ce9d1849477645646601a6d444597013355467e24066dad2c1dc9b740"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
browse_data = response.get("data", {}).get("browseStart", {}).get("sections", {})
|
||||
sections = browse_data.get("items", [])
|
||||
|
||||
return [self._parse_browse_section(section) for section in sections]
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching browse sections: {e}")
|
||||
return []
|
||||
|
||||
def browse_page(self, uri: str) -> List[Playlist]:
|
||||
"""
|
||||
Fetch the content of a browse page using the URI.
|
||||
|
||||
:param uri: Should start with 'spotify:page'.
|
||||
:return: A list of Playlist objects from the browse page.
|
||||
"""
|
||||
|
||||
if not uri or not uri.startswith("spotify:page"):
|
||||
raise ValueError("The 'uri' parameter must be provided and start with 'spotify:page'.")
|
||||
|
||||
query_parameters = {
|
||||
"operationName": "browsePage",
|
||||
"variables": json.dumps({
|
||||
"pagePagination": {"offset": 0, "limit": 10},
|
||||
"sectionPagination": {"offset": 0, "limit": 10},
|
||||
"uri": uri
|
||||
}),
|
||||
"extensions": json.dumps({
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "d8346883162a16a62a5b69e73e70c66a68c27b14265091cd9e1517f48334bbb3"
|
||||
}
|
||||
})
|
||||
}
|
||||
encoded_query = urlencode(query_parameters)
|
||||
url = f"pathfinder/v1/query?{encoded_query}"
|
||||
|
||||
try:
|
||||
response = self._make_request(url)
|
||||
browse_data = response.get("data", {}).get("browse", {})
|
||||
sections = browse_data.get("sections", {}).get("items", [])
|
||||
|
||||
playlists = []
|
||||
for section in sections:
|
||||
section_items = section.get("sectionItems", {}).get("items", [])
|
||||
for item in section_items:
|
||||
content = item.get("content", {}).get("data", {})
|
||||
if content.get("__typename") == "Playlist":
|
||||
playlists.append(self._parse_playlist(content))
|
||||
|
||||
return playlists
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while fetching the browse page: {e}")
|
||||
return []
|
||||
3
app/registry/__init__.py
Normal file
3
app/registry/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .music_provider_registry import MusicProviderRegistry
|
||||
|
||||
__all__ = ["MusicProviderRegistry"]
|
||||
35
app/registry/music_provider_registry.py
Normal file
35
app/registry/music_provider_registry.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from app.providers.base import MusicProviderClient
|
||||
|
||||
|
||||
class MusicProviderRegistry:
|
||||
"""
|
||||
Registry to manage and retrieve music provider clients.
|
||||
"""
|
||||
_providers = {}
|
||||
|
||||
@classmethod
|
||||
def register_provider(cls, provider: MusicProviderClient):
|
||||
"""
|
||||
Registers a music provider client instance.
|
||||
:param provider: An instance of a MusicProviderClient subclass.
|
||||
"""
|
||||
cls._providers[provider._identifier] = provider
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, identifier: str) -> MusicProviderClient:
|
||||
"""
|
||||
Retrieves a registered music provider client by its identifier.
|
||||
:param identifier: The unique identifier for the provider.
|
||||
:return: An instance of MusicProviderClient.
|
||||
"""
|
||||
if identifier not in cls._providers:
|
||||
raise ValueError(f"No provider found with identifier '{identifier}'.")
|
||||
return cls._providers[identifier]
|
||||
|
||||
@classmethod
|
||||
def list_providers(cls) -> list:
|
||||
"""
|
||||
Lists all registered providers.
|
||||
:return: A list of registered provider identifiers.
|
||||
"""
|
||||
return list(cls._providers.keys())
|
||||
267
app/routes.py
267
app/routes.py
@@ -1,267 +0,0 @@
|
||||
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash
|
||||
from app import app, db, functions, sp, jellyfin, celery, jellyfin_admin_token, jellyfin_admin_id,device_id, cache
|
||||
from app.models import JellyfinUser,Playlist,Track
|
||||
from celery.result import AsyncResult
|
||||
from .version import __version__
|
||||
|
||||
@app.context_processor
|
||||
def add_context():
|
||||
unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all())
|
||||
version = f"v{__version__}"
|
||||
return dict(unlinked_track_count = unlinked_track_count, version = version)
|
||||
|
||||
@app.after_request
|
||||
def render_messages(response: Response) -> Response:
|
||||
if request.headers.get("HX-Request"):
|
||||
messages = render_template("partials/alerts.jinja2")
|
||||
response.headers['HX-Trigger'] = 'showToastMessages'
|
||||
response.data = response.data + messages.encode("utf-8")
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@app.route('/admin/tasks')
|
||||
@functions.jellyfin_admin_required
|
||||
def task_manager():
|
||||
statuses = {}
|
||||
for task_name, task_id in functions.TASK_STATUS.items():
|
||||
if task_id:
|
||||
result = AsyncResult(task_id)
|
||||
statuses[task_name] = {'state': result.state, 'info': result.info if result.info else {}}
|
||||
else:
|
||||
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
|
||||
|
||||
return render_template('admin/tasks.html', tasks=statuses)
|
||||
|
||||
@app.route('/admin')
|
||||
@app.route('/admin/link_issues')
|
||||
@functions.jellyfin_admin_required
|
||||
def link_issues():
|
||||
unlinked_tracks = Track.query.filter_by(downloaded=True,jellyfin_id=None).all()
|
||||
tracks = []
|
||||
for ult in unlinked_tracks:
|
||||
sp_track = functions.get_cached_spotify_track(ult.spotify_track_id)
|
||||
duration_ms = sp_track['duration_ms']
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
tracks.append({
|
||||
'title': sp_track['name'],
|
||||
'artist': ', '.join([artist['name'] for artist in sp_track['artists']]),
|
||||
'url': sp_track['external_urls']['spotify'],
|
||||
'duration': f'{minutes}:{seconds:02d}',
|
||||
'preview_url': sp_track['preview_url'],
|
||||
'downloaded': ult.downloaded,
|
||||
'filesystem_path': ult.filesystem_path,
|
||||
'jellyfin_id': ult.jellyfin_id,
|
||||
'spotify_id': sp_track['id'],
|
||||
'duration_ms': duration_ms,
|
||||
'download_status' : ult.download_status
|
||||
})
|
||||
|
||||
return render_template('admin/link_issues.html' , tracks = tracks )
|
||||
|
||||
|
||||
|
||||
@app.route('/run_task/<task_name>', methods=['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def run_task(task_name):
|
||||
status, info = functions.manage_task(task_name)
|
||||
|
||||
# Rendere nur die aktualisierte Zeile der Task
|
||||
task_info = {task_name: {'state': status, 'info': info}}
|
||||
return render_template('partials/_task_status.html', tasks=task_info)
|
||||
|
||||
|
||||
@app.route('/task_status')
|
||||
@functions.jellyfin_admin_required
|
||||
def task_status():
|
||||
statuses = {}
|
||||
for task_name, task_id in functions.TASK_STATUS.items():
|
||||
if task_id:
|
||||
result = AsyncResult(task_id)
|
||||
statuses[task_name] = {'state': result.state, 'info': result.info if result.info else {}}
|
||||
else:
|
||||
statuses[task_name] = {'state': 'NOT STARTED', 'info': {}}
|
||||
|
||||
# Render the HTML partial template instead of returning JSON
|
||||
return render_template('partials/_task_status.html', tasks=statuses)
|
||||
|
||||
|
||||
|
||||
@app.route('/')
|
||||
@functions.jellyfin_login_required
|
||||
def index():
|
||||
users = JellyfinUser.query.all()
|
||||
return render_template('index.html', user=session['jellyfin_user_name'], users=users)
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
try:
|
||||
jellylogin = jellyfin.login_with_password(username=username, password=password)
|
||||
if jellylogin:
|
||||
session['jellyfin_access_token'], session['jellyfin_user_id'], session['jellyfin_user_name'],session['is_admin'] = jellylogin
|
||||
session['debug'] = app.debug
|
||||
# Check if the user already exists
|
||||
user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||
if not user:
|
||||
# Add the user to the database if they don't exist
|
||||
new_user = JellyfinUser(name=session['jellyfin_user_name'], jellyfin_user_id=session['jellyfin_user_id'], is_admin = session['is_admin'])
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
return redirect('/playlists')
|
||||
except:
|
||||
flash('Login failed. Please check your Jellyfin credentials and try again.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('jellyfin_user_name', None)
|
||||
session.pop('jellyfin_access_token', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
@app.route('/playlists')
|
||||
@app.route('/categories')
|
||||
@app.route('/playlists/monitored')
|
||||
@functions.jellyfin_login_required
|
||||
def loaditems():
|
||||
country = 'DE'
|
||||
offset = int(request.args.get('offset', 0)) # Get the offset (default to 0 for initial load)
|
||||
limit = 20 # Define a limit for pagination
|
||||
additional_query = ''
|
||||
items_subtitle = ''
|
||||
|
||||
if request.path == '/playlists/monitored':
|
||||
# Step 1: Query the database for monitored playlists
|
||||
db_playlists = db.session.query(Playlist).offset(offset).limit(limit).all()
|
||||
max_items = db.session.query(Playlist).count()
|
||||
|
||||
# Collect Spotify Playlist IDs from the database
|
||||
spotify_playlist_ids = [playlist.spotify_playlist_id for playlist in db_playlists]
|
||||
|
||||
spotify_data = functions.get_cached_spotify_playlists(tuple(spotify_playlist_ids))
|
||||
|
||||
# Step 3: Pass the Spotify data to prepPlaylistData for processing
|
||||
data = functions.prepPlaylistData(spotify_data)
|
||||
items_title = "Monitored Playlists"
|
||||
items_subtitle = "This playlists are already monitored by the Server, if you add one of these to your Jellyfin account, they will be available immediately."
|
||||
|
||||
elif request.path == '/playlists':
|
||||
cat = request.args.get('cat', None)
|
||||
if cat is not None:
|
||||
data, max_items, items_title = functions.getCategoryPlaylists(category=cat, offset=offset)
|
||||
additional_query += f"&cat={cat}"
|
||||
else:
|
||||
data, max_items, items_title = functions.getFeaturedPlaylists(country=country, offset=offset)
|
||||
|
||||
elif request.path == '/categories':
|
||||
data, max_items, items_title = functions.getCategories(country=country, offset=offset)
|
||||
|
||||
next_offset = offset + len(data)
|
||||
total_items = max_items
|
||||
context = {
|
||||
'items': data,
|
||||
'next_offset': next_offset,
|
||||
'total_items': total_items,
|
||||
'endpoint': request.path,
|
||||
'items_title': items_title,
|
||||
'items_subtitle' : items_subtitle,
|
||||
'additional_query': additional_query
|
||||
}
|
||||
|
||||
if request.headers.get('HX-Request'): # Check if the request is from HTMX
|
||||
return render_template('partials/_spotify_items.html', **context)
|
||||
else:
|
||||
return render_template('items.html', **context)
|
||||
|
||||
|
||||
@app.route('/search')
|
||||
@functions.jellyfin_login_required
|
||||
def searchResults():
|
||||
query = request.args.get('query')
|
||||
context = {}
|
||||
if query:
|
||||
# Add your logic here to perform the search on Spotify (or Jellyfin)
|
||||
search_result = sp.search(q = query, type= 'track,album,artist,playlist')
|
||||
context = {
|
||||
'artists' : functions.prepArtistData(search_result ),
|
||||
'playlists' : functions.prepPlaylistData(search_result ),
|
||||
'albums' : functions.prepAlbumData(search_result ),
|
||||
'query' : query
|
||||
}
|
||||
return render_template('search.html', **context)
|
||||
else:
|
||||
return render_template('search.html', query=None, results={})
|
||||
|
||||
|
||||
@app.route('/playlist/view/<playlist_id>')
|
||||
@functions.jellyfin_login_required
|
||||
def get_playlist_tracks(playlist_id):
|
||||
# Hol dir alle Tracks für die Playlist
|
||||
data = functions.get_full_playlist_data(playlist_id) # Diese neue Funktion holt alle Tracks der Playlist
|
||||
tracks = functions.get_tracks_for_playlist(data) # Deine Funktion, um Tracks zu holen
|
||||
# Berechne die gesamte Dauer der Playlist
|
||||
total_duration_ms = sum([track['track']['duration_ms'] for track in data['tracks'] if track['track']])
|
||||
|
||||
# Konvertiere die Gesamtdauer in ein lesbares Format
|
||||
hours, remainder = divmod(total_duration_ms // 1000, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
# Formatierung der Dauer
|
||||
if hours > 0:
|
||||
total_duration = f"{hours}h {minutes}min"
|
||||
else:
|
||||
total_duration = f"{minutes}min"
|
||||
|
||||
return render_template(
|
||||
'tracks_table.html',
|
||||
tracks=tracks,
|
||||
total_duration=total_duration,
|
||||
track_count=len(data['tracks']),
|
||||
playlist_name=data['name'],
|
||||
playlist_cover=data['images'][0]['url'],
|
||||
playlist_description=data['description'],
|
||||
last_updated = data['prepped_data'][0]['last_updated'],
|
||||
last_changed = data['prepped_data'][0]['last_changed'],
|
||||
item = data['prepped_data'][0],
|
||||
|
||||
)
|
||||
@app.route('/associate_track', methods=['POST'])
|
||||
@functions.jellyfin_login_required
|
||||
def associate_track():
|
||||
jellyfin_id = request.form.get('jellyfin_id')
|
||||
spotify_id = request.form.get('spotify_id')
|
||||
|
||||
if not jellyfin_id or not spotify_id:
|
||||
flash('Missing Jellyfin or Spotify ID')
|
||||
|
||||
# Retrieve the track by Spotify ID
|
||||
track = Track.query.filter_by(spotify_track_id=spotify_id).first()
|
||||
|
||||
if not track:
|
||||
flash('Track not found')
|
||||
return ''
|
||||
|
||||
# Associate the Jellyfin ID with the track
|
||||
track.jellyfin_id = jellyfin_id
|
||||
|
||||
try:
|
||||
# Commit the changes to the database
|
||||
db.session.commit()
|
||||
flash("Track associated","success")
|
||||
return ''
|
||||
except Exception as e:
|
||||
db.session.rollback() # Roll back the session in case of an error
|
||||
flash(str(e))
|
||||
return ''
|
||||
|
||||
|
||||
@app.route('/test')
|
||||
def test():
|
||||
return ''
|
||||
17
app/routes/__init__.py
Normal file
17
app/routes/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from flask import Blueprint, request, g
|
||||
from app import app
|
||||
from app.registry.music_provider_registry import MusicProviderRegistry
|
||||
|
||||
pl_bp = Blueprint('playlist', __name__)
|
||||
|
||||
@pl_bp.before_request
|
||||
def set_active_provider():
|
||||
"""
|
||||
Middleware to select the active provider based on request parameters.
|
||||
"""
|
||||
app.logger.debug(f"Setting active provider: {request.args.get('provider', 'Spotify')}")
|
||||
provider_id = request.args.get('provider', 'Spotify') # Default to Spotify
|
||||
try:
|
||||
g.music_provider = MusicProviderRegistry.get_provider(provider_id)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}, 400
|
||||
250
app/routes/jellyfin_routes.py
Normal file
250
app/routes/jellyfin_routes.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from collections import defaultdict
|
||||
import time
|
||||
from flask import Blueprint, Flask, jsonify, render_template, request, redirect, url_for, session, flash
|
||||
from sqlalchemy import insert
|
||||
from app import app, db, jellyfin, functions, device_id,sp
|
||||
from app.models import JellyfinUser, Playlist,Track, playlist_tracks
|
||||
from spotipy.exceptions import SpotifyException
|
||||
from app.tasks import task_manager
|
||||
|
||||
from app.registry.music_provider_registry import MusicProviderRegistry
|
||||
from jellyfin.objects import PlaylistMetadata
|
||||
from app.routes import pl_bp, routes
|
||||
|
||||
@app.route('/jellyfin_playlists')
|
||||
@functions.jellyfin_login_required
|
||||
def jellyfin_playlists():
|
||||
playlists = jellyfin.get_playlists(session_token=functions._get_token_from_sessioncookie())
|
||||
playlists_by_provider = defaultdict(list)
|
||||
provider_playlists_data = {}
|
||||
|
||||
for pl in playlists:
|
||||
from_db : Playlist | None = Playlist.query.filter_by(jellyfin_id=pl['Id']).first()
|
||||
if from_db and from_db.provider_playlist_id:
|
||||
pl_id = from_db.provider_playlist_id
|
||||
playlists_by_provider[from_db.provider_id].append(from_db)
|
||||
|
||||
# 3. Fetch all Data from the provider using the get_playlist() method
|
||||
for provider_id, playlists in playlists_by_provider.items():
|
||||
try:
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
except ValueError:
|
||||
flash(f"Provider {provider_id} not found.", "error")
|
||||
continue
|
||||
|
||||
combined_playlists = []
|
||||
for pl in playlists:
|
||||
# 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:
|
||||
combined_playlists.append(combined_data)
|
||||
|
||||
provider_playlists_data[provider_id] = combined_playlists
|
||||
|
||||
# 5. Display the resulting Groups in a template called 'monitored_playlists.html', one Heading per Provider
|
||||
return render_template('monitored_playlists.html', provider_playlists_data=provider_playlists_data,title="Jellyfin Playlists" , subtitle="Playlists you have added to Jellyfin")
|
||||
|
||||
@pl_bp.route('/addplaylist', methods=['POST'])
|
||||
@functions.jellyfin_login_required
|
||||
def add_playlist():
|
||||
playlist_id = request.form.get('item_id')
|
||||
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:
|
||||
flash('No playlist ID provided')
|
||||
return ''
|
||||
# if no provider_id is provided, then show an error and return an empty string
|
||||
if not provider_id:
|
||||
flash('No provider ID provided')
|
||||
return ''
|
||||
try:
|
||||
# get the playlist from the correct provider
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
playlist_data = provider_client.get_playlist(playlist_id)
|
||||
# Check if playlist already exists in the database, using the provider_id and the provider_playlist_id
|
||||
playlist = Playlist.query.filter_by(provider_playlist_id=playlist_id, provider_id=provider_id).first()
|
||||
# Add new playlist in the database if it doesn't exist
|
||||
# create the playlist via api key, with the first admin as 'owner'
|
||||
if not playlist:
|
||||
fromJellyfin = jellyfin.create_music_playlist(functions._get_api_token(),playlist_data.name,[],functions._get_admin_id())['Id']
|
||||
playlist = Playlist(name=playlist_data.name, provider_playlist_id=playlist_id,provider_uri=playlist_data.uri,track_count = len(playlist_data.tracks), tracks_available=0, jellyfin_id = fromJellyfin, provider_id=provider_id)
|
||||
db.session.add(playlist)
|
||||
db.session.commit()
|
||||
if app.config['START_DOWNLOAD_AFTER_PLAYLIST_ADD']:
|
||||
task_manager.start_task('download_missing_tracks')
|
||||
# Get the logged-in user
|
||||
user : JellyfinUser = functions._get_logged_in_user()
|
||||
playlist.tracks_available = 0
|
||||
|
||||
for idx, track_data in enumerate(playlist_data.tracks):
|
||||
|
||||
track = Track.query.filter_by(provider_track_id=track_data.track.id, provider_id=provider_id).first()
|
||||
|
||||
if not track:
|
||||
# Add new track if it doesn't exist
|
||||
track = Track(name=track_data.track.name, provider_track_id=track_data.track.id, provider_uri=track_data.track.uri, downloaded=False,provider_id = provider_id)
|
||||
db.session.add(track)
|
||||
db.session.commit()
|
||||
elif track.downloaded:
|
||||
playlist.tracks_available += 1
|
||||
db.session.commit()
|
||||
|
||||
# Add track to playlist with order if it's not already associated
|
||||
if track not in playlist.tracks:
|
||||
# Insert into playlist_tracks with track order
|
||||
stmt = insert(playlist_tracks).values(
|
||||
playlist_id=playlist.id,
|
||||
track_id=track.id,
|
||||
track_order=idx # Maintain the order of tracks
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
db.session.commit()
|
||||
|
||||
functions.update_playlist_metadata(playlist,playlist_data)
|
||||
|
||||
if playlist not in user.playlists:
|
||||
user.playlists.append(playlist)
|
||||
db.session.commit()
|
||||
jellyfin.add_users_to_playlist(session_token=functions._get_api_token(), user_id=functions._get_admin_id(),playlist_id = playlist.jellyfin_id,user_ids= [user.jellyfin_user_id])
|
||||
flash(f'Playlist "{playlist_data.name}" successfully added','success')
|
||||
|
||||
else:
|
||||
flash(f'Playlist "{playlist_data.name}" already in your list')
|
||||
item = {
|
||||
"name" : playlist_data.name,
|
||||
"id" : playlist_id,
|
||||
"can_add":False,
|
||||
"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)
|
||||
|
||||
|
||||
|
||||
|
||||
except Exception as e:
|
||||
flash(str(e))
|
||||
return ''
|
||||
|
||||
|
||||
@app.route('/delete_playlist/<playlist_id>', methods=['DELETE'])
|
||||
@functions.jellyfin_login_required
|
||||
def delete_playlist(playlist_id):
|
||||
# Logic to delete the playlist using JellyfinClient
|
||||
try:
|
||||
user = functions._get_logged_in_user()
|
||||
for pl in user.playlists:
|
||||
if pl.jellyfin_id == playlist_id:
|
||||
user.playlists.remove(pl)
|
||||
playlist = pl
|
||||
jellyfin.remove_user_from_playlist(session_token= functions._get_api_token(), playlist_id= playlist_id, user_id=user.jellyfin_user_id)
|
||||
db.session.commit()
|
||||
flash('Playlist removed')
|
||||
item = {
|
||||
"name" : playlist.name,
|
||||
"id" : playlist.provider_playlist_id,
|
||||
"can_add":True,
|
||||
"can_remove":False,
|
||||
"jellyfin_id" : playlist.jellyfin_id
|
||||
}
|
||||
return render_template('partials/_add_remove_button.html',item= item)
|
||||
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
|
||||
def wipe_playlist(playlist_id):
|
||||
playlist = Playlist.query.filter_by(jellyfin_id=playlist_id).first()
|
||||
name = ""
|
||||
id = ""
|
||||
jf_id = ""
|
||||
try:
|
||||
jellyfin.remove_item(session_token=functions._get_api_token(), playlist_id=playlist_id)
|
||||
except Exception as e:
|
||||
flash(f"Jellyfin API Error: {str(e)}")
|
||||
if playlist:
|
||||
# Delete the playlist
|
||||
name = playlist.name
|
||||
id = playlist.provider_playlist_id
|
||||
jf_id = playlist.jellyfin_id
|
||||
db.session.delete(playlist)
|
||||
db.session.commit()
|
||||
flash('Playlist Deleted', category='info')
|
||||
item = {
|
||||
"name" : name,
|
||||
"id" : id,
|
||||
"can_add":True,
|
||||
"can_remove":False,
|
||||
"jellyfin_id" : jf_id
|
||||
}
|
||||
return render_template('partials/_add_remove_button.html',item= item)
|
||||
|
||||
@functions.jellyfin_login_required
|
||||
@app.route('/get_jellyfin_stream/<string:jellyfin_id>')
|
||||
def get_jellyfin_stream(jellyfin_id):
|
||||
user_id = session['jellyfin_user_id'] # Beispiel: dynamischer Benutzer
|
||||
api_key = functions._get_token_from_sessioncookie() # Beispiel: dynamischer API-Schlüssel
|
||||
stream_url = f"{app.config['JELLYFIN_SERVER_URL']}/Audio/{jellyfin_id}/universal?UserId={user_id}&DeviceId={device_id}&MaxStreamingBitrate=140000000&Container=opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg&TranscodingContainer=mp4&TranscodingProtocol=hls&AudioCodec=aac&api_key={api_key}&PlaySessionId={int(time.time())}&StartTimeTicks=0&EnableRedirection=true&EnableRemoteMedia=false"
|
||||
return jsonify({'stream_url': stream_url})
|
||||
|
||||
@app.route('/search_jellyfin', methods=['GET'])
|
||||
@functions.jellyfin_login_required
|
||||
def search_jellyfin():
|
||||
search_query = request.args.get('search_query')
|
||||
provider_track_id = request.args.get('provider_track_id')
|
||||
if search_query:
|
||||
results = jellyfin.search_music_tracks(functions._get_token_from_sessioncookie(), search_query)
|
||||
# Render only the search results section as response
|
||||
return render_template('partials/_jf_search_results.html', results=results,provider_track_id= provider_track_id,search_query = search_query)
|
||||
return jsonify({'error': 'No search query provided'}), 400
|
||||
556
app/routes/routes.py
Normal file
556
app/routes/routes.py
Normal file
@@ -0,0 +1,556 @@
|
||||
from dbm import error
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from flask import Flask, Response, jsonify, render_template, request, redirect, url_for, session, flash, Blueprint, g
|
||||
from app import app, db, functions, jellyfin, read_dev_build_file, tasks, save_yaml_settings
|
||||
from app.classes import AudioProfile, CombinedPlaylistData
|
||||
from app.models import JellyfinUser,Playlist,Track
|
||||
from celery.result import AsyncResult
|
||||
from typing import List
|
||||
|
||||
from app.providers import base
|
||||
from app.providers.base import MusicProviderClient
|
||||
from app.providers.spotify import SpotifyClient
|
||||
from app.registry.music_provider_registry import MusicProviderRegistry
|
||||
from lidarr.classes import Album, Artist
|
||||
from lidarr.client import LidarrClient
|
||||
from ..version import __version__
|
||||
from spotipy.exceptions import SpotifyException
|
||||
from collections import defaultdict
|
||||
from app.routes import pl_bp
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def add_context():
|
||||
unlinked_track_count = len(Track.query.filter_by(downloaded=True,jellyfin_id=None).all())
|
||||
version = f"v{__version__}{read_dev_build_file()}"
|
||||
return dict(unlinked_track_count = unlinked_track_count, version = version, config = app.config , registered_providers = MusicProviderRegistry.list_providers())
|
||||
|
||||
|
||||
# this feels wrong
|
||||
skip_endpoints = ['task_status']
|
||||
@app.after_request
|
||||
def render_messages(response: Response) -> Response:
|
||||
if request.headers.get("HX-Request"):
|
||||
if request.endpoint not in skip_endpoints:
|
||||
messages = render_template("partials/alerts.jinja2")
|
||||
response.headers['HX-Trigger'] = 'showToastMessages'
|
||||
response.data = response.data + messages.encode("utf-8")
|
||||
return response
|
||||
|
||||
|
||||
@app.route('/admin/lidarr')
|
||||
@functions.jellyfin_admin_required
|
||||
def admin_lidarr():
|
||||
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
|
||||
from app import lidarr_client
|
||||
q_profiles = lidarr_client.get_quality_profiles()
|
||||
root_folders = lidarr_client.get_root_folders()
|
||||
return render_template('admin/lidarr.html',quality_profiles = q_profiles, root_folders = root_folders, current_quality_profile = functions.lidarr_quality_profile_id(), current_root_folder = functions.lidarr_root_folder_path())
|
||||
return render_template('admin/lidarr.html', error = 'Lidarr not configured')
|
||||
|
||||
@app.route('/admin/lidarr/save', methods=['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def save_lidarr_config():
|
||||
quality_profile_id = request.form.get('qualityProfile')
|
||||
root_folder_id = request.form.get('rootFolder')
|
||||
|
||||
if not quality_profile_id or not root_folder_id:
|
||||
flash('Both Quality Profile and Root Folder must be selected', 'danger')
|
||||
return redirect(url_for('admin_lidarr'))
|
||||
functions.lidarr_quality_profile_id(quality_profile_id)
|
||||
functions.lidarr_root_folder_path(root_folder_id)
|
||||
flash('Configuration saved successfully', 'success')
|
||||
return redirect(url_for('admin_lidarr'))
|
||||
|
||||
@app.route('/admin/tasks')
|
||||
@functions.jellyfin_admin_required
|
||||
def task_manager():
|
||||
statuses = {}
|
||||
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)
|
||||
|
||||
@app.route('/admin/link_issues')
|
||||
@functions.jellyfin_admin_required
|
||||
def link_issues():
|
||||
# add the ability to pass a query parameter to dislplay even undownloaded tracks
|
||||
list_undownloaded = request.args.get('list_undownloaded')
|
||||
if list_undownloaded:
|
||||
unlinked_tracks = Track.query.filter_by(jellyfin_id=None).all()
|
||||
else:
|
||||
unlinked_tracks = Track.query.filter_by(downloaded=True,jellyfin_id=None).all()
|
||||
tracks = []
|
||||
for ult in unlinked_tracks:
|
||||
provider_track = functions.get_cached_provider_track(ult.provider_track_id, ult.provider_id)
|
||||
duration_ms = provider_track.duration_ms
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
tracks.append({
|
||||
'title': provider_track.name,
|
||||
'artist': ', '.join([artist.name for artist in provider_track.artists]),
|
||||
'url': provider_track.external_urls,
|
||||
'duration': f'{minutes}:{seconds:02d}',
|
||||
'preview_url': '',
|
||||
'downloaded': ult.downloaded,
|
||||
'filesystem_path': ult.filesystem_path,
|
||||
'jellyfin_id': ult.jellyfin_id,
|
||||
'provider_track_id': provider_track.id,
|
||||
'duration_ms': duration_ms,
|
||||
'download_status' : ult.download_status,
|
||||
'provider_id' : ult.provider_id
|
||||
})
|
||||
|
||||
return render_template('admin/link_issues.html' , tracks = tracks )
|
||||
|
||||
@app.route('/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'])
|
||||
@functions.jellyfin_admin_required
|
||||
def run_task(task_name):
|
||||
status, info = tasks.task_manager.start_task(task_name)
|
||||
|
||||
# Rendere nur die aktualisierte Zeile der Task
|
||||
task_info = {task_name: {'state': status, 'info': info}}
|
||||
|
||||
return render_template('partials/_task_status.html', tasks=task_info)
|
||||
|
||||
|
||||
@app.route('/task_status')
|
||||
@functions.jellyfin_admin_required
|
||||
def task_status():
|
||||
statuses = {}
|
||||
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, lock_keys = lock_keys)
|
||||
|
||||
|
||||
|
||||
@app.route('/')
|
||||
@functions.jellyfin_login_required
|
||||
def index():
|
||||
users = JellyfinUser.query.all()
|
||||
return render_template('index.html', user=session['jellyfin_user_name'], users=users)
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
try:
|
||||
jellylogin = jellyfin.login_with_password(username=username, password=password)
|
||||
if jellylogin:
|
||||
session['jellyfin_access_token'], session['jellyfin_user_id'], session['jellyfin_user_name'],session['is_admin'] = jellylogin
|
||||
session['debug'] = app.debug
|
||||
# Check if the user already exists
|
||||
user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||
if not user:
|
||||
# Add the user to the database if they don't exist
|
||||
new_user = JellyfinUser(name=session['jellyfin_user_name'], jellyfin_user_id=session['jellyfin_user_id'], is_admin = session['is_admin'])
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
return redirect('/')
|
||||
except:
|
||||
flash('Login failed. Please check your Jellyfin credentials and try again.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('jellyfin_user_name', None)
|
||||
session.pop('jellyfin_access_token', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/playlist/open',methods=['GET'])
|
||||
@functions.jellyfin_login_required
|
||||
def openPlaylist():
|
||||
playlist = request.args.get('playlist')
|
||||
error = None
|
||||
errdata= None
|
||||
if playlist:
|
||||
for provider_id in MusicProviderRegistry.list_providers():
|
||||
try:
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
extracted_playlist_id = provider_client.extract_playlist_id(playlist)
|
||||
provider_playlist = functions.get_cached_provider_playlist(extracted_playlist_id, provider_id)
|
||||
|
||||
combined_data = functions.prepPlaylistData(provider_playlist)
|
||||
if combined_data:
|
||||
# If the playlist is found, redirect to the playlist view, but also include the provider ID in the URL
|
||||
return redirect(url_for('playlist.get_playlist_tracks', playlist_id=extracted_playlist_id, provider=provider_id))
|
||||
except Exception as e:
|
||||
error = f"Error fetching playlist from {provider_id}: {str(e)}"
|
||||
errdata = e
|
||||
|
||||
return render_template('index.html',error_message = error, error_data = errdata)
|
||||
|
||||
@pl_bp.route('/browse')
|
||||
@functions.jellyfin_login_required
|
||||
def browse():
|
||||
provider: MusicProviderClient = g.music_provider
|
||||
|
||||
browse_data = provider.browse()
|
||||
return render_template('browse.html', browse_data=browse_data,provider_id=provider._identifier)
|
||||
|
||||
@pl_bp.route('/browse/page/<page_id>')
|
||||
@functions.jellyfin_login_required
|
||||
def browse_page(page_id):
|
||||
provider: MusicProviderClient = g.music_provider
|
||||
combined_playlist_data : List[CombinedPlaylistData] = []
|
||||
|
||||
data = provider.browse_page(page_id)
|
||||
for item in data:
|
||||
cpd = functions.prepPlaylistData(item)
|
||||
if cpd:
|
||||
combined_playlist_data.append(cpd)
|
||||
return render_template('browse_page.html', data=combined_playlist_data,provider_id=provider._identifier)
|
||||
|
||||
@pl_bp.route('/playlists/monitored')
|
||||
@functions.jellyfin_login_required
|
||||
def monitored_playlists():
|
||||
|
||||
# 1. Get all Playlists from the Database and order them by Id
|
||||
all_playlists = Playlist.query.order_by(Playlist.id).all()
|
||||
|
||||
# 2. Group them by provider
|
||||
playlists_by_provider = defaultdict(list)
|
||||
for playlist in all_playlists:
|
||||
playlists_by_provider[playlist.provider_id].append(playlist)
|
||||
|
||||
provider_playlists_data = {}
|
||||
# 3. Fetch all Data from the provider using the get_playlist() method
|
||||
for provider_id, playlists in playlists_by_provider.items():
|
||||
try:
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
except ValueError:
|
||||
flash(f"Provider {provider_id} not found.", "error")
|
||||
continue
|
||||
|
||||
combined_playlists = []
|
||||
for pl in playlists:
|
||||
provider_playlist = 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:
|
||||
combined_playlists.append(combined_data)
|
||||
|
||||
provider_playlists_data[provider_id] = combined_playlists
|
||||
|
||||
# 5. Display the resulting Groups in a template called 'monitored_playlists.html', one Heading per Provider
|
||||
return render_template('monitored_playlists.html', provider_playlists_data=provider_playlists_data, title="Monitored Playlists", subtitle="Playlists which are already monitored by Jellyplist and are available immediately")
|
||||
|
||||
@app.route('/search')
|
||||
@functions.jellyfin_login_required
|
||||
def searchResults():
|
||||
query = request.args.get('query')
|
||||
context = {}
|
||||
if query:
|
||||
#iterate through every registered music provider and perform the search with it.
|
||||
# Group the results by provider and display them using monitorerd_playlists.html
|
||||
search_results = defaultdict(list)
|
||||
for provider_id in MusicProviderRegistry.list_providers():
|
||||
try:
|
||||
provider_client = MusicProviderRegistry.get_provider(provider_id)
|
||||
results = provider_client.search_playlist(query)
|
||||
for result in results:
|
||||
search_results[provider_id].append(result)
|
||||
except Exception as e:
|
||||
flash(f"Error fetching search results from {provider_id}: {str(e)}", "error")
|
||||
# the grouped search results, must be prepared using the prepPlaylistData function
|
||||
for provider_id, playlists in search_results.items():
|
||||
combined_playlists = []
|
||||
for pl in playlists:
|
||||
combined_data = functions.prepPlaylistData(pl)
|
||||
if combined_data:
|
||||
combined_playlists.append(combined_data)
|
||||
search_results[provider_id] = combined_playlists
|
||||
|
||||
context['provider_playlists_data'] = search_results
|
||||
context['title'] = 'Search Results'
|
||||
context['subtitle'] = 'Search results from all providers'
|
||||
return render_template('monitored_playlists.html', **context)
|
||||
|
||||
@pl_bp.route('/track_details/<track_id>')
|
||||
@functions.jellyfin_login_required
|
||||
def track_details(track_id):
|
||||
provider_id = request.args.get('provider')
|
||||
if not provider_id:
|
||||
return jsonify({'error': 'Provider not specified'}), 400
|
||||
|
||||
track = Track.query.filter_by(provider_track_id=track_id, provider_id=provider_id).first()
|
||||
if not track:
|
||||
return jsonify({'error': 'Track not found'}), 404
|
||||
|
||||
provider_track = functions.get_cached_provider_track(track.provider_track_id, track.provider_id)
|
||||
# query also this track using the jellyfin id directly from jellyfin
|
||||
if track.jellyfin_id:
|
||||
jellyfin_track = jellyfin.get_item(session_token=functions._get_api_token(), item_id=track.jellyfin_id)
|
||||
if jellyfin_track:
|
||||
jellyfin_filesystem_path = jellyfin_track['Path']
|
||||
duration_ms = provider_track.duration_ms
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
|
||||
track_details = {
|
||||
'title': provider_track.name,
|
||||
'artist': ', '.join([artist.name for artist in provider_track.artists]),
|
||||
'url': provider_track.external_urls,
|
||||
'duration': f'{minutes}:{seconds:02d}',
|
||||
'downloaded': track.downloaded,
|
||||
'filesystem_path': track.filesystem_path,
|
||||
'jellyfin_id': track.jellyfin_id,
|
||||
'provider_track_id': provider_track.id,
|
||||
'provider_track_url': provider_track.external_urls[0].url if provider_track.external_urls else None,
|
||||
'duration_ms': duration_ms,
|
||||
'download_status': track.download_status,
|
||||
'provider_id': track.provider_id,
|
||||
'jellyfin_filesystem_path': jellyfin_filesystem_path if track.jellyfin_id else None,
|
||||
}
|
||||
|
||||
return render_template('partials/track_details.html', track=track_details)
|
||||
|
||||
@pl_bp.route('/playlist/view/<playlist_id>')
|
||||
@functions.jellyfin_login_required
|
||||
def get_playlist_tracks(playlist_id):
|
||||
provider: MusicProviderClient = g.music_provider
|
||||
playlist: base.Playlist = provider.get_playlist(playlist_id)
|
||||
tracks = functions.get_tracks_for_playlist(playlist.tracks, provider_id=provider._identifier)
|
||||
total_duration_ms = sum([track.duration_ms for track in tracks])
|
||||
|
||||
# Convert the total duration to a readable format
|
||||
hours, remainder = divmod(total_duration_ms // 1000, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
# Format the duration
|
||||
if hours > 0:
|
||||
total_duration = f"{hours}h {minutes}min"
|
||||
else:
|
||||
total_duration = f"{minutes}min"
|
||||
|
||||
return render_template(
|
||||
'tracks_table.html',
|
||||
tracks=tracks,
|
||||
total_duration=total_duration,
|
||||
track_count=len(tracks),
|
||||
provider_id = provider._identifier,
|
||||
item=functions.prepPlaylistData(playlist),
|
||||
|
||||
)
|
||||
|
||||
@app.route('/associate_track', methods=['POST'])
|
||||
@functions.jellyfin_login_required
|
||||
def associate_track():
|
||||
jellyfin_id = request.form.get('jellyfin_id')
|
||||
provider_track_id = request.form.get('provider_track_id')
|
||||
|
||||
if not jellyfin_id or not provider_track_id:
|
||||
flash('Missing Jellyfin or Spotify ID')
|
||||
|
||||
# Retrieve the track by Spotify ID
|
||||
track = Track.query.filter_by(provider_track_id=provider_track_id).first()
|
||||
|
||||
if not track:
|
||||
flash('Track not found')
|
||||
return ''
|
||||
|
||||
# Associate the Jellyfin ID with the track
|
||||
track.jellyfin_id = jellyfin_id
|
||||
track.downloaded = True
|
||||
|
||||
|
||||
try:
|
||||
# Commit the changes to the database
|
||||
db.session.commit()
|
||||
flash("Track associated","success")
|
||||
return ''
|
||||
except Exception as e:
|
||||
db.session.rollback() # Roll back the session in case of an error
|
||||
flash(str(e))
|
||||
return ''
|
||||
|
||||
|
||||
@app.route("/unlock_key",methods = ['POST'])
|
||||
@functions.jellyfin_admin_required
|
||||
def unlock_key():
|
||||
key_name = request.form.get('inputLockKey')
|
||||
if key_name:
|
||||
tasks.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():
|
||||
tasks.update_all_playlists_track_status()
|
||||
return ''
|
||||
|
||||
671
app/tasks.py
671
app/tasks.py
@@ -1,28 +1,36 @@
|
||||
from datetime import datetime,timezone
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import insert
|
||||
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id
|
||||
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id, redis_client
|
||||
|
||||
from app.classes import AudioProfile
|
||||
from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks
|
||||
import os
|
||||
import redis
|
||||
from celery import current_task
|
||||
import asyncio
|
||||
import requests
|
||||
from celery import current_task,signals
|
||||
from celery.result import AsyncResult
|
||||
|
||||
redis_client = redis.StrictRedis(host='redis', port=6379, db=0)
|
||||
def acquire_lock(lock_name, expiration=60):
|
||||
return redis_client.set(lock_name, "locked", ex=expiration, nx=True)
|
||||
from app.providers import base
|
||||
from app.registry.music_provider_registry import MusicProviderRegistry
|
||||
from lidarr.classes import Artist
|
||||
|
||||
def release_lock(lock_name):
|
||||
redis_client.delete(lock_name)
|
||||
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def setup_log_format(sender, conf, **kwargs):
|
||||
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)
|
||||
|
||||
@celery.task(bind=True)
|
||||
def update_all_playlists_track_status(self):
|
||||
lock_key = "update_all_playlists_track_status_lock"
|
||||
|
||||
if acquire_lock(lock_key, expiration=600):
|
||||
if task_manager.acquire_lock(lock_key, expiration=600):
|
||||
try:
|
||||
with app.app_context():
|
||||
playlists = Playlist.query.all()
|
||||
@@ -37,11 +45,38 @@ def update_all_playlists_track_status(self):
|
||||
for playlist in playlists:
|
||||
total_tracks = 0
|
||||
available_tracks = 0
|
||||
|
||||
app.logger.info(f"Current Playlist: {playlist.name} [{playlist.id}:{playlist.provider_playlist_id}]" )
|
||||
for track in playlist.tracks:
|
||||
total_tracks += 1
|
||||
app.logger.debug(f"Processing track: {track.name} [{track.provider_track_id}]")
|
||||
app.logger.debug(f"\tPath = {track.filesystem_path}")
|
||||
if track.filesystem_path:
|
||||
app.logger.debug(f"\tPath exists = {os.path.exists(track.filesystem_path)}")
|
||||
app.logger.debug(f"\tJellyfinID = {track.jellyfin_id}")
|
||||
if track.filesystem_path and os.path.exists(track.filesystem_path):
|
||||
app.logger.info(f"Track {track.name} is already downloaded at {track.filesystem_path}.")
|
||||
available_tracks += 1
|
||||
track.downloaded = True
|
||||
db.session.commit()
|
||||
#If not found in filesystem, but a jellyfin_id is set, query the jellyfin server for the track and populate the filesystem_path from the response with the path
|
||||
elif track.jellyfin_id:
|
||||
jellyfin_track = jellyfin.get_item(jellyfin_admin_token, track.jellyfin_id)
|
||||
app.logger.debug(f"\tJellyfin Path: {jellyfin_track['Path']}")
|
||||
app.logger.debug(f"\tJellyfin Path exists: {os.path.exists(jellyfin_track['Path'])}")
|
||||
if jellyfin_track and os.path.exists(jellyfin_track['Path']):
|
||||
app.logger.info(f"Track {track.name} found in Jellyfin at {jellyfin_track['Path']}.")
|
||||
track.filesystem_path = jellyfin_track['Path']
|
||||
track.downloaded = True
|
||||
db.session.commit()
|
||||
available_tracks += 1
|
||||
else:
|
||||
track.downloaded = False
|
||||
track.filesystem_path = None
|
||||
db.session.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
else:
|
||||
track.downloaded = False
|
||||
track.filesystem_path = None
|
||||
@@ -59,8 +94,11 @@ 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:
|
||||
release_lock(lock_key)
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
app.logger.info("Skipping task. Another instance is already running.")
|
||||
return {'status': 'Task skipped, another instance is running'}
|
||||
@@ -70,90 +108,159 @@ def update_all_playlists_track_status(self):
|
||||
def download_missing_tracks(self):
|
||||
lock_key = "download_missing_tracks_lock"
|
||||
|
||||
if acquire_lock(lock_key, expiration=1800):
|
||||
if task_manager.acquire_lock(lock_key, expiration=1800):
|
||||
try:
|
||||
app.logger.info("Starting track download job...")
|
||||
|
||||
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['SPOTIPY_CLIENT_ID']
|
||||
client_secret = app.config['SPOTIPY_CLIENT_SECRET']
|
||||
client_id = app.config['SPOTIFY_CLIENT_ID']
|
||||
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
|
||||
search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD']
|
||||
|
||||
undownloaded_tracks = Track.query.filter_by(downloaded=False).all()
|
||||
# Downloading using SpotDL only works for Spotify tracks
|
||||
undownloaded_tracks : List[Track] = Track.query.filter_by(downloaded=False,provider_id = "Spotify").all()
|
||||
total_tracks = len(undownloaded_tracks)
|
||||
if not undownloaded_tracks:
|
||||
app.logger.info("No undownloaded tracks found.")
|
||||
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:
|
||||
app.logger.info(f"Processing track: {track.name} ({track.spotify_track_id})")
|
||||
|
||||
app.logger.info(f"Processing track: {track.name} [{track.provider_track_id}]")
|
||||
self.update_state(state=f'[{processed_tracks}/{total_tracks}] {track.name} [{track.provider_track_id}]', meta={
|
||||
'current': processed_tracks,
|
||||
'total': total_tracks,
|
||||
'percent': (processed_tracks / total_tracks) * 100 if processed_tracks > 0 else 0,
|
||||
'failed': failed_downloads
|
||||
})
|
||||
# Check if the track already exists in the output directory
|
||||
file_path = f"{output_dir.replace('{track-id}', track.spotify_track_id)}.mp3"
|
||||
|
||||
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
|
||||
track.filesystem_path = file_path
|
||||
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
|
||||
|
||||
# If search_before_download is enabled, perform matching
|
||||
|
||||
|
||||
|
||||
# region search before download
|
||||
if search_before_download:
|
||||
app.logger.info(f"Searching for track in Jellyfin: {track.name}")
|
||||
# Retrieve the Spotify track and preview URL
|
||||
spotify_track = sp.track(track.spotify_track_id)
|
||||
preview_url = spotify_track.get('preview_url')
|
||||
if not preview_url:
|
||||
app.logger.error(f"Preview URL not found for track {track.name}.")
|
||||
# Decide whether to skip or proceed to download
|
||||
# For now, we'll proceed to download
|
||||
else:
|
||||
# Get the list of Spotify artist names
|
||||
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
|
||||
# at first try to find the track without fingerprinting it
|
||||
best_match = find_best_match_from_jellyfin(track)
|
||||
if best_match:
|
||||
track.downloaded = True
|
||||
if track.jellyfin_id != best_match['Id']:
|
||||
track.jellyfin_id = best_match['Id']
|
||||
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.provider_track_id})")
|
||||
if track.filesystem_path != best_match['Path']:
|
||||
track.filesystem_path = best_match['Path']
|
||||
db.session.commit()
|
||||
processed_tracks+=1
|
||||
continue
|
||||
|
||||
# region search with fingerprinting
|
||||
# as long as there is no endpoint found providing a preview url, we can't use this feature
|
||||
# if spotify_track:
|
||||
# preview_url = spotify_track.get('preview_url')
|
||||
# if not preview_url:
|
||||
# app.logger.error(f"Preview URL not found for track {track.name}.")
|
||||
# # Decide whether to skip or proceed to download
|
||||
# # For now, we'll proceed to download
|
||||
# else:
|
||||
# # Get the list of Spotify artist names
|
||||
# spotify_artists = [artist['name'] for artist in spotify_track['artists']]
|
||||
|
||||
# Perform the search in Jellyfin
|
||||
match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
|
||||
session_token=jellyfin_admin_token,
|
||||
preview_url=preview_url,
|
||||
song_name=track.name,
|
||||
artist_names=spotify_artists
|
||||
)
|
||||
if match_found:
|
||||
app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
|
||||
track.downloaded = True
|
||||
track.filesystem_path = jellyfin_file_path
|
||||
db.session.commit()
|
||||
continue
|
||||
else:
|
||||
app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
|
||||
# # Perform the search in Jellyfin
|
||||
# match_found, jellyfin_file_path = jellyfin.search_track_in_jellyfin(
|
||||
# session_token=jellyfin_admin_token,
|
||||
# preview_url=preview_url,
|
||||
# song_name=track.name,
|
||||
# artist_names=spotify_artists
|
||||
# )
|
||||
# if match_found:
|
||||
# app.logger.info(f"Match found in Jellyfin for track {track.name}. Skipping download.")
|
||||
# track.downloaded = True
|
||||
# track.filesystem_path = jellyfin_file_path
|
||||
# db.session.commit()
|
||||
# continue
|
||||
# else:
|
||||
# app.logger.info(f"No match found in Jellyfin for track {track.name}. Proceeding to download.")
|
||||
# else:
|
||||
# app.logger.warning(f"spotify_track not set, see previous log messages")
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
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
|
||||
track.filesystem_path = file_path
|
||||
db.session.commit()
|
||||
continue
|
||||
|
||||
|
||||
|
||||
# Attempt to download the track using spotdl
|
||||
try:
|
||||
app.logger.info(f"Trying to download track: {track.name} ({track.spotify_track_id}), spotdl timeout = 90")
|
||||
s_url = f"https://open.spotify.com/track/{track.spotify_track_id}"
|
||||
|
||||
app.logger.info(f"Trying to download track: {track.name} ({track.provider_track_id}), spotdl timeout = 90")
|
||||
s_url = f"https://open.spotify.com/track/{track.provider_track_id}"
|
||||
|
||||
command = [
|
||||
"spotdl", "download", s_url,
|
||||
"--output", output_dir,
|
||||
"--cookie-file", cookie_file,
|
||||
"--client-id", client_id,
|
||||
"--client-secret", client_secret
|
||||
]
|
||||
|
||||
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
|
||||
track.filesystem_path = file_path
|
||||
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
|
||||
if file_path:
|
||||
track.filesystem_path = file_path
|
||||
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
|
||||
else:
|
||||
app.logger.error(f"Download failed for track {track.name}.")
|
||||
if result.stdout:
|
||||
app.logger.error(f"\t stdout: {result.stdout}")
|
||||
if result.stderr:
|
||||
app.logger.error(f"\t stderr: {result.stderr} ")
|
||||
failed_downloads += 1
|
||||
track.download_status = result.stdout[:2048]
|
||||
except Exception as e:
|
||||
@@ -165,7 +272,7 @@ def download_missing_tracks(self):
|
||||
progress = (processed_tracks / total_tracks) * 100
|
||||
db.session.commit()
|
||||
|
||||
self.update_state(state='PROGRESS', meta={
|
||||
self.update_state(state=f'[{processed_tracks}/{total_tracks}] {track.name} [{track.provider_track_id}]', meta={
|
||||
'current': processed_tracks,
|
||||
'total': total_tracks,
|
||||
'percent': progress,
|
||||
@@ -179,8 +286,16 @@ 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:
|
||||
release_lock(lock_key)
|
||||
task_manager.release_lock(lock_key)
|
||||
if app.config['REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK']:
|
||||
libraries = jellyfin.get_libraries(jellyfin_admin_token)
|
||||
for lib in libraries:
|
||||
if lib['CollectionType'] == 'music':
|
||||
jellyfin.refresh_library(jellyfin_admin_token, lib['ItemId'])
|
||||
else:
|
||||
app.logger.info("Skipping task. Another instance is already running.")
|
||||
return {'status': 'Task skipped, another instance is running'}
|
||||
@@ -189,11 +304,11 @@ def download_missing_tracks(self):
|
||||
def check_for_playlist_updates(self):
|
||||
lock_key = "check_for_playlist_updates_lock"
|
||||
|
||||
if acquire_lock(lock_key, expiration=600):
|
||||
if task_manager.acquire_lock(lock_key, expiration=600):
|
||||
try:
|
||||
app.logger.info('Starting playlist update check...')
|
||||
with app.app_context():
|
||||
playlists = Playlist.query.all()
|
||||
playlists: List[Playlist] = Playlist.query.all()
|
||||
total_playlists = len(playlists)
|
||||
if not playlists:
|
||||
app.logger.info("No playlists found.")
|
||||
@@ -204,76 +319,72 @@ def check_for_playlist_updates(self):
|
||||
|
||||
for playlist in playlists:
|
||||
playlist.last_updated = datetime.now( timezone.utc)
|
||||
sp_playlist = sp.playlist(playlist.spotify_playlist_id)
|
||||
|
||||
app.logger.info(f'Checking updates for playlist: {playlist.name}, s_snapshot = {sp_playlist['snapshot_id']}')
|
||||
# get the correct MusicProvider from the registry
|
||||
provider = MusicProviderRegistry.get_provider(playlist.provider_id)
|
||||
provider_playlist = provider.get_playlist(playlist.provider_playlist_id)
|
||||
provider_tracks = provider_playlist.tracks
|
||||
full_update = True
|
||||
app.logger.info(f'Checking updates for playlist: {playlist.name}')
|
||||
db.session.commit()
|
||||
if sp_playlist['snapshot_id'] == playlist.snapshot_id:
|
||||
app.logger.info(f'playlist: {playlist.name} , no changes detected, snapshot_id {sp_playlist['snapshot_id']}')
|
||||
continue
|
||||
|
||||
try:
|
||||
#region Check for updates
|
||||
# Fetch all playlist data from Spotify
|
||||
spotify_tracks = {}
|
||||
offset = 0
|
||||
playlist.snapshot_id = sp_playlist['snapshot_id']
|
||||
while True:
|
||||
playlist_data = sp.playlist_items(playlist.spotify_playlist_id, offset=offset, limit=100)
|
||||
items = playlist_data['items']
|
||||
spotify_tracks.update({offset + idx: track['track'] for idx, track in enumerate(items) if track['track']})
|
||||
|
||||
if len(items) < 100: # No more tracks to fetch
|
||||
break
|
||||
offset += 100 # Move to the next batch
|
||||
|
||||
existing_tracks = {track.spotify_track_id: track for track in playlist.tracks}
|
||||
if full_update:
|
||||
existing_tracks = {track.provider_track_id: track for track in playlist.tracks}
|
||||
|
||||
# Determine tracks to add and remove
|
||||
tracks_to_add = []
|
||||
for idx, track_info in spotify_tracks.items():
|
||||
if track_info:
|
||||
track_id = track_info['id']
|
||||
if track_id not in existing_tracks:
|
||||
track = Track.query.filter_by(spotify_track_id=track_id).first()
|
||||
if not track:
|
||||
track = Track(name=track_info['name'], spotify_track_id=track_id, spotify_uri=track_info['uri'], downloaded=False)
|
||||
db.session.add(track)
|
||||
# Determine tracks to add and remove
|
||||
tracks_to_add = []
|
||||
for idx, track_info in enumerate(provider_tracks):
|
||||
if track_info:
|
||||
track_id = track_info.track.id
|
||||
if track_id not in existing_tracks:
|
||||
track = Track.query.filter_by(provider_track_id=track_id,provider_id = playlist.provider_id).first()
|
||||
if not track:
|
||||
track = Track(name=track_info.track.name, provider_track_id=track_id, provider_uri=track_info.track.uri, downloaded=False,provider_id = playlist.provider_id)
|
||||
db.session.add(track)
|
||||
db.session.commit()
|
||||
app.logger.info(f'Added new track: {track.name}')
|
||||
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()
|
||||
app.logger.info(f'Added new track: {track.name}')
|
||||
tracks_to_add.append((track, idx))
|
||||
|
||||
tracks_to_remove = [
|
||||
existing_tracks[track_id]
|
||||
for track_id in existing_tracks
|
||||
if track_id not in {track['id'] for track in spotify_tracks.values() if track}
|
||||
]
|
||||
tracks_to_remove = [
|
||||
existing_tracks[track_id]
|
||||
for track_id in existing_tracks
|
||||
if track_id not in {track.track.id for track in provider_tracks if track}
|
||||
]
|
||||
|
||||
if tracks_to_add or tracks_to_remove:
|
||||
playlist.last_changed = datetime.now( timezone.utc)
|
||||
if tracks_to_add or tracks_to_remove:
|
||||
playlist.last_changed = datetime.now( timezone.utc)
|
||||
|
||||
# Add and remove tracks while maintaining order
|
||||
|
||||
if tracks_to_add:
|
||||
# Add and remove tracks while maintaining order
|
||||
|
||||
for track, track_order in tracks_to_add:
|
||||
stmt = insert(playlist_tracks).values(
|
||||
playlist_id=playlist.id,
|
||||
track_id=track.id,
|
||||
track_order=track_order
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
db.session.commit()
|
||||
app.logger.info(f'Added {len(tracks_to_add)} tracks to playlist: {playlist.name}')
|
||||
if tracks_to_add:
|
||||
|
||||
for track, track_order in tracks_to_add:
|
||||
stmt = insert(playlist_tracks).values(
|
||||
playlist_id=playlist.id,
|
||||
track_id=track.id,
|
||||
track_order=track_order
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
db.session.commit()
|
||||
app.logger.info(f'Added {len(tracks_to_add)} tracks to playlist: {playlist.name}')
|
||||
|
||||
if tracks_to_remove:
|
||||
for track in tracks_to_remove:
|
||||
playlist.tracks.remove(track)
|
||||
db.session.commit()
|
||||
app.logger.info(f'Removed {len(tracks_to_remove)} tracks from playlist: {playlist.name}')
|
||||
#endregion
|
||||
if tracks_to_remove:
|
||||
for track in tracks_to_remove:
|
||||
playlist.tracks.remove(track)
|
||||
db.session.commit()
|
||||
app.logger.info(f'Removed {len(tracks_to_remove)} tracks from playlist: {playlist.name}')
|
||||
#endregion
|
||||
|
||||
#region Update Playlist Items and Metadata
|
||||
functions.update_playlist_metadata(playlist, sp_playlist)
|
||||
functions.update_playlist_metadata(playlist, provider_playlist)
|
||||
ordered_tracks = db.session.execute(
|
||||
db.select(Track, playlist_tracks.c.track_order)
|
||||
.join(playlist_tracks, playlist_tracks.c.track_id == Track.id)
|
||||
@@ -282,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:
|
||||
@@ -297,8 +409,11 @@ 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:
|
||||
release_lock(lock_key)
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
app.logger.info("Skipping task. Another instance is already running.")
|
||||
return {'status': 'Task skipped, another instance is running'}
|
||||
@@ -306,62 +421,53 @@ def check_for_playlist_updates(self):
|
||||
@celery.task(bind=True)
|
||||
def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
lock_key = "update_jellyfin_id_for_downloaded_tracks_lock"
|
||||
|
||||
if acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
|
||||
full_update_key = 'full_update_jellyfin_ids_lock'
|
||||
if task_manager.acquire_lock(lock_key, expiration=600): # Lock for 10 minutes
|
||||
try:
|
||||
app.logger.info("Starting Jellyfin ID update for downloaded tracks...")
|
||||
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)")
|
||||
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)
|
||||
if not downloaded_tracks:
|
||||
app.logger.info("No downloaded tracks without Jellyfin ID found.")
|
||||
return {'status': 'No tracks to update'}
|
||||
|
||||
app.logger.info(f"Found {total_tracks} tracks to update with Jellyfin IDs.")
|
||||
app.logger.info(f"Found {total_tracks} tracks to update ")
|
||||
processed_tracks = 0
|
||||
|
||||
for track in downloaded_tracks:
|
||||
app.logger.info(f"Fetching track details from Spotify: {track.name} ({track.spotify_track_id})")
|
||||
search_results = jellyfin.search_music_tracks(jellyfin_admin_token,track.name)
|
||||
spotify_track = None
|
||||
|
||||
try:
|
||||
best_match = None
|
||||
for result in search_results:
|
||||
# if there is only one result , assume it´s the right track.
|
||||
if len(search_results) == 1:
|
||||
best_match = result
|
||||
break
|
||||
# Ensure the result is structured as expected
|
||||
jellyfin_track_name = result.get('Name', '').lower()
|
||||
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
|
||||
jellyfin_path = result.get('Path','')
|
||||
if jellyfin_path == track.filesystem_path:
|
||||
best_match = result
|
||||
break
|
||||
elif not spotify_track:
|
||||
try:
|
||||
spotify_track = sp.track(track.spotify_track_id)
|
||||
spotify_track_name = spotify_track['name']
|
||||
spotify_artists = [artist['name'] for artist in spotify_track['artists']]
|
||||
spotify_album = spotify_track['album']['name']
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching track details from Spotify for {track.name}: {str(e)}")
|
||||
continue
|
||||
# Compare name, artists, and album (case-insensitive comparison)
|
||||
if (spotify_track_name.lower() == jellyfin_track_name and
|
||||
set(artist.lower() for artist in spotify_artists) == set(jellyfin_artists) ):
|
||||
best_match = result
|
||||
break # Stop when a match is found
|
||||
|
||||
# Step 4: If a match is found, update jellyfin_id
|
||||
best_match = find_best_match_from_jellyfin(track)
|
||||
|
||||
if best_match:
|
||||
track.jellyfin_id = best_match['Id']
|
||||
track.downloaded = True
|
||||
if track.jellyfin_id != best_match['Id']:
|
||||
track.jellyfin_id = best_match['Id']
|
||||
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.provider_track_id})")
|
||||
if track.filesystem_path != best_match['Path']:
|
||||
track.filesystem_path = best_match['Path']
|
||||
app.logger.info(f"Updated filesystem_path for track: {track.name} ({track.provider_track_id})")
|
||||
|
||||
track.quality_score = best_match['quality_score']
|
||||
|
||||
db.session.commit()
|
||||
app.logger.info(f"Updated Jellyfin ID for track: {track.name} ({track.spotify_track_id})")
|
||||
else:
|
||||
app.logger.info(f"No matching track found in Jellyfin for {track.name}.")
|
||||
|
||||
app.logger.warning(f"No matching track found in Jellyfin for {track.name}.")
|
||||
|
||||
spotify_track = None
|
||||
|
||||
@@ -370,13 +476,242 @@ def update_jellyfin_id_for_downloaded_tracks(self):
|
||||
|
||||
processed_tracks += 1
|
||||
progress = (processed_tracks / total_tracks) * 100
|
||||
self.update_state(state='PROGRESS', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
|
||||
|
||||
self.update_state(state=f'{processed_tracks}/{total_tracks}: {track.name}', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
|
||||
|
||||
app.logger.info("Finished updating Jellyfin IDs for all tracks.")
|
||||
return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks}
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error updating jellyfin ids: {str(e)}", exc_info=True)
|
||||
return {'status': 'Error updating jellyfin ids '}
|
||||
finally:
|
||||
release_lock(lock_key)
|
||||
task_manager.release_lock(lock_key)
|
||||
else:
|
||||
app.logger.info("Skipping task. Another instance is already running.")
|
||||
return {'status': 'Task skipped, another instance is running'}
|
||||
|
||||
@celery.task(bind=True)
|
||||
def request_lidarr(self):
|
||||
lock_key = "request_lidarr_lock"
|
||||
|
||||
if task_manager.acquire_lock(lock_key, expiration=600):
|
||||
with app.app_context():
|
||||
if app.config['LIDARR_API_KEY'] and app.config['LIDARR_URL']:
|
||||
from app import lidarr_client
|
||||
try:
|
||||
app.logger.info('Submitting request to Lidarr...')
|
||||
# get all tracks from db
|
||||
tracks = Track.query.filter_by(lidarr_processed=False).all()
|
||||
total_items = len(tracks)
|
||||
processed_items = 0
|
||||
for track in tracks:
|
||||
tfp = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id)
|
||||
if tfp:
|
||||
if app.config['LIDARR_MONITOR_ARTISTS']:
|
||||
app.logger.debug("Monitoring artists instead of albums")
|
||||
# get all artists from all tracks_from_provider and unique them
|
||||
artists : dict[str,base.Artist] = {}
|
||||
|
||||
for artist in tfp.artists:
|
||||
artists[artist.name] = artist
|
||||
app.logger.debug(f"Found {len(artists)} artists to monitor")
|
||||
#pylint: disable=consider-using-dict-items
|
||||
for artist in artists:
|
||||
artist_from_lidarr = None
|
||||
search_result = lidarr_client.search(artists[artist].name)
|
||||
for url in artists[artist].external_urls:
|
||||
artist_from_lidarr : Artist = lidarr_client.get_object_by_external_url(search_result, url.url)
|
||||
if artist_from_lidarr:
|
||||
app.logger.debug(f"Found artist {artist_from_lidarr.artistName} by external url {url.url}")
|
||||
functions.apply_default_profile_and_root_folder(artist_from_lidarr)
|
||||
try:
|
||||
lidarr_client.monitor_artist(artist_from_lidarr)
|
||||
track.lidarr_processed = True
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error monitoring artist {artist_from_lidarr.artistName}: {str(e)}")
|
||||
|
||||
if not artist_from_lidarr:
|
||||
# if the artist isnt found by the external url, search by name
|
||||
artist_from_lidarr = lidarr_client.get_artists_by_name(search_result, artists[artist].name)
|
||||
for artist2 in artist_from_lidarr:
|
||||
functions.apply_default_profile_and_root_folder(artist2)
|
||||
try:
|
||||
lidarr_client.monitor_artist(artist2)
|
||||
track.lidarr_processed = True
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error monitoring artist {artist2.artistName}: {str(e)}")
|
||||
|
||||
processed_items += 1
|
||||
self.update_state(state=f'{processed_items}/{total_items}: {artist}', meta={'current': processed_items, 'total': total_items, 'percent': (processed_items / total_items) * 100})
|
||||
|
||||
else:
|
||||
if tfp.album:
|
||||
album_from_lidarr = None
|
||||
search_result = lidarr_client.search(tfp.album.name)
|
||||
# if the album isnt found by the external url, search by name
|
||||
album_from_lidarr = lidarr_client.get_albums_by_name(search_result, tfp.album.name)
|
||||
for album2 in album_from_lidarr:
|
||||
functions.apply_default_profile_and_root_folder(album2.artist)
|
||||
try:
|
||||
lidarr_client.monitor_album(album2)
|
||||
track.lidarr_processed = True
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error monitoring album {album2.title}: {str(e)}")
|
||||
processed_items += 1
|
||||
self.update_state(state=f'{processed_items}/{total_items}: {tfp.album.name}', meta={'current': processed_items, 'total': total_items, 'percent': (processed_items / total_items) * 100})
|
||||
|
||||
|
||||
app.logger.info(f'Requests sent to Lidarr. Total items: {total_items}')
|
||||
return {'status': 'Request sent to Lidarr'}
|
||||
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:
|
||||
app.logger.info('Lidarr API key or URL not set. Skipping request.')
|
||||
task_manager.release_lock(lock_key)
|
||||
|
||||
|
||||
else:
|
||||
app.logger.info("Skipping task. Another instance is already running.")
|
||||
return {'status': 'Task skipped, another instance is running'}
|
||||
|
||||
def find_best_match_from_jellyfin(track: Track):
|
||||
app.logger.debug(f"Trying to find best match from Jellyfin server for track: {track.name}")
|
||||
search_results = jellyfin.search_music_tracks(jellyfin_admin_token, functions.get_longest_substring(track.name))
|
||||
provider_track = None
|
||||
try:
|
||||
best_match = None
|
||||
best_quality_score = -1 # Initialize with the lowest possible score
|
||||
for result in search_results:
|
||||
app.logger.debug(f"Processing search result: {result['Id']}, Path = {result['Path']}")
|
||||
quality_score = compute_quality_score(result, app.config['FIND_BEST_MATCH_USE_FFPROBE'])
|
||||
try:
|
||||
provider_track = functions.get_cached_provider_track(track.provider_track_id, provider_id=track.provider_id)
|
||||
provider_track_name = provider_track.name.lower()
|
||||
provider_artists = [artist.name.lower() for artist in provider_track.artists]
|
||||
except Exception as e:
|
||||
app.logger.error(f"\tError fetching track details from Spotify for {track.name}: {str(e)}")
|
||||
continue
|
||||
jellyfin_track_name = result.get('Name', '').lower()
|
||||
if len(result.get('Artists', [])) == 1:
|
||||
jellyfin_artists = [a.lower() for a in result.get('Artists', [])[0].split('/')]
|
||||
else:
|
||||
jellyfin_artists = [artist.lower() for artist in result.get('Artists', [])]
|
||||
jellyfin_album_artists = [artist['Name'].lower() for artist in result.get('AlbumArtists', [])]
|
||||
|
||||
if provider_track and jellyfin_track_name and jellyfin_artists and provider_artists:
|
||||
app.logger.debug("\tTrack details to compare: ")
|
||||
app.logger.debug(f"\t\tJellyfin-Trackname : {jellyfin_track_name}")
|
||||
app.logger.debug(f"\t\t Spotify-Trackname : {provider_track_name}")
|
||||
app.logger.debug(f"\t\t Jellyfin-Artists : {jellyfin_artists}")
|
||||
app.logger.debug(f"\t\t Spotify-Artists : {provider_artists}")
|
||||
app.logger.debug(f"\t\t Jellyfin-Alb.Art.: {jellyfin_album_artists}")
|
||||
if len(search_results) == 1:
|
||||
app.logger.debug(f"\tOnly 1 search_result: {result['Id']} ({app.config['JELLYFIN_SERVER_URL']}/web/#/details?id={result['Id']})")
|
||||
|
||||
if (provider_track_name.lower() == jellyfin_track_name and
|
||||
(set(artist.lower() for artist in provider_artists) == set(jellyfin_artists) or set(jellyfin_album_artists) == set(artist.lower() for artist in provider_artists))):
|
||||
|
||||
app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
|
||||
best_match = result
|
||||
best_quality_score = quality_score
|
||||
break
|
||||
|
||||
|
||||
if (provider_track_name.lower() == jellyfin_track_name and
|
||||
(set(artist.lower() for artist in provider_artists) == set(jellyfin_artists) or set(jellyfin_album_artists) == set(artist.lower() for artist in provider_artists))):
|
||||
app.logger.debug(f"\tQuality score for track {result['Name']}: {quality_score} [{result['Path']}]")
|
||||
|
||||
if quality_score > best_quality_score:
|
||||
best_match = result
|
||||
best_quality_score = quality_score
|
||||
# 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)}")
|
||||
return None
|
||||
|
||||
def compute_quality_score(result, use_ffprobe=False) -> float:
|
||||
"""
|
||||
Compute a quality score for a track based on its metadata or detailed analysis using ffprobe.
|
||||
"""
|
||||
score = 0
|
||||
container = result.get('Container', '').lower()
|
||||
if container == 'flac':
|
||||
score += 100
|
||||
elif container == 'wav':
|
||||
score += 50
|
||||
elif container == 'mp3':
|
||||
score += 10
|
||||
elif container == 'aac':
|
||||
score += 5
|
||||
|
||||
if result.get('HasLyrics'):
|
||||
score += 10
|
||||
|
||||
#runtime_ticks = result.get('RunTimeTicks', 0)
|
||||
#score += runtime_ticks / 1e6
|
||||
|
||||
if use_ffprobe:
|
||||
path = result.get('Path')
|
||||
if path:
|
||||
profile = AudioProfile.analyze_audio_quality_with_ffprobe(path)
|
||||
ffprobe_score = profile.compute_quality_score()
|
||||
score += ffprobe_score
|
||||
else:
|
||||
app.logger.warning(f"No valid file path for track {result.get('Name')} - Skipping ffprobe analysis.")
|
||||
|
||||
return score
|
||||
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self):
|
||||
self.tasks = {
|
||||
'update_all_playlists_track_status': None,
|
||||
'download_missing_tracks': None,
|
||||
'check_for_playlist_updates': None,
|
||||
'update_jellyfin_id_for_downloaded_tracks': None
|
||||
}
|
||||
if app.config['LIDARR_API_KEY']:
|
||||
self.tasks['request_lidarr'] = None
|
||||
|
||||
def start_task(self, task_name, *args, **kwargs):
|
||||
if task_name not in self.tasks:
|
||||
raise ValueError(f"Task {task_name} is not defined.")
|
||||
task = globals()[task_name].delay(*args, **kwargs)
|
||||
self.tasks[task_name] = task.id
|
||||
return task.id,'STARTED'
|
||||
|
||||
def get_task_status(self, task_name):
|
||||
if task_name not in self.tasks:
|
||||
raise ValueError(f"Task {task_name} is not defined.")
|
||||
task_id = self.tasks[task_name]
|
||||
if not task_id:
|
||||
return {'state': 'NOT STARTED', 'info': {}, 'lock_status': False}
|
||||
result = AsyncResult(task_id)
|
||||
lock_status = True if self.get_lock(f"{task_name}_lock") else False
|
||||
return {'state': result.state, 'info': result.info if result.info else {}, 'lock_status': lock_status}
|
||||
|
||||
def acquire_lock(self, lock_name, expiration=60):
|
||||
return redis_client.set(lock_name, "locked", ex=expiration, nx=True)
|
||||
|
||||
def release_lock(self, lock_name):
|
||||
redis_client.delete(lock_name)
|
||||
|
||||
def get_lock(self, lock_name):
|
||||
return redis_client.get(lock_name)
|
||||
|
||||
def prepare_logger(self):
|
||||
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)20s() ] %(message)s"
|
||||
logging.basicConfig(format=FORMAT)
|
||||
|
||||
task_manager = TaskManager()
|
||||
7
app/typings.pyi
Normal file
7
app/typings.pyi
Normal file
@@ -0,0 +1,7 @@
|
||||
from flask import g
|
||||
from providers.base import MusicProviderClient
|
||||
|
||||
g: "Global"
|
||||
|
||||
class Global:
|
||||
music_provider: MusicProviderClient
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "v0.1.10"
|
||||
60
changelogs/0.1.6.md
Normal file
60
changelogs/0.1.6.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Whats up in Jellyplist 0.1.6?
|
||||
### 🆕Better Linking (in preparation for Lidarr integration)
|
||||
During the link-task `(update_jellyfin_id_for_downloaded_tracks)`, where Jellyplist tries to link a `Spotify-Track-Id` to a `Jellyfin-Track-Id` it performs now a search and tries to find a best match from the results also considering quality aspects of a file.
|
||||
You can also make use of `ffprobe`, so jellyplist get´s more detailed information about the quality profile of a file.
|
||||
To use `ffprobe` set the environment variable `FIND_BEST_MATCH_USE_FFPROBE` to `true` otherwise jellyplist will use quality information provided by the Jellyfin API.
|
||||
Fixes #14
|
||||
|
||||
In the Debug logs it will look like this:
|
||||
```log
|
||||
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 4410.866669999999 [/storage/media/music/Bronski Beat/The Age of Reason (2017)/CD 01/Bronski Beat - The Age of Reason - 05 - Smalltown Boy.flac]
|
||||
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 4100.6 [/storage/media/music/Bronski Beat/The Age of Consent (1984)/CD 01/Bronski Beat - The Age of Consent - 06 - Smalltown Boy.flac]
|
||||
find_best_match_from_jellyfin() ] DEBUG - Quality score for track Smalltown Boy: 3240.48 [/storage/media/music/__jellyplist/5vmRQ3zELMLUQPo2FLQ76x.mp3]
|
||||
```
|
||||
**What´s the benefit?**
|
||||
|
||||
Once a day, the task `update_jellyfin_id_for_downloaded_tracks` will do a full update on all tracks. This way you can listen to tracks and make use of the playlists until Lidarr provides you the same track but with better audio quality.
|
||||
|
||||
### 🆕Added REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK
|
||||
When setting the new environment variable `REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK` to `true` , jellyplist will trigger a music library update on your Jellyfin server, in case you dont have `Realtime Monitoring` enabled on your Jellyfin library.
|
||||
Fixes #10
|
||||
|
||||
### 🆕Removed cookies.txt requirement
|
||||
No need to use `cookies.txt` file to download tracks via spotDL
|
||||
>[!IMPORTANT]
|
||||
> Not using a cookies.txt file will limit the bitrate of downloaded tracks to `128kbit/s` 📻
|
||||
|
||||
### 🆕Added LOG_LEVEL
|
||||
Via the environment variable `LOG_LEVEL` you can control the log output now. The default python log levels are available:
|
||||
- CRITICAL
|
||||
- FATAL
|
||||
- ERROR
|
||||
- WARNING
|
||||
- INFO
|
||||
- DEBUG
|
||||
- NOTSET
|
||||
|
||||
### 🆕Added the possibility for admins to release task lock´s
|
||||
When a task will crash or whatsoever , the lock won´t be released and you have to wait for it to expire until you can run it manually. Now you can release it manually, in case you need it.
|
||||
>[!IMPORTANT]
|
||||
>You must be logged in as an admin
|
||||
|
||||
### 🆕Added the possibility for admins to remove playlists completely
|
||||
This way the playlist will be removed from "monitoring" and also be removed from jellyfin.
|
||||
>[!IMPORTANT]
|
||||
>You must be logged in as an admin
|
||||
|
||||
### 🆕Allow manual track re-linking
|
||||
In case something went wrong and you want to assign another Jellyfin track to a Spotify-Track-Id you can do it now manually.
|
||||
Just go to "View Playlist Details", in the table where the tracks are listed, hold the `CTRL` Key while clicking on the Play from Jellyfin button. You will be presented with the search modal and can choose whatever track you like.
|
||||
Fixex #13
|
||||
|
||||
### 🆕Added a badge on the lower left corner indicating the current version
|
||||
|
||||
### ⚒️Overall improvements in logging
|
||||
Changed log format and also added debug logging where (I think) it´s appropriate.
|
||||
|
||||
### 🐛 Bugfixes
|
||||
- Fixed a bug where playlists weren´t updated until the `snapshot-id` of a playlist changed. Fixes #9
|
||||
- Fixed a dependency error, which caused `chromaprint` fingerprinting to error out. Fixes #12
|
||||
- Fixed a paging error, which caused that only the first 100 elements of a playlists were added
|
||||
62
changelogs/0.1.7.md
Normal file
62
changelogs/0.1.7.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Whats up in Jellyplist 0.1.7?
|
||||
### Major overhaul
|
||||
I´ve been working the past week to make this project work again, after [Spotify announced to deprecate](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api) the playlist discover API´s , which were a crucial part of this project.
|
||||
I also took this opportunity at the same time to do a major overhaul, on how Jellyplist gathers data from a music provider. Music provider API implementations must now implement defined abstract classes to work with Jellyplist, think of it like _plugins_. Jellyplist now, in theory, can gather data from any music provider - just the _plugins_ must be written. It also doesn´t matter, if it have 1,2 or 10 Music Providers to playlists. So stay tuned for more to come.
|
||||
The next ones will be Deezer and YTM
|
||||
|
||||
After the providers will be implemented, I will at some point do the same with the media backend - so Jellyplist will be able to support other media backends like Navidrome, Plex, Emby and so on...
|
||||
|
||||
### 🆕New API Implementation for Spotify
|
||||
As mentioned above, I needed a new way to get playlists.
|
||||
Now, to get them , you don´t need an API Key to authenticate, you even don´t need to be authenticated at all. If you like to have Playlists recommended or created for you, you can use authentication via a cookie.
|
||||
To do this, add a env var to you `.env` file:
|
||||
```bash
|
||||
SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt'
|
||||
```
|
||||
And map the cookie from your local filesystem to the container path you´ve set in the `.env`file
|
||||
```yaml
|
||||
...
|
||||
...
|
||||
volumes:
|
||||
- /your/local/path/open.spotify.com_cookies.txt:${SPOTIFY_COOKIE_FILE}
|
||||
...
|
||||
...
|
||||
```
|
||||
|
||||
### 🆕Lidarr integration is here
|
||||
To enable the Lidarr integration add these to your `.env` file
|
||||
```bash
|
||||
LIDARR_API_KEY = aabbccddeeffgghh11223344 # self explaining
|
||||
LIDARR_URL = http://<your_lidarr_ip>:8686 # too
|
||||
LIDARR_MONITOR_ARTISTS = false # If false, only the corresponding
|
||||
# album will be set to monitored in lidarr, if true the whole artist
|
||||
# will be set as monitored. Be careful in the beginning as you might
|
||||
# hammer your lidarr instance and you indexers. Defaults to false
|
||||
```
|
||||
After you enabled Lidarr integration, make sure to go to _"Admin -> Lidarr"_ and set the default quality profile and music root folder.
|
||||
|
||||
With the Lidarr integration you get a nice workflow:
|
||||
1. Add Playlist
|
||||
2. Playlist gets downloaded via SpotDL and is available after some time
|
||||
3. At some point (every hour on x:50) the requests to Lidarr are made.
|
||||
4. Lidarr gets all files.
|
||||
5. Once a day Jellyplist is doing a full update on all tracks, and searches for the same track but with a better quality profile.
|
||||
|
||||
### ⚠️ New required env var
|
||||
Ensure to add `MUSIC_STORAGE_BASE_PATH` to your `.env` file.
|
||||
```bash
|
||||
MUSIC_STORAGE_BASE_PATH = '/storage/media/music' # The base path where
|
||||
# your music library is located.
|
||||
# Must be the same value as your music library in jellyfin
|
||||
```
|
||||
|
||||
### ⚠️ Breaking change
|
||||
As some table columns has been renamed, make sure to wipe your existing Jellyplist pgdata. Sorry for the inconvenience.
|
||||
|
||||
### Other changes, improvements and fixes
|
||||
- UI/UX: The index page now has content. From there you can directly drop a playlist link
|
||||
- UI/UX: The Search bar now works with the new API implementation
|
||||
- UI/UX: A new `Browse All` (per Music Provider) Page from where you can discover playlists
|
||||
- UI/UX: Check technical details on a track. Just doubleclick a row in the details view of a playlist.
|
||||
- UI/UX: Allow to link a track even it´s not marked as downloaded.
|
||||
- UI/UX: Reworked celery task management and the /admin/tasks UI
|
||||
15
changelogs/0.1.8.md
Normal file
15
changelogs/0.1.8.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Whats up in Jellyplist 0.1.8?
|
||||
Not much this time, just some small fixes and one enhancement.
|
||||
|
||||
### 🆕Jellyplist now checks for updates
|
||||
Jellyplist now checks the GitHub releases for new version.
|
||||
If a new version is available, you will notice the small badge on the lower left will pulsate slighty, so you don´t miss any new release :smile:
|
||||
|
||||
If you don´t like that Jellyplist is doing this, you can opt out by setting this env var in your `.env` file
|
||||
```bash
|
||||
CHECK_FOR_UPDATES = false
|
||||
```
|
||||
|
||||
### Other changes, improvements and fixes
|
||||
- Fix for #30 , where the output path for spotDL wasn´t created correctly
|
||||
|
||||
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
|
||||
|
||||
57
config.py
57
config.py
@@ -1,34 +1,65 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import app
|
||||
|
||||
class Config:
|
||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
JELLYFIN_SERVER_URL = os.getenv('JELLYFIN_SERVER_URL')
|
||||
JELLYFIN_ADMIN_USER = os.getenv('JELLYFIN_ADMIN_USER')
|
||||
JELLYFIN_ADMIN_PASSWORD = os.getenv('JELLYFIN_ADMIN_PASSWORD')
|
||||
SPOTIPY_CLIENT_ID = os.getenv('SPOTIPY_CLIENT_ID')
|
||||
SPOTIPY_CLIENT_SECRET = os.getenv('SPOTIPY_CLIENT_SECRET')
|
||||
JELLYFIN_REQUEST_TIMEOUT = int(os.getenv('JELLYFIN_REQUEST_TIMEOUT','10'))
|
||||
SPOTIFY_CLIENT_ID = os.getenv('SPOTIFY_CLIENT_ID')
|
||||
SPOTIFY_CLIENT_SECRET = os.getenv('SPOTIFY_CLIENT_SECRET')
|
||||
SPOTIFY_COOKIE_FILE = os.getenv('SPOTIFY_COOKIE_FILE')
|
||||
JELLYPLIST_DB_HOST = os.getenv('JELLYPLIST_DB_HOST')
|
||||
JELLYPLIST_DB_PORT = int(os.getenv('JELLYPLIST_DB_PORT','5432'))
|
||||
JELLYPLIST_DB_USER = os.getenv('JELLYPLIST_DB_USER')
|
||||
JELLYPLIST_DB_PASSWORD = os.getenv('JELLYPLIST_DB_PASSWORD')
|
||||
START_DOWNLOAD_AFTER_PLAYLIST_ADD = os.getenv('START_DOWNLOAD_AFTER_PLAYLIST_ADD',"true").lower() == 'true' # If a new Playlist is added, the Download Task will be scheduled immediately
|
||||
START_DOWNLOAD_AFTER_PLAYLIST_ADD = os.getenv('START_DOWNLOAD_AFTER_PLAYLIST_ADD',"false").lower() == 'true' # If a new Playlist is added, the Download Task will be scheduled immediately
|
||||
REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK = os.getenv('REFRESH_LIBRARIES_AFTER_DOWNLOAD_TASK',"false").lower() == 'true'
|
||||
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')
|
||||
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = os.getenv('SEARCH_JELLYFIN_BEFORE_DOWNLOAD',"true").lower() == 'true'
|
||||
FIND_BEST_MATCH_USE_FFPROBE = os.getenv('FIND_BEST_MATCH_USE_FFPROBE','false').lower() == 'true'
|
||||
SPOTIFY_COUNTRY_CODE = os.getenv('SPOTIFY_COUNTRY_CODE','DE')
|
||||
LIDARR_API_KEY = os.getenv('LIDARR_API_KEY','')
|
||||
LIDARR_URL = os.getenv('LIDARR_URL','')
|
||||
LIDARR_MONITOR_ARTISTS = os.getenv('LIDARR_MONITOR_ARTISTS','false').lower() == 'true'
|
||||
MUSIC_STORAGE_BASE_PATH = os.getenv('MUSIC_STORAGE_BASE_PATH')
|
||||
CHECK_FOR_UPDATES = os.getenv('CHECK_FOR_UPDATES','true').lower() == 'true'
|
||||
SPOTDL_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))
|
||||
|
||||
|
||||
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = True
|
||||
ENABLE_DEEZER = os.getenv('ENABLE_DEEZER','false').lower() == 'true'
|
||||
# SpotDL specific configuration
|
||||
SPOTDL_CONFIG = {
|
||||
'cookie_file': '/jellyplist/cookies.txt',
|
||||
'output': '/jellyplist_downloads/__jellyplist/{track-id}',
|
||||
'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)
|
||||
|
||||
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 = {
|
||||
@@ -36,12 +67,14 @@ class Config:
|
||||
'JELLYFIN_SERVER_URL': cls.JELLYFIN_SERVER_URL,
|
||||
'JELLYFIN_ADMIN_USER': cls.JELLYFIN_ADMIN_USER,
|
||||
'JELLYFIN_ADMIN_PASSWORD': cls.JELLYFIN_ADMIN_PASSWORD,
|
||||
'SPOTIPY_CLIENT_ID': cls.SPOTIPY_CLIENT_ID,
|
||||
'SPOTIPY_CLIENT_SECRET': cls.SPOTIPY_CLIENT_SECRET,
|
||||
|
||||
'SPOTIFY_CLIENT_ID': cls.SPOTIFY_CLIENT_ID,
|
||||
'SPOTIFY_CLIENT_SECRET': cls.SPOTIFY_CLIENT_SECRET,
|
||||
'JELLYPLIST_DB_HOST' : cls.JELLYPLIST_DB_HOST,
|
||||
'JELLYPLIST_DB_USER' : cls.JELLYPLIST_DB_USER,
|
||||
'JELLYPLIST_DB_PASSWORD' : cls.JELLYPLIST_DB_PASSWORD,
|
||||
'REDIS_URL': cls.REDIS_URL
|
||||
'REDIS_URL': cls.REDIS_URL,
|
||||
'MUSIC_STORAGE_BASE_PATH': cls.MUSIC_STORAGE_BASE_PATH
|
||||
}
|
||||
|
||||
missing_vars = [var for var, value in required_vars.items() if not value]
|
||||
@@ -49,4 +82,4 @@ class Config:
|
||||
if missing_vars:
|
||||
missing = ', '.join(missing_vars)
|
||||
sys.stderr.write(f"Error: The following environment variables are not set: {missing}\n")
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -2,13 +2,13 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import requests
|
||||
import base64
|
||||
from urllib.parse import quote
|
||||
import acoustid
|
||||
import chromaprint
|
||||
|
||||
import logging
|
||||
from jellyfin.objects import PlaylistMetadata
|
||||
|
||||
def _clean_query(query):
|
||||
@@ -23,12 +23,18 @@ def _clean_query(query):
|
||||
return cleaned_query
|
||||
|
||||
class JellyfinClient:
|
||||
def __init__(self, base_url):
|
||||
def __init__(self, base_url, timeout = 10):
|
||||
"""
|
||||
Initialize the Jellyfin client with the base URL of the server.
|
||||
:param base_url: The base URL of the Jellyfin server (e.g., 'http://localhost:8096')
|
||||
"""
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
self.logger.setLevel(os.getenv('LOG_LEVEL', 'INFO').upper())
|
||||
FORMAT = "[%(asctime)s][%(filename)18s:%(lineno)4s - %(funcName)23s() ] %(levelname)7s - %(message)s"
|
||||
logging.basicConfig(format=FORMAT)
|
||||
self.logger.debug(f"Initialized Jellyfin API Client. Base = '{self.base_url}', timeout = {timeout}")
|
||||
|
||||
def _get_headers(self, session_token: str):
|
||||
"""
|
||||
@@ -46,7 +52,7 @@ class JellyfinClient:
|
||||
:param password: The password of the user.
|
||||
:return: Access token and user ID
|
||||
"""
|
||||
login_url = f'{self.base_url}/Users/AuthenticateByName'
|
||||
url = f'{self.base_url}/Users/AuthenticateByName'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Emby-Authorization': f'MediaBrowser Client="JellyPlist", Device="Web", DeviceId="{device_id}", Version="1.0"'
|
||||
@@ -55,9 +61,10 @@ class JellyfinClient:
|
||||
'Username': username,
|
||||
'Pw': password
|
||||
}
|
||||
|
||||
response = requests.post(login_url, json=data, headers=headers)
|
||||
|
||||
self.logger.debug(f"Url={url}")
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result['AccessToken'], result['User']['Id'], result['User']['Name'],result['User']['Policy']['IsAdministrator']
|
||||
@@ -73,7 +80,7 @@ class JellyfinClient:
|
||||
:param song_ids: A list of song IDs to include in the playlist.
|
||||
:return: The newly created playlist object
|
||||
"""
|
||||
create_url = f'{self.base_url}/Playlists'
|
||||
url = f'{self.base_url}/Playlists'
|
||||
data = {
|
||||
'Name': name,
|
||||
'UserId': user_id,
|
||||
@@ -81,8 +88,10 @@ class JellyfinClient:
|
||||
'Ids': ','.join(song_ids), # Join song IDs with commas
|
||||
'IsPublic' : False
|
||||
}
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
response = requests.post(create_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10)
|
||||
response = requests.post(url, json=data, 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()
|
||||
@@ -96,24 +105,46 @@ class JellyfinClient:
|
||||
:param song_ids: A list of song IDs to include in the playlist.
|
||||
:return: The updated playlist object
|
||||
"""
|
||||
update_url = f'{self.base_url}/Playlists/{playlist_id}/Items'
|
||||
url = f'{self.base_url}/Playlists/{playlist_id}/Items'
|
||||
data = {
|
||||
'Ids': ','.join(song_ids) # Join song IDs with commas
|
||||
}
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
response = requests.post(update_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10)
|
||||
response = requests.post(url, json=data, headers=self._get_headers(session_token=session_token), 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": "Playlist updated successfully"}
|
||||
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:
|
||||
playlist_metadata_url = f'{self.base_url}/Items/{playlist_id}'
|
||||
url = f'{self.base_url}/Items/{playlist_id}'
|
||||
params = {
|
||||
'UserId' : user_id
|
||||
}
|
||||
response = requests.get(playlist_metadata_url, headers=self._get_headers(session_token=session_token), timeout=10, params = params)
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout, params = params)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Failed to fetch playlist metadata: {response.content}")
|
||||
@@ -144,8 +175,11 @@ class JellyfinClient:
|
||||
setattr(metadata_obj, key, value)
|
||||
|
||||
# Send the updated metadata to Jellyfin
|
||||
update_url = f'{self.base_url}/Items/{playlist_id}'
|
||||
response = requests.post(update_url, json=metadata_obj.to_dict(), headers=self._get_headers(session_token= session_token), timeout=10, params = params)
|
||||
url = f'{self.base_url}/Items/{playlist_id}'
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
response = requests.post(url, json=metadata_obj.to_dict(), headers=self._get_headers(session_token= session_token), timeout = self.timeout, params = params)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
|
||||
if response.status_code == 204:
|
||||
return {"status": "success", "message": "Playlist metadata updated successfully"}
|
||||
@@ -158,37 +192,79 @@ class JellyfinClient:
|
||||
Get all music playlists for the currently authenticated user.
|
||||
:return: A list of the user's music playlists
|
||||
"""
|
||||
playlists_url = f'{self.base_url}/Items'
|
||||
url = f'{self.base_url}/Items'
|
||||
params = {
|
||||
'IncludeItemTypes': 'Playlist', # Retrieve only playlists
|
||||
'Recursive': 'true', # Include nested playlists
|
||||
'Fields': 'OpenAccess' # Fields we want
|
||||
}
|
||||
|
||||
response = requests.get(playlists_url, headers=self._get_headers(session_token=session_token), params=params , timeout = 10)
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
response = requests.get(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 == 200:
|
||||
return response.json()['Items']
|
||||
else:
|
||||
raise Exception(f"Failed to get playlists: {response.content}")
|
||||
|
||||
|
||||
|
||||
def get_libraries(self, session_token: str):
|
||||
url = f'{self.base_url}/Library/VirtualFolders'
|
||||
params = {
|
||||
|
||||
}
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
response = requests.get(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 == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get playlists: {response.content}")
|
||||
|
||||
def refresh_library(self, session_token: str, library_id: str) -> bool:
|
||||
url = f'{self.base_url}/Items/{library_id}/Refresh'
|
||||
|
||||
params = {
|
||||
"Recursive": "true",
|
||||
"ImageRefreshMode": "Default",
|
||||
"MetadataRefreshMode": "Default",
|
||||
"ReplaceAllImages": "false",
|
||||
"RegenerateTrickplay": "false",
|
||||
"ReplaceAllMetadata": "false"
|
||||
}
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
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}")
|
||||
if response.status_code == 204:
|
||||
return True
|
||||
else:
|
||||
raise Exception(f"Failed to update library: {response.content}")
|
||||
|
||||
|
||||
|
||||
def search_music_tracks(self, session_token: str, search_query: str):
|
||||
"""
|
||||
Search for music tracks by title, song name, and optionally Spotify-ID.
|
||||
:param search_query: The search term (title or song name).
|
||||
:return: A list of matching songs.
|
||||
"""
|
||||
search_url = f'{self.base_url}/Items'
|
||||
url = f'{self.base_url}/Items'
|
||||
params = {
|
||||
'SearchTerm': _clean_query(search_query),
|
||||
'SearchTerm': search_query.replace('\'',"´").replace('’','´'),
|
||||
|
||||
'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}")
|
||||
|
||||
|
||||
response = requests.get(search_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
|
||||
response = requests.get(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 == 200:
|
||||
return response.json()['Items']
|
||||
@@ -197,26 +273,37 @@ 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
|
||||
add_url = f'{self.base_url}/Playlists/{playlist_id}/Items'
|
||||
params = {
|
||||
'ids': ','.join(song_ids), # Comma-separated song IDs
|
||||
'userId': user_id
|
||||
}
|
||||
# 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}")
|
||||
|
||||
# Send the request to Jellyfin API with query parameters
|
||||
response = requests.post(add_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
|
||||
for i in range(0, total_songs, batch_size):
|
||||
batch = song_ids[i:i + batch_size]
|
||||
params = {
|
||||
'ids': ','.join(batch), # Comma-separated song IDs
|
||||
'userId': user_id
|
||||
}
|
||||
self.logger.debug(f"Url={url} - Adding batch: {batch}")
|
||||
|
||||
# Check for success
|
||||
if response.status_code == 204: # 204 No Content indicates success
|
||||
return {"status": "success", "message": "Songs added to playlist successfully"}
|
||||
else:
|
||||
raise Exception(f"Failed to add songs to playlist: {response.status_code} - {response.content}")
|
||||
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}")
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -225,17 +312,25 @@ class JellyfinClient:
|
||||
:param song_ids: A list of song IDs to remove.
|
||||
:return: A success message.
|
||||
"""
|
||||
remove_url = f'{self.base_url}/Playlists/{playlist_id}/Items'
|
||||
params = {
|
||||
'EntryIds': ','.join(song_ids) # Join song IDs with commas
|
||||
}
|
||||
batch_size = 50
|
||||
total_songs = len(song_ids)
|
||||
self.logger.debug(f"Total songs to remove: {total_songs}")
|
||||
|
||||
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
|
||||
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(batch) # Join song IDs with commas
|
||||
}
|
||||
self.logger.debug(f"Url={url} - Removing batch: {batch}")
|
||||
|
||||
if response.status_code == 204: # 204 No Content indicates success for updating
|
||||
return {"status": "success", "message": "Songs removed from playlist successfully"}
|
||||
else:
|
||||
raise Exception(f"Failed to remove songs from playlist: {response.content}")
|
||||
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
|
||||
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):
|
||||
"""
|
||||
@@ -243,14 +338,26 @@ class JellyfinClient:
|
||||
:param playlist_id: The ID of the playlist to remove.
|
||||
:return: A success message upon successful deletion.
|
||||
"""
|
||||
remove_url = f'{self.base_url}/Items/{playlist_id}'
|
||||
|
||||
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), timeout=10)
|
||||
url = f'{self.base_url}/Items/{playlist_id}'
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
response = requests.delete(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||
|
||||
if response.status_code == 204: # 204 No Content indicates successful deletion
|
||||
return {"status": "success", "message": "Playlist removed successfully"}
|
||||
else:
|
||||
raise Exception(f"Failed to remove playlist: {response.content}")
|
||||
|
||||
def get_item(self, session_token: str, item_id: str):
|
||||
url = f'{self.base_url}/Items/{item_id}'
|
||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get item: {response.content}")
|
||||
|
||||
def remove_user_from_playlist(self, session_token: str, playlist_id: str, user_id: str):
|
||||
"""
|
||||
@@ -263,9 +370,11 @@ class JellyfinClient:
|
||||
"""
|
||||
# Construct the API endpoint URL
|
||||
url = f'{self.base_url}/Playlists/{playlist_id}/Users/{user_id}'
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
# Send the DELETE request to remove the user from the playlist
|
||||
response = requests.delete(url, headers=self._get_headers(session_token= session_token), timeout=10)
|
||||
response = requests.delete(url, headers=self._get_headers(session_token= session_token), timeout = self.timeout)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
|
||||
if response.status_code == 204:
|
||||
# 204 No Content indicates the user was successfully removed
|
||||
@@ -274,8 +383,36 @@ 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)
|
||||
|
||||
def set_playlist_cover_image(self, session_token: str, playlist_id: str, spotify_image_url: str):
|
||||
# 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):
|
||||
"""
|
||||
Set the cover image of a playlist in Jellyfin using an image URL from Spotify.
|
||||
|
||||
@@ -285,14 +422,14 @@ class JellyfinClient:
|
||||
:return: Success message or raises an exception on failure.
|
||||
"""
|
||||
# Step 1: Download the image from the Spotify URL
|
||||
response = requests.get(spotify_image_url, timeout=10)
|
||||
response = requests.get(provider_image_url, timeout = self.timeout)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Failed to download image from Spotify: {response.content}")
|
||||
|
||||
# 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':
|
||||
@@ -305,11 +442,13 @@ class JellyfinClient:
|
||||
headers['Content-Type'] = content_type # Set to the correct image type
|
||||
headers['Accept'] = '*/*'
|
||||
|
||||
# Step 5: Upload the Base64-encoded image to Jellyfin as a plain string in the request body
|
||||
upload_url = f'{self.base_url}/Items/{playlist_id}/Images/Primary'
|
||||
# url 5: Upload the Base64-encoded image to Jellyfin as a plain string in the request body
|
||||
url = f'{self.base_url}/Items/{playlist_id}/Images/Primary'
|
||||
self.logger.debug(f"Url={url}")
|
||||
|
||||
# Send the Base64-encoded image data
|
||||
upload_response = requests.post(upload_url, headers=headers, data=image_base64, timeout=10)
|
||||
upload_response = requests.post(url, headers=headers, data=image_base64, timeout = self.timeout)
|
||||
self.logger.debug(f"Response = {response.status_code}")
|
||||
|
||||
if upload_response.status_code == 204: # 204 No Content indicates success
|
||||
return {"status": "success", "message": "Playlist cover image updated successfully"}
|
||||
@@ -345,7 +484,7 @@ class JellyfinClient:
|
||||
headers = self._get_headers(session_token=session_token)
|
||||
|
||||
# Send the request to Jellyfin API
|
||||
response = requests.post(url, headers=headers, json=data,timeout = 10)
|
||||
response = requests.post(url, headers=headers, json=data,timeout = self.timeout)
|
||||
|
||||
# Check for success
|
||||
if response.status_code == 204:
|
||||
@@ -359,7 +498,7 @@ class JellyfinClient:
|
||||
|
||||
"""
|
||||
me_url = f'{self.base_url}/Users/Me'
|
||||
response = requests.get(me_url, headers=self._get_headers(session_token=session_token), timeout = 10)
|
||||
response = requests.get(me_url, headers=self._get_headers(session_token=session_token), timeout = self.timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
@@ -369,14 +508,25 @@ class JellyfinClient:
|
||||
def get_playlist_users(self, session_token: str, playlist_id: str):
|
||||
url = f'{self.base_url}/Playlists/{playlist_id}/Users'
|
||||
|
||||
response = requests.get(url, headers=self._get_headers(session_token=session_token), timeout=10)
|
||||
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 playlist metadata: {response.content}")
|
||||
|
||||
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.
|
||||
@@ -388,20 +538,30 @@ class JellyfinClient:
|
||||
"""
|
||||
try:
|
||||
# Download the Spotify preview audio
|
||||
|
||||
|
||||
self.logger.debug(f"Downloading preview {preview_url} to tmp file")
|
||||
tmp = self.download_preview_to_tempfile(preview_url=preview_url)
|
||||
if tmp is None:
|
||||
self.logger.error(f"Downloading preview {preview_url} to tmp file failed, not continuing")
|
||||
return False, None
|
||||
|
||||
# Convert the preview file to a normalized WAV file
|
||||
self.logger.debug(f"Converting preview to WAV file")
|
||||
|
||||
tmp_wav = self.convert_to_wav(tmp)
|
||||
if tmp_wav is None:
|
||||
self.logger.error(f"Converting preview to WAV failed, not continuing")
|
||||
os.remove(tmp)
|
||||
return False, None
|
||||
|
||||
# Fingerprint the normalized preview WAV file
|
||||
self.logger.debug(f"Performing fingerprinting on preview {tmp_wav}")
|
||||
|
||||
_, tmp_fp = acoustid.fingerprint_file(tmp_wav)
|
||||
tmp_fp_dec, version = chromaprint.decode_fingerprint(tmp_fp)
|
||||
tmp_fp_dec = np.array(tmp_fp_dec, dtype=np.uint32)
|
||||
self.logger.debug(f"decoded fingerprint for preview: {tmp_fp_dec[:5]}")
|
||||
|
||||
# Search for matching tracks in Jellyfin using only the song name
|
||||
search_query = song_name # Only use the song name in the search query
|
||||
@@ -468,12 +628,10 @@ class JellyfinClient:
|
||||
print(f"Error in search_track_in_jellyfin: {str(e)}")
|
||||
return False, None
|
||||
|
||||
|
||||
|
||||
# Helper methods used in search_track_in_jellyfin
|
||||
def download_preview_to_tempfile(self, preview_url):
|
||||
try:
|
||||
response = requests.get(preview_url, timeout=10)
|
||||
response = requests.get(preview_url, timeout = self.timeout)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
@@ -497,14 +655,18 @@ class JellyfinClient:
|
||||
"-acodec", "pcm_s16le", "-ar", "44100",
|
||||
"-ac", "2", output_file.name
|
||||
]
|
||||
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
self.logger.error(f"Error converting to WAV, subprocess exitcode: {result.returncode} , input_file_path = {input_file_path}")
|
||||
self.logger.error(f"\tprocess stdout: {result.stdout}")
|
||||
self.logger.error(f"\tprocess stderr: {result.stderr}")
|
||||
|
||||
os.remove(output_file.name)
|
||||
return None
|
||||
|
||||
return output_file.name
|
||||
except Exception as e:
|
||||
print(f"Error converting to WAV: {str(e)}")
|
||||
self.logger.error(f"Error converting to WAV: {str(e)}")
|
||||
return None
|
||||
|
||||
def sliding_fingerprint_similarity(self, full_fp, preview_fp):
|
||||
@@ -533,4 +695,4 @@ class JellyfinClient:
|
||||
|
||||
similarity = (1 - best_score) * 100 # Convert to percentage
|
||||
|
||||
return similarity, best_offset
|
||||
return similarity, best_offset
|
||||
|
||||
2
lidarr/__init__.py
Normal file
2
lidarr/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .client import LidarrClient
|
||||
__all__ = ["LidarrClient"]
|
||||
174
lidarr/classes.py
Normal file
174
lidarr/classes.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
@dataclass
|
||||
class Image:
|
||||
url: str
|
||||
coverType: str
|
||||
extension: str
|
||||
remoteUrl: str
|
||||
|
||||
@dataclass
|
||||
class Link:
|
||||
url: str
|
||||
name: str
|
||||
|
||||
@dataclass
|
||||
class Ratings:
|
||||
votes: int
|
||||
value: float
|
||||
|
||||
@dataclass
|
||||
class AddOptions:
|
||||
monitor: str
|
||||
albumsToMonitor: List[str]
|
||||
monitored: bool
|
||||
searchForMissingAlbums: bool
|
||||
|
||||
@dataclass
|
||||
class Statistics:
|
||||
albumCount: int
|
||||
trackFileCount: int
|
||||
trackCount: int
|
||||
totalTrackCount: int
|
||||
sizeOnDisk: int
|
||||
percentOfTracks: float
|
||||
|
||||
@dataclass
|
||||
class Member:
|
||||
name: str
|
||||
instrument: str
|
||||
images: List[Image]
|
||||
|
||||
@dataclass
|
||||
class Artist:
|
||||
mbId: Optional[str] = None
|
||||
tadbId: Optional[int] = None
|
||||
discogsId: Optional[int] = None
|
||||
allMusicId: Optional[str] = None
|
||||
overview: str = ""
|
||||
artistType: str = ""
|
||||
disambiguation: str = ""
|
||||
links: List[Link] = field(default_factory=list)
|
||||
nextAlbum: str = ""
|
||||
lastAlbum: str = ""
|
||||
images: List[Image] = field(default_factory=list)
|
||||
members: List[Member] = field(default_factory=list)
|
||||
remotePoster: str = ""
|
||||
path: str = ""
|
||||
qualityProfileId: int = 0
|
||||
metadataProfileId: int = 0
|
||||
monitored: bool = False
|
||||
monitorNewItems: str = ""
|
||||
rootFolderPath: Optional[str] = None
|
||||
folder: str = ""
|
||||
genres: List[str] = field(default_factory=list)
|
||||
cleanName: str = ""
|
||||
sortName: str = ""
|
||||
tags: List[int] = field(default_factory=list)
|
||||
added: str = ""
|
||||
addOptions: Optional[AddOptions] = None
|
||||
ratings: Optional[Ratings] = None
|
||||
statistics: Optional[Statistics] = None
|
||||
status : str = ""
|
||||
ended : bool = False
|
||||
artistName : str = ""
|
||||
foreignArtistId : str = ""
|
||||
id : int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Media:
|
||||
mediumNumber: int
|
||||
mediumName: str
|
||||
mediumFormat: str
|
||||
|
||||
@dataclass
|
||||
class Release:
|
||||
id: int
|
||||
albumId: int
|
||||
foreignReleaseId: str
|
||||
title: str
|
||||
status: str
|
||||
duration: int
|
||||
trackCount: int
|
||||
media: List[Media]
|
||||
mediumCount: int
|
||||
disambiguation: str
|
||||
country: List[str]
|
||||
label: List[str]
|
||||
format: str
|
||||
monitored: bool
|
||||
|
||||
@dataclass
|
||||
class Album:
|
||||
id: int = 0
|
||||
title: str = ""
|
||||
disambiguation: str = ""
|
||||
overview: str = ""
|
||||
artistId: int = 0
|
||||
foreignAlbumId: str = ""
|
||||
monitored: bool = False
|
||||
anyReleaseOk: bool = False
|
||||
profileId: int = 0
|
||||
duration: int = 0
|
||||
albumType: str = ""
|
||||
secondaryTypes: List[str] = field(default_factory=list)
|
||||
mediumCount: int = 0
|
||||
ratings: Ratings = None
|
||||
releaseDate: str = ""
|
||||
releases: List[Release] = field(default_factory=list)
|
||||
genres: List[str] = field(default_factory=list)
|
||||
media: List[Media] = field(default_factory=list)
|
||||
artist: Artist = field(default_factory=Artist)
|
||||
images: List[Image] = field(default_factory=list)
|
||||
links: List[Link] = field(default_factory=list)
|
||||
lastSearchTime: str = ""
|
||||
statistics: Statistics = None
|
||||
addOptions: Optional[dict] = field(default_factory=dict)
|
||||
remoteCover: str = ""
|
||||
|
||||
@dataclass
|
||||
class RootFolder:
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
path: str = ""
|
||||
defaultMetadataProfileId: int = 0
|
||||
defaultQualityProfileId: int = 0
|
||||
defaultMonitorOption: str = ""
|
||||
defaultNewItemMonitorOption: str = ""
|
||||
defaultTags: List[int] = field(default_factory=list)
|
||||
accessible: bool = False
|
||||
freeSpace: int = 0
|
||||
totalSpace: int = 0
|
||||
|
||||
@dataclass
|
||||
class Quality:
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
quality: Quality = field(default_factory=Quality)
|
||||
items: List[str] = field(default_factory=list)
|
||||
allowed: bool = False
|
||||
|
||||
@dataclass
|
||||
class FormatItem:
|
||||
id: int = 0
|
||||
format: int = 0
|
||||
name: str = ""
|
||||
score: int = 0
|
||||
|
||||
@dataclass
|
||||
class QualityProfile:
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
upgradeAllowed: bool = False
|
||||
cutoff: int = 0
|
||||
items: List[Item] = field(default_factory=list)
|
||||
minFormatScore: int = 0
|
||||
cutoffFormatScore: int = 0
|
||||
formatItems: List[FormatItem] = field(default_factory=list)
|
||||
143
lidarr/client.py
Normal file
143
lidarr/client.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import json
|
||||
import re
|
||||
from flask import jsonify
|
||||
import requests
|
||||
from typing import List, Optional
|
||||
from .classes import Album, Artist, QualityProfile, RootFolder
|
||||
import logging
|
||||
l = logging.getLogger(__name__)
|
||||
|
||||
class LidarrClient:
|
||||
def __init__(self, base_url: str, api_token: str):
|
||||
self.base_url = base_url
|
||||
self.api_token = api_token
|
||||
self.headers = {
|
||||
'X-Api-Key': self.api_token
|
||||
}
|
||||
|
||||
def _get(self, endpoint: str, params: Optional[dict] = None):
|
||||
response = requests.get(f"{self.base_url}{endpoint}", headers=self.headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
def _post(self, endpoint: str, json: dict):
|
||||
response = requests.post(f"{self.base_url}{endpoint}", headers=self.headers, json=json)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _put(self, endpoint: str, json: dict):
|
||||
response = requests.put(f"{self.base_url}{endpoint}", headers=self.headers, json=json)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_album(self, album_id: int) -> Album:
|
||||
l.debug(f"Getting album {album_id}")
|
||||
data = self._get(f"/api/v1/album/{album_id}")
|
||||
return Album(**data)
|
||||
|
||||
def get_artist(self, artist_id: int) -> Artist:
|
||||
l.debug(f"Getting artist {artist_id}")
|
||||
data = self._get(f"/api/v1/artist/{artist_id}")
|
||||
return Artist(**data)
|
||||
|
||||
def search(self, term: str) -> List[object]:
|
||||
l.debug(f"Searching for {term}")
|
||||
data = self._get("/api/v1/search", params={"term": term})
|
||||
results = []
|
||||
for item in data:
|
||||
if 'artist' in item:
|
||||
results.append(Artist(**item['artist']))
|
||||
elif 'album' in item:
|
||||
results.append(Album(**item['album']))
|
||||
return results
|
||||
# A method which takes a List[object] end external URL as parameter, and returns the object from the List[object] which has the same external URL as the parameter.
|
||||
def get_object_by_external_url(self, objects: List[object], external_url: str) -> object:
|
||||
l.debug(f"Getting object by external URL {external_url}")
|
||||
# We need to check whether the external_url matches intl-[a-zA-Z]{2}\/ it has to be replaced by an empty string
|
||||
external_url = re.sub(r"intl-[a-zA-Z]{2}\/", "", external_url)
|
||||
for obj in objects:
|
||||
# object can either be an Album or an Artist, so it can be verified and casted
|
||||
if isinstance(obj, Album):
|
||||
for link in obj.links:
|
||||
if link['url'] == external_url:
|
||||
return obj
|
||||
elif isinstance(obj, Artist):
|
||||
for link in obj.links:
|
||||
if link['url'] == external_url:
|
||||
return obj
|
||||
|
||||
return None
|
||||
# A method to get all Albums from List[object] where the name equals the parameter name
|
||||
def get_albums_by_name(self, objects: List[object], name: str) -> List[Album]:
|
||||
l.debug(f"Getting albums by name {name}")
|
||||
albums = []
|
||||
for obj in objects:
|
||||
if isinstance(obj, Album) and obj.title == name:
|
||||
artist = Artist(**obj.artist)
|
||||
obj.artist = artist
|
||||
albums.append(obj)
|
||||
return albums
|
||||
|
||||
# a method to get all artists from List[object] where the name equals the parameter name
|
||||
def get_artists_by_name(self, objects: List[object], name: str) -> List[Artist]:
|
||||
l.debug(f"Getting artists by name {name}")
|
||||
artists = []
|
||||
for obj in objects:
|
||||
if isinstance(obj, Artist) and obj.artistName == name:
|
||||
artists.append(obj)
|
||||
return artists
|
||||
|
||||
def create_album(self, album: Album) -> Album:
|
||||
l.debug(f"Creating album {album.title}")
|
||||
json_artist = album.artist.__dict__
|
||||
album.artist = json_artist
|
||||
data = self._post("/api/v1/album", json=album.__dict__)
|
||||
return Album(**data)
|
||||
|
||||
def update_album(self, album_id: int, album: Album) -> Album:
|
||||
l.debug(f"Updating album {album_id}")
|
||||
json_artist = album.artist.__dict__
|
||||
album.artist = json_artist
|
||||
data = self._put(f"/api/v1/album/{album_id}", json=album.__dict__)
|
||||
return Album(**data)
|
||||
|
||||
def create_artist(self, artist: Artist) -> Artist:
|
||||
l.debug(f"Creating artist {artist.artistName}")
|
||||
data = self._post("/api/v1/artist", json=artist.__dict__)
|
||||
return Artist(**data)
|
||||
|
||||
def update_artist(self, artist_id: int, artist: Artist) -> Artist:
|
||||
l.debug(f"Updating artist {artist_id}")
|
||||
data = self._put(f"/api/v1/artist/{artist_id}", json=artist.__dict__)
|
||||
return Artist(**data)
|
||||
|
||||
# shorthand method to set artist to monitored
|
||||
def monitor_artist(self, artist: Artist):
|
||||
artist.monitored = True
|
||||
l.debug(f"Monitoring artist {artist.artistName}")
|
||||
if artist.id == 0:
|
||||
artist = self.create_artist(artist)
|
||||
else:
|
||||
self.update_artist(artist.id, artist)
|
||||
# shorthand method to set album to monitored
|
||||
def monitor_album(self, album: Album):
|
||||
album.monitored = True
|
||||
|
||||
l.debug(f"Monitoring album {album.title}")
|
||||
if album.id == 0:
|
||||
album = self.create_album(album)
|
||||
else:
|
||||
self.update_album(album.id, album)
|
||||
|
||||
# a method to query /api/v1/rootfolder and return a List[RootFolder]
|
||||
def get_root_folders(self) -> List[RootFolder]:
|
||||
l.debug("Getting root folders")
|
||||
data = self._get("/api/v1/rootfolder")
|
||||
return [RootFolder(**folder) for folder in data]
|
||||
|
||||
# a method to query /api/v1/qualityprofile and return a List[QualityProfile]
|
||||
def get_quality_profiles(self) -> List[QualityProfile]:
|
||||
l.debug("Getting quality profiles")
|
||||
data = self._get("/api/v1/qualityprofile")
|
||||
return [QualityProfile(**profile) for profile in data]
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""refacotring db to work with multiple music provider
|
||||
|
||||
Revision ID: 18d056f49f59
|
||||
Revises: d4fef99d5d3c
|
||||
Create Date: 2024-11-29 22:51:41.271688
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '18d056f49f59'
|
||||
down_revision = 'd4fef99d5d3c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('provider_playlist_id', sa.String(length=120), nullable=False))
|
||||
batch_op.add_column(sa.Column('provider_uri', sa.String(length=120), nullable=False))
|
||||
batch_op.add_column(sa.Column('provider_id', sa.String(length=20), nullable=True))
|
||||
batch_op.drop_constraint('playlist_spotify_playlist_id_key', type_='unique')
|
||||
batch_op.drop_constraint('playlist_spotify_uri_key', type_='unique')
|
||||
batch_op.create_unique_constraint(None, ['provider_uri'])
|
||||
batch_op.create_unique_constraint(None, ['provider_playlist_id'])
|
||||
batch_op.drop_column('spotify_playlist_id')
|
||||
batch_op.drop_column('spotify_uri')
|
||||
|
||||
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('provider_track_id', sa.String(length=120), nullable=False))
|
||||
batch_op.add_column(sa.Column('provider_uri', sa.String(length=120), nullable=False))
|
||||
batch_op.add_column(sa.Column('provider_id', sa.String(length=20), nullable=True))
|
||||
batch_op.drop_constraint('track_spotify_track_id_key', type_='unique')
|
||||
batch_op.drop_constraint('track_spotify_uri_key', type_='unique')
|
||||
batch_op.create_unique_constraint(None, ['provider_track_id'])
|
||||
batch_op.create_unique_constraint(None, ['provider_uri'])
|
||||
batch_op.drop_column('spotify_track_id')
|
||||
batch_op.drop_column('spotify_uri')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('spotify_uri', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
|
||||
batch_op.add_column(sa.Column('spotify_track_id', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.create_unique_constraint('track_spotify_uri_key', ['spotify_uri'])
|
||||
batch_op.create_unique_constraint('track_spotify_track_id_key', ['spotify_track_id'])
|
||||
batch_op.drop_column('provider_id')
|
||||
batch_op.drop_column('provider_uri')
|
||||
batch_op.drop_column('provider_track_id')
|
||||
|
||||
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('spotify_uri', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
|
||||
batch_op.add_column(sa.Column('spotify_playlist_id', sa.VARCHAR(length=120), autoincrement=False, nullable=False))
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.create_unique_constraint('playlist_spotify_uri_key', ['spotify_uri'])
|
||||
batch_op.create_unique_constraint('playlist_spotify_playlist_id_key', ['spotify_playlist_id'])
|
||||
batch_op.drop_column('provider_id')
|
||||
batch_op.drop_column('provider_uri')
|
||||
batch_op.drop_column('provider_playlist_id')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add 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 ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add lidarr_processed flag
|
||||
|
||||
Revision ID: d13088ebddc5
|
||||
Revises: 18d056f49f59
|
||||
Create Date: 2024-12-03 22:44:21.287754
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd13088ebddc5'
|
||||
down_revision = '18d056f49f59'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('lidarr_processed', sa.Boolean(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||
batch_op.drop_column('lidarr_processed')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
111
readme.md
111
readme.md
@@ -1,7 +1,7 @@
|
||||

|
||||
|
||||
> [!WARNING]
|
||||
> Jellyplist is still at a very early stage: expect Bugs and weird behaviour
|
||||
> Jellyplist is still at a very early stage: expect Bugs and weird behaviour. Especially the UI and UX are a bit clunky and unresponsive
|
||||
|
||||
## What is Jellyplist ?
|
||||
Jellyplist aims to be a companion app for your self-hosted [Jellyfin](https://jellyfin.org/) Server. With Jellyplist you will be able to replicate/sync playlists from Spotify to your local Jellyfin account. Under the hood, it uses [SpotDL](https://spotdl.readthedocs.io/en/latest/) for downloading the corresponding tracks from the available sources if a track isn´t found in your local library.
|
||||
@@ -9,32 +9,59 @@ Jellyplist aims to be a companion app for your self-hosted [Jellyfin](https://je
|
||||
It´s definitely not a general Playlist Manager for Jellyfin.
|
||||
|
||||
## Features
|
||||
- **Discover Playlists**: Use well-known *Featured Playlists* listings.
|
||||
- **Categories**: Browse playlists by categories
|
||||
- **Discover Playlists**: Browse playlists like its nothing.
|
||||
- **View Monitored Playlists**: View playlists which are already synced by the server, adding these to your Jellyfin account will make them available immediately
|
||||
- **Search Playlist**: Search for playlists
|
||||
- **No Sign-Up or User-Accounts**: Jellyplist uses your local Jellyfin server for authentication
|
||||
- **Automatically keep track of changes**: Changes in order, added or removed songs will be tracked and synced with Jellyfin.
|
||||
- **Metadata Sync**: Playlist Metadata will be available at your Jellyfin Server
|
||||
- **Lidarr Integrations**: Automatically submit Artists or only Albums to your Lidarr instance
|
||||
- **Automatic Quality Upgrades**: When the same track from a playlist is added later with better quality, the playlist in Jellyfin will be updated to use the better sounding track.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The easiest way to start is by using docker and compose.
|
||||
1. Log in on https://developers.spotify.com/. Go to the dashboard, create an app and get your Client ID and Secret
|
||||
2. Get your [cookies.txt file for spot-dl ](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium)
|
||||
> [!IMPORTANT]
|
||||
> Currently a [youtube premium account](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium) is required, the next release will mitigate this.
|
||||
3. Prepare a `.env` File
|
||||
2. Get your [cookies.txt file for spot-dl ](https://spotdl.readthedocs.io/en/latest/usage/#youtube-music-premium) if you want downloaded files to have 256kbit/s, otherwise 128kbit/s
|
||||
3. Get your cookie-file from open.spotify.com , this works the same way as in step 2.
|
||||
4. Prepare a `.env` File
|
||||
```
|
||||
IMAGE = ghcr.io/kamilkosek/jellyplist:latest
|
||||
POSTGRES_USER = jellyplist
|
||||
POSTGRES_PASSWORD = jellyplist
|
||||
SECRET_KEY = Keykeykesykykesky # Secret key for session management
|
||||
JELLYFIN_SERVER_URL = http://192.168.178.14:8096 # local Jellyfin server
|
||||
JELLYFIN_ADMIN_USER = admin # due to api limitations jellyplist uses user authentication rather than api tokens
|
||||
JELLYFIN_ADMIN_PASSWORD = admin_password_for_your_jellyifn_admin
|
||||
SPOTIPY_CLIENT_ID = <Client ID from Step 1>
|
||||
SPOTIPY_CLIENT_SECRET = <Secret from Step 1>
|
||||
SPOTIFY_CLIENT_ID = <Client ID from Step 1>
|
||||
SPOTIFY_CLIENT_SECRET = <Secret from Step 1>
|
||||
JELLYPLIST_DB_HOST = postgres-jellyplist #Hostname of the db Container
|
||||
JELLYPLIST_DB_USER = jellyplist
|
||||
JELLYPLIST_DB_PASSWORD = jellyplist
|
||||
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 ("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. ("true" MAY INCURE PERFORMENCE ISSUES)
|
||||
|
||||
# LOG_LEVEL = DEBUG # Defaults to INFO
|
||||
|
||||
# SPOTIFY_COOKIE_FILE = '/jellyplist/spotify-cookie.txt' # Not necesarily needed, but if you like to browse your personal recomendations you must provide it so that the new api implementation is able to authenticate
|
||||
|
||||
### Lidarr integration
|
||||
# LIDARR_API_KEY = aabbccddeeffgghh11223344 # self explaining
|
||||
# LIDARR_URL = http://<your_lidarr_ip>:8686 # too
|
||||
# LIDARR_MONITOR_ARTISTS = false # If false, only the corresponding album will be set to monitored in lidarr, if true the whole artist will be set as monitored. Be careful in the beginning as you might hammer your lidarr instance and you indexers. Defaults to false
|
||||
|
||||
|
||||
```
|
||||
|
||||
4. Prepare a `docker-compose.yml`
|
||||
@@ -49,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:
|
||||
@@ -64,7 +91,7 @@ services:
|
||||
|
||||
jellyplist:
|
||||
container_name: jellyplist
|
||||
image: ghcr.io/kamilkosek/jellyplist:latest
|
||||
image: ${IMAGE}
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
@@ -73,49 +100,18 @@ services:
|
||||
networks:
|
||||
- jellyplist-network
|
||||
volumes:
|
||||
# Map Your cookies.txt file to exac
|
||||
- /your/local/path/cookies.txt:/jellyplist/cookies.txt
|
||||
- /storage/media/music:/jellyplist_downloads
|
||||
- /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
|
||||
- /storage/media/music:/jellyplist_downloads
|
||||
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`
|
||||
@@ -124,6 +120,7 @@ volumes:
|
||||
|
||||
## Technical Details/FAQ
|
||||
|
||||
|
||||
- _Why have I to provide a Jellyfin Admin and Password instead of a API Token ?_
|
||||
|
||||
Its because of some limitations in the Jellyfin API. The goal of Jellyplist was to always maintain only one copy of a playlist in Jellyfin and to use SharedPlaylists which are "owned" by one admin user.
|
||||
@@ -158,7 +155,25 @@ Jellyplist will cache requests where possible. Especially the `/tracks` endpoint
|
||||
|
||||
|
||||
- When logged in as admin, you will see the admin section in the sidebar. From there you can some kind of `batch linking`. All unlinked tracks will be displayed at once.
|
||||
> [!TIP]
|
||||
> Linking of tracks had to be done only once. If a different playlist has the same track, Jellyplist will reuse the link
|
||||

|
||||
|
||||
> [!TIP]
|
||||
> Linking of tracks had to be done only once. If a different playlist has the same track, Jellyplist will reuse the link
|
||||
|
||||
|
||||
#### After you added your first playlist´s, the worker and scheduler will take over from this point.
|
||||
The default schedules are:
|
||||
| **Schedule Name** | **Task** | **Schedule** |
|
||||
|--------------------------------------------|----------------------------------------------|----------------------|
|
||||
| `download-missing-tracks-schedule` | `app.tasks.download_missing_tracks` | Every day at minute 30 |
|
||||
| `check-playlist-updates-schedule` | `app.tasks.check_for_playlist_updates` | Every day at minute 25 |
|
||||
| `update_all_playlists_track_status-schedule`| `app.tasks.update_all_playlists_track_status`| Every 2 minutes |
|
||||
| `update_jellyfin_id_for_downloaded_tracks-schedule` | `app.tasks.update_jellyfin_id_for_downloaded_tracks` | Every 10 minutes |
|
||||
|
||||
For now the schedules aren´t configurable, but this is subject to change.
|
||||
> [!TIP]
|
||||
> Please be patient after you added your first batch of playlists! Jellyplist currently processes one track at a time, and this means it can take some time for you to see the first results.
|
||||
|
||||
Then let Jellyplist do it´s work, after some time you should be able to see the playlist in Jellyfin.
|
||||
|
||||
Have Fun ✌🏽
|
||||
@@ -9,12 +9,17 @@ 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
|
||||
chromaprint
|
||||
psycopg2-binary
|
||||
eventlet
|
||||
pydub
|
||||
fuzzywuzzy
|
||||
fuzzywuzzy
|
||||
pyyaml
|
||||
click
|
||||
pycryptodomex
|
||||
mutagen
|
||||
requests
|
||||
deezer-py
|
||||
@@ -3,16 +3,18 @@ body {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #1a1d21;
|
||||
background-color: #1a1d21;
|
||||
height: 100vh;
|
||||
padding-top: 20px;
|
||||
padding-left: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background-color: #1a1d21;
|
||||
background-color: #1a1d21;
|
||||
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
color: white;
|
||||
padding-left: 15px;
|
||||
@@ -50,6 +52,76 @@ body {
|
||||
width: 140px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.logo img{
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1600px) {
|
||||
.modal-xl {
|
||||
max-width: 90%;
|
||||
/* New width for default modal */
|
||||
}
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
margin-bottom: auto;
|
||||
margin-top: auto;
|
||||
height: 60px;
|
||||
background-color: #353b48;
|
||||
border-radius: 30px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.search_input {
|
||||
color: white;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: none;
|
||||
width: 450px;
|
||||
caret-color: transparent;
|
||||
line-height: 40px;
|
||||
transition: width 0.4s linear;
|
||||
}
|
||||
|
||||
.searchbar:hover>.search_input {
|
||||
/* padding: 0 10px; */
|
||||
width: 450px;
|
||||
caret-color: red;
|
||||
/* transition: width 0.4s linear; */
|
||||
}
|
||||
|
||||
.searchbar:hover>.search_icon {
|
||||
background: white;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.search_icon {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
float: right;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-pulsing {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
BIN
static/images/placeholder.png
Normal file
BIN
static/images/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -5,14 +5,14 @@ var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
})
|
||||
|
||||
// Function to open the search modal and trigger the search automatically
|
||||
function openSearchModal(trackTitle, spotify_id) {
|
||||
function openSearchModal(trackTitle, provider_track_id) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('searchModal'));
|
||||
const searchQueryInput = document.getElementById('search-query');
|
||||
const spotifyIdInput = document.getElementById('spotify-id');
|
||||
const providerTrackIdInput = document.getElementById('provider-track-id');
|
||||
|
||||
// Pre-fill the input fields
|
||||
searchQueryInput.value = trackTitle;
|
||||
spotifyIdInput.value = spotify_id;
|
||||
providerTrackIdInput.value = provider_track_id;
|
||||
|
||||
// Show the modal
|
||||
modal.show();
|
||||
@@ -83,4 +83,14 @@ function playJellyfinTrack(button, jellyfinId) {
|
||||
};
|
||||
})
|
||||
.catch(error => console.error('Error fetching Jellyfin stream URL:', error));
|
||||
}
|
||||
|
||||
function handleJellyfinClick(event, jellyfinId, trackTitle, providerTrackId) {
|
||||
if (event.ctrlKey) {
|
||||
// CTRL key is pressed, open the search modal
|
||||
openSearchModal(trackTitle, providerTrackId);
|
||||
} else {
|
||||
// CTRL key is not pressed, play the track
|
||||
playJellyfinTrack(event.target, jellyfinId);
|
||||
}
|
||||
}
|
||||
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,13 +4,23 @@
|
||||
<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>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/tasks">Tasks</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/lidarr">Lidarr</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/logs?name=logs">Logs</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
32
templates/admin/lidarr.html
Normal file
32
templates/admin/lidarr.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="container mt-5">
|
||||
<h1>Lidarr Configuration</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% else %}
|
||||
<form id="lidarrConfigForm" method="POST" action="{{ url_for('save_lidarr_config') }}">
|
||||
<div class="mb-3">
|
||||
<label for="qualityProfile" class="form-label">Default Quality Profile</label>
|
||||
<select class="form-select" id="qualityProfile" name="qualityProfile" required>
|
||||
{% for profile in quality_profiles %}
|
||||
<option value="{{ profile.id }}" {% if profile.id == current_quality_profile|int %}selected{% endif %}>{{ profile.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rootFolder" class="form-label">Default Root Folder</label>
|
||||
<select class="form-select" id="rootFolder" name="rootFolder" required>
|
||||
{% for folder in root_folders %}
|
||||
<option value="{{ folder.path }}" {% if folder.path == current_root_folder %}selected{% endif %}>{{ folder.path }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
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 %}
|
||||
@@ -1,21 +1,40 @@
|
||||
{% extends "admin.html" %}
|
||||
{% block admin_content %}
|
||||
<div class="container mt-5">
|
||||
|
||||
<!-- Tabelle für den Task-Status -->
|
||||
<table class="table">
|
||||
<div class="">
|
||||
<table class="table ">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Locked</th>
|
||||
<th>Task Name</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Das Partial wird dynamisch über HTMX geladen -->
|
||||
<tbody id="task-status" hx-get="/task_status" hx-trigger="every 1s" hx-swap="innerHTML">
|
||||
{% include 'partials/_task_status.html' %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h4>Unlock blocked tasks</h4>
|
||||
<div>
|
||||
<form hx-post="/unlock_key" hx-swap="outerHTML" hx-target="#empty">
|
||||
<div class="mb-3">
|
||||
<label for="inputLockKey" class="form-label">Lock Key Name</label>
|
||||
<select class="form-select form-select-lg mb-3" aria-label="Select task lock to unlock" id="inputLockKey" name="inputLockKey">
|
||||
{% for value in lock_keys %}
|
||||
<option value="{{value}}">{{value}}</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
<div id="inputLockKeyHelp" class="form-text">Provide a key name to to reset a lock. </div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Unlock</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="empty"></div>
|
||||
|
||||
{% 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>
|
||||
@@ -34,21 +35,30 @@
|
||||
<nav>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="fab fa-house"></i> Home</a>
|
||||
</li>
|
||||
{% for provider in registered_providers %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link " href="/browse?provider={{provider}}">
|
||||
<i class="fab fa-{{provider.lower()}}"></i> Browse {{provider}}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/categories"><i class="fa-solid fa-layer-group"></i>
|
||||
Categories</a>
|
||||
</li>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/playlists/monitored"><i
|
||||
class="fa-solid fa-tower-observation"></i> Monitored</a>
|
||||
</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>
|
||||
@@ -69,19 +79,28 @@
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="fas fa-house"></i> Home</a>
|
||||
</li>
|
||||
{% for provider in registered_providers %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link " href="/browse?provider={{provider}}">
|
||||
<i class="fab fa-{{provider.lower()}}"></i> Browse {{provider}}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link text-white" href="/playlists"><i class="fab fa-spotify"></i> Featured</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white" href="/categories"><i class="fa-solid fa-layer-group"></i>
|
||||
Categories</a>
|
||||
</li>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white" href="/playlists/monitored"><i
|
||||
class="fa-solid fa-tower-observation"></i> Monitored </a>
|
||||
</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">
|
||||
@@ -96,7 +115,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<span class="fixed-bottom m-3">{{version}}</span>
|
||||
<span class="fixed-bottom m-3 ms-5">{{version | version_check}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main content with toggle button for mobile sidebar -->
|
||||
@@ -108,36 +127,29 @@
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<h1 class="mb-4 ms-3">{{ title }}</h1>
|
||||
|
||||
<div class="d-flex align-items-center ">
|
||||
<form action="/search" method="GET" class="w-100">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="search"
|
||||
class="form-control"
|
||||
name="query"
|
||||
placeholder="Search Spotify..."
|
||||
aria-label="Search"
|
||||
>
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="ms-4">
|
||||
<!-- Display Initials Badge -->
|
||||
<span >{{ session.get('jellyfin_user_name') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search Form -->
|
||||
<form action="/search" method="GET" class="d-flex flex-grow-1 mb-1 me-2">
|
||||
<input
|
||||
type="search"
|
||||
class="form-control me-2"
|
||||
name="query"
|
||||
placeholder="Search ..."
|
||||
aria-label="Search"
|
||||
>
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<!-- Display Initials Badge -->
|
||||
<span>{{ session.get('jellyfin_user_name') }}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-1 ">{{ title }}</h1>
|
||||
<h3 class="mb-4 ">{{ subtitle }}</h3>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<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")
|
||||
|
||||
61
templates/browse.html
Normal file
61
templates/browse.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% for section in browse_data %}
|
||||
<div class="browse-section">
|
||||
<h1>{{ section.title }}</h1>
|
||||
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4">
|
||||
{% for card in section.items %}
|
||||
<div class="col">
|
||||
<div class="card shadow h-100 d-flex flex-column position-relative" >
|
||||
<a href="/browse/page/{{ card.uri }}?provider={{provider_id}}">
|
||||
<img src="{{ card.artwork.0.url }}" class="card-img-top" alt="{{ card.title }}">
|
||||
<div class="card-body d-flex flex-column justify-content-between">
|
||||
<h5 class="card-title">{{ card.title }}</h5>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
<style>
|
||||
.browse-section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.browse-section h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.browse-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.browse-card {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.browse-card img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.browse-card .title {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
font-size: 18px;
|
||||
text-shadow: 1px 1px 2px #000;
|
||||
}
|
||||
</style>
|
||||
12
templates/browse_page.html
Normal file
12
templates/browse_page.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- <h1 class="mb-4">{{ data.title }}</h1>
|
||||
<h6 class="mb-4">{{ data.subtitle }}</h6> -->
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4 mt-4" id="items-container">
|
||||
{% for item in data %}
|
||||
{% include 'partials/playlist_item.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,5 +2,36 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container h-100">
|
||||
<div class="d-flex justify-content-center h-100">
|
||||
<div class="searchbar">
|
||||
<!-- Form element with HTMX attributes -->
|
||||
<form method="GET" action="/playlist/open" >
|
||||
<input
|
||||
class="search_input"
|
||||
type="text"
|
||||
name="playlist"
|
||||
placeholder="Paste a Playlist ID or a complete link to a playlist"
|
||||
required
|
||||
>
|
||||
<button type="submit" class="search_icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger mt-5" role="alert">
|
||||
<h4 class="alert-heading">🚨Cant fetch playlist🚨</h4>
|
||||
<p>{{ error_message }}</p>
|
||||
<hr>
|
||||
<p>Additional Information:</p>
|
||||
<p>{{error_data}}</p>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger mt-5" role="alert">
|
||||
<h4 class="alert-heading">🚨Something went wrong🚨</h4>
|
||||
<p>{{ error_message }}</p>
|
||||
<hr>
|
||||
<p>Additional Information:</p>
|
||||
<p>{{error_data}}</p>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="mb-4">{{ items_title }}</h1>
|
||||
<h6 class="mb-4">{{ items_subtitle }}</h6>
|
||||
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
|
||||
@@ -8,5 +18,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger mt-5" role="alert">
|
||||
<h4 class="alert-heading">🚨Something went wrong🚨</h4>
|
||||
<p>{{ error_message }}</p>
|
||||
<hr>
|
||||
<p>Additional Information:</p>
|
||||
<p>{{error_data}}</p>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 >Your subscribed Jellyfin Playlists</h1>
|
||||
<h6 ></h6>
|
||||
<div class="row row-cols-1 row-cols-md-4 row-cols-lg-6 g-4" id="items-container">
|
||||
@@ -10,6 +20,6 @@
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
{%endif%}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
16
templates/monitored_playlists.html
Normal file
16
templates/monitored_playlists.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
{% for provider_id, playlists in provider_playlists_data.items() %}
|
||||
<div class="provider-section mb-5">
|
||||
<h2>{{ provider_id }}</h2>
|
||||
<div class="row row-cols-2 row-cols-md-6 g-4">
|
||||
{% for item in playlists %}
|
||||
{% include 'partials/playlist_item.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,12 +1,204 @@
|
||||
{% if item.can_add %}
|
||||
<button class="btn btn-success" hx-post="/addplaylist" hx-include="this" hx-swap="outerHTML" hx-target="this"
|
||||
|
||||
|
||||
{% 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 %}
|
||||
<button class="btn btn-warning" hx-delete="{{ url_for('delete_playlist', playlist_id=item['jellyfin_id']) }}"
|
||||
<span id="item-can-remove-{{ item.id }}" >
|
||||
<button class="btn btn-warning" hx-delete="{{ url_for('delete_playlist', playlist_id=item['jellyfin_id']) }}"
|
||||
hx-include="this" hx-swap="outerHTML" hx-target="this" data-bs-toggle="tooltip" title="Remove from Jellyfin">
|
||||
<i class="fa-solid fa-circle-minus"> </i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if session['is_admin'] and item.can_remove %}
|
||||
<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,6 +7,10 @@
|
||||
<th>Name</th>
|
||||
<th>Artist(s)</th>
|
||||
<th>Path</th>
|
||||
<th>Container</th>
|
||||
{% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -14,17 +18,21 @@
|
||||
<tbody>
|
||||
{% for track in results %}
|
||||
<tr>
|
||||
<td>{{ track.Name }}</td>
|
||||
<td>{{ track.Name | highlight(search_query) }}</td>
|
||||
<td>{{ ', '.join(track.Artists) }}</td>
|
||||
<td>{{ track.Path}}</td>
|
||||
<td>{{ track.Container }}</td>
|
||||
{% if config['DISPLAY_EXTENDED_AUDIO_DATA'] %}
|
||||
<td> {{track.Path | audioprofile(track.Path) }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="playJellyfinTrack(this, '{{ track.Id }}')">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button hx-swap="beforebegin" class="btn btn-sm btn-success" hx-post="/associate_track" hx-vals='{"jellyfin_id": "{{ track.Id }}","spotify_id": "{{ spotify_id}}"}'>
|
||||
Associate Track
|
||||
<button hx-swap="beforebegin" class="btn btn-sm btn-success" hx-post="/associate_track" hx-vals='{"jellyfin_id": "{{ track.Id }}","provider_track_id": "{{ provider_track_id}}"}' data-bs-toggle="tooltip" title="Link this Track [{{track.Id}}] with Provider-Track-ID {{ provider_track_id }}">
|
||||
Link Track
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
<div class="d-flex align-items-center row sticky-top py-3 mb-3 bg-dark" style="top: 0; z-index: 1000;">
|
||||
<div class="col-6">
|
||||
<img src="{{ playlist_cover }}" class="img-fluid">
|
||||
<img src="{{ item.image }}" class="img-fluid">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="playlist-info">
|
||||
<h1>{{ playlist_name }}</h1>
|
||||
<p>{{ playlist_description }}</p>
|
||||
<p>{{ track_count }} songs, {{ total_duration }}</p>
|
||||
<p>Last Updated: {{ last_updated}} | Last Change: {{ last_changed}}</p>
|
||||
<h1>{{ item.name }}</h1>
|
||||
<p>{{ item.description }}</p>
|
||||
<p>{{ item.track_count }} songs, {{ total_duration }}</p>
|
||||
<p>Last Updated: {{ item.last_updated | human_datetime}} | Last Change: {{ item.last_changed | human_datetime}}</p>
|
||||
{% include 'partials/_add_remove_button.html' %}
|
||||
<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>
|
||||
@@ -1,24 +0,0 @@
|
||||
{% for item in items %}
|
||||
{% include 'partials/_spotify_item.html' %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% if next_offset < total_items %}
|
||||
<div hx-get="{{ endpoint }}?offset={{ next_offset }}{{ additional_query }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="beforeend"
|
||||
hx-indicator=".loading-indicator"
|
||||
hx-target="#items-container"
|
||||
class="loading-indicator text-center">
|
||||
Loading more items...
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Show the loading indicator only when it is active
|
||||
document.querySelectorAll('.loading-indicator').forEach(indicator => {
|
||||
indicator.addEventListener('htmx:afterRequest', () => {
|
||||
indicator.style.display = 'none'; // Hide the indicator after the request completes
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,22 +1,40 @@
|
||||
{% for task_name, task in tasks.items() %}
|
||||
<tr id="task-row-{{ task_name }}">
|
||||
<td>{{ task_name }}</td>
|
||||
<td>{{ task.state }}</td>
|
||||
<td class="w-auto">
|
||||
{% if task.lock_status %}
|
||||
<i class="fas fa-lock text-warning"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-unlock text-success"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="w-25">{{ task_name }}</td>
|
||||
<td class="w-50">{{ task.state }}</td>
|
||||
<td>
|
||||
{% if task.info.percent %}
|
||||
{{ task.info.percent }}%
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div
|
||||
class="progress-bar {% if task.info.percent|round(0) == 100 %}bg-success{% else %}bg-primary{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ task.info.percent|round(2) }}%;"
|
||||
aria-valuenow="{{ task.info.percent|round(2) }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
{{ task.info.percent|round(2) }}%
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
N/A
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
|
||||
<button hx-post="/run_task/{{ task_name }}" hx-target="#task-row-{{ task_name }}" hx-swap="outerHTML"
|
||||
class="btn btn-primary">
|
||||
<button hx-post="/run_task/{{ task_name }}"
|
||||
hx-target="#task-row-{{ task_name }}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-primary">
|
||||
Run Task
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<div class="toast align-items-center text-white {{ 'bg-success' if success else 'bg-danger' }} border-0" role="alert"
|
||||
aria-live="assertive" aria-atomic="true" style="position: fixed; bottom: 20px; right: 20px; z-index: 1000;">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var toastElList = [].slice.call(document.querySelectorAll('.toast'))
|
||||
var toastList = toastElList.map(function (toastEl) {
|
||||
return new bootstrap.Toast(toastEl)
|
||||
})
|
||||
toastList.forEach(toast => toast.show());
|
||||
</script>
|
||||
@@ -1,76 +1,88 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Duration</th>
|
||||
<th scope="col">Spotify</th>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Jellyfin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for track in tracks %}
|
||||
<tr>
|
||||
<th scope="row">{{ loop.index }}</th>
|
||||
<td>{{ track.title }}</td>
|
||||
<td>{{ track.artist }}</td>
|
||||
<td>{{ track.duration }}</td>
|
||||
<td>
|
||||
<a href="{{ track.url }}" target="_blank" class="text-success" data-bs-toggle="tooltip" title="Open in Spotify">
|
||||
<i class="fab fa-spotify fa-lg"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if track.preview_url %}
|
||||
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')" data-bs-toggle="tooltip" title="Play Preview">
|
||||
<i class="fas fa-play"></i>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Duration</th>
|
||||
<th scope="col">{{provider_id}}</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Jellyfin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for track in tracks %}
|
||||
<tr hx-get="/track_details/{{track.provider_track_id}}?provider={{ provider_id }}"
|
||||
hx-target="#trackDetailsModalcontent" hx-trigger="dblclick" hx-on="htmx:afterOnLoad:showModal">
|
||||
<th scope="row">{{ loop.index }}</th>
|
||||
<td>{{ track.title }}</td>
|
||||
<td>{{ track.artist }}</td>
|
||||
<td>{{ track.duration }}</td>
|
||||
<td>
|
||||
<a href="{{ track.url[0] }}" target="_blank" class="text-success" data-bs-toggle="tooltip"
|
||||
title="Open in {{ track.provider_id }}">
|
||||
<i class="fab fa-{{ track.provider_id.lower() }} fa-lg"></i>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if not track.downloaded %}
|
||||
<button class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
|
||||
title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Downloaded">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set title = track.title | replace("'","") %}
|
||||
|
||||
{% if track.jellyfin_id %}
|
||||
<button class="btn btn-sm btn-success"
|
||||
onclick="handleJellyfinClick(event, '{{ track.jellyfin_id }}', '{{ title }}', '{{ track.provider_track_id }}')"
|
||||
data-bs-toggle="tooltip" title="Play from Jellyfin (Hold CTRL Key to reassing a new track)">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% elif track.downloaded %}
|
||||
<span data-bs-toggle="tooltip"
|
||||
title="Track Downloaded, but not in Jellyfin or could not be associated automatically. You can try to do the association manually">
|
||||
<button class="btn btn-sm btn-warning"
|
||||
onclick="openSearchModal('{{ title }}','{{track.provider_track_id}}')">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span data-bs-toggle="tooltip" title="No Preview Available">
|
||||
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not track.downloaded %}
|
||||
<button class="btn btn-sm btn-danger"
|
||||
data-bs-toggle="tooltip" title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Track downloaded">
|
||||
<i class="fa-solid fa-circle-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if track.jellyfin_id %}
|
||||
<button class="btn btn-sm btn-success" onclick="playJellyfinTrack(this, '{{ track.jellyfin_id }}')" data-bs-toggle="tooltip" title="Play from Jellyfin">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% elif track.downloaded %}
|
||||
<span data-bs-toggle="tooltip" title="Track Downloaded, but not in Jellyfin or could not be associated automatically. You can try to do the association manually">
|
||||
{% set title = track.title | replace("'","") %}
|
||||
<button class="btn btn-sm btn-warning" onclick="openSearchModal('{{ title }}','{{track.spotify_id}}')">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
</button>
|
||||
</span>
|
||||
{% else %}
|
||||
<span data-bs-toggle="tooltip" title="Not Available">
|
||||
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</span>
|
||||
{% else %}
|
||||
<span>
|
||||
<button class="btn btn-sm" onclick="openSearchModal('{{ title }}','{{track.provider_track_id}}')"
|
||||
data-bs-toggle="tooltip" title="Click to assign a track"><i class="fas fa-ban"></i></button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="modal fade" id="trackDetailsModal" tabindex="-1" aria-labelledby="trackDetailsModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content" id="trackDetailsModalcontent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('htmx:afterOnLoad', function(event) {
|
||||
if (event.detail.target.id === 'trackDetailsModalcontent') {
|
||||
const modal = new bootstrap.Modal(document.getElementById('trackDetailsModal'));
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="searchModalLabel">Search Jellyfin for Track</h5>
|
||||
@@ -80,15 +92,21 @@
|
||||
<!-- htmx-enabled form -->
|
||||
<form id="search-form" hx-get="/search_jellyfin" hx-target="#search-results" hx-trigger="submit">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" id="search-query" name="search_query" placeholder="Search for a track...">
|
||||
<input type="hidden" class="form-control" id="spotify-id" name="spotify_id" >
|
||||
<input type="text" class="form-control" id="search-query" name="search_query"
|
||||
placeholder="Search for a track...">
|
||||
<input type="hidden" class="form-control" id="provider-track-id" name="provider_track_id">
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="search-results">
|
||||
<!-- Search results will be inserted here by htmx -->
|
||||
<div id="loading-spinner" class="d-flex justify-content-center my-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="col">
|
||||
<div class="col" id="item-id-{{ item.id }}">
|
||||
<div class="card shadow h-100 d-flex flex-column position-relative">
|
||||
|
||||
<!-- Badge: Only show if status is available (i.e., playlist has been requested) -->
|
||||
@@ -20,7 +20,10 @@
|
||||
|
||||
<!-- Card Image -->
|
||||
<div style="position: relative;">
|
||||
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
|
||||
<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 Playlists">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</a>
|
||||
{%else%}
|
||||
|
||||
<a href="/playlist/view/{{ item.id }}" class="btn btn-primary" data-bs-toggle="tooltip"
|
||||
<a href="/playlist/view/{{ item.id }}?provider={{provider_id}}" class="btn btn-primary" data-bs-toggle="tooltip"
|
||||
title="View Playlist details">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</a>
|
||||
{%endif%}
|
||||
{% include 'partials/_add_remove_button.html' %}
|
||||
</div>
|
||||
</div>
|
||||
23
templates/partials/track_details.html
Normal file
23
templates/partials/track_details.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="trackDetailsModalLabel">Track Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p><strong>Title:</strong> {{ track.title }}</p>
|
||||
<p><strong>Artist:</strong> {{ track.artist }}</p>
|
||||
<p><strong>Duration:</strong> {{ track.duration }}</p>
|
||||
<p><strong>Duration (ms):</strong> {{ track.duration_ms }}</p>
|
||||
<p><strong>Provider:</strong> {{ track.provider_id }}</p>
|
||||
<p><strong>Provider Track URL:</strong> <a href="{{track.provider_track_url}}" target="_blank">{{track.provider_track_url}}</a></p>
|
||||
<!-- <p><strong>Preview URL:</strong> <a href="{{ track.preview_url }}" target="_blank">{{ track.preview_url if track.preview_url else 'No Preview Available' }}</a></p> -->
|
||||
<p><strong>Status:</strong> {{ 'Downloaded' if track.downloaded else 'Not Downloaded' }}</p>
|
||||
<p><strong>Jellyfin ID:</strong> {{ track.jellyfin_id | jellyfin_link }}</p>
|
||||
<p><strong>Provider Track ID:</strong> {{ track.provider_track_id }}</p>
|
||||
<p><strong>Download Status:</strong> {{ track.download_status }}</p>
|
||||
<p><strong>Filesystem Path:</strong> {{ track.filesystem_path }}</p>
|
||||
<p><strong>Jellyfin Filesystem Path:</strong> {{ track.jellyfin_filesystem_path if track.jellyfin_filesystem_path else 'N/A' }}</p>
|
||||
<p>{{ track.jellyfin_filesystem_path | audioprofile(track.jellyfin_filesystem_path) if track.jellyfin_filesystem_path else 'N/A' }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "v0.1.10"
|
||||
|
||||
Reference in New Issue
Block a user