v0.1.0
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Ignore Python cache files
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Ignore environment files
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Ignore system files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Ignore Git files
|
||||||
|
.git
|
||||||
|
cookies*
|
||||||
|
set_env.sh
|
||||||
|
jellyplist.code-workspace
|
||||||
78
.gitignore
vendored
Normal file
78
.gitignore
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Flask instance folder
|
||||||
|
instance/
|
||||||
|
|
||||||
|
# Flask static files
|
||||||
|
staticfiles/
|
||||||
|
|
||||||
|
# Flask cache and logs
|
||||||
|
*.log
|
||||||
|
*.pot
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# Flask session files
|
||||||
|
*.session
|
||||||
|
|
||||||
|
# Pytest
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
nosetests.xml
|
||||||
|
coverage/
|
||||||
|
*.coveragerc
|
||||||
|
*.coveragerc.bak
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# dotenv
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE specific
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.cache
|
||||||
|
cookies*.txt
|
||||||
|
*.code-workspace
|
||||||
|
set_env.sh
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Use the official Python image as a base image
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /jellyplist
|
||||||
|
|
||||||
|
# Copy requirements file and install dependencies
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN apt update
|
||||||
|
RUN apt install ffmpeg netcat-openbsd -y
|
||||||
|
# Copy the application code
|
||||||
|
COPY . .
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# Expose the port the app runs on
|
||||||
|
EXPOSE 5055
|
||||||
|
|
||||||
|
|
||||||
|
# Set the entrypoint
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "run.py"]
|
||||||
298
app/__init__.py
298
app/__init__.py
@@ -1,189 +1,151 @@
|
|||||||
import atexit
|
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
import os
|
import os
|
||||||
from flask import Flask
|
import time
|
||||||
|
from flask_socketio import SocketIO
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from flask import Flask, has_request_context
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
|
from psycopg2 import OperationalError
|
||||||
import spotipy
|
import spotipy
|
||||||
from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials
|
from spotipy.oauth2 import SpotifyClientCredentials
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
from sqlalchemy import create_engine
|
||||||
from config import Config
|
from config import Config
|
||||||
from jellyfin.client import JellyfinClient
|
from jellyfin.client import JellyfinClient
|
||||||
import logging
|
import logging
|
||||||
from spotdl import Spotdl
|
|
||||||
from spotdl.utils.config import DEFAULT_CONFIG
|
from spotdl.utils.config import DEFAULT_CONFIG
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_uri (str): The database URI.
|
||||||
|
retries (int): Number of retry attempts.
|
||||||
|
delay (int): Delay between retries in seconds.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If the database is not reachable after retries.
|
||||||
|
"""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
engine = create_engine(db_uri)
|
||||||
|
connection = engine.connect()
|
||||||
|
connection.close()
|
||||||
|
app.logger.info("Successfully connected to the database.")
|
||||||
|
return
|
||||||
|
except OperationalError as e:
|
||||||
|
app.logger.error(f"Database connection failed on attempt {attempt}/{retries}: {e}")
|
||||||
|
if attempt < retries:
|
||||||
|
app.logger.info(f"Retrying in {delay} seconds...")
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
app.logger.critical("Could not connect to the database. Exiting application.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Celery setup
|
||||||
|
def make_celery(app):
|
||||||
|
celery = Celery(
|
||||||
|
app.import_name,
|
||||||
|
backend=app.config['result_backend'],
|
||||||
|
broker=app.config['CELERY_BROKER_URL'],
|
||||||
|
include=['app.tasks']
|
||||||
|
)
|
||||||
|
celery.conf.update(app.config)
|
||||||
|
# Configure Celery Beat schedule
|
||||||
|
celery.conf.beat_schedule = {
|
||||||
|
'download-missing-tracks-schedule': {
|
||||||
|
'task': 'app.tasks.download_missing_tracks',
|
||||||
|
'schedule': crontab(minute='30'),
|
||||||
|
},
|
||||||
|
'check-playlist-updates-schedule': {
|
||||||
|
'task': 'app.tasks.check_for_playlist_updates',
|
||||||
|
'schedule': crontab(minute='25'),
|
||||||
|
},
|
||||||
|
'update_all_playlists_track_status-schedule': {
|
||||||
|
'task': 'app.tasks.update_all_playlists_track_status',
|
||||||
|
'schedule': crontab(minute='*/2'),
|
||||||
|
|
||||||
|
},
|
||||||
|
'update_jellyfin_id_for_downloaded_tracks-schedule': {
|
||||||
|
'task': 'app.tasks.update_jellyfin_id_for_downloaded_tracks',
|
||||||
|
'schedule': crontab(minute='*/10'),
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = Flask(__name__, template_folder="../templates")
|
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
sp = spotipy.Spotify(auth_manager= SpotifyClientCredentials(client_id=app.config['SPOTIPY_CLIENT_ID'], client_secret=app.config['SPOTIPY_CLIENT_SECRET']))
|
app.logger.setLevel(logging.DEBUG)
|
||||||
|
Config.validate_env_vars()
|
||||||
|
cache = Cache(app)
|
||||||
|
|
||||||
|
|
||||||
|
# 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']
|
||||||
|
))
|
||||||
|
|
||||||
|
app.logger.info(f"setting up jellyfin client")
|
||||||
|
|
||||||
jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL'])
|
jellyfin = JellyfinClient(app.config['JELLYFIN_SERVER_URL'])
|
||||||
spotdl_config = DEFAULT_CONFIG
|
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
|
||||||
|
)
|
||||||
|
|
||||||
spotdl_config['cookie_file'] = '/jellyplist/cookies.txt'
|
# SQLAlchemy and Migrate setup
|
||||||
spotdl_config['output'] = '/storage/media/music/_spotify_playlists/{track-id}'
|
app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}")
|
||||||
spotdl_config['threads'] = 12
|
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)
|
||||||
spotdl = Spotdl( app.config['SPOTIPY_CLIENT_ID'],app.config['SPOTIPY_CLIENT_SECRET'], downloader_settings=spotdl_config)
|
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{app.config['JELLYPLIST_DB_USER']}:{app.config['JELLYPLIST_DB_PASSWORD']}@{app.config['JELLYPLIST_DB_HOST']}/jellyplist'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Configurations
|
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://jellyplist:jellyplist@192.168.178.14/jellyplist'
|
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
|
app.logger.info(f"applying db migrations")
|
||||||
migrate = Migrate(app, db)
|
migrate = Migrate(app, db)
|
||||||
|
|
||||||
# Configure Logging
|
# Celery Configuration (Updated)
|
||||||
log_file = 'app.log'
|
app.config.update(
|
||||||
handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3) # 100KB per file, with 3 backups
|
CELERY_BROKER_URL=app.config['REDIS_URL'],
|
||||||
handler.setLevel(logging.INFO)
|
result_backend=app.config['REDIS_URL']
|
||||||
|
)
|
||||||
app.logger.info('Application started')
|
|
||||||
|
|
||||||
from app import routes, models
|
|
||||||
from app.models import JellyfinUser,Track,Playlist
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
# Initialize the scheduler
|
app.logger.info(f'Jellyplist {__version__} started')
|
||||||
scheduler = BackgroundScheduler()
|
from app import routes
|
||||||
|
from app import jellyfin_routes, tasks
|
||||||
def update_all_playlists_track_status():
|
if "worker" in sys.argv:
|
||||||
"""
|
tasks.release_lock("download_missing_tracks_lock")
|
||||||
Update track_count and tracks_available for all playlists in the database.
|
|
||||||
For each track, check if the file exists on the filesystem. If not, reset the downloaded flag and filesystem_path.
|
|
||||||
"""
|
|
||||||
with app.app_context():
|
|
||||||
playlists = Playlist.query.all()
|
|
||||||
|
|
||||||
if not playlists:
|
|
||||||
app.logger.info("No playlists found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
for playlist in playlists:
|
|
||||||
total_tracks = 0
|
|
||||||
available_tracks = 0
|
|
||||||
|
|
||||||
for track in playlist.tracks:
|
|
||||||
total_tracks += 1
|
|
||||||
|
|
||||||
# Check if the file exists
|
|
||||||
if track.filesystem_path and os.path.exists(track.filesystem_path):
|
|
||||||
available_tracks += 1
|
|
||||||
else:
|
|
||||||
# If the file doesn't exist, reset the 'downloaded' flag and 'filesystem_path'
|
|
||||||
track.downloaded = False
|
|
||||||
track.filesystem_path = None
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Update playlist fields
|
|
||||||
playlist.track_count = total_tracks
|
|
||||||
playlist.tracks_available = available_tracks
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
app.logger.info("All playlists' track statuses updated.")
|
|
||||||
|
|
||||||
def download_missing_tracks():
|
|
||||||
app.logger.info("Starting track download job...")
|
|
||||||
with app.app_context():
|
|
||||||
# Get all tracks that are not downloaded
|
|
||||||
undownloaded_tracks = Track.query.filter_by(downloaded=False).all()
|
|
||||||
|
|
||||||
if not undownloaded_tracks:
|
|
||||||
app.logger.info("No undownloaded tracks found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
for track in undownloaded_tracks:
|
|
||||||
app.logger.info(f"Trying to downloading track: {track.name} ({track.spotify_track_id})")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Download track using spotDL
|
|
||||||
s_url = f"https://open.spotify.com/track/{track.spotify_track_id}"
|
|
||||||
search = spotdl.search([s_url])
|
|
||||||
if search:
|
|
||||||
song = search[0]
|
|
||||||
dl_request = spotdl.download(song)
|
|
||||||
# Assuming spotDL downloads files to the './downloads' directory, set the filesystem path
|
|
||||||
file_path = dl_request[1].__str__() # Adjust according to naming conventions
|
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
# Update the track's downloaded status and filesystem path
|
|
||||||
track.downloaded = True
|
|
||||||
track.filesystem_path = file_path
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
app.logger.info(f"Track {track.name} downloaded successfully to {file_path}.")
|
|
||||||
else:
|
|
||||||
app.logger.error(f"Download failed for track {track.name}: file not found.")
|
|
||||||
else:
|
|
||||||
app.logger.warning(f"{track.name} ({track.spotify_track_id}) not Found")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Error downloading track {track.name}: {str(e)}")
|
|
||||||
|
|
||||||
app.logger.info("Track download job finished.")
|
|
||||||
update_all_playlists_track_status()
|
|
||||||
|
|
||||||
def check_for_playlist_updates():
|
|
||||||
app.logger.info('Starting playlist update check...')
|
|
||||||
with app.app_context():
|
|
||||||
try:
|
|
||||||
playlists = Playlist.query.all() # Get all users
|
|
||||||
for playlist in playlists:
|
|
||||||
app.logger.info(f'Checking updates for playlist: {playlist.name}')
|
|
||||||
try:
|
|
||||||
# Fetch the latest data from the Spotify API
|
|
||||||
playlist_data = sp.playlist(playlist.spotify_playlist_id)
|
|
||||||
spotify_tracks = {track['track']['id']: track['track'] for track in playlist_data['tracks']['items']}
|
|
||||||
existing_tracks = {track.spotify_track_id: track for track in playlist.tracks}
|
|
||||||
|
|
||||||
# Tracks to add
|
|
||||||
tracks_to_add = []
|
|
||||||
for track_id, track_info in spotify_tracks.items():
|
|
||||||
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)
|
|
||||||
db.session.commit()
|
|
||||||
app.logger.info(f'Added new track: {track.name}')
|
|
||||||
tracks_to_add.append(track)
|
|
||||||
|
|
||||||
# Tracks to remove
|
|
||||||
tracks_to_remove = [existing_tracks[track_id] for track_id in existing_tracks if track_id not in spotify_tracks]
|
|
||||||
|
|
||||||
if tracks_to_add:
|
|
||||||
for track in tracks_to_add:
|
|
||||||
playlist.tracks.append(track)
|
|
||||||
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}')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Error updating playlist {playlist.name}: {str(e)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Error in check_for_playlist_updates: {str(e)}")
|
|
||||||
|
|
||||||
app.logger.info('Finished playlist update check.')
|
|
||||||
update_all_playlists_track_status()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Add the job to run every 10 minutes (customize the interval as needed)
|
|
||||||
#scheduler.add_job(download_missing_tracks, 'interval', seconds=30, max_instances=1)
|
|
||||||
#scheduler.add_job(check_for_playlist_updates, 'interval', minutes=10, max_instances=1)
|
|
||||||
#download_missing_tracks()
|
|
||||||
#check_for_playlist_updates()
|
|
||||||
# Start the scheduler
|
|
||||||
scheduler.start()
|
|
||||||
|
|
||||||
# Ensure the scheduler shuts down properly when the app stops
|
|
||||||
atexit.register(lambda: scheduler.shutdown())
|
|
||||||
295
app/functions.py
Normal file
295
app/functions.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
from flask import flash, redirect, session, url_for
|
||||||
|
from app.models import JellyfinUser, Playlist,Track
|
||||||
|
from app import sp, cache, app, jellyfin ,jellyfin_admin_token, jellyfin_admin_id,device_id, cache
|
||||||
|
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 jellyfin.objects import PlaylistMetadata
|
||||||
|
|
||||||
|
TASK_STATUS = {
|
||||||
|
'update_all_playlists_track_status': None,
|
||||||
|
'download_missing_tracks': None,
|
||||||
|
'check_for_playlist_updates': None,
|
||||||
|
'update_jellyfin_id_for_downloaded_tracks' : None
|
||||||
|
}
|
||||||
|
|
||||||
|
def manage_task(task_name):
|
||||||
|
task_id = TASK_STATUS.get(task_name)
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
result = AsyncResult(task_id)
|
||||||
|
if result.state in ['PENDING', 'STARTED']:
|
||||||
|
return result.state, result.info if result.info else {}
|
||||||
|
if task_name == 'update_all_playlists_track_status':
|
||||||
|
result = update_all_playlists_track_status.delay()
|
||||||
|
elif task_name == 'download_missing_tracks':
|
||||||
|
result = download_missing_tracks.delay()
|
||||||
|
elif task_name == 'check_for_playlist_updates':
|
||||||
|
result = check_for_playlist_updates.delay()
|
||||||
|
elif task_name == 'update_jellyfin_id_for_downloaded_tracks':
|
||||||
|
result = update_jellyfin_id_for_downloaded_tracks.delay()
|
||||||
|
|
||||||
|
TASK_STATUS[task_name] = result.id
|
||||||
|
return result.state, result.info if result.info else {}
|
||||||
|
|
||||||
|
|
||||||
|
def prepPlaylistData(data):
|
||||||
|
playlists = []
|
||||||
|
jellyfin_user = JellyfinUser.query.filter_by(jellyfin_user_id=session['jellyfin_user_id']).first()
|
||||||
|
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)}")
|
||||||
|
return None
|
||||||
|
@cache.memoize(timeout=3600*24*10)
|
||||||
|
def get_cached_spotify_track(track_id):
|
||||||
|
"""
|
||||||
|
Fetches a Spotify track by its ID, utilizing caching to minimize API calls.
|
||||||
|
|
||||||
|
:param track_id: The Spotify playlist 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
|
||||||
|
return track_data
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error fetching track {track_id} from Spotify: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prepAlbumData(data):
|
||||||
|
items = []
|
||||||
|
for item in data['albums']['items']:
|
||||||
|
items.append({
|
||||||
|
'name': item['name'],
|
||||||
|
'description': f"Released: {item['release_date']}",
|
||||||
|
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
|
||||||
|
'url': item['external_urls']['spotify'],
|
||||||
|
'id' : item['id'],
|
||||||
|
'can_add' : False
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
def prepArtistData(data):
|
||||||
|
items = []
|
||||||
|
for item in data['artists']['items']:
|
||||||
|
items.append({
|
||||||
|
'name': item['name'],
|
||||||
|
'description': f"Popularity: {item['popularity']}",
|
||||||
|
'image': item['images'][0]['url'] if item['images'] else 'default-image.jpg',
|
||||||
|
'url': item['external_urls']['spotify'],
|
||||||
|
'id' : item['id'],
|
||||||
|
'can_add' : False
|
||||||
|
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def getFeaturedPlaylists(country,offset):
|
||||||
|
playlists_data = sp.featured_playlists(country=country, limit=16, offset=offset)
|
||||||
|
|
||||||
|
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],'Featured Playlists'
|
||||||
|
|
||||||
|
def getCategoryPlaylists(category,offset):
|
||||||
|
playlists_data = sp.category_playlists(category_id=category, limit=16, offset=offset)
|
||||||
|
|
||||||
|
return prepPlaylistData(playlists_data), playlists_data['playlists']['total'],f"Category {playlists_data['message']}"
|
||||||
|
|
||||||
|
def getCategories(country,offset):
|
||||||
|
categories_data = sp.categories(limit=16, offset= offset)
|
||||||
|
categories = []
|
||||||
|
|
||||||
|
for cat in categories_data['categories']['items']:
|
||||||
|
categories.append({
|
||||||
|
'name': cat['name'],
|
||||||
|
'description': '',
|
||||||
|
'image': cat['icons'][0]['url'] if cat['icons'] else 'default-image.jpg',
|
||||||
|
'url': f"/playlists?cat={cat['id']}",
|
||||||
|
'id' : cat['id'],
|
||||||
|
'type':'category'
|
||||||
|
})
|
||||||
|
return categories, categories_data['categories']['total'],'Browse Categories'
|
||||||
|
|
||||||
|
def get_tracks_for_playlist(data):
|
||||||
|
results = data
|
||||||
|
tracks = []
|
||||||
|
is_admin = session.get('is_admin', False)
|
||||||
|
|
||||||
|
for idx, item in enumerate(results['tracks']):
|
||||||
|
track_data = item['track']
|
||||||
|
if track_data:
|
||||||
|
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()
|
||||||
|
|
||||||
|
if track_db:
|
||||||
|
downloaded = track_db.downloaded
|
||||||
|
filesystem_path = track_db.filesystem_path if is_admin else None
|
||||||
|
jellyfin_id = track_db.jellyfin_id
|
||||||
|
download_status = track_db.download_status
|
||||||
|
else:
|
||||||
|
downloaded = False
|
||||||
|
filesystem_path = None
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
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):
|
||||||
|
if 'jellyfin_user_name' not in session:
|
||||||
|
flash('You need to log in using your Jellyfin Credentials to access this page.', 'warning')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def jellyfin_admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not session['is_admin']:
|
||||||
|
flash('You need to be a Jellyfin admin.', 'warning')
|
||||||
|
return 404 # Redirect to your login route
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def update_playlist_metadata(playlist,spotify_playlist_data):
|
||||||
|
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']
|
||||||
|
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'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_token_from_sessioncookie() -> str:
|
||||||
|
return session['jellyfin_access_token']
|
||||||
|
def _get_api_token() -> str:
|
||||||
|
#return app.config['JELLYFIN_ACCESS_TOKEN']
|
||||||
|
return jellyfin_admin_token
|
||||||
|
def _get_logged_in_user():
|
||||||
|
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
|
||||||
176
app/jellyfin_routes.py
Normal file
176
app/jellyfin_routes.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
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
|
||||||
62
app/models.py
Normal file
62
app/models.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from app import db
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
class JellyfinUser(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
|
jellyfin_user_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||||
|
is_admin = db.Column(db.Boolean, default=False, nullable=False) # New property
|
||||||
|
|
||||||
|
# Relationship with Playlist
|
||||||
|
playlists = db.relationship('Playlist', secondary='user_playlists', back_populates='users')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<JellyfinUser {self.name}:{self.jellyfin_user_id}>'
|
||||||
|
|
||||||
|
# Association table between Users and Playlists
|
||||||
|
user_playlists = db.Table('user_playlists',
|
||||||
|
db.Column('user_id', db.Integer, db.ForeignKey('jellyfin_user.id'), primary_key=True),
|
||||||
|
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id'), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Relationship with Tracks
|
||||||
|
tracks = db.relationship('Track', secondary='playlist_tracks', back_populates='playlists')
|
||||||
|
track_count = db.Column(db.Integer())
|
||||||
|
tracks_available = db.Column(db.Integer())
|
||||||
|
jellyfin_id = db.Column(db.String(120), nullable=True)
|
||||||
|
last_updated = db.Column(db.DateTime )
|
||||||
|
last_changed = db.Column(db.DateTime )
|
||||||
|
# Many-to-Many relationship with JellyfinUser
|
||||||
|
users = db.relationship('JellyfinUser', secondary=user_playlists, back_populates='playlists')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Playlist {self.name}:{self.spotify_playlist_id}>'
|
||||||
|
|
||||||
|
# Association table between Playlists and Tracks
|
||||||
|
playlist_tracks = db.Table('playlist_tracks',
|
||||||
|
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id'), primary_key=True),
|
||||||
|
db.Column('track_id', db.Integer, db.ForeignKey('track.id'), primary_key=True),
|
||||||
|
db.Column('track_order', db.Integer, nullable=False) # New field for track order
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Many-to-Many relationship with Playlists
|
||||||
|
playlists = db.relationship('Playlist', secondary=playlist_tracks, back_populates='tracks')
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Track {self.name}:{self.spotify_track_id}>'
|
||||||
265
app/routes.py
Normal file
265
app/routes.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
# 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': utl.filesystem_path,
|
||||||
|
# 'jellyfin_id': ult.jellyfin_id,
|
||||||
|
# 'spotify_id': sp_track['id'],
|
||||||
|
# 'duration_ms': duration_ms,
|
||||||
|
# 'download_status' : 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 ''
|
||||||
376
app/tasks.py
Normal file
376
app/tasks.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
from datetime import datetime,timezone
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from sqlalchemy import insert
|
||||||
|
from app import celery, app, db, functions, sp, jellyfin, jellyfin_admin_token, jellyfin_admin_id
|
||||||
|
|
||||||
|
from app.models import JellyfinUser,Playlist,Track, user_playlists, playlist_tracks
|
||||||
|
import os
|
||||||
|
import redis
|
||||||
|
from celery import current_task
|
||||||
|
import asyncio
|
||||||
|
import requests
|
||||||
|
|
||||||
|
redis_client = redis.StrictRedis(host='redis', port=6379, db=0)
|
||||||
|
def acquire_lock(lock_name, expiration=60):
|
||||||
|
return redis_client.set(lock_name, "locked", ex=expiration, nx=True)
|
||||||
|
|
||||||
|
def release_lock(lock_name):
|
||||||
|
redis_client.delete(lock_name)
|
||||||
|
|
||||||
|
@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):
|
||||||
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
playlists = Playlist.query.all()
|
||||||
|
total_playlists = len(playlists)
|
||||||
|
if not playlists:
|
||||||
|
app.logger.info("No playlists found.")
|
||||||
|
return {'status': 'No playlists found'}
|
||||||
|
|
||||||
|
app.logger.info(f"Found {total_playlists} playlists to update.")
|
||||||
|
processed_playlists = 0
|
||||||
|
|
||||||
|
for playlist in playlists:
|
||||||
|
total_tracks = 0
|
||||||
|
available_tracks = 0
|
||||||
|
|
||||||
|
for track in playlist.tracks:
|
||||||
|
total_tracks += 1
|
||||||
|
if track.filesystem_path and os.path.exists(track.filesystem_path):
|
||||||
|
available_tracks += 1
|
||||||
|
else:
|
||||||
|
track.downloaded = False
|
||||||
|
track.filesystem_path = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
playlist.track_count = total_tracks
|
||||||
|
playlist.tracks_available = available_tracks
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
processed_playlists += 1
|
||||||
|
progress = (processed_playlists / total_playlists) * 100
|
||||||
|
self.update_state(state='PROGRESS', meta={'current': processed_playlists, 'total': total_playlists, 'percent': progress})
|
||||||
|
if processed_playlists % 10 == 0 or processed_playlists == total_playlists:
|
||||||
|
app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.")
|
||||||
|
|
||||||
|
app.logger.info("All playlists' track statuses updated.")
|
||||||
|
return {'status': 'All playlists updated', 'total': total_playlists, 'processed': processed_playlists}
|
||||||
|
finally:
|
||||||
|
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 download_missing_tracks(self):
|
||||||
|
lock_key = "download_missing_tracks_lock"
|
||||||
|
|
||||||
|
if 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']
|
||||||
|
output_dir = spotdl_config['output']
|
||||||
|
client_id = app.config['SPOTIPY_CLIENT_ID']
|
||||||
|
client_secret = app.config['SPOTIPY_CLIENT_SECRET']
|
||||||
|
search_before_download = app.config['SEARCH_JELLYFIN_BEFORE_DOWNLOAD']
|
||||||
|
|
||||||
|
undownloaded_tracks = Track.query.filter_by(downloaded=False).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.")
|
||||||
|
processed_tracks = 0
|
||||||
|
failed_downloads = 0
|
||||||
|
for track in undownloaded_tracks:
|
||||||
|
app.logger.info(f"Processing track: {track.name} ({track.spotify_track_id})")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
db.session.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If search_before_download is enabled, perform matching
|
||||||
|
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']]
|
||||||
|
|
||||||
|
# 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.")
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
|
||||||
|
command = [
|
||||||
|
"spotdl", "download", s_url,
|
||||||
|
"--output", output_dir,
|
||||||
|
"--cookie-file", cookie_file,
|
||||||
|
"--client-id", client_id,
|
||||||
|
"--client-secret", client_secret
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True, timeout=90)
|
||||||
|
if result.returncode == 0 and os.path.exists(file_path):
|
||||||
|
track.downloaded = True
|
||||||
|
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}.")
|
||||||
|
failed_downloads += 1
|
||||||
|
track.download_status = result.stdout[:2048]
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error downloading track {track.name}: {str(e)}")
|
||||||
|
failed_downloads += 1
|
||||||
|
track.download_status = str(e)[:2048]
|
||||||
|
|
||||||
|
processed_tracks += 1
|
||||||
|
progress = (processed_tracks / total_tracks) * 100
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
self.update_state(state='PROGRESS', meta={
|
||||||
|
'current': processed_tracks,
|
||||||
|
'total': total_tracks,
|
||||||
|
'percent': progress,
|
||||||
|
'failed': failed_downloads
|
||||||
|
})
|
||||||
|
|
||||||
|
app.logger.info("Track download job finished.")
|
||||||
|
return {
|
||||||
|
'status': 'download_missing_tracks finished',
|
||||||
|
'total': total_tracks,
|
||||||
|
'processed': processed_tracks,
|
||||||
|
'failed': failed_downloads
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
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 check_for_playlist_updates(self):
|
||||||
|
lock_key = "check_for_playlist_updates_lock"
|
||||||
|
|
||||||
|
if acquire_lock(lock_key, expiration=600):
|
||||||
|
try:
|
||||||
|
app.logger.info('Starting playlist update check...')
|
||||||
|
with app.app_context():
|
||||||
|
playlists = Playlist.query.all()
|
||||||
|
total_playlists = len(playlists)
|
||||||
|
if not playlists:
|
||||||
|
app.logger.info("No playlists found.")
|
||||||
|
return {'status': 'No playlists found'}
|
||||||
|
|
||||||
|
app.logger.info(f"Found {total_playlists} playlists to check for updates.")
|
||||||
|
processed_playlists = 0
|
||||||
|
|
||||||
|
for playlist in playlists:
|
||||||
|
app.logger.info(f'Checking updates for playlist: {playlist.name}')
|
||||||
|
playlist.last_updated = datetime.now( timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
try:
|
||||||
|
#region Check for updates
|
||||||
|
# Fetch all playlist data from Spotify
|
||||||
|
spotify_tracks = {}
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
playlist_data = sp.playlist_items(playlist.spotify_playlist_id, offset=offset, limit=100)
|
||||||
|
items = playlist_data['items']
|
||||||
|
spotify_tracks.update({offset + idx: track['track'] for idx, track in enumerate(items) if track['track']})
|
||||||
|
|
||||||
|
if len(items) < 100: # No more tracks to fetch
|
||||||
|
break
|
||||||
|
offset += 100 # Move to the next batch
|
||||||
|
|
||||||
|
existing_tracks = {track.spotify_track_id: track for track in playlist.tracks}
|
||||||
|
|
||||||
|
# Determine tracks to add and remove
|
||||||
|
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)
|
||||||
|
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}
|
||||||
|
]
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
#region Update Playlist Items and Metadata
|
||||||
|
functions.update_playlist_metadata(playlist, sp.playlist(playlist.spotify_playlist_id))
|
||||||
|
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.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:
|
||||||
|
app.logger.error(f"Error updating playlist {playlist.name}: {str(e)}")
|
||||||
|
|
||||||
|
processed_playlists += 1
|
||||||
|
progress = (processed_playlists / total_playlists) * 100
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
# self.update_state(state='PROGRESS', meta={'current': processed_playlists, 'total': total_playlists, 'percent': progress})
|
||||||
|
|
||||||
|
if processed_playlists % 10 == 0 or processed_playlists == total_playlists:
|
||||||
|
app.logger.info(f"Processed {processed_playlists}/{total_playlists} playlists.")
|
||||||
|
|
||||||
|
return {'status': 'Playlist update check completed', 'total': total_playlists, 'processed': processed_playlists}
|
||||||
|
finally:
|
||||||
|
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 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
|
||||||
|
try:
|
||||||
|
app.logger.info("Starting Jellyfin ID update for downloaded tracks...")
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
downloaded_tracks = Track.query.filter_by(downloaded=True, jellyfin_id=None).all()
|
||||||
|
total_tracks = len(downloaded_tracks)
|
||||||
|
if not downloaded_tracks:
|
||||||
|
app.logger.info("No downloaded tracks without Jellyfin ID found.")
|
||||||
|
return {'status': 'No tracks to update'}
|
||||||
|
|
||||||
|
app.logger.info(f"Found {total_tracks} tracks to update with Jellyfin IDs.")
|
||||||
|
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
|
||||||
|
if best_match:
|
||||||
|
track.jellyfin_id = best_match['Id']
|
||||||
|
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}.")
|
||||||
|
|
||||||
|
spotify_track = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error searching Jellyfin for track {track.name}: {str(e)}")
|
||||||
|
|
||||||
|
processed_tracks += 1
|
||||||
|
progress = (processed_tracks / total_tracks) * 100
|
||||||
|
self.update_state(state='PROGRESS', meta={'current': processed_tracks, 'total': total_tracks, 'percent': progress})
|
||||||
|
|
||||||
|
app.logger.info("Finished updating Jellyfin IDs for all tracks.")
|
||||||
|
return {'status': 'All tracks updated', 'total': total_tracks, 'processed': processed_tracks}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
release_lock(lock_key)
|
||||||
|
else:
|
||||||
|
app.logger.info("Skipping task. Another instance is already running.")
|
||||||
|
return {'status': 'Task skipped, another instance is running'}
|
||||||
1
app/version.py
Normal file
1
app/version.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
BIN
celerybeat-schedule
Normal file
BIN
celerybeat-schedule
Normal file
Binary file not shown.
54
config.py
Normal file
54
config.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||||
|
JELLYFIN_SERVER_URL = os.getenv('JELLYFIN_SERVER_URL')
|
||||||
|
JELLYFIN_ACCESS_TOKEN = os.getenv('JELLYFIN_ACCESS_TOKEN')
|
||||||
|
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')
|
||||||
|
JELLYPLIST_DB_HOST = os.getenv('JELLYPLIST_DB_HOST')
|
||||||
|
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
|
||||||
|
CACHE_TYPE = 'redis'
|
||||||
|
CACHE_REDIS_PORT = 6379
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
||||||
|
SEARCH_JELLYFIN_BEFORE_DOWNLOAD = True
|
||||||
|
# SpotDL specific configuration
|
||||||
|
SPOTDL_CONFIG = {
|
||||||
|
'cookie_file': '/jellyplist/cookies.txt',
|
||||||
|
'output': '/jellyplist_downloads/__jellyplist/{track-id}',
|
||||||
|
'threads': 12
|
||||||
|
}
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls):
|
||||||
|
required_vars = {
|
||||||
|
'SECRET_KEY': cls.SECRET_KEY,
|
||||||
|
'JELLYFIN_SERVER_URL': cls.JELLYFIN_SERVER_URL,
|
||||||
|
'JELLYFIN_ACCESS_TOKEN': cls.JELLYFIN_ACCESS_TOKEN,
|
||||||
|
'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,
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_vars = [var for var, value in required_vars.items() if not value]
|
||||||
|
|
||||||
|
if missing_vars:
|
||||||
|
missing = ', '.join(missing_vars)
|
||||||
|
sys.stderr.write(f"Error: The following environment variables are not set: {missing}\n")
|
||||||
|
sys.exit(1)
|
||||||
40
entrypoint.sh
Normal file
40
entrypoint.sh
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Exit immediately if a command exits with a non-zero status
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Function to wait for a service to be ready
|
||||||
|
wait_for_service() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
local retries=30
|
||||||
|
local wait=2
|
||||||
|
|
||||||
|
until nc -z "$host" "$port" || [ "$retries" -eq 0 ]; do
|
||||||
|
echo "Waiting for $host:$port..."
|
||||||
|
sleep "$wait"
|
||||||
|
retries=$((retries - 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$retries" -eq 0 ]; then
|
||||||
|
echo "Service $host:$port is not available after waiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure the required environment variable is set
|
||||||
|
if [ -z "$JELLYPLIST_DB_HOST" ]; then
|
||||||
|
echo "Environment variable JELLYPLIST_DB_HOST is not set. Exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for PostgreSQL to be ready using the environment variable
|
||||||
|
wait_for_service "$JELLYPLIST_DB_HOST" 5432
|
||||||
|
|
||||||
|
# Apply database migrations
|
||||||
|
echo "Applying database migrations..."
|
||||||
|
flask db upgrade
|
||||||
|
|
||||||
|
# Start the Flask application
|
||||||
|
echo "Starting Flask application..."
|
||||||
|
exec "$@"
|
||||||
536
jellyfin/client.py
Normal file
536
jellyfin/client.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
from urllib.parse import quote
|
||||||
|
import acoustid
|
||||||
|
import chromaprint
|
||||||
|
|
||||||
|
from jellyfin.objects import PlaylistMetadata
|
||||||
|
|
||||||
|
def _clean_query(query):
|
||||||
|
# Regex to match any word containing problematic characters: ', `, or ´
|
||||||
|
pattern = re.compile(r"[`´'’]")
|
||||||
|
|
||||||
|
# Split the query into words and filter out words with problematic characters
|
||||||
|
cleaned_words = [word for word in query.split() if not pattern.search(word)]
|
||||||
|
|
||||||
|
# Join the cleaned words back into a query string
|
||||||
|
cleaned_query = " ".join(cleaned_words)
|
||||||
|
return cleaned_query
|
||||||
|
|
||||||
|
class JellyfinClient:
|
||||||
|
def __init__(self, base_url):
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
def _get_headers(self, session_token: str):
|
||||||
|
"""
|
||||||
|
Get the authentication headers for requests.
|
||||||
|
:return: A dictionary of headers
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'X-Emby-Token': session_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
def login_with_password(self, username: str, password: str, device_id = 'JellyPlist'):
|
||||||
|
"""
|
||||||
|
Log in to Jellyfin using a username and password.
|
||||||
|
:param username: The username of the user.
|
||||||
|
:param password: The password of the user.
|
||||||
|
:return: Access token and user ID
|
||||||
|
"""
|
||||||
|
login_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"'
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
'Username': username,
|
||||||
|
'Pw': password
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(login_url, json=data, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
return result['AccessToken'], result['User']['Id'], result['User']['Name'],result['User']['Policy']['IsAdministrator']
|
||||||
|
else:
|
||||||
|
raise Exception(f"Login failed: {response.content}")
|
||||||
|
|
||||||
|
def create_music_playlist(self, session_token: str, name: str, song_ids, user_id : str):
|
||||||
|
"""
|
||||||
|
Create a new music playlist.
|
||||||
|
:param access_token: The access token of the authenticated user.
|
||||||
|
:param user_id: The user ID.
|
||||||
|
:param name: The name of the playlist.
|
||||||
|
: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'
|
||||||
|
data = {
|
||||||
|
'Name': name,
|
||||||
|
'UserId': user_id,
|
||||||
|
'MediaType': 'Audio',
|
||||||
|
'Ids': ','.join(song_ids), # Join song IDs with commas
|
||||||
|
'IsPublic' : False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(create_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
raise Exception(f"Failed to create playlist: {response.content}")
|
||||||
|
|
||||||
|
def update_music_playlist(self, session_token: str, playlist_id: str, song_ids):
|
||||||
|
"""
|
||||||
|
Update an existing music playlist by adding or removing songs.
|
||||||
|
:param playlist_id: The ID of the playlist to update.
|
||||||
|
: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'
|
||||||
|
data = {
|
||||||
|
'Ids': ','.join(song_ids) # Join song IDs with commas
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(update_url, json=data, headers=self._get_headers(session_token=session_token), timeout=10)
|
||||||
|
|
||||||
|
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_playlist_metadata(self, session_token: str, user_id: str,playlist_id: str) -> PlaylistMetadata:
|
||||||
|
playlist_metadata_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)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to fetch playlist metadata: {response.content}")
|
||||||
|
|
||||||
|
return PlaylistMetadata( response.json())
|
||||||
|
|
||||||
|
def update_playlist_metadata(self, session_token: str, user_id: str, playlist_id: str, updates: PlaylistMetadata):
|
||||||
|
"""
|
||||||
|
Update the metadata of an existing playlist using a PlaylistMetadata object.
|
||||||
|
|
||||||
|
:param session: The user's session containing the Jellyfin access token.
|
||||||
|
:param user_id: the user id, since we are updating the playlist using an api key, we do it with the user id of the first logged in admin
|
||||||
|
:param playlist_id: The ID of the playlist to update.
|
||||||
|
:param updates: A PlaylistMetadata object containing the metadata to update.
|
||||||
|
:return: Response data indicating the result of the update operation.
|
||||||
|
"""
|
||||||
|
# Fetch the existing metadata for the playlist
|
||||||
|
params = {
|
||||||
|
'UserId' : user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize PlaylistMetadata with current data and apply updates
|
||||||
|
metadata_obj = self.get_playlist_metadata(session_token=session_token, user_id= user_id, playlist_id= playlist_id)
|
||||||
|
|
||||||
|
# Update only the provided fields in the updates object
|
||||||
|
for key, value in updates.to_dict().items():
|
||||||
|
if value is not None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
return {"status": "success", "message": "Playlist metadata updated successfully"}
|
||||||
|
else:
|
||||||
|
raise Exception(f"Failed to update playlist metadata: {response.content} \nReason: {response.reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_playlists(self, session_token: str):
|
||||||
|
"""
|
||||||
|
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'
|
||||||
|
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)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()['Items']
|
||||||
|
else:
|
||||||
|
raise Exception(f"Failed to get playlists: {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'
|
||||||
|
params = {
|
||||||
|
'SearchTerm': _clean_query(search_query),
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(search_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()['Items']
|
||||||
|
else:
|
||||||
|
raise Exception(f"Failed to search music tracks: {response.content}")
|
||||||
|
|
||||||
|
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.
|
||||||
|
: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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
||||||
|
def remove_songs_from_playlist(self, session_token: str, playlist_id: str, song_ids):
|
||||||
|
"""
|
||||||
|
Remove songs from an existing playlist.
|
||||||
|
:param playlist_id: The ID of the playlist to update.
|
||||||
|
: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
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.delete(remove_url, headers=self._get_headers(session_token=session_token), params=params, timeout=10)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
def remove_item(self, session_token: str, playlist_id: str):
|
||||||
|
"""
|
||||||
|
Remove an existing playlist by its ID.
|
||||||
|
: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)
|
||||||
|
|
||||||
|
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 remove_user_from_playlist(self, session_token: str, playlist_id: str, user_id: str):
|
||||||
|
"""
|
||||||
|
Remove a user from a playlist.
|
||||||
|
|
||||||
|
:param session: The user's session containing the Jellyfin access token.
|
||||||
|
:param playlist_id: The ID of the playlist from which to remove the user.
|
||||||
|
:param user_id: The ID of the user to be removed from the playlist.
|
||||||
|
:return: Success message or raises an exception on failure.
|
||||||
|
"""
|
||||||
|
# Construct the API endpoint URL
|
||||||
|
url = f'{self.base_url}/Playlists/{playlist_id}/Users/{user_id}'
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
# 204 No Content indicates the user was successfully removed
|
||||||
|
return {"status": "success", "message": f"User {user_id} removed from playlist {playlist_id}"}
|
||||||
|
else:
|
||||||
|
# Raise an exception if the request failed
|
||||||
|
raise Exception(f"Failed to remove user from playlist: {response.content}")
|
||||||
|
|
||||||
|
|
||||||
|
def set_playlist_cover_image(self, session_token: str, playlist_id: str, spotify_image_url: str):
|
||||||
|
"""
|
||||||
|
Set the cover image of a playlist in Jellyfin using an image URL from Spotify.
|
||||||
|
|
||||||
|
:param session: The user's session containing the Jellyfin access token.
|
||||||
|
:param playlist_id: The ID of the playlist in Jellyfin.
|
||||||
|
:param spotify_image_url: The URL of the image from Spotify.
|
||||||
|
: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)
|
||||||
|
|
||||||
|
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']:
|
||||||
|
raise Exception(f"Unsupported image format: {content_type}")
|
||||||
|
# Todo:
|
||||||
|
if content_type == 'application/octet-stream':
|
||||||
|
content_type = 'image/jpeg'
|
||||||
|
# Step 3: Encode the image content as Base64
|
||||||
|
image_base64 = base64.b64encode(response.content).decode('utf-8')
|
||||||
|
|
||||||
|
# Step 4: Prepare the headers for the Jellyfin API request
|
||||||
|
headers = self._get_headers(session_token= session_token)
|
||||||
|
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'
|
||||||
|
|
||||||
|
# Send the Base64-encoded image data
|
||||||
|
upload_response = requests.post(upload_url, headers=headers, data=image_base64, timeout=10)
|
||||||
|
|
||||||
|
if upload_response.status_code == 204: # 204 No Content indicates success
|
||||||
|
return {"status": "success", "message": "Playlist cover image updated successfully"}
|
||||||
|
else:
|
||||||
|
raise Exception(f"Failed to upload image to Jellyfin: {upload_response.status_code} - {upload_response.content}")
|
||||||
|
|
||||||
|
|
||||||
|
def add_users_to_playlist(self, session_token: str,user_id: str, playlist_id: str, user_ids: list[str], can_edit: bool = False):
|
||||||
|
"""
|
||||||
|
Add users to a Jellyfin playlist with no editing rights by default.
|
||||||
|
|
||||||
|
:param session: The user's session containing the Jellyfin access token.
|
||||||
|
:param playlist_id: The ID of the playlist in Jellyfin.
|
||||||
|
:param user_ids: List of user IDs to add to the playlist.
|
||||||
|
:param can_edit: Set to True if users should have editing rights (default is False).
|
||||||
|
:return: Success message or raises an exception on failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# FOr some reason when updating the users, all metadata gets wiped
|
||||||
|
metadata = self.get_playlist_metadata(session_token= session_token, user_id= user_id, playlist_id= playlist_id)
|
||||||
|
|
||||||
|
# Construct the API URL
|
||||||
|
url = f'{self.base_url}/Playlists/{playlist_id}'
|
||||||
|
users_data = [{'UserId': user_id, 'CanEdit': can_edit} for user_id in user_ids]
|
||||||
|
# get current users:
|
||||||
|
current_users = self.get_playlist_users(session_token=session_token, playlist_id= playlist_id)
|
||||||
|
for cu in current_users:
|
||||||
|
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 = 10)
|
||||||
|
|
||||||
|
# Check for success
|
||||||
|
if response.status_code == 204:
|
||||||
|
self.update_playlist_metadata(session_token= session_token, user_id= 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 get_me(self, session_token: str):
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
me_url = f'{self.base_url}/Users/Me'
|
||||||
|
response = requests.get(me_url, headers=self._get_headers(session_token=session_token), timeout = 10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
raise Exception(f"Failed to get playlists: {response.content}")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to fetch playlist metadata: {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.
|
||||||
|
:param session_token: The session token for Jellyfin API access.
|
||||||
|
:param preview_url: The URL to the Spotify preview audio.
|
||||||
|
:param song_name: The name of the song to search for.
|
||||||
|
:param artist_names: A list of artist names.
|
||||||
|
:return: Tuple (match_found: bool, jellyfin_file_path: Optional[str])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Download the Spotify preview audio
|
||||||
|
tmp = self.download_preview_to_tempfile(preview_url=preview_url)
|
||||||
|
if tmp is None:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
# Convert the preview file to a normalized WAV file
|
||||||
|
tmp_wav = self.convert_to_wav(tmp)
|
||||||
|
if tmp_wav is None:
|
||||||
|
os.remove(tmp)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
# Fingerprint the normalized preview WAV file
|
||||||
|
_, 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)
|
||||||
|
|
||||||
|
# Search for matching tracks in Jellyfin using only the song name
|
||||||
|
search_query = song_name # Only use the song name in the search query
|
||||||
|
jellyfin_results = self.search_music_tracks(session_token, search_query)
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
|
||||||
|
# Prepare the list of Spotify artists in lowercase
|
||||||
|
spotify_artists = [artist.lower() for artist in artist_names]
|
||||||
|
|
||||||
|
for result in jellyfin_results:
|
||||||
|
jellyfin_artists = [artist.lower() for artist in result.get("Artists", [])]
|
||||||
|
|
||||||
|
# Check for matching artists u
|
||||||
|
artist_match = any(artist in spotify_artists for artist in jellyfin_artists)
|
||||||
|
if not artist_match:
|
||||||
|
continue # Skip if no artist matches
|
||||||
|
|
||||||
|
jellyfin_file_path = result.get("Path")
|
||||||
|
if not jellyfin_file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert the full Jellyfin track to a normalized WAV file
|
||||||
|
jellyfin_wav = self.convert_to_wav(jellyfin_file_path)
|
||||||
|
if jellyfin_wav is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fingerprint the normalized Jellyfin WAV file
|
||||||
|
_, full_fp = acoustid.fingerprint_file(jellyfin_wav)
|
||||||
|
full_fp_dec, version2 = chromaprint.decode_fingerprint(full_fp)
|
||||||
|
full_fp_dec = np.array(full_fp_dec, dtype=np.uint32)
|
||||||
|
|
||||||
|
# Compare fingerprints using the sliding similarity function
|
||||||
|
sim, best_offset = self.sliding_fingerprint_similarity(full_fp_dec, tmp_fp_dec)
|
||||||
|
|
||||||
|
# Clean up temporary files
|
||||||
|
os.remove(jellyfin_wav)
|
||||||
|
|
||||||
|
# Store the match data
|
||||||
|
matches.append({
|
||||||
|
'jellyfin_file_path': jellyfin_file_path,
|
||||||
|
'similarity': sim,
|
||||||
|
'best_offset': best_offset,
|
||||||
|
'track_name': result.get('Name'),
|
||||||
|
'artists': jellyfin_artists,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clean up the preview files
|
||||||
|
os.remove(tmp_wav)
|
||||||
|
os.remove(tmp)
|
||||||
|
|
||||||
|
# After processing all tracks, select the best match
|
||||||
|
if matches:
|
||||||
|
best_match = max(matches, key=lambda x: x['similarity'])
|
||||||
|
if best_match['similarity'] > 60: # Adjust the threshold as needed
|
||||||
|
return True, best_match['jellyfin_file_path']
|
||||||
|
else:
|
||||||
|
return False, None
|
||||||
|
else:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error (assuming you have a logging mechanism)
|
||||||
|
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)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Save to a temporary file
|
||||||
|
tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3")
|
||||||
|
tmp_file.write(response.content)
|
||||||
|
tmp_file.close()
|
||||||
|
return tmp_file.name
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading preview: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_to_wav(self, input_file_path):
|
||||||
|
try:
|
||||||
|
output_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
|
||||||
|
output_file.close()
|
||||||
|
|
||||||
|
# Use ffmpeg to convert to WAV and normalize audio
|
||||||
|
command = [
|
||||||
|
"ffmpeg", "-y", "-i", input_file_path,
|
||||||
|
"-acodec", "pcm_s16le", "-ar", "44100",
|
||||||
|
"-ac", "2", output_file.name
|
||||||
|
]
|
||||||
|
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if result.returncode != 0:
|
||||||
|
os.remove(output_file.name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return output_file.name
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error converting to WAV: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sliding_fingerprint_similarity(self, full_fp, preview_fp):
|
||||||
|
len_full = len(full_fp)
|
||||||
|
len_preview = len(preview_fp)
|
||||||
|
|
||||||
|
best_score = float('inf')
|
||||||
|
best_offset = 0
|
||||||
|
|
||||||
|
max_offset = len_full - len_preview
|
||||||
|
|
||||||
|
if max_offset < 0:
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
total_bits = len_preview * 32 # Total bits in the preview fingerprint
|
||||||
|
|
||||||
|
for offset in range(max_offset + 1):
|
||||||
|
segment = full_fp[offset:offset + len_preview]
|
||||||
|
xored = np.bitwise_xor(segment, preview_fp)
|
||||||
|
diff_bits = np.unpackbits(xored.view(np.uint8)).sum()
|
||||||
|
score = diff_bits / total_bits # Lower score is better
|
||||||
|
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_offset = offset
|
||||||
|
|
||||||
|
similarity = (1 - best_score) * 100 # Convert to percentage
|
||||||
|
|
||||||
|
return similarity, best_offset
|
||||||
89
jellyfin/objects.py
Normal file
89
jellyfin/objects.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from ast import List
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistMetadata:
|
||||||
|
def __init__(self, playlist_data=None):
|
||||||
|
# Initialize with existing data if available, otherwise use default values
|
||||||
|
self.Id = playlist_data.get("Id") if playlist_data else None
|
||||||
|
self.Name = playlist_data.get("Name", "") if playlist_data else None
|
||||||
|
self.OriginalTitle = playlist_data.get("OriginalTitle", "") if playlist_data else None
|
||||||
|
self.ForcedSortName = playlist_data.get("ForcedSortName", "") if playlist_data else None
|
||||||
|
self.CommunityRating = playlist_data.get("CommunityRating", "") if playlist_data else None
|
||||||
|
self.CriticRating = playlist_data.get("CriticRating", "") if playlist_data else None
|
||||||
|
self.IndexNumber = playlist_data.get("IndexNumber", None) if playlist_data else None
|
||||||
|
self.AirsBeforeSeasonNumber = playlist_data.get("AirsBeforeSeasonNumber", "") if playlist_data else None
|
||||||
|
self.AirsAfterSeasonNumber = playlist_data.get("AirsAfterSeasonNumber", "") if playlist_data else None
|
||||||
|
self.AirsBeforeEpisodeNumber = playlist_data.get("AirsBeforeEpisodeNumber", "") if playlist_data else None
|
||||||
|
self.ParentIndexNumber = playlist_data.get("ParentIndexNumber", None) if playlist_data else None
|
||||||
|
self.DisplayOrder = playlist_data.get("DisplayOrder", "") if playlist_data else None
|
||||||
|
self.Album = playlist_data.get("Album", "") if playlist_data else None
|
||||||
|
self.AlbumArtists = playlist_data.get("AlbumArtists", []) if playlist_data else []
|
||||||
|
self.ArtistItems = playlist_data.get("ArtistItems", []) if playlist_data else []
|
||||||
|
self.Overview = playlist_data.get("Overview", "") if playlist_data else None
|
||||||
|
self.Status = playlist_data.get("Status", "") if playlist_data else None
|
||||||
|
self.AirDays = playlist_data.get("AirDays", []) if playlist_data else []
|
||||||
|
self.AirTime = playlist_data.get("AirTime", "") if playlist_data else None
|
||||||
|
self.Genres = playlist_data.get("Genres", []) if playlist_data else []
|
||||||
|
self.Tags = playlist_data.get("Tags", []) if playlist_data else list[str]
|
||||||
|
self.Studios = playlist_data.get("Studios", []) if playlist_data else []
|
||||||
|
self.PremiereDate = playlist_data.get("PremiereDate", None) if playlist_data else None
|
||||||
|
self.DateCreated = playlist_data.get("DateCreated", None) if playlist_data else None
|
||||||
|
self.EndDate = playlist_data.get("EndDate", None) if playlist_data else None
|
||||||
|
self.ProductionYear = playlist_data.get("ProductionYear", "") if playlist_data else None
|
||||||
|
self.Height = playlist_data.get("Height", "") if playlist_data else None
|
||||||
|
self.AspectRatio = playlist_data.get("AspectRatio", "") if playlist_data else None
|
||||||
|
self.Video3DFormat = playlist_data.get("Video3DFormat", "") if playlist_data else None
|
||||||
|
self.OfficialRating = playlist_data.get("OfficialRating", "") if playlist_data else None
|
||||||
|
self.CustomRating = playlist_data.get("CustomRating", "") if playlist_data else None
|
||||||
|
self.People = playlist_data.get("People", []) if playlist_data else []
|
||||||
|
self.LockData = playlist_data.get("LockData", False) if playlist_data else False
|
||||||
|
self.LockedFields = playlist_data.get("LockedFields", []) if playlist_data else []
|
||||||
|
self.ProviderIds = playlist_data.get("ProviderIds", {}) if playlist_data else {}
|
||||||
|
self.PreferredMetadataLanguage = playlist_data.get("PreferredMetadataLanguage", "") if playlist_data else None
|
||||||
|
self.PreferredMetadataCountryCode = playlist_data.get("PreferredMetadataCountryCode", "") if playlist_data else None
|
||||||
|
self.Taglines = playlist_data.get("Taglines", []) if playlist_data else []
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""
|
||||||
|
Converts the PlaylistMetadata object to a dictionary.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"Id": self.Id,
|
||||||
|
"Name": self.Name,
|
||||||
|
"OriginalTitle": self.OriginalTitle,
|
||||||
|
"ForcedSortName": self.ForcedSortName,
|
||||||
|
"CommunityRating": self.CommunityRating,
|
||||||
|
"CriticRating": self.CriticRating,
|
||||||
|
"IndexNumber": self.IndexNumber,
|
||||||
|
"AirsBeforeSeasonNumber": self.AirsBeforeSeasonNumber,
|
||||||
|
"AirsAfterSeasonNumber": self.AirsAfterSeasonNumber,
|
||||||
|
"AirsBeforeEpisodeNumber": self.AirsBeforeEpisodeNumber,
|
||||||
|
"ParentIndexNumber": self.ParentIndexNumber,
|
||||||
|
"DisplayOrder": self.DisplayOrder,
|
||||||
|
"Album": self.Album,
|
||||||
|
"AlbumArtists": self.AlbumArtists,
|
||||||
|
"ArtistItems": self.ArtistItems,
|
||||||
|
"Overview": self.Overview,
|
||||||
|
"Status": self.Status,
|
||||||
|
"AirDays": self.AirDays,
|
||||||
|
"AirTime": self.AirTime,
|
||||||
|
"Genres": self.Genres,
|
||||||
|
"Tags": self.Tags,
|
||||||
|
"Studios": self.Studios,
|
||||||
|
"PremiereDate": self.PremiereDate,
|
||||||
|
"DateCreated": self.DateCreated,
|
||||||
|
"EndDate": self.EndDate,
|
||||||
|
"ProductionYear": self.ProductionYear,
|
||||||
|
"Height": self.Height,
|
||||||
|
"AspectRatio": self.AspectRatio,
|
||||||
|
"Video3DFormat": self.Video3DFormat,
|
||||||
|
"OfficialRating": self.OfficialRating,
|
||||||
|
"CustomRating": self.CustomRating,
|
||||||
|
"People": self.People,
|
||||||
|
"LockData": self.LockData,
|
||||||
|
"LockedFields": self.LockedFields,
|
||||||
|
"ProviderIds": self.ProviderIds,
|
||||||
|
"PreferredMetadataLanguage": self.PreferredMetadataLanguage,
|
||||||
|
"PreferredMetadataCountryCode": self.PreferredMetadataCountryCode,
|
||||||
|
"Taglines": self.Taglines,
|
||||||
|
}
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Added Playlist and Track models, relationships
|
||||||
|
|
||||||
|
Revision ID: 05f2ef26e1a8
|
||||||
|
Revises: dfcca9d99ce7
|
||||||
|
Create Date: 2024-10-10 12:09:05.659154
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '05f2ef26e1a8'
|
||||||
|
down_revision = 'dfcca9d99ce7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('jellyfin_user',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('jellyfin_user_id', sa.String(length=120), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('jellyfin_user_id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('playlist',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=150), nullable=False),
|
||||||
|
sa.Column('spotify_playlist_id', sa.String(length=120), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('spotify_playlist_id')
|
||||||
|
)
|
||||||
|
op.create_table('track',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=150), nullable=False),
|
||||||
|
sa.Column('spotify_track_id', sa.String(length=120), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('spotify_track_id')
|
||||||
|
)
|
||||||
|
op.create_table('playlist_tracks',
|
||||||
|
sa.Column('playlist_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('track_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['playlist_id'], ['playlist.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['track_id'], ['track.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('playlist_id', 'track_id')
|
||||||
|
)
|
||||||
|
op.create_table('user_playlists',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('playlist_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['playlist_id'], ['playlist.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['jellyfin_user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'playlist_id')
|
||||||
|
)
|
||||||
|
op.drop_table('user')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('email', sa.VARCHAR(length=120), autoincrement=False, nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='user_pkey'),
|
||||||
|
sa.UniqueConstraint('email', name='user_email_key')
|
||||||
|
)
|
||||||
|
op.drop_table('user_playlists')
|
||||||
|
op.drop_table('playlist_tracks')
|
||||||
|
op.drop_table('track')
|
||||||
|
op.drop_table('playlist')
|
||||||
|
op.drop_table('jellyfin_user')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add jellyfin_id to Track and user_playlists
|
||||||
|
|
||||||
|
Revision ID: 2b8f75ed0a54
|
||||||
|
Revises: 70b2970ce195
|
||||||
|
Create Date: 2024-10-22 17:16:37.844073
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2b8f75ed0a54'
|
||||||
|
down_revision = '70b2970ce195'
|
||||||
|
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('jellyfin_id', sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('jellyfin_id', sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('jellyfin_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('jellyfin_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
32
migrations/versions/30e948342792_add_track_order.py
Normal file
32
migrations/versions/30e948342792_add_track_order.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add track_order
|
||||||
|
|
||||||
|
Revision ID: 30e948342792
|
||||||
|
Revises: 95f1a12a7da3
|
||||||
|
Create Date: 2024-10-23 13:56:43.511626
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '30e948342792'
|
||||||
|
down_revision = '95f1a12a7da3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('playlist_tracks', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('track_order', sa.Integer(), nullable=False))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('playlist_tracks', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('track_order')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
38
migrations/versions/41cfd15a9050_extend_download_status.py
Normal file
38
migrations/versions/41cfd15a9050_extend_download_status.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""extend download status
|
||||||
|
|
||||||
|
Revision ID: 41cfd15a9050
|
||||||
|
Revises: a1478d4d2c47
|
||||||
|
Create Date: 2024-10-26 11:35:42.853550
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '41cfd15a9050'
|
||||||
|
down_revision = 'a1478d4d2c47'
|
||||||
|
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('download_status',
|
||||||
|
existing_type=sa.VARCHAR(length=200),
|
||||||
|
type_=sa.String(length=2048),
|
||||||
|
existing_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.alter_column('download_status',
|
||||||
|
existing_type=sa.String(length=2048),
|
||||||
|
type_=sa.VARCHAR(length=200),
|
||||||
|
existing_nullable=True)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
34
migrations/versions/52d71c294c8f_added_spotify_u12ri.py
Normal file
34
migrations/versions/52d71c294c8f_added_spotify_u12ri.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Added spotify_u12ri
|
||||||
|
|
||||||
|
Revision ID: 52d71c294c8f
|
||||||
|
Revises: 8843534fa7ea
|
||||||
|
Create Date: 2024-10-10 13:38:07.247311
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '52d71c294c8f'
|
||||||
|
down_revision = '8843534fa7ea'
|
||||||
|
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('downloaded', sa.Boolean(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('filesystem_path', sa.String(), 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('filesystem_path')
|
||||||
|
batch_op.drop_column('downloaded')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add jellyfin_id to Playlists, remove jellyfin_id from user_playlists, the second again
|
||||||
|
|
||||||
|
Revision ID: 5a06403b57cf
|
||||||
|
Revises: 6089eb604dbf
|
||||||
|
Create Date: 2024-10-23 08:32:11.962812
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '5a06403b57cf'
|
||||||
|
down_revision = '6089eb604dbf'
|
||||||
|
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('jellyfin_id', sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('jellyfin_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('jellyfin_id', sa.VARCHAR(length=120), autoincrement=False, nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('jellyfin_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add jellyfin_id to Track and user_playlists, second
|
||||||
|
|
||||||
|
Revision ID: 6089eb604dbf
|
||||||
|
Revises: 7cc06fc32cc7
|
||||||
|
Create Date: 2024-10-22 21:08:41.605515
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6089eb604dbf'
|
||||||
|
down_revision = '7cc06fc32cc7'
|
||||||
|
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.drop_column('jellyfin_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('jellyfin_id', sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('jellyfin_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('jellyfin_id', sa.VARCHAR(length=120), autoincrement=False, nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add track_count and tracks_available to Playlist
|
||||||
|
|
||||||
|
Revision ID: 70b2970ce195
|
||||||
|
Revises: 52d71c294c8f
|
||||||
|
Create Date: 2024-10-10 21:37:22.419162
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '70b2970ce195'
|
||||||
|
down_revision = '52d71c294c8f'
|
||||||
|
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('track_count', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('tracks_available', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('tracks_available')
|
||||||
|
batch_op.drop_column('track_count')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add jellyfin_id to Playlists, remove jellyfin_id from user_playlists
|
||||||
|
|
||||||
|
Revision ID: 7cc06fc32cc7
|
||||||
|
Revises: 2b8f75ed0a54
|
||||||
|
Create Date: 2024-10-22 17:24:39.761794
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7cc06fc32cc7'
|
||||||
|
down_revision = '2b8f75ed0a54'
|
||||||
|
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('jellyfin_id', sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('jellyfin_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('user_playlists', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('jellyfin_id', sa.VARCHAR(length=120), autoincrement=False, nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('jellyfin_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
42
migrations/versions/8843534fa7ea_added_spotify_uri.py
Normal file
42
migrations/versions/8843534fa7ea_added_spotify_uri.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Added spotify_uri
|
||||||
|
|
||||||
|
Revision ID: 8843534fa7ea
|
||||||
|
Revises: 05f2ef26e1a8
|
||||||
|
Create Date: 2024-10-10 13:27:34.675770
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '8843534fa7ea'
|
||||||
|
down_revision = '05f2ef26e1a8'
|
||||||
|
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('spotify_uri', sa.String(length=120), nullable=False))
|
||||||
|
batch_op.create_unique_constraint(None, ['spotify_uri'])
|
||||||
|
|
||||||
|
with op.batch_alter_table('track', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('spotify_uri', sa.String(length=120), nullable=False))
|
||||||
|
batch_op.create_unique_constraint(None, ['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.drop_constraint(None, type_='unique')
|
||||||
|
batch_op.drop_column('spotify_uri')
|
||||||
|
|
||||||
|
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint(None, type_='unique')
|
||||||
|
batch_op.drop_column('spotify_uri')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add last_updated and last_changed fields to Playlist model
|
||||||
|
|
||||||
|
Revision ID: 8fcd403f0c45
|
||||||
|
Revises: 41cfd15a9050
|
||||||
|
Create Date: 2024-11-21 16:09:39.875467
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '8fcd403f0c45'
|
||||||
|
down_revision = '41cfd15a9050'
|
||||||
|
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('last_updated', sa.DateTime(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('last_changed', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('playlist', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('last_changed')
|
||||||
|
batch_op.drop_column('last_updated')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
32
migrations/versions/95f1a12a7da3_add_is_admin.py
Normal file
32
migrations/versions/95f1a12a7da3_add_is_admin.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add is_admin
|
||||||
|
|
||||||
|
Revision ID: 95f1a12a7da3
|
||||||
|
Revises: 5a06403b57cf
|
||||||
|
Create Date: 2024-10-23 09:55:36.772967
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '95f1a12a7da3'
|
||||||
|
down_revision = '5a06403b57cf'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('jellyfin_user', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=False))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('jellyfin_user', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_admin')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
32
migrations/versions/a1478d4d2c47_add_download_status.py
Normal file
32
migrations/versions/a1478d4d2c47_add_download_status.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add download_Status
|
||||||
|
|
||||||
|
Revision ID: a1478d4d2c47
|
||||||
|
Revises: 30e948342792
|
||||||
|
Create Date: 2024-10-25 19:37:41.898682
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a1478d4d2c47'
|
||||||
|
down_revision = '30e948342792'
|
||||||
|
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('download_status', sa.String(length=200), 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('download_status')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
34
migrations/versions/dfcca9d99ce7_initial.py
Normal file
34
migrations/versions/dfcca9d99ce7_initial.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Initial
|
||||||
|
|
||||||
|
Revision ID: dfcca9d99ce7
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-10-08 21:18:33.666046
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'dfcca9d99ce7'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=120), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('email')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('user')
|
||||||
|
# ### end Alembic commands ###
|
||||||
20
requirements.txt
Normal file
20
requirements.txt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
alembic==1.13.3
|
||||||
|
celery==5.4.0
|
||||||
|
Flask==3.1.0
|
||||||
|
Flask_Caching==2.3.0
|
||||||
|
Flask_Migrate==4.0.7
|
||||||
|
Flask_SocketIO==5.4.1
|
||||||
|
flask_sqlalchemy==3.1.1
|
||||||
|
numpy==2.1.3
|
||||||
|
pyacoustid==1.3.0
|
||||||
|
redis==5.1.1
|
||||||
|
Requests==2.32.3
|
||||||
|
spotdl==4.2.10
|
||||||
|
spotipy==2.24.0
|
||||||
|
SQLAlchemy==2.0.35
|
||||||
|
Unidecode==1.3.8
|
||||||
|
chromaprint
|
||||||
|
psycopg2-binary
|
||||||
|
eventlet
|
||||||
|
pydub
|
||||||
|
fuzzywuzzy
|
||||||
5
run.py
Normal file
5
run.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from app import app, socketio
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
#app.run(debug=True, port=5001,host="0.0.0.0")
|
||||||
|
socketio.run(app,host="0.0.0.0", port = 5055)
|
||||||
55
static/css/styles.css
Normal file
55
static/css/styles.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
body {
|
||||||
|
/* background-color: #141A32; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background-color: #1a1d21;
|
||||||
|
height: 100vh;
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-left: 10px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.top-bar {
|
||||||
|
background-color: #1a1d21;
|
||||||
|
|
||||||
|
}
|
||||||
|
.sidebar h3 {
|
||||||
|
color: white;
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #c2c7d0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: #343a40;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: #6f42c1;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link i {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo img {
|
||||||
|
width: 140px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.logo img{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
BIN
static/images/favicon.ico
Normal file
BIN
static/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
static/images/logo.png
Normal file
BIN
static/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
static/images/logo_large.png
Normal file
BIN
static/images/logo_large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
86
static/js/preview.js
Normal file
86
static/js/preview.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Initialize all tooltips on the page
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Function to open the search modal and trigger the search automatically
|
||||||
|
function openSearchModal(trackTitle, spotify_id) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('searchModal'));
|
||||||
|
const searchQueryInput = document.getElementById('search-query');
|
||||||
|
const spotifyIdInput = document.getElementById('spotify-id');
|
||||||
|
|
||||||
|
// Pre-fill the input fields
|
||||||
|
searchQueryInput.value = trackTitle;
|
||||||
|
spotifyIdInput.value = spotify_id;
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
searchQueryInput.form.requestSubmit(); // Trigger the form submission
|
||||||
|
}, 200); // Delay the search slightly to ensure the modal is visible before searching
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentAudio = null;
|
||||||
|
let currentButton = null;
|
||||||
|
|
||||||
|
function playPreview(button, previewUrl) {
|
||||||
|
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
if (currentButton) {
|
||||||
|
currentButton.innerHTML = '<i class="fas fa-play"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAudio && currentAudio.src === previewUrl) {
|
||||||
|
currentAudio = null;
|
||||||
|
currentButton = null;
|
||||||
|
} else {
|
||||||
|
currentAudio = new Audio(previewUrl);
|
||||||
|
currentAudio.play();
|
||||||
|
currentButton = button;
|
||||||
|
button.innerHTML = '<i class="fas fa-pause"></i>';
|
||||||
|
|
||||||
|
currentAudio.onended = function () {
|
||||||
|
button.innerHTML = '<i class="fas fa-play"></i>';
|
||||||
|
currentAudio = null;
|
||||||
|
currentButton = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playJellyfinTrack(button, jellyfinId) {
|
||||||
|
if (currentAudio && currentButton === button) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio = null;
|
||||||
|
currentButton.innerHTML = '<i class="fas fa-play"></i>';
|
||||||
|
currentButton = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
if (currentButton) {
|
||||||
|
currentButton.innerHTML = '<i class="fas fa-play"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/get_jellyfin_stream/${jellyfinId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const streamUrl = data.stream_url;
|
||||||
|
currentAudio = new Audio(streamUrl);
|
||||||
|
currentAudio.play();
|
||||||
|
currentButton = button;
|
||||||
|
button.innerHTML = '<i class="fas fa-stop"></i>';
|
||||||
|
|
||||||
|
currentAudio.onended = function () {
|
||||||
|
button.innerHTML = '<i class="fas fa-play"></i>';
|
||||||
|
currentAudio = null;
|
||||||
|
currentButton = null;
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching Jellyfin stream URL:', error));
|
||||||
|
}
|
||||||
20
templates/admin.html
Normal file
20
templates/admin.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark border-bottom">
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item active">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% block admin_content %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
8
templates/admin/link_issues.html
Normal file
8
templates/admin/link_issues.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "admin.html" %}
|
||||||
|
{% block admin_content %}
|
||||||
|
<h4>Resolve Issues which occured during the linking between a spotify track and jellyfin track</h4>
|
||||||
|
|
||||||
|
|
||||||
|
{% include 'partials/_track_table.html' %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
21
templates/admin/tasks.html
Normal file
21
templates/admin/tasks.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "admin.html" %}
|
||||||
|
{% block admin_content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
|
||||||
|
<!-- Tabelle für den Task-Status -->
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
{% endblock %}
|
||||||
171
templates/base.html
Normal file
171
templates/base.html
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ 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') }}">
|
||||||
|
<!-- Icons (optional) -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
|
||||||
|
integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg=="
|
||||||
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- Sidebar container with static sidebar for larger screens and offcanvas for mobile -->
|
||||||
|
<div class="d-flex">
|
||||||
|
<!-- Offcanvas sidebar for mobile screens -->
|
||||||
|
<div class="offcanvas offcanvas-start d-md-none" tabindex="-1" id="mobileSidebar"
|
||||||
|
aria-labelledby="mobileSidebarLabel">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title" id="mobileSidebarLabel">Jellyplist</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body">
|
||||||
|
<nav>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<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 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>
|
||||||
|
</li>
|
||||||
|
{% if session.get('is_admin') and session.get('debug') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin"><i class="fas fa-flask"></i> Admin</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/logout"><i class="fas fa-sign-in-alt"></i> Logout</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar for larger screens -->
|
||||||
|
<div class="d-none d-md-block sidebar p-4 vh-100 sticky-top">
|
||||||
|
<nav>
|
||||||
|
<div class="sidebar-logo mb-4 text-center text-white">
|
||||||
|
<img src="/static/images/logo_large.png" alt="Logo">
|
||||||
|
</div>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<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 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>
|
||||||
|
</li>
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
|
||||||
|
<a class="nav-link text-white" href="/admin"><i class="fas fa-flask"></i> Admin
|
||||||
|
{% include 'partials/_unlinked_tracks_badge.html' %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-white" href="/logout"><i class="fas fa-sign-in-alt"></i> Logout</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span class="fixed-bottom m-3">{{version}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content with toggle button for mobile sidebar -->
|
||||||
|
<div class="container-fluid p-4 ">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<!-- Toggle button for mobile sidebar -->
|
||||||
|
<button class="btn btn-primary d-md-none" type="button" data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#mobileSidebar" aria-controls="mobileSidebar">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% 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")
|
||||||
|
var toastElements = document.querySelectorAll('.toast');
|
||||||
|
console.log(toastElements)
|
||||||
|
|
||||||
|
toastElements.forEach(function (toastNode) {
|
||||||
|
var toast = new bootstrap.Toast(toastNode);
|
||||||
|
console.log(toast);
|
||||||
|
|
||||||
|
toast.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("htmx:afterSwap", (event) => {
|
||||||
|
console.log("htmx.afterswap")
|
||||||
|
var toastElements = document.querySelectorAll('.toast');
|
||||||
|
console.log(toastElements)
|
||||||
|
toastElements.forEach(function (toastNode) {
|
||||||
|
var toast = new bootstrap.Toast(toastNode);
|
||||||
|
console.log(toast);
|
||||||
|
toast.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="/static/js/preview.js"></script>
|
||||||
|
{% block scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
6
templates/index.html
Normal file
6
templates/index.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
12
templates/items.html
Normal file
12
templates/items.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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">
|
||||||
|
{% include 'partials/_spotify_items.html' %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
15
templates/jellyfin_playlists.html
Normal file
15
templates/jellyfin_playlists.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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">
|
||||||
|
{% for item in playlists %}
|
||||||
|
{% include 'partials/_spotify_item.html' %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
55
templates/login.html
Normal file
55
templates/login.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Jellyplist Login</title>
|
||||||
|
<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') }}">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section class="vh-100" >
|
||||||
|
<div class="container py-5 h-100">
|
||||||
|
<div class="row d-flex justify-content-center align-items-center h-100">
|
||||||
|
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
|
||||||
|
<div class="card shadow " >
|
||||||
|
<div class="card-header logo">
|
||||||
|
<img src="/static/images/logo_large.png" alt="Logo">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-5 text-center">
|
||||||
|
<h4 class="mb-5">Login using your Jellyfin Credentials</h4>
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control form-control-lg" id="username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control form-control-lg" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Login</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="alert alert-danger mt-3" role="alert">
|
||||||
|
{{ messages[0][1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
12
templates/partials/_add_remove_button.html
Normal file
12
templates/partials/_add_remove_button.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% if item.can_add %}
|
||||||
|
<button class="btn btn-success" hx-post="/addplaylist" 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>
|
||||||
|
{% elif item.can_remove %}
|
||||||
|
<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 %}
|
||||||
37
templates/partials/_jf_search_results.html
Normal file
37
templates/partials/_jf_search_results.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<div id="empty"></div>
|
||||||
|
<div class="search-results">
|
||||||
|
{% if results %}
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Artist(s)</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for track in results %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ track.Name }}</td>
|
||||||
|
<td>{{ ', '.join(track.Artists) }}</td>
|
||||||
|
<td>{{ track.Path}}</td>
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No results found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
14
templates/partials/_playlist_info.html
Normal file
14
templates/partials/_playlist_info.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<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="figure-img">
|
||||||
|
</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>
|
||||||
|
{% include 'partials/_add_remove_button.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
31
templates/partials/_searchresults.html
Normal file
31
templates/partials/_searchresults.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% if playlists %}
|
||||||
|
<h1 class="mb-4">Playlists</h1>
|
||||||
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% for item in playlists %}
|
||||||
|
{% include 'partials/_spotify_item.html' %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if albums %}
|
||||||
|
<h1 class="mb-4">Albums</h1>
|
||||||
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
|
||||||
|
{% for item in albums %}
|
||||||
|
{% include 'partials/_spotify_item.html' %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if artists %}
|
||||||
|
<h1 class="mb-4">Artists</h1>
|
||||||
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
|
||||||
|
{% for item in artists %}
|
||||||
|
{% include 'partials/_spotify_item.html' %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
49
templates/partials/_spotify_item.html
Normal file
49
templates/partials/_spotify_item.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 d-flex flex-column position-relative">
|
||||||
|
|
||||||
|
<!-- Badge: Only show if status is available (i.e., playlist has been requested) -->
|
||||||
|
{% if item.status %}
|
||||||
|
<span style="z-index: 99;" class="badge position-absolute top-0 end-0 m-2
|
||||||
|
{% if item.status == 'green' %} bg-success
|
||||||
|
{% elif item.status == 'yellow' %} bg-warning text-dark
|
||||||
|
{% else %} bg-danger {% endif %}" data-bs-toggle="tooltip" title="{% if item.track_count > 0 %}
|
||||||
|
{{ item.tracks_available }} Track Available / {{ item.tracks_linked}} Tracks linked/ {{ item.track_count}} Total
|
||||||
|
{%endif%}
|
||||||
|
">
|
||||||
|
{% if item.track_count > 0 %}
|
||||||
|
{{ item.tracks_available }} / {{ item.tracks_linked}} / {{ item.track_count}}
|
||||||
|
{% else %}
|
||||||
|
not Available
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Card Image -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Body -->
|
||||||
|
<div class="card-body d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title">{{ item.name }}</h5>
|
||||||
|
<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"
|
||||||
|
title="View Playlist details">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
{%endif%}
|
||||||
|
{% include 'partials/_add_remove_button.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
24
templates/partials/_spotify_items.html
Normal file
24
templates/partials/_spotify_items.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% 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>
|
||||||
22
templates/partials/_task_status.html
Normal file
22
templates/partials/_task_status.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% for task_name, task in tasks.items() %}
|
||||||
|
<tr id="task-row-{{ task_name }}">
|
||||||
|
<td>{{ task_name }}</td>
|
||||||
|
<td>{{ task.state }}</td>
|
||||||
|
<td>
|
||||||
|
{% if task.info.percent %}
|
||||||
|
{{ task.info.percent }}%
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<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 %}
|
||||||
18
templates/partials/_toast.html
Normal file
18
templates/partials/_toast.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
94
templates/partials/_track_table.html
Normal file
94
templates/partials/_track_table.html
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">#</th>
|
||||||
|
<th scope="col">Title</th>
|
||||||
|
<th scope="col">Artist</th>
|
||||||
|
<th scope="col">Duration</th>
|
||||||
|
<th scope="col">Spotify</th>
|
||||||
|
<th scope="col">Preview</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Jellyfin</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for track in tracks %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{ loop.index }}</th>
|
||||||
|
<td>{{ track.title }}</td>
|
||||||
|
<td>{{ track.artist }}</td>
|
||||||
|
<td>{{ track.duration }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ track.url }}" target="_blank" class="text-success" data-bs-toggle="tooltip" title="Open in Spotify">
|
||||||
|
<i class="fab fa-spotify fa-lg"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if track.preview_url %}
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="playPreview(this, '{{ track.preview_url }}')" data-bs-toggle="tooltip" title="Play Preview">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span data-bs-toggle="tooltip" title="No Preview Available">
|
||||||
|
<button class="btn btn-sm" disabled><i class="fas fa-ban"></i></button>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if not track.downloaded %}
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
data-bs-toggle="tooltip" title="{{ track.download_status if track.download_status else 'Not downloaded'}}">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-sm btn-success" data-bs-toggle="tooltip" title="Track downloaded">
|
||||||
|
<i class="fa-solid fa-circle-check"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
<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-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="searchModalLabel">Search Jellyfin for Track</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- 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" >
|
||||||
|
<button class="btn btn-primary" type="submit">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="search-results">
|
||||||
|
<!-- Search results will be inserted here by htmx -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
6
templates/partials/_unlinked_tracks_badge.html
Normal file
6
templates/partials/_unlinked_tracks_badge.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{% if unlinked_track_count > 0 %}
|
||||||
|
<span class="badge rounded-pill bg-danger ms-2 mb-2">
|
||||||
|
{{unlinked_track_count}}
|
||||||
|
<span class="visually-hidden">Unlinked Tracks</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
17
templates/partials/alerts.jinja2
Normal file
17
templates/partials/alerts.jinja2
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<div id="alerts" hx-swap-oob="true">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="toast align-items-center text-bg-{{ 'success' if category == 'success' else 'danger' }}" 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 me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
11
templates/search.html
Normal file
11
templates/search.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div id="search-container">
|
||||||
|
{% if query %}
|
||||||
|
<h2>Search Results for "{{ query }}"</h2>
|
||||||
|
{% include 'partials/_searchresults.html' %}
|
||||||
|
{% else %}
|
||||||
|
<p>No search query provided.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
9
templates/tracks_table.html
Normal file
9
templates/tracks_table.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content%}
|
||||||
|
<div class="container">
|
||||||
|
{% include 'partials/_playlist_info.html' %}
|
||||||
|
|
||||||
|
{% include 'partials/_track_table.html' %}
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user