v0.1.0
This commit is contained in:
298
app/__init__.py
298
app/__init__.py
@@ -1,189 +1,151 @@
|
||||
import atexit
|
||||
from logging.handlers import RotatingFileHandler
|
||||
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_migrate import Migrate
|
||||
from psycopg2 import OperationalError
|
||||
import spotipy
|
||||
from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials
|
||||
from spotipy.oauth2 import SpotifyClientCredentials
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from sqlalchemy import create_engine
|
||||
from config import Config
|
||||
from jellyfin.client import JellyfinClient
|
||||
import logging
|
||||
from spotdl import Spotdl
|
||||
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)
|
||||
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'])
|
||||
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'
|
||||
spotdl_config['output'] = '/storage/media/music/_spotify_playlists/{track-id}'
|
||||
spotdl_config['threads'] = 12
|
||||
spotdl = Spotdl( app.config['SPOTIPY_CLIENT_ID'],app.config['SPOTIPY_CLIENT_SECRET'], downloader_settings=spotdl_config)
|
||||
|
||||
|
||||
|
||||
# Configurations
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://jellyplist:jellyplist@192.168.178.14/jellyplist'
|
||||
# SQLAlchemy and Migrate setup
|
||||
app.logger.info(f"connecting to db: {app.config['JELLYPLIST_DB_HOST']}")
|
||||
check_db_connection(f'postgresql://{app.config["JELLYPLIST_DB_USER"]}:{app.config["JELLYPLIST_DB_PASSWORD"]}@{app.config["JELLYPLIST_DB_HOST"]}/jellyplist',retries=5,delay=2)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{app.config['JELLYPLIST_DB_USER']}:{app.config['JELLYPLIST_DB_PASSWORD']}@{app.config['JELLYPLIST_DB_HOST']}/jellyplist'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
app.logger.info(f"applying db migrations")
|
||||
migrate = Migrate(app, db)
|
||||
|
||||
# Configure Logging
|
||||
log_file = 'app.log'
|
||||
handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3) # 100KB per file, with 3 backups
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
app.logger.info('Application started')
|
||||
|
||||
from app import routes, models
|
||||
from app.models import JellyfinUser,Track,Playlist
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
# Celery Configuration (Updated)
|
||||
app.config.update(
|
||||
CELERY_BROKER_URL=app.config['REDIS_URL'],
|
||||
result_backend=app.config['REDIS_URL']
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
def update_all_playlists_track_status():
|
||||
"""
|
||||
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())
|
||||
app.logger.info(f'Jellyplist {__version__} started')
|
||||
from app import routes
|
||||
from app import jellyfin_routes, tasks
|
||||
if "worker" in sys.argv:
|
||||
tasks.release_lock("download_missing_tracks_lock")
|
||||
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"
|
||||
Reference in New Issue
Block a user