mirror of
https://github.com/Dvorinka/PlexSync.git
synced 2026-06-03 20:12:57 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
/.venv
|
||||||
|
/venv
|
||||||
|
/__pycache__
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, stream_with_context
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from functools import wraps
|
||||||
|
from plexapi.server import PlexServer
|
||||||
|
from plexapi.exceptions import NotFound, Unauthorized
|
||||||
|
from datetime import datetime
|
||||||
|
from flask_session import Session
|
||||||
|
from unidecode import unidecode
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = 'your-secret-key-here' # Change this to a secure secret key
|
||||||
|
app.config['UPLOAD_FOLDER'] = 'uploads'
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||||
|
app.config['ALLOWED_EXTENSIONS'] = {'csv'} # Only allow CSV uploads
|
||||||
|
app.config['SESSION_TYPE'] = 'filesystem' # Store sessions server-side to avoid large cookies
|
||||||
|
app.config['SESSION_PERMANENT'] = False
|
||||||
|
Session(app)
|
||||||
|
|
||||||
|
# Ensure upload folder exists
|
||||||
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
|
|
||||||
|
# Import the find_best_match function from plexsync
|
||||||
|
from plexsync import find_best_match
|
||||||
|
|
||||||
|
# Inject current time into all templates for use as {{ now }}
|
||||||
|
@app.context_processor
|
||||||
|
def inject_now():
|
||||||
|
return {'now': datetime.now()}
|
||||||
|
|
||||||
|
# Format milliseconds to mm:ss string
|
||||||
|
def _format_duration_ms(ms):
|
||||||
|
try:
|
||||||
|
if not ms:
|
||||||
|
return ''
|
||||||
|
total_seconds = int(round(ms / 1000))
|
||||||
|
minutes = total_seconds // 60
|
||||||
|
seconds = total_seconds % 60
|
||||||
|
return f"{minutes}:{seconds:02d}"
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Build robust query variants to handle apostrophes and special characters
|
||||||
|
def _query_variants(text: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
base = (text or '').strip()
|
||||||
|
if not base:
|
||||||
|
return []
|
||||||
|
variants = []
|
||||||
|
# Original
|
||||||
|
variants.append(base)
|
||||||
|
# ASCII fold
|
||||||
|
folded = unidecode(base)
|
||||||
|
if folded != base:
|
||||||
|
variants.append(folded)
|
||||||
|
# Swap straight and curly apostrophes
|
||||||
|
swapped_curly = base.replace("'", "’")
|
||||||
|
swapped_straight = base.replace("’", "'")
|
||||||
|
if swapped_curly not in variants:
|
||||||
|
variants.append(swapped_curly)
|
||||||
|
if swapped_straight not in variants:
|
||||||
|
variants.append(swapped_straight)
|
||||||
|
# Remove apostrophes and quotes
|
||||||
|
no_quotes = folded.replace("'", '').replace('"', '')
|
||||||
|
if no_quotes not in variants:
|
||||||
|
variants.append(no_quotes)
|
||||||
|
# Replace & with and and vice versa
|
||||||
|
amp_to_and = no_quotes.replace('&', 'and')
|
||||||
|
if amp_to_and not in variants:
|
||||||
|
variants.append(amp_to_and)
|
||||||
|
and_to_amp = amp_to_and.replace(' and ', ' & ')
|
||||||
|
if and_to_amp not in variants:
|
||||||
|
variants.append(and_to_amp)
|
||||||
|
# Remove content in parentheses/brackets and after dashes
|
||||||
|
simple = and_to_amp.split(' (')[0].split(' [')[0].split(' - ')[0].strip()
|
||||||
|
if simple and simple not in variants:
|
||||||
|
variants.append(simple)
|
||||||
|
# Collapse multiple spaces
|
||||||
|
collapsed = ' '.join(simple.split())
|
||||||
|
if collapsed and collapsed not in variants:
|
||||||
|
variants.append(collapsed)
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen = set()
|
||||||
|
uniq = []
|
||||||
|
for v in variants:
|
||||||
|
if v and v not in seen:
|
||||||
|
seen.add(v)
|
||||||
|
uniq.append(v)
|
||||||
|
return uniq
|
||||||
|
except Exception:
|
||||||
|
return [text] if text else []
|
||||||
|
|
||||||
|
# Handle favicon requests to avoid 404s in logs
|
||||||
|
@app.route('/favicon.ico')
|
||||||
|
def favicon():
|
||||||
|
# Return 204 No Content; optionally place a file at static/favicon.ico and redirect to it
|
||||||
|
return ('', 204)
|
||||||
|
|
||||||
|
# Login required decorator
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if 'config' not in session:
|
||||||
|
flash('Please configure your Plex server first', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
return '.' in filename and \
|
||||||
|
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
|
def index():
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Save configuration
|
||||||
|
# Checkbox sends 'on' when checked and is absent when unchecked
|
||||||
|
unified_playlist = request.form.get('unified_playlist') == 'on'
|
||||||
|
|
||||||
|
# Initialize session data structure
|
||||||
|
session['config'] = {
|
||||||
|
'PLEX_BASE_URL': request.form.get('plex_url', '').strip(),
|
||||||
|
'PLEX_TOKEN': request.form.get('plex_token', '').strip(),
|
||||||
|
'MUSIC_LIBRARY_NAME': 'Music', # Default value, will be updated later
|
||||||
|
'UNIFIED_PLAYLIST': unified_playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle file uploads
|
||||||
|
if 'files' not in request.files:
|
||||||
|
flash('No file part', 'error')
|
||||||
|
return redirect(request.url)
|
||||||
|
|
||||||
|
files = request.files.getlist('files')
|
||||||
|
if not files or not any(f.filename for f in files):
|
||||||
|
flash('No selected files', 'error')
|
||||||
|
return redirect(request.url)
|
||||||
|
|
||||||
|
valid_files = []
|
||||||
|
all_records = []
|
||||||
|
file_playlists = {}
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
if file and file.filename and allowed_file(file.filename):
|
||||||
|
try:
|
||||||
|
# Read and validate the CSV file
|
||||||
|
content = file.read()
|
||||||
|
try:
|
||||||
|
text = content.decode('utf-8-sig')
|
||||||
|
except Exception:
|
||||||
|
text = content.decode('utf-8')
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
required_columns = ['Artist Name(s)']
|
||||||
|
fieldnames = reader.fieldnames or []
|
||||||
|
|
||||||
|
if not all(col in fieldnames for col in required_columns):
|
||||||
|
flash(f'File {file.filename} is missing required columns. Must contain at least "Artist Name(s)"', 'error')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
|
||||||
|
# Make sure we don't overwrite existing files
|
||||||
|
counter = 1
|
||||||
|
base, ext = os.path.splitext(filename)
|
||||||
|
while os.path.exists(filepath):
|
||||||
|
filename = f"{base}_{counter}{ext}"
|
||||||
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
file.stream.seek(0)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
# Read the records
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
records = list(reader)
|
||||||
|
|
||||||
|
# Store file info and records
|
||||||
|
playlist_name = os.path.splitext(filename)[0].replace('_', ' ').strip()
|
||||||
|
file_info = {
|
||||||
|
'filename': filename,
|
||||||
|
'filepath': filepath,
|
||||||
|
'playlist_name': playlist_name,
|
||||||
|
'track_count': len(records)
|
||||||
|
}
|
||||||
|
valid_files.append(file_info)
|
||||||
|
|
||||||
|
# Store records with file reference
|
||||||
|
for record in records:
|
||||||
|
record['_source_file'] = filename
|
||||||
|
all_records.extend(records)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error processing file {file.filename}: {str(e)}', 'error')
|
||||||
|
else:
|
||||||
|
flash(f'Invalid file type for {file.filename}. Only CSV files are allowed.', 'error')
|
||||||
|
|
||||||
|
if not valid_files:
|
||||||
|
flash('No valid CSV files were uploaded.', 'error')
|
||||||
|
return redirect(request.url)
|
||||||
|
|
||||||
|
# Store the files and records in the session
|
||||||
|
session['uploaded_files'] = valid_files
|
||||||
|
session['tracks'] = all_records
|
||||||
|
session['total_tracks'] = len(all_records)
|
||||||
|
|
||||||
|
# If unified playlist, set the playlist name to the first file's name
|
||||||
|
if unified_playlist and valid_files:
|
||||||
|
session['config']['PLAYLIST_NAME'] = valid_files[0]['playlist_name']
|
||||||
|
|
||||||
|
return redirect(url_for('match_tracks'))
|
||||||
|
|
||||||
|
return render_template('index.html', config=session.get('config', {}))
|
||||||
|
|
||||||
|
@app.route('/configure', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def configure():
|
||||||
|
config = session.get('config', {})
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Update config from form
|
||||||
|
playlist_name = (request.form.get('playlist_name', 'My Playlist') or 'My Playlist').strip().replace('_', ' ')
|
||||||
|
config.update({
|
||||||
|
'PLEX_BASE_URL': request.form.get('plex_url', '').strip(),
|
||||||
|
'PLEX_TOKEN': request.form.get('plex_token', '').strip(),
|
||||||
|
'MUSIC_LIBRARY_NAME': request.form.get('library_name', 'Music').strip(),
|
||||||
|
'PLAYLIST_NAME': playlist_name
|
||||||
|
})
|
||||||
|
session['config'] = config
|
||||||
|
flash('Configuration updated!', 'success')
|
||||||
|
|
||||||
|
return render_template('configure.html', config=config)
|
||||||
|
|
||||||
|
def generate_sync_progress(config, csv_file):
|
||||||
|
try:
|
||||||
|
# Initialize Plex connection
|
||||||
|
plex = PlexServer(config['PLEX_BASE_URL'], config['PLEX_TOKEN'])
|
||||||
|
|
||||||
|
# Read the CSV file
|
||||||
|
try:
|
||||||
|
tracks = []
|
||||||
|
with open(csv_file, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
try:
|
||||||
|
text = content.decode('utf-8-sig')
|
||||||
|
except Exception:
|
||||||
|
text = content.decode('utf-8')
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
for row in reader:
|
||||||
|
tracks.append({
|
||||||
|
'title': row.get('Track Name', ''),
|
||||||
|
'artist': row.get('Artist Name(s)', ''),
|
||||||
|
'album': row.get('Album Name', '')
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Error reading CSV file: {str(e)}'
|
||||||
|
}) + '\n'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Initialize counters
|
||||||
|
total_tracks = len(tracks)
|
||||||
|
found_tracks = []
|
||||||
|
missing_tracks = []
|
||||||
|
|
||||||
|
# Process each track
|
||||||
|
for i, track in enumerate(tracks, 1):
|
||||||
|
progress = int((i / total_tracks) * 100)
|
||||||
|
track_info = f"{track.get('title', 'Unknown')} - {track.get('artist', 'Unknown')}"
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'processing',
|
||||||
|
'progress': progress,
|
||||||
|
'track': track_info,
|
||||||
|
'message': f'Processing track {i} of {total_tracks}'
|
||||||
|
}) + '\n'
|
||||||
|
try:
|
||||||
|
# Try to find the best match in Plex
|
||||||
|
matched_track = find_best_match(
|
||||||
|
track.get('title', ''),
|
||||||
|
track.get('artist', ''),
|
||||||
|
track.get('album', ''),
|
||||||
|
plex,
|
||||||
|
config['MUSIC_LIBRARY_NAME']
|
||||||
|
)
|
||||||
|
|
||||||
|
if matched_track:
|
||||||
|
found_tracks.append(matched_track)
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'found',
|
||||||
|
'track': track_info,
|
||||||
|
'match': f"{matched_track.title} - {matched_track.artist().title if matched_track.artist() else 'Unknown'}"
|
||||||
|
}) + '\n'
|
||||||
|
else:
|
||||||
|
missing_tracks.append({
|
||||||
|
'title': track.get('title', 'Unknown'),
|
||||||
|
'artist': track.get('artist', 'Unknown'),
|
||||||
|
'album': track.get('album', '')
|
||||||
|
})
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'missing',
|
||||||
|
'track': track_info
|
||||||
|
}) + '\n'
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'track': track_info,
|
||||||
|
'message': str(e),
|
||||||
|
'details': str(e)
|
||||||
|
}) + '\n'
|
||||||
|
# Create or update the playlist with found tracks
|
||||||
|
if found_tracks:
|
||||||
|
try:
|
||||||
|
# Remove duplicate tracks while preserving order
|
||||||
|
unique_tracks = []
|
||||||
|
seen = set()
|
||||||
|
for track in found_tracks:
|
||||||
|
if track.ratingKey not in seen:
|
||||||
|
seen.add(track.ratingKey)
|
||||||
|
unique_tracks.append(track)
|
||||||
|
|
||||||
|
# Create or update the playlist
|
||||||
|
try:
|
||||||
|
playlist = plex.playlist(config['PLAYLIST_NAME'])
|
||||||
|
# Clear existing items and add new ones
|
||||||
|
playlist.removeItems(playlist.items())
|
||||||
|
playlist.addItems(unique_tracks)
|
||||||
|
except NotFound:
|
||||||
|
# Create a new playlist if it doesn't exist
|
||||||
|
playlist = plex.createPlaylist(config['PLAYLIST_NAME'], items=unique_tracks)
|
||||||
|
|
||||||
|
# Final success message
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'completed',
|
||||||
|
'found': len(unique_tracks),
|
||||||
|
'missing': len(missing_tracks),
|
||||||
|
'message': f'Successfully created/updated playlist "{config["PLAYLIST_NAME"]}" with {len(unique_tracks)} tracks.'
|
||||||
|
}) + '\n'
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Error creating/updating playlist: {str(e)}',
|
||||||
|
'details': str(e)
|
||||||
|
}) + '\n'
|
||||||
|
# If we have missing tracks, return them
|
||||||
|
if missing_tracks:
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'missing_tracks',
|
||||||
|
'found': len(found_tracks) - len(missing_tracks),
|
||||||
|
'missing': len(missing_tracks),
|
||||||
|
'missing_tracks': missing_tracks,
|
||||||
|
'message': f'Found {len(found_tracks) - len(missing_tracks)} tracks, but {len(missing_tracks)} were not found.'
|
||||||
|
}) + '\n'
|
||||||
|
except Unauthorized:
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Unauthorized: Invalid Plex token',
|
||||||
|
'details': 'Please check your Plex token and try again.'
|
||||||
|
}) + '\n'
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'An error occurred: {str(e)}',
|
||||||
|
'details': str(e)
|
||||||
|
}) + '\n'
|
||||||
|
|
||||||
|
@app.route('/run_sync', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def run_sync():
|
||||||
|
config = session.get('config', {})
|
||||||
|
csv_file = session.get('csv_file')
|
||||||
|
|
||||||
|
if not csv_file or not os.path.exists(csv_file):
|
||||||
|
return jsonify({'status': 'error', 'message': 'No valid CSV file found. Please upload again.'})
|
||||||
|
|
||||||
|
# Validate required config
|
||||||
|
required = ['PLEX_BASE_URL', 'PLEX_TOKEN', 'MUSIC_LIBRARY_NAME', 'PLAYLIST_NAME']
|
||||||
|
if not all(config.get(field) for field in required):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Missing required configuration'})
|
||||||
|
|
||||||
|
# Return a streaming response for progress updates
|
||||||
|
return Response(
|
||||||
|
stream_with_context(generate_sync_progress(config, csv_file)),
|
||||||
|
mimetype='text/event-stream'
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route('/test_connection', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def test_connection():
|
||||||
|
data = request.get_json()
|
||||||
|
plex_url = data.get('plex_url')
|
||||||
|
plex_token = data.get('plex_token')
|
||||||
|
|
||||||
|
if not plex_url or not plex_token:
|
||||||
|
return jsonify({'success': False, 'message': 'Missing Plex URL or token'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to connect to Plex server
|
||||||
|
plex = PlexServer(plex_url, plex_token, timeout=10)
|
||||||
|
server_name = plex.friendlyName
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'server_name': server_name,
|
||||||
|
'message': f'Successfully connected to {server_name}'
|
||||||
|
})
|
||||||
|
except Unauthorized:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid Plex token'}), 401
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': f'Failed to connect: {str(e)}'}), 500
|
||||||
|
|
||||||
|
@app.route('/search_plex', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def search_plex():
|
||||||
|
config = session.get('config', {})
|
||||||
|
data = request.get_json()
|
||||||
|
query = data.get('query', '').strip()
|
||||||
|
original_artist = (data.get('original_artist') or '').strip()
|
||||||
|
|
||||||
|
if not query and not original_artist:
|
||||||
|
return jsonify({'success': False, 'message': 'Enter a track or artist to search'}), 200
|
||||||
|
|
||||||
|
try:
|
||||||
|
plex = PlexServer(config['PLEX_BASE_URL'], config['PLEX_TOKEN'])
|
||||||
|
|
||||||
|
# Search for tracks in the music library
|
||||||
|
library_name = config.get('MUSIC_LIBRARY_NAME') or 'Music'
|
||||||
|
try:
|
||||||
|
library = plex.library.section(library_name)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Music library "{library_name}" not found. Please check your configuration.'
|
||||||
|
}), 200
|
||||||
|
results = []
|
||||||
|
tried = set()
|
||||||
|
# Try multiple variants of the provided query
|
||||||
|
for q in _query_variants(query):
|
||||||
|
if q in tried:
|
||||||
|
continue
|
||||||
|
tried.add(q)
|
||||||
|
try:
|
||||||
|
res = library.searchTracks(title=q, maxresults=20)
|
||||||
|
if res:
|
||||||
|
results.extend(res)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not results and original_artist:
|
||||||
|
try:
|
||||||
|
results = library.searchTracks(artist=original_artist, maxresults=20)
|
||||||
|
except Exception:
|
||||||
|
results = []
|
||||||
|
if not results:
|
||||||
|
# Fallback broad search; filter tracks only
|
||||||
|
try:
|
||||||
|
broad_query = (query or original_artist)
|
||||||
|
# Try variants for broad search too
|
||||||
|
items = []
|
||||||
|
for q in _query_variants(broad_query):
|
||||||
|
res = library.search(q, libtype='track', maxresults=20)
|
||||||
|
if res:
|
||||||
|
items.extend(res)
|
||||||
|
results = items or []
|
||||||
|
except Exception:
|
||||||
|
results = []
|
||||||
|
# Deduplicate results by ratingKey while preserving order
|
||||||
|
deduped = []
|
||||||
|
seen_keys = set()
|
||||||
|
for t in results:
|
||||||
|
rk = getattr(t, 'ratingKey', None)
|
||||||
|
if rk is None or rk in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(rk)
|
||||||
|
deduped.append(t)
|
||||||
|
results = deduped
|
||||||
|
|
||||||
|
# Format results for the UI
|
||||||
|
formatted_results = []
|
||||||
|
for track in results:
|
||||||
|
try:
|
||||||
|
artist_obj = track.artist() if hasattr(track, 'artist') else None
|
||||||
|
except Exception:
|
||||||
|
artist_obj = None
|
||||||
|
try:
|
||||||
|
album_obj = track.album() if hasattr(track, 'album') else None
|
||||||
|
except Exception:
|
||||||
|
album_obj = None
|
||||||
|
|
||||||
|
formatted_results.append({
|
||||||
|
'title': getattr(track, 'title', 'Unknown'),
|
||||||
|
'artist': getattr(artist_obj, 'title', 'Unknown') if artist_obj else 'Unknown',
|
||||||
|
'album': getattr(album_obj, 'title', 'Unknown') if album_obj else 'Unknown',
|
||||||
|
'year': getattr(track, 'year', None),
|
||||||
|
'duration': _format_duration_ms(getattr(track, 'duration', None)),
|
||||||
|
'ratingKey': getattr(track, 'ratingKey', None),
|
||||||
|
'thumb': getattr(track, 'thumbUrl', None) if hasattr(track, 'thumbUrl') else None,
|
||||||
|
'albumArtist': getattr(track, 'originalTitle', '') or (getattr(album_obj, 'originalTitle', '') if album_obj else '')
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'results': formatted_results
|
||||||
|
})
|
||||||
|
except Unauthorized:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid Plex token. Please re-enter your token.'}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Search failed: {str(e)}'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@app.route('/add_to_playlist', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_to_playlist():
|
||||||
|
config = session.get('config', {})
|
||||||
|
data = request.get_json()
|
||||||
|
track_key = data.get('track_key')
|
||||||
|
original_track = data.get('original_track')
|
||||||
|
|
||||||
|
if not track_key or not original_track:
|
||||||
|
return jsonify({'success': False, 'message': 'Missing track information'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
plex = PlexServer(config['PLEX_BASE_URL'], config['PLEX_TOKEN'])
|
||||||
|
|
||||||
|
# Get the track from Plex
|
||||||
|
track = plex.fetchItem(int(track_key))
|
||||||
|
|
||||||
|
# Get or create the playlist
|
||||||
|
try:
|
||||||
|
playlist = plex.playlist(config['PLAYLIST_NAME'])
|
||||||
|
except NotFound:
|
||||||
|
# Create a new playlist if it doesn't exist
|
||||||
|
playlist = plex.createPlaylist(config['PLAYLIST_NAME'], items=[])
|
||||||
|
|
||||||
|
# Add the track to the playlist if not already present
|
||||||
|
if track.ratingKey not in [item.ratingKey for item in playlist.items()]:
|
||||||
|
playlist.addItems([track])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Added \"{track.title}\" to playlist',
|
||||||
|
'track': {
|
||||||
|
'title': track.title,
|
||||||
|
'artist': track.artist().title if track.artist() else 'Unknown',
|
||||||
|
'album': track.album().title if track.album() else 'Unknown'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Failed to add track to playlist: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/match-tracks')
|
||||||
|
@login_required
|
||||||
|
def match_tracks():
|
||||||
|
config = session.get('config', {})
|
||||||
|
tracks = session.get('tracks', [])
|
||||||
|
uploaded_files = session.get('uploaded_files', [])
|
||||||
|
unified_playlist = config.get('UNIFIED_PLAYLIST', True)
|
||||||
|
|
||||||
|
if not tracks:
|
||||||
|
flash('No tracks found in the uploaded files.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
# If separate playlists workflow, go to the first file page
|
||||||
|
if not unified_playlist and uploaded_files:
|
||||||
|
return redirect(url_for('match_tracks_file', file_index=0))
|
||||||
|
|
||||||
|
# Attempt lightweight auto-matching to reduce missing list
|
||||||
|
total_tracks = len(tracks)
|
||||||
|
found_count = 0
|
||||||
|
missing_by_file = []
|
||||||
|
|
||||||
|
# Prepare a lookup for files
|
||||||
|
files_by_name = {f['filename']: f for f in uploaded_files}
|
||||||
|
per_file_missing = {f['filename']: [] for f in uploaded_files} if uploaded_files else {'ALL': []}
|
||||||
|
|
||||||
|
# Try to connect to Plex and pre-match
|
||||||
|
plex = None
|
||||||
|
music_library = None
|
||||||
|
try:
|
||||||
|
if config.get('PLEX_BASE_URL') and config.get('PLEX_TOKEN'):
|
||||||
|
plex = PlexServer(config['PLEX_BASE_URL'], config['PLEX_TOKEN'])
|
||||||
|
music_library = plex.library.section(config.get('MUSIC_LIBRARY_NAME', 'Music'))
|
||||||
|
except Exception:
|
||||||
|
plex = None
|
||||||
|
music_library = None
|
||||||
|
|
||||||
|
from plexsync import find_best_match # local import to avoid circulars at top
|
||||||
|
|
||||||
|
for t in tracks:
|
||||||
|
src = t.get('_source_file') or 'ALL'
|
||||||
|
artist = t.get('Artist Name(s)', '')
|
||||||
|
title = t.get('Track Name', '')
|
||||||
|
album = t.get('Album Name', '')
|
||||||
|
matched = None
|
||||||
|
if plex and music_library:
|
||||||
|
try:
|
||||||
|
matched = find_best_match(title, artist, album, plex, config.get('MUSIC_LIBRARY_NAME', 'Music'))
|
||||||
|
except Exception:
|
||||||
|
matched = None
|
||||||
|
if matched:
|
||||||
|
found_count += 1
|
||||||
|
else:
|
||||||
|
per_file_missing.setdefault(src, []).append(t)
|
||||||
|
|
||||||
|
# Build missing_by_file structure for template
|
||||||
|
if uploaded_files:
|
||||||
|
for f in uploaded_files:
|
||||||
|
missing_list = per_file_missing.get(f['filename'], [])
|
||||||
|
missing_by_file.append({
|
||||||
|
'filename': f['filename'],
|
||||||
|
'playlist_name': f.get('playlist_name') or os.path.splitext(f['filename'])[0].replace('_', ' ').strip(),
|
||||||
|
'missing_tracks': missing_list,
|
||||||
|
'missing_count': len(missing_list)
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Single list
|
||||||
|
missing_list = per_file_missing.get('ALL', [])
|
||||||
|
missing_by_file.append({
|
||||||
|
'filename': 'ALL',
|
||||||
|
'playlist_name': config.get('PLAYLIST_NAME', 'Playlist'),
|
||||||
|
'missing_tracks': missing_list,
|
||||||
|
'missing_count': len(missing_list)
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template('match_tracks.html',
|
||||||
|
config=config,
|
||||||
|
uploaded_files=uploaded_files,
|
||||||
|
unified_playlist=unified_playlist,
|
||||||
|
total_tracks=total_tracks,
|
||||||
|
found_count=found_count,
|
||||||
|
missing_by_file=missing_by_file,
|
||||||
|
file_mode=False,
|
||||||
|
file_index=None,
|
||||||
|
total_files=len(uploaded_files) if uploaded_files else 1)
|
||||||
|
|
||||||
|
@app.route('/match-tracks/<int:file_index>')
|
||||||
|
@login_required
|
||||||
|
def match_tracks_file(file_index: int):
|
||||||
|
config = session.get('config', {})
|
||||||
|
tracks = session.get('tracks', [])
|
||||||
|
uploaded_files = session.get('uploaded_files', [])
|
||||||
|
unified_playlist = config.get('UNIFIED_PLAYLIST', True)
|
||||||
|
|
||||||
|
if not uploaded_files or file_index < 0 or file_index >= len(uploaded_files):
|
||||||
|
return redirect(url_for('match_tracks'))
|
||||||
|
|
||||||
|
current_file = uploaded_files[file_index]
|
||||||
|
filename = current_file['filename']
|
||||||
|
file_tracks = [t for t in tracks if t.get('_source_file') == filename]
|
||||||
|
|
||||||
|
total_tracks = len(file_tracks)
|
||||||
|
found_count = 0
|
||||||
|
missing_by_file = []
|
||||||
|
|
||||||
|
# Try to connect to Plex
|
||||||
|
plex = None
|
||||||
|
music_library = None
|
||||||
|
try:
|
||||||
|
if config.get('PLEX_BASE_URL') and config.get('PLEX_TOKEN'):
|
||||||
|
plex = PlexServer(config['PLEX_BASE_URL'], config['PLEX_TOKEN'])
|
||||||
|
music_library = plex.library.section(config.get('MUSIC_LIBRARY_NAME', 'Music'))
|
||||||
|
except Exception:
|
||||||
|
plex = None
|
||||||
|
music_library = None
|
||||||
|
|
||||||
|
from plexsync import find_best_match
|
||||||
|
per_file_missing = []
|
||||||
|
for t in file_tracks:
|
||||||
|
artist = t.get('Artist Name(s)', '')
|
||||||
|
title = t.get('Track Name', '')
|
||||||
|
album = t.get('Album Name', '')
|
||||||
|
matched = None
|
||||||
|
if plex and music_library:
|
||||||
|
try:
|
||||||
|
matched = find_best_match(title, artist, album, plex, config.get('MUSIC_LIBRARY_NAME', 'Music'))
|
||||||
|
except Exception:
|
||||||
|
matched = None
|
||||||
|
if matched:
|
||||||
|
found_count += 1
|
||||||
|
else:
|
||||||
|
per_file_missing.append(t)
|
||||||
|
|
||||||
|
missing_by_file.append({
|
||||||
|
'filename': filename,
|
||||||
|
'playlist_name': current_file.get('playlist_name') or os.path.splitext(filename)[0].replace('_', ' ').strip(),
|
||||||
|
'missing_tracks': per_file_missing,
|
||||||
|
'missing_count': len(per_file_missing)
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template('match_tracks.html',
|
||||||
|
config=config,
|
||||||
|
uploaded_files=uploaded_files,
|
||||||
|
unified_playlist=False, # in file-mode, we are handling separate playlists
|
||||||
|
total_tracks=total_tracks,
|
||||||
|
found_count=found_count,
|
||||||
|
missing_by_file=missing_by_file,
|
||||||
|
file_mode=True,
|
||||||
|
file_index=file_index,
|
||||||
|
total_files=len(uploaded_files))
|
||||||
|
|
||||||
|
@app.route('/create-playlist', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_playlist():
|
||||||
|
try:
|
||||||
|
config = session.get('config', {})
|
||||||
|
tracks = session.get('tracks', [])
|
||||||
|
uploaded_files = session.get('uploaded_files', [])
|
||||||
|
unified_playlist = config.get('UNIFIED_PLAYLIST', True)
|
||||||
|
created_playlists = session.get('created_playlists', [])
|
||||||
|
|
||||||
|
if not tracks or not uploaded_files:
|
||||||
|
flash('No tracks found in the uploaded files.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
# Connect to Plex
|
||||||
|
try:
|
||||||
|
plex = PlexServer(config['PLEX_BASE_URL'], config['PLEX_TOKEN'])
|
||||||
|
music_library = plex.library.section(config.get('MUSIC_LIBRARY_NAME', 'Music'))
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Error connecting to Plex: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
# Optional per-file index for sequential workflow
|
||||||
|
file_index_str = request.form.get('file_index')
|
||||||
|
per_file_mode = (not unified_playlist) and (file_index_str is not None)
|
||||||
|
next_index = None
|
||||||
|
only_selected = bool(request.form.get('only_selected'))
|
||||||
|
|
||||||
|
if per_file_mode:
|
||||||
|
try:
|
||||||
|
current_index = int(file_index_str)
|
||||||
|
except Exception:
|
||||||
|
current_index = 0
|
||||||
|
if current_index < 0 or current_index >= len(uploaded_files):
|
||||||
|
return redirect(url_for('match_tracks'))
|
||||||
|
target_files = [uploaded_files[current_index]]
|
||||||
|
next_index = current_index + 1 if current_index + 1 < len(uploaded_files) else None
|
||||||
|
else:
|
||||||
|
target_files = uploaded_files if not unified_playlist else None
|
||||||
|
|
||||||
|
if unified_playlist:
|
||||||
|
# Create a single unified playlist from all files
|
||||||
|
playlist_name = request.form.get('playlist_name', config.get('PLAYLIST_NAME', 'Imported Playlist'))
|
||||||
|
# Use selected ratingKeys if provided
|
||||||
|
rk_list = request.form.getlist('track_ratingKey[]')
|
||||||
|
matched_tracks = []
|
||||||
|
|
||||||
|
if rk_list:
|
||||||
|
for rk in rk_list:
|
||||||
|
try:
|
||||||
|
item = plex.fetchItem(int(rk))
|
||||||
|
matched_tracks.append(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif not only_selected:
|
||||||
|
# Process all tracks together (fallback)
|
||||||
|
for track in tracks:
|
||||||
|
artist = track.get('Artist Name(s)', '')
|
||||||
|
title = track.get('Track Name', '')
|
||||||
|
album = track.get('Album Name', '')
|
||||||
|
|
||||||
|
best_match = find_best_match(title, artist, album, plex, config.get('MUSIC_LIBRARY_NAME', 'Music'))
|
||||||
|
if best_match:
|
||||||
|
matched_tracks.append(best_match)
|
||||||
|
|
||||||
|
if matched_tracks:
|
||||||
|
# Remove duplicate tracks while preserving order
|
||||||
|
seen = set()
|
||||||
|
unique_tracks = []
|
||||||
|
for track in matched_tracks:
|
||||||
|
if track.ratingKey not in seen:
|
||||||
|
seen.add(track.ratingKey)
|
||||||
|
unique_tracks.append(track)
|
||||||
|
|
||||||
|
# Create the playlist
|
||||||
|
playlist = music_library.createPlaylist(playlist_name, items=unique_tracks)
|
||||||
|
created_playlists.append({
|
||||||
|
'name': playlist.title,
|
||||||
|
'track_count': len(unique_tracks),
|
||||||
|
'source': 'Multiple files'
|
||||||
|
})
|
||||||
|
elif per_file_mode:
|
||||||
|
# Create a playlist for a single file (sequential workflow)
|
||||||
|
f = target_files[0]
|
||||||
|
filename = f['filename']
|
||||||
|
default_name = os.path.splitext(filename)[0].replace('_', ' ').strip()
|
||||||
|
playlist_name = (request.form.get('playlist_name') or default_name).strip()
|
||||||
|
file_tracks = [t for t in tracks if t.get('_source_file') == filename]
|
||||||
|
rk_list = request.form.getlist('track_ratingKey[]')
|
||||||
|
matched_tracks = []
|
||||||
|
|
||||||
|
if rk_list:
|
||||||
|
for rk in rk_list:
|
||||||
|
try:
|
||||||
|
item = plex.fetchItem(int(rk))
|
||||||
|
matched_tracks.append(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif not only_selected:
|
||||||
|
for track in file_tracks:
|
||||||
|
artist = track.get('Artist Name(s)', '')
|
||||||
|
title = track.get('Track Name', '')
|
||||||
|
album = track.get('Album Name', '')
|
||||||
|
best_match = find_best_match(title, artist, album, plex, config.get('MUSIC_LIBRARY_NAME', 'Music'))
|
||||||
|
if best_match:
|
||||||
|
matched_tracks.append(best_match)
|
||||||
|
|
||||||
|
if matched_tracks:
|
||||||
|
seen = set()
|
||||||
|
unique_tracks = []
|
||||||
|
for track in matched_tracks:
|
||||||
|
if track.ratingKey not in seen:
|
||||||
|
seen.add(track.ratingKey)
|
||||||
|
unique_tracks.append(track)
|
||||||
|
playlist = music_library.createPlaylist(playlist_name, items=unique_tracks)
|
||||||
|
created_playlists.append({
|
||||||
|
'name': playlist.title,
|
||||||
|
'track_count': len(unique_tracks),
|
||||||
|
'source': filename
|
||||||
|
})
|
||||||
|
# Store progress and decide where to go next
|
||||||
|
session['created_playlists'] = created_playlists
|
||||||
|
if next_index is not None:
|
||||||
|
return redirect(url_for('match_tracks_file', file_index=next_index))
|
||||||
|
# Finalize when last file done
|
||||||
|
# Cleanup: delete uploaded files and clear session data for uploads/tracks
|
||||||
|
try:
|
||||||
|
for f2 in uploaded_files or []:
|
||||||
|
try:
|
||||||
|
path = f2.get('filepath')
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
session.pop('uploaded_files', None)
|
||||||
|
session.pop('tracks', None)
|
||||||
|
session.pop('total_tracks', None)
|
||||||
|
return redirect(url_for('playlist_created'))
|
||||||
|
else:
|
||||||
|
# Create separate playlists for each file
|
||||||
|
for file_info in uploaded_files:
|
||||||
|
filename = file_info['filename']
|
||||||
|
playlist_name = os.path.splitext(filename)[0].replace('_', ' ').strip()
|
||||||
|
file_tracks = [t for t in tracks if t.get('_source_file') == filename]
|
||||||
|
rk_list = request.form.getlist('track_ratingKey[]')
|
||||||
|
matched_tracks = []
|
||||||
|
|
||||||
|
if rk_list:
|
||||||
|
for rk in rk_list:
|
||||||
|
try:
|
||||||
|
item = plex.fetchItem(int(rk))
|
||||||
|
matched_tracks.append(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif not only_selected:
|
||||||
|
for track in file_tracks:
|
||||||
|
artist = track.get('Artist Name(s)', '')
|
||||||
|
title = track.get('Track Name', '')
|
||||||
|
album = track.get('Album Name', '')
|
||||||
|
|
||||||
|
best_match = find_best_match(title, artist, album, plex, config.get('MUSIC_LIBRARY_NAME', 'Music'))
|
||||||
|
if best_match:
|
||||||
|
matched_tracks.append(best_match)
|
||||||
|
|
||||||
|
if matched_tracks:
|
||||||
|
# Remove duplicate tracks while preserving order
|
||||||
|
seen = set()
|
||||||
|
unique_tracks = []
|
||||||
|
for track in matched_tracks:
|
||||||
|
if track.ratingKey not in seen:
|
||||||
|
seen.add(track.ratingKey)
|
||||||
|
unique_tracks.append(track)
|
||||||
|
|
||||||
|
# Create the playlist
|
||||||
|
playlist = music_library.createPlaylist(playlist_name, items=unique_tracks)
|
||||||
|
created_playlists.append({
|
||||||
|
'name': playlist.title,
|
||||||
|
'track_count': len(unique_tracks),
|
||||||
|
'source': filename
|
||||||
|
})
|
||||||
|
|
||||||
|
if not created_playlists:
|
||||||
|
flash('No matching tracks found in your Plex library.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
# For unified or all-at-once separate flow, cleanup now and store created playlists
|
||||||
|
try:
|
||||||
|
for f in uploaded_files or []:
|
||||||
|
try:
|
||||||
|
path = f.get('filepath')
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
session.pop('uploaded_files', None)
|
||||||
|
session.pop('tracks', None)
|
||||||
|
session.pop('total_tracks', None)
|
||||||
|
session['created_playlists'] = created_playlists
|
||||||
|
|
||||||
|
return redirect(url_for('playlist_created'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'An error occurred: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
@app.route('/playlist-created')
|
||||||
|
@login_required
|
||||||
|
def playlist_created():
|
||||||
|
playlists = session.get('created_playlists')
|
||||||
|
if not playlists:
|
||||||
|
flash('No playlists were created.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
return render_template('playlist_created.html',
|
||||||
|
playlists=playlists,
|
||||||
|
unified_playlist=len(playlists) == 1 and 'source' not in playlists[0])
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True)
|
||||||
Binary file not shown.
Binary file not shown.
+408
@@ -0,0 +1,408 @@
|
|||||||
|
from plexapi.server import PlexServer
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
import re
|
||||||
|
from unidecode import unidecode
|
||||||
|
import os
|
||||||
|
|
||||||
|
def normalize_text(text):
|
||||||
|
"""Normalize text for better matching.
|
||||||
|
- ASCII-fold accents
|
||||||
|
- Lowercase
|
||||||
|
- Replace '&' with 'and'
|
||||||
|
- Remove punctuation
|
||||||
|
- Collapse whitespace
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
t = unidecode(text)
|
||||||
|
t = t.lower()
|
||||||
|
t = t.replace('&', ' and ')
|
||||||
|
t = re.sub(r"[\u2018\u2019'\"`]+", '', t) # remove quotes/apostrophes (including curly)
|
||||||
|
t = re.sub(r"[^a-z0-9\s]", ' ', t) # remove other punctuation
|
||||||
|
t = re.sub(r"\s+", ' ', t).strip()
|
||||||
|
return t
|
||||||
|
|
||||||
|
def split_artists(artist: str):
|
||||||
|
"""Produce a list of possible artist tokens from a combined artist string."""
|
||||||
|
if not artist:
|
||||||
|
return []
|
||||||
|
a = unidecode(artist)
|
||||||
|
# Split on common separators
|
||||||
|
parts = re.split(r";|,|\s+feat\.?\s+|\s+ft\.?\s+|\s+with\s+|\s*&\s*", a, flags=re.IGNORECASE)
|
||||||
|
parts = [normalize_text(p) for p in parts if p and p.strip()]
|
||||||
|
# Dedupe
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for p in parts:
|
||||||
|
if p and p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def build_track_variations(track_name: str):
|
||||||
|
if not track_name:
|
||||||
|
return []
|
||||||
|
originals = [track_name]
|
||||||
|
# Remove common suffix patterns like (Live), [Remastered], - Acoustic, etc.
|
||||||
|
base = re.split(r"\s*\(|\[| - ", track_name)[0].strip()
|
||||||
|
originals.append(base)
|
||||||
|
# Remove quotes/apostrophes
|
||||||
|
originals.append(track_name.replace("'", '').replace('"', ''))
|
||||||
|
# Swap straight and curly apostrophes
|
||||||
|
originals.append(track_name.replace("'", "’"))
|
||||||
|
originals.append(track_name.replace("’", "'"))
|
||||||
|
# Replace & / and
|
||||||
|
originals.append(track_name.replace('&', 'and'))
|
||||||
|
originals.append(track_name.replace(' and ', ' & '))
|
||||||
|
# Collapse spaces
|
||||||
|
originals.extend([' '.join(o.split()) for o in list(originals)])
|
||||||
|
# ASCII-fold
|
||||||
|
originals.extend([unidecode(o) for o in list(originals)])
|
||||||
|
# Deduplicate
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for o in originals:
|
||||||
|
if o and o not in seen:
|
||||||
|
seen.add(o)
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def similarity_ratio(a, b):
|
||||||
|
"""Calculate similarity ratio between two strings"""
|
||||||
|
return SequenceMatcher(None, a, b).ratio()
|
||||||
|
|
||||||
|
def find_best_match(track_name, artist_name, album_name, plex, library_name):
|
||||||
|
"""Find the best matching track in Plex library with improved matching for special cases.
|
||||||
|
|
||||||
|
If track_name is missing, fall back to artist-only search and pick the best candidate by artist similarity.
|
||||||
|
"""
|
||||||
|
if not artist_name or not plex:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# First, try to find exact matches in the library
|
||||||
|
music_library = plex.library.section(library_name)
|
||||||
|
|
||||||
|
# If no track name provided, do an artist-only search
|
||||||
|
if not track_name:
|
||||||
|
try:
|
||||||
|
results = music_library.searchTracks(artist=artist_name, maxresults=20)
|
||||||
|
except Exception:
|
||||||
|
results = []
|
||||||
|
# Pick the best by artist similarity
|
||||||
|
best_match = None
|
||||||
|
best_score = 0.75
|
||||||
|
artist_tokens = split_artists(artist_name)
|
||||||
|
main_artist = artist_tokens[0] if artist_tokens else normalize_text(artist_name)
|
||||||
|
for track in results:
|
||||||
|
plex_artist = ''
|
||||||
|
try:
|
||||||
|
plex_artist = track.grandparentTitle if hasattr(track, 'grandparentTitle') else (track.artist().title if hasattr(track, 'artist') and track.artist() else '')
|
||||||
|
except Exception:
|
||||||
|
plex_artist = ''
|
||||||
|
plex_artists = split_artists(plex_artist)
|
||||||
|
plex_main_artist = plex_artists[0] if plex_artists else normalize_text(plex_artist)
|
||||||
|
artist_score = similarity_ratio(main_artist, plex_main_artist)
|
||||||
|
if artist_score > best_score:
|
||||||
|
best_score = artist_score
|
||||||
|
best_match = track
|
||||||
|
return best_match if best_match else None
|
||||||
|
|
||||||
|
# Generate search queries with different combinations (track provided)
|
||||||
|
# Build query variations for robustness
|
||||||
|
search_queries = []
|
||||||
|
for tn in build_track_variations(track_name):
|
||||||
|
search_queries.append(f"{tn} {artist_name}")
|
||||||
|
search_queries.append(tn)
|
||||||
|
# Also try first artist token
|
||||||
|
artist_tokens = split_artists(artist_name)
|
||||||
|
if artist_tokens:
|
||||||
|
for tn in build_track_variations(track_name):
|
||||||
|
search_queries.append(f"{tn} {artist_tokens[0]}")
|
||||||
|
|
||||||
|
# Add album-specific searches if available
|
||||||
|
if album_name:
|
||||||
|
search_queries.extend([
|
||||||
|
f"{track_name} {album_name}",
|
||||||
|
f"{track_name} {artist_name} {album_name}",
|
||||||
|
])
|
||||||
|
|
||||||
|
# Try each search query until we find a good match
|
||||||
|
best_match = None
|
||||||
|
best_score = 0.7 # Minimum threshold for a match
|
||||||
|
|
||||||
|
for query in search_queries:
|
||||||
|
try:
|
||||||
|
# Search in the music library
|
||||||
|
results = music_library.searchTracks(title=query, maxresults=30)
|
||||||
|
|
||||||
|
# If no results, try with a more general search
|
||||||
|
if not results and ' ' in query:
|
||||||
|
# Try with just the first few words of the query
|
||||||
|
partial_query = ' '.join(query.split()[:3])
|
||||||
|
if partial_query != query:
|
||||||
|
results = music_library.searchTracks(title=partial_query, maxresults=30)
|
||||||
|
|
||||||
|
# Broader fallback using library.search for tracks
|
||||||
|
if not results:
|
||||||
|
try:
|
||||||
|
broad_items = music_library.search(query, libtype='track', maxresults=30)
|
||||||
|
results = broad_items or []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Deduplicate by ratingKey
|
||||||
|
seen_keys = set()
|
||||||
|
deduped = []
|
||||||
|
for t in results:
|
||||||
|
rk = getattr(t, 'ratingKey', None)
|
||||||
|
if rk is None or rk in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(rk)
|
||||||
|
deduped.append(t)
|
||||||
|
results = deduped
|
||||||
|
|
||||||
|
# Now use the existing matching logic on the search results
|
||||||
|
normalized_artist = normalize_text(artist_name)
|
||||||
|
artist_tokens = split_artists(artist_name)
|
||||||
|
main_artist = artist_tokens[0] if artist_tokens else normalized_artist
|
||||||
|
|
||||||
|
# Create multiple variations of the track name
|
||||||
|
track_variations = build_track_variations(track_name)
|
||||||
|
|
||||||
|
for track in results:
|
||||||
|
plex_track = track.title
|
||||||
|
plex_artist = track.grandparentTitle if hasattr(track, 'grandparentTitle') else ''
|
||||||
|
|
||||||
|
for variation in track_variations:
|
||||||
|
normalized_track = normalize_text(variation)
|
||||||
|
normalized_plex_track = normalize_text(plex_track)
|
||||||
|
normalized_plex_artist = normalize_text(plex_artist)
|
||||||
|
|
||||||
|
plex_artists = split_artists(plex_artist)
|
||||||
|
plex_main_artist = plex_artists[0] if plex_artists else normalized_plex_artist
|
||||||
|
|
||||||
|
track_score = similarity_ratio(normalized_track, normalized_plex_track)
|
||||||
|
artist_score = similarity_ratio(normalized_artist, normalized_plex_artist)
|
||||||
|
main_artist_score = similarity_ratio(main_artist, plex_main_artist)
|
||||||
|
|
||||||
|
if (normalized_track in normalized_plex_track or
|
||||||
|
normalized_plex_track in normalized_track):
|
||||||
|
track_score = max(track_score, 0.8)
|
||||||
|
|
||||||
|
common_patterns = [
|
||||||
|
(f'{normalized_track} {main_artist}', f'{normalized_plex_track} {plex_main_artist}'),
|
||||||
|
(f'{main_artist} {normalized_track}', f'{plex_main_artist} {normalized_plex_track}')
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern1, pattern2 in common_patterns:
|
||||||
|
if similarity_ratio(pattern1, pattern2) > 0.8:
|
||||||
|
track_score = max(track_score, 0.9)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Slight boost if album matches when provided
|
||||||
|
album_boost = 0.0
|
||||||
|
if album_name:
|
||||||
|
try:
|
||||||
|
plex_album = track.album().title if hasattr(track, 'album') and track.album() else ''
|
||||||
|
except Exception:
|
||||||
|
plex_album = ''
|
||||||
|
if normalize_text(album_name) and normalize_text(album_name) in normalize_text(plex_album):
|
||||||
|
album_boost = 0.05
|
||||||
|
effective_artist_score = max(artist_score, main_artist_score * 0.9)
|
||||||
|
total_score = (track_score * 0.6) + (effective_artist_score * 0.4) + album_boost
|
||||||
|
|
||||||
|
if main_artist_score > 0.8 and track_score > 0.6:
|
||||||
|
total_score = max(total_score, 0.85)
|
||||||
|
|
||||||
|
if total_score > best_score:
|
||||||
|
best_score = total_score
|
||||||
|
best_match = track
|
||||||
|
|
||||||
|
if best_score > 0.9:
|
||||||
|
return best_match
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error searching for '{query}': {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return best_match if best_score >= 0.7 else None
|
||||||
|
|
||||||
|
def sync_playlist(plex_url, plex_token, library_name, playlist_name, csv_file):
|
||||||
|
"""Main function to sync playlist with progress tracking"""
|
||||||
|
try:
|
||||||
|
# Load the exported Spotify playlist CSV using csv module
|
||||||
|
with open(csv_file, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
try:
|
||||||
|
text = content.decode('utf-8-sig')
|
||||||
|
except Exception:
|
||||||
|
text = content.decode('utf-8')
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
# Connect to Plex
|
||||||
|
plex = PlexServer(plex_url, plex_token)
|
||||||
|
music_library = plex.library.section(library_name)
|
||||||
|
|
||||||
|
# Delete existing playlist if it exists
|
||||||
|
for pl in plex.playlists():
|
||||||
|
if pl.title == playlist_name:
|
||||||
|
pl.delete()
|
||||||
|
break
|
||||||
|
|
||||||
|
playlist = None
|
||||||
|
missing_tracks = []
|
||||||
|
found_tracks = 0
|
||||||
|
total_tracks = len(rows)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Process each track in the playlist
|
||||||
|
for row in rows:
|
||||||
|
track_name = row.get('Track Name', '')
|
||||||
|
artist_name = row.get('Artist Name(s)', '')
|
||||||
|
track_info = f"{track_name} - {artist_name}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generate base track name variations
|
||||||
|
base_variations = set()
|
||||||
|
|
||||||
|
# Original track name
|
||||||
|
base_variations.add(track_name.strip())
|
||||||
|
|
||||||
|
# Common patterns to remove
|
||||||
|
patterns_to_remove = [
|
||||||
|
' - From', ' - Live', ' - Acoustic', ' - Remastered',
|
||||||
|
' - Remaster', ' - Single Version', ' - Album Version',
|
||||||
|
' (feat.', ' (with ', ' (from ', ' [', ' (', ' - ', '...',
|
||||||
|
'"', "'"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Generate variations by removing patterns
|
||||||
|
for pattern in patterns_to_remove:
|
||||||
|
for v in list(base_variations):
|
||||||
|
if pattern in v:
|
||||||
|
# Remove pattern and everything after
|
||||||
|
clean = v.split(pattern)[0].strip()
|
||||||
|
if clean: # Only add non-empty variations
|
||||||
|
base_variations.add(clean)
|
||||||
|
|
||||||
|
# Special character handling
|
||||||
|
special_chars = {
|
||||||
|
'ø': 'o',
|
||||||
|
'é': 'e',
|
||||||
|
'&': 'and',
|
||||||
|
' and ': ' & ',
|
||||||
|
"'": '',
|
||||||
|
'...': ' ',
|
||||||
|
' ': ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add variations with special characters replaced
|
||||||
|
for v in list(base_variations):
|
||||||
|
for char, replacement in special_chars.items():
|
||||||
|
if char in v:
|
||||||
|
new_variation = v.replace(char, replacement).strip()
|
||||||
|
if new_variation and new_variation != v:
|
||||||
|
base_variations.add(new_variation)
|
||||||
|
|
||||||
|
# Generate artist variations
|
||||||
|
artist_variations = set()
|
||||||
|
artist_variations.add(artist_name.strip())
|
||||||
|
|
||||||
|
# Main artist (first one listed)
|
||||||
|
main_artist = artist_name.split(';')[0].split('&')[0].split('feat')[0].strip()
|
||||||
|
if main_artist and main_artist != artist_name:
|
||||||
|
artist_variations.add(main_artist)
|
||||||
|
|
||||||
|
# Generate search queries by combining track and artist variations
|
||||||
|
search_queries = []
|
||||||
|
|
||||||
|
# Try exact matches first
|
||||||
|
for track_variant in base_variations:
|
||||||
|
for artist_variant in artist_variations:
|
||||||
|
search_queries.append(f'"{track_variant}" "{artist_variant}"')
|
||||||
|
search_queries.append(f'{track_variant} {artist_variant}')
|
||||||
|
|
||||||
|
# Then try track name only (in case artist is in track name)
|
||||||
|
for track_variant in base_variations:
|
||||||
|
search_queries.append(track_variant)
|
||||||
|
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
seen = set()
|
||||||
|
search_queries = [q for q in search_queries if not (q in seen or seen.add(q))]
|
||||||
|
|
||||||
|
# Execute searches until we get results
|
||||||
|
search_results = []
|
||||||
|
max_results = 30 # Increased for better matching
|
||||||
|
|
||||||
|
for query in search_queries:
|
||||||
|
if not search_results and query.strip():
|
||||||
|
try:
|
||||||
|
results = music_library.searchTracks(
|
||||||
|
title=query,
|
||||||
|
maxresults=max_results
|
||||||
|
)
|
||||||
|
if results:
|
||||||
|
search_results = results
|
||||||
|
# Don't break, keep searching for better matches
|
||||||
|
except Exception as e:
|
||||||
|
continue # Try next query if there's an error
|
||||||
|
|
||||||
|
if search_results:
|
||||||
|
best_match = find_best_match(track_name, artist_name, search_results)
|
||||||
|
if best_match:
|
||||||
|
if playlist is None:
|
||||||
|
playlist = plex.createPlaylist(playlist_name, items=[best_match])
|
||||||
|
else:
|
||||||
|
playlist.addItems(best_match)
|
||||||
|
found_tracks += 1
|
||||||
|
results.append({
|
||||||
|
'status': 'success',
|
||||||
|
'track': track_info,
|
||||||
|
'match': f"{best_match.title} - {best_match.grandparentTitle}"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we get here, no match was found
|
||||||
|
missing_tracks.append(track_info)
|
||||||
|
results.append({
|
||||||
|
'status': 'missing',
|
||||||
|
'track': track_info,
|
||||||
|
'match': None
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results.append({
|
||||||
|
'status': 'error',
|
||||||
|
'track': track_info,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
missing_tracks.append(f"{track_info} (error)")
|
||||||
|
|
||||||
|
# Save missing songs to a text file
|
||||||
|
if missing_tracks:
|
||||||
|
with open('missing_tracks.txt', 'w', encoding='utf-8') as f:
|
||||||
|
f.write("\n".join(missing_tracks))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'completed',
|
||||||
|
'total': total_tracks,
|
||||||
|
'found': found_tracks,
|
||||||
|
'missing': len(missing_tracks),
|
||||||
|
'success_rate': (found_tracks / total_tracks) * 100 if total_tracks > 0 else 0,
|
||||||
|
'results': results
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
# This script is meant to be imported by app.py
|
||||||
|
# Configuration is now handled through the web interface
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Flask>=2.0.0,<3.0.0
|
||||||
|
Flask-Session>=0.5.0
|
||||||
|
python-dotenv>=0.19.0,<1.0.0
|
||||||
|
requests>=2.26.0,<3.0.0
|
||||||
|
unidecode>=1.2.0,<2.0.0
|
||||||
|
PlexAPI>=4.9.2,<5.0.0
|
||||||
|
Werkzeug>=2.0.2,<3.0.0
|
||||||
|
setuptools>=65.5.1
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
echo Installing required Python packages...
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Starting Plex Playlist Sync Web Interface...
|
||||||
|
python app.py
|
||||||
|
|
||||||
|
pause
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* General Styles */
|
||||||
|
body {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styles */
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: #80bdff;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Styles */
|
||||||
|
.btn {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #0b5ed7;
|
||||||
|
border-color: #0a58ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress {
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
font-weight: 500;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log Container */
|
||||||
|
.log-container {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track Items */
|
||||||
|
.track-item {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.success {
|
||||||
|
background-color: #d1e7dd;
|
||||||
|
border-left: 4px solid #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.missing {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card {
|
||||||
|
border-radius: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Plex Playlist Sync</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
<style>
|
||||||
|
.progress { height: 25px; }
|
||||||
|
.progress-bar { line-height: 25px; }
|
||||||
|
.track-item { padding: 10px; border-bottom: 1px solid #eee; }
|
||||||
|
.track-item.success { background-color: #d4edda; }
|
||||||
|
.track-item.missing { background-color: #fff3cd; }
|
||||||
|
.track-item.error { background-color: #f8d7da; }
|
||||||
|
.log-container {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.track-item {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('index') }}">Plex Playlist Sync</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-5 mb-4 text-center text-muted">
|
||||||
|
<div class="container">
|
||||||
|
<p>Plex Playlist Sync © {{ now.year }}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||||
|
<h2 class="h4 mb-0">Playlist Sync Configuration</h2>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-outline-light btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" class="mb-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="border-bottom pb-2 mb-3">Connection Settings</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="plex_url" class="form-label">Plex Server URL</label>
|
||||||
|
<input type="url" class="form-control" id="plex_url" name="plex_url"
|
||||||
|
value="{{ config.get('PLEX_BASE_URL', '') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="plex_token" class="form-label">Plex Token</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="plex_token"
|
||||||
|
name="plex_token" value="{{ config.get('PLEX_TOKEN', '') }}" required>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="toggleToken">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="border-bottom pb-2 mb-3">Playlist Settings</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="library_name" class="form-label">Music Library Name</label>
|
||||||
|
<input type="text" class="form-control" id="library_name"
|
||||||
|
name="library_name" value="{{ config.get('MUSIC_LIBRARY_NAME', 'Music') }}" required>
|
||||||
|
<div class="form-text">The name of your music library in Plex</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="playlist_name" class="form-label">Playlist Name</label>
|
||||||
|
<input type="text" class="form-control" id="playlist_name"
|
||||||
|
name="playlist_name" value="{{ config.get('PLAYLIST_NAME', 'My Playlist') }}" required>
|
||||||
|
<div class="form-text">Name for the new playlist in Plex</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-3">
|
||||||
|
<button type="button" class="btn btn-outline-secondary me-2" id="test-connection">
|
||||||
|
<i class="bi bi-plug me-1"></i> Test Connection
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-save me-1"></i> Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="connection-status" class="mt-3"></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-secondary text-white">
|
||||||
|
<h5 class="mb-0">File Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>CSV File:</span>
|
||||||
|
<span class="text-end">{{ session.csv_file|basename }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>File Size:</span>
|
||||||
|
<span>{{ '%0.2f'|format(session.csv_file|filesize / 1024) }} KB</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>Last Modified:</span>
|
||||||
|
<span>{{ session.csv_file|filemodtime|datetimeformat('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-secondary text-white">
|
||||||
|
<h5 class="mb-0">Sync Status</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body d-flex flex-column">
|
||||||
|
<div id="sync-status" class="mb-3">
|
||||||
|
<div class="progress mb-3" style="height: 30px;">
|
||||||
|
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated"
|
||||||
|
role="progressbar" style="width: 0%; font-weight: 500;"
|
||||||
|
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
|
||||||
|
</div>
|
||||||
|
<div id="status-text" class="text-center mb-3">
|
||||||
|
<i class="bi bi-info-circle me-2"></i> Ready to start sync
|
||||||
|
</div>
|
||||||
|
<div id="stats" class="text-center mb-4">
|
||||||
|
<span id="found" class="badge bg-success rounded-pill me-2 px-3 py-2">
|
||||||
|
<i class="bi bi-check-circle me-1"></i> <span id="found-count">0</span> found
|
||||||
|
</span>
|
||||||
|
<span id="missing" class="badge bg-warning rounded-pill px-3 py-2">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i> <span id="missing-count">0</span> missing
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<button id="start-sync" class="btn btn-primary btn-lg w-100">
|
||||||
|
<i class="bi bi-play-fill me-2"></i> Start Sync
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Missing Tracks Section -->
|
||||||
|
<div class="card mt-4" id="missing-tracks-card" style="display: none;">
|
||||||
|
<div class="card-header bg-warning text-dark d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="h5 mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i> Missing Tracks
|
||||||
|
</h3>
|
||||||
|
<span id="missing-count-badge" class="badge bg-dark">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="missing-tracks-list">
|
||||||
|
<!-- Filled by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Search Modal -->
|
||||||
|
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="searchModalLabel">Search for Track in Plex</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="search-query" class="form-label">Search Query</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="search-query" placeholder="Track name - Artist">
|
||||||
|
<button class="btn btn-primary" type="button" id="search-button">
|
||||||
|
<i class="bi bi-search me-1"></i> Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="search-results" class="mt-3">
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-search d-block mb-2" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mb-0">Enter a search query to find tracks in your Plex library</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="add-to-playlist" disabled>
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Add to Playlist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
|
||||||
|
<h3 class="h5 mb-0">
|
||||||
|
<i class="bi bi-card-checklist me-2"></i> Sync Log
|
||||||
|
</h3>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button id="clear-log" class="btn btn-sm btn-outline-light">
|
||||||
|
<i class="bi bi-trash me-1"></i> Clear
|
||||||
|
</button>
|
||||||
|
<button id="copy-log" class="btn btn-sm btn-outline-light">
|
||||||
|
<i class="bi bi-clipboard me-1"></i> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="log-container" class="log-container">
|
||||||
|
<div id="sync-log">
|
||||||
|
<div class="text-muted text-center py-4">
|
||||||
|
<i class="bi bi-arrow-repeat d-block mb-2" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mb-0">Waiting to start sync...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted small d-flex justify-content-between align-items-center">
|
||||||
|
<span id="log-count">0 items</span>
|
||||||
|
<span id="last-updated">Never synced</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Custom filters for template
|
||||||
|
const formatBytes = (bytes, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add formatBytes to window for template use
|
||||||
|
window.formatBytes = formatBytes;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// UI Elements
|
||||||
|
const startButton = document.getElementById('start-sync');
|
||||||
|
const syncLog = document.getElementById('sync-log');
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
const foundCount = document.getElementById('found-count');
|
||||||
|
const missingCount = document.getElementById('missing-count');
|
||||||
|
const logContainer = document.getElementById('log-container');
|
||||||
|
const clearLogBtn = document.getElementById('clear-log');
|
||||||
|
const copyLogBtn = document.getElementById('copy-log');
|
||||||
|
const logCountEl = document.getElementById('log-count');
|
||||||
|
const lastUpdatedEl = document.getElementById('last-updated');
|
||||||
|
const toggleTokenBtn = document.getElementById('toggleToken');
|
||||||
|
|
||||||
|
// State
|
||||||
|
let logEntries = 0;
|
||||||
|
let syncInProgress = false;
|
||||||
|
let missingTracks = [];
|
||||||
|
let currentTrack = null;
|
||||||
|
let selectedTrack = null;
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const formatTime = (date) => {
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLogEntry = (message, type = 'info', data = null) => {
|
||||||
|
const logEntry = document.createElement('div');
|
||||||
|
logEntry.className = `log-entry d-flex align-items-start mb-2`;
|
||||||
|
|
||||||
|
// Add icon based on type
|
||||||
|
let iconClass = 'bi-info-circle';
|
||||||
|
if (type === 'success') iconClass = 'bi-check-circle';
|
||||||
|
else if (type === 'warning') iconClass = 'bi-exclamation-triangle';
|
||||||
|
else if (type === 'error') iconClass = 'bi-x-circle';
|
||||||
|
|
||||||
|
logEntry.innerHTML = `
|
||||||
|
<div class="flex-shrink-0 me-2 text-${type}">
|
||||||
|
<i class="bi ${iconClass}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="fw-medium">${message}</span>
|
||||||
|
<small class="text-muted ms-2">${formatTime(new Date())}</small>
|
||||||
|
</div>
|
||||||
|
${data ? `<pre class="text-muted small mt-1 mb-0">${JSON.stringify(data, null, 2)}</pre>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to log
|
||||||
|
syncLog.appendChild(logEntry);
|
||||||
|
logContainer.scrollTop = logContainer.scrollHeight;
|
||||||
|
|
||||||
|
// Update log count
|
||||||
|
logEntries++;
|
||||||
|
logCountEl.textContent = `${logEntries} ${logEntries === 1 ? 'entry' : 'entries'}`;
|
||||||
|
lastUpdatedEl.textContent = `Last updated: ${new Date().toLocaleString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
const updateProgress = (percent, message = '') => {
|
||||||
|
const roundedPercent = Math.round(percent);
|
||||||
|
progressBar.style.width = `${roundedPercent}%`;
|
||||||
|
progressBar.setAttribute('aria-valuenow', roundedPercent);
|
||||||
|
progressBar.textContent = `${roundedPercent}%`;
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
statusText.innerHTML = `<i class="bi ${syncInProgress ? 'bi-arrow-repeat spin' : 'bi-info-circle'} me-2"></i> ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bar color based on completion
|
||||||
|
progressBar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
|
||||||
|
if (roundedPercent >= 90) progressBar.classList.add('bg-success');
|
||||||
|
else if (roundedPercent >= 50) progressBar.classList.add('bg-primary');
|
||||||
|
else progressBar.classList.add('bg-info');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const updateStats = (found, missing) => {
|
||||||
|
foundCount.textContent = found;
|
||||||
|
missingCount.textContent = missing;
|
||||||
|
|
||||||
|
// Update badges
|
||||||
|
document.getElementById('found').classList.toggle('bg-success', found > 0);
|
||||||
|
document.getElementById('missing').classList.toggle('bg-warning', missing > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test Plex connection
|
||||||
|
const testConnection = async () => {
|
||||||
|
const url = document.getElementById('plex_url').value;
|
||||||
|
const token = document.getElementById('plex_token').value;
|
||||||
|
const statusEl = document.getElementById('connection-status');
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
Please enter both Plex URL and token
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
Testing connection to Plex server...
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/test_connection', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ plex_url: url, plex_token: token })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<div class="alert alert-success mb-0">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>
|
||||||
|
Successfully connected to Plex server! ${result.server_name ? `(${result.server_name})` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Connection failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Connection test failed:', error);
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<div class="alert alert-danger mb-0">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>
|
||||||
|
Connection failed: ${error.message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show missing tracks in the UI
|
||||||
|
const showMissingTracks = (tracks) => {
|
||||||
|
missingTracks = tracks;
|
||||||
|
const container = document.getElementById('missing-tracks-list');
|
||||||
|
const card = document.getElementById('missing-tracks-card');
|
||||||
|
const countBadge = document.getElementById('missing-count-badge');
|
||||||
|
|
||||||
|
if (!tracks || tracks.length === 0) {
|
||||||
|
card.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update count
|
||||||
|
countBadge.textContent = tracks.length;
|
||||||
|
|
||||||
|
// Clear existing rows
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Add each missing track
|
||||||
|
tracks.forEach((track, index) => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.dataset.index = index;
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${track.title || 'Unknown'}</td>
|
||||||
|
<td>${track.artist || 'Unknown'}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i> Missing
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary search-track" data-index="${index}">
|
||||||
|
<i class="bi bi-search me-1"></i> Find in Plex
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the card
|
||||||
|
card.style.display = 'block';
|
||||||
|
|
||||||
|
// Add event listeners to search buttons
|
||||||
|
document.querySelectorAll('.search-track').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const index = e.target.closest('button').dataset.index;
|
||||||
|
openSearchModal(missingTracks[index]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open search modal for a track
|
||||||
|
const openSearchModal = (track) => {
|
||||||
|
currentTrack = track;
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('searchModal'));
|
||||||
|
const query = `${track.title} ${track.artist}`.trim();
|
||||||
|
|
||||||
|
// Set initial search query
|
||||||
|
document.getElementById('search-query').value = query;
|
||||||
|
|
||||||
|
// Clear previous results
|
||||||
|
document.getElementById('search-results').innerHTML = `
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-search d-block mb-2" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mb-0">Enter a search query to find tracks in your Plex library</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Disable add button until a track is selected
|
||||||
|
document.getElementById('add-to-playlist').disabled = true;
|
||||||
|
|
||||||
|
// Show modal and trigger search if we have a query
|
||||||
|
modal.show();
|
||||||
|
if (query) {
|
||||||
|
searchPlex(query);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search Plex library
|
||||||
|
const searchPlex = async (query) => {
|
||||||
|
const resultsEl = document.getElementById('search-results');
|
||||||
|
resultsEl.innerHTML = `
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Searching...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 mb-0">Searching for "${query}" in your Plex library...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/search_plex', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || 'Search failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.results.length === 0) {
|
||||||
|
resultsEl.innerHTML = `
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-search d-block mb-2" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mb-0">No results found for "${query}"</p>
|
||||||
|
<p class="small">Try a different search term or check your library</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display search results
|
||||||
|
let html = '<div class="list-group">';
|
||||||
|
data.results.forEach((track, index) => {
|
||||||
|
const isSelected = selectedTrack && selectedTrack.ratingKey === track.ratingKey;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="list-group-item list-group-item-action ${isSelected ? 'active' : ''}"
|
||||||
|
data-track='${JSON.stringify(track)}'
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h6 class="mb-1">${track.title || 'Unknown'}</h6>
|
||||||
|
<small>${track.duration || ''}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1">${track.artist || 'Unknown'} • ${track.album || 'Unknown'}</p>
|
||||||
|
<small>${track.year || ''} • ${track.albumArtist || ''}</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
resultsEl.innerHTML = html;
|
||||||
|
|
||||||
|
// Add click handlers to search results
|
||||||
|
resultsEl.querySelectorAll('.list-group-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
// Remove active class from all items
|
||||||
|
resultsEl.querySelectorAll('.list-group-item').forEach(i => {
|
||||||
|
i.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active class to selected item
|
||||||
|
item.classList.add('active');
|
||||||
|
|
||||||
|
// Store selected track
|
||||||
|
selectedTrack = JSON.parse(item.dataset.track);
|
||||||
|
|
||||||
|
// Enable add button
|
||||||
|
document.getElementById('add-to-playlist').disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
resultsEl.innerHTML = `
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
Search failed: ${error.message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add track to playlist
|
||||||
|
const addToPlaylist = async () => {
|
||||||
|
if (!selectedTrack || !currentTrack) return;
|
||||||
|
|
||||||
|
const addButton = document.getElementById('add-to-playlist');
|
||||||
|
const originalText = addButton.innerHTML;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update button state
|
||||||
|
addButton.disabled = true;
|
||||||
|
addButton.innerHTML = `
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
Adding...
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Call server to add track to playlist
|
||||||
|
const response = await fetch('/add_to_playlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
track_key: selectedTrack.ratingKey,
|
||||||
|
original_track: currentTrack
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || 'Failed to add track to playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
addLogEntry(`✓ Added "${selectedTrack.title}" to playlist`, 'success');
|
||||||
|
|
||||||
|
// Update missing tracks list
|
||||||
|
const index = missingTracks.findIndex(t =>
|
||||||
|
t.title === currentTrack.title && t.artist === currentTrack.artist
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
missingTracks.splice(index, 1);
|
||||||
|
showMissingTracks(missingTracks);
|
||||||
|
|
||||||
|
// Update counters
|
||||||
|
const foundCount = parseInt(document.getElementById('found-count').textContent) + 1;
|
||||||
|
const missingCount = parseInt(document.getElementById('missing-count').textContent) - 1;
|
||||||
|
updateStats(foundCount, missingCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('searchModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add track:', error);
|
||||||
|
addLogEntry(`✗ Failed to add track: ${error.message}`, 'error');
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
addButton.disabled = false;
|
||||||
|
addButton.innerHTML = originalText;
|
||||||
|
|
||||||
|
// Show error in modal
|
||||||
|
const resultsEl = document.getElementById('search-results');
|
||||||
|
const errorEl = document.createElement('div');
|
||||||
|
errorEl.className = 'alert alert-danger mt-3 mb-0';
|
||||||
|
errorEl.innerHTML = `
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
Failed to add track: ${error.message}
|
||||||
|
`;
|
||||||
|
resultsEl.prepend(errorEl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle token visibility
|
||||||
|
if (toggleTokenBtn) {
|
||||||
|
toggleTokenBtn.addEventListener('click', () => {
|
||||||
|
const tokenInput = document.getElementById('plex_token');
|
||||||
|
const icon = toggleTokenBtn.querySelector('i');
|
||||||
|
|
||||||
|
if (tokenInput.type === 'password') {
|
||||||
|
tokenInput.type = 'text';
|
||||||
|
icon.classList.remove('bi-eye');
|
||||||
|
icon.classList.add('bi-eye-slash');
|
||||||
|
} else {
|
||||||
|
tokenInput.type = 'password';
|
||||||
|
icon.classList.remove('bi-eye-slash');
|
||||||
|
icon.classList.add('bi-eye');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear log
|
||||||
|
if (clearLogBtn) {
|
||||||
|
clearLogBtn.addEventListener('click', () => {
|
||||||
|
syncLog.innerHTML = `
|
||||||
|
<div class="text-muted text-center py-4">
|
||||||
|
<i class="bi bi-arrow-repeat d-block mb-2" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mb-0">Log cleared. Ready for next sync...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
logEntries = 0;
|
||||||
|
logCountEl.textContent = '0 entries';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy log to clipboard
|
||||||
|
if (copyLogBtn) {
|
||||||
|
copyLogBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const logText = Array.from(syncLog.children)
|
||||||
|
.map(el => el.textContent?.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(logText);
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
const originalText = copyLogBtn.innerHTML;
|
||||||
|
copyLogBtn.innerHTML = '<i class="bi bi-check2 me-1"></i> Copied!';
|
||||||
|
setTimeout(() => {
|
||||||
|
copyLogBtn.innerHTML = originalText;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
addLogEntry('Log copied to clipboard', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
addLogEntry('Failed to copy log to clipboard', 'error');
|
||||||
|
console.error('Failed to copy log:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start sync process
|
||||||
|
startButton.addEventListener('click', async function() {
|
||||||
|
if (syncInProgress) {
|
||||||
|
addLogEntry('Sync is already in progress', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncInProgress = true;
|
||||||
|
startButton.disabled = true;
|
||||||
|
startButton.innerHTML = `
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
Syncing...
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Clear previous logs if not already populated
|
||||||
|
if (logEntries === 0) {
|
||||||
|
syncLog.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress(0, 'Initializing sync...');
|
||||||
|
updateStats(0, 0);
|
||||||
|
|
||||||
|
// Add animation to progress bar
|
||||||
|
progressBar.classList.add('progress-bar-animated');
|
||||||
|
|
||||||
|
try {
|
||||||
|
addLogEntry('Starting playlist sync...', 'info');
|
||||||
|
|
||||||
|
const response = await fetch('/run_sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
playlist_name: document.getElementById('playlist_name').value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
// Process the chunk of data
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Process complete JSON objects from the buffer
|
||||||
|
let boundary;
|
||||||
|
while ((boundary = buffer.indexOf('\n')) !== -1) {
|
||||||
|
const line = buffer.substring(0, boundary).trim();
|
||||||
|
buffer = buffer.substring(boundary + 1);
|
||||||
|
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line);
|
||||||
|
|
||||||
|
if (data.progress !== undefined) {
|
||||||
|
updateProgress(data.progress, data.message || 'Syncing...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'processing') {
|
||||||
|
addLogEntry(`Processing: ${data.track}`, 'info');
|
||||||
|
}
|
||||||
|
else if (data.status === 'found') {
|
||||||
|
addLogEntry(`✓ Found: ${data.track} → ${data.match}`, 'success');
|
||||||
|
updateStats(parseInt(foundCount.textContent) + 1, parseInt(missingCount.textContent));
|
||||||
|
}
|
||||||
|
else if (data.status === 'missing') {
|
||||||
|
addLogEntry(`⚠ Missing: ${data.track}`, 'warning');
|
||||||
|
updateStats(parseInt(foundCount.textContent), parseInt(missingCount.textContent) + 1);
|
||||||
|
}
|
||||||
|
else if (data.status === 'error') {
|
||||||
|
addLogEntry(`✗ Error: ${data.message}`, 'error', data.details);
|
||||||
|
}
|
||||||
|
else if (data.status === 'missing_tracks') {
|
||||||
|
// Show missing tracks in the UI
|
||||||
|
showMissingTracks(data.missing_tracks);
|
||||||
|
|
||||||
|
// Update progress and stats
|
||||||
|
updateProgress(100, 'Sync completed with missing tracks');
|
||||||
|
updateStats(data.found || 0, data.missing_tracks?.length || 0);
|
||||||
|
|
||||||
|
// Add summary with warning
|
||||||
|
const summary = document.createElement('div');
|
||||||
|
summary.className = 'alert alert-warning mt-3';
|
||||||
|
summary.innerHTML = `
|
||||||
|
<h5><i class="bi bi-exclamation-triangle me-2"></i> Sync Completed with Missing Tracks</h5>
|
||||||
|
<p>Processed ${(data.found || 0) + (data.missing_tracks?.length || 0)} tracks:</p>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li class="text-success">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
${data.found || 0} tracks added to playlist
|
||||||
|
</li>
|
||||||
|
<li class="text-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
${data.missing_tracks?.length || 0} tracks not found in library
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-sm btn-outline-warning" id="show-missing-tracks">
|
||||||
|
<i class="bi bi-list-ul me-1"></i> Show Missing Tracks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
syncLog.appendChild(summary);
|
||||||
|
|
||||||
|
// Scroll to show summary
|
||||||
|
summary.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
||||||
|
// Add click handler for show missing tracks button
|
||||||
|
document.getElementById('show-missing-tracks')?.addEventListener('click', () => {
|
||||||
|
const missingTracksCard = document.getElementById('missing-tracks-card');
|
||||||
|
missingTracksCard.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
addLogEntry(`Sync completed with ${data.missing_tracks?.length || 0} missing tracks`, 'warning');
|
||||||
|
}
|
||||||
|
else if (data.status === 'completed') {
|
||||||
|
updateProgress(100, 'Sync completed!');
|
||||||
|
updateStats(data.found || 0, 0);
|
||||||
|
|
||||||
|
// Add summary
|
||||||
|
const summary = document.createElement('div');
|
||||||
|
summary.className = 'alert alert-success mt-3';
|
||||||
|
summary.innerHTML = `
|
||||||
|
<h5><i class="bi bi-check-circle me-2"></i> Sync Completed Successfully</h5>
|
||||||
|
<p>Successfully processed ${data.found || 0} tracks:</p>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li class="text-success">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
${data.found || 0} tracks added to playlist
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
syncLog.appendChild(summary);
|
||||||
|
|
||||||
|
// Scroll to show summary
|
||||||
|
summary.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
||||||
|
addLogEntry(`Sync completed successfully! All tracks were added to the playlist.`, 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing JSON:', e, 'Line:', line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sync error:', error);
|
||||||
|
updateProgress(0, 'Sync failed');
|
||||||
|
addLogEntry(`Sync failed: ${error.message}`, 'error', error.stack);
|
||||||
|
|
||||||
|
// Show error to user
|
||||||
|
const errorAlert = document.createElement('div');
|
||||||
|
errorAlert.className = 'alert alert-danger mt-3';
|
||||||
|
errorAlert.innerHTML = `
|
||||||
|
<h5><i class="bi bi-x-circle-fill me-2"></i> Sync Failed</h5>
|
||||||
|
<p class="mb-1">${error.message}</p>
|
||||||
|
<button class="btn btn-sm btn-outline-danger mt-2" onclick="this.nextElementSibling.classList.toggle('d-none')">
|
||||||
|
Show details
|
||||||
|
</button>
|
||||||
|
<pre class="d-none small bg-dark text-light p-2 rounded mt-2">${error.stack || 'No stack trace available'}</pre>
|
||||||
|
`;
|
||||||
|
syncLog.appendChild(errorAlert);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
syncInProgress = false;
|
||||||
|
startButton.disabled = false;
|
||||||
|
startButton.innerHTML = `
|
||||||
|
<i class="bi bi-arrow-repeat me-2"></i> Sync Again
|
||||||
|
`;
|
||||||
|
progressBar.classList.remove('progress-bar-animated');
|
||||||
|
|
||||||
|
// Update last updated time
|
||||||
|
lastUpdatedEl.textContent = `Last updated: ${new Date().toLocaleString()}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Add spin animation class
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-left: -1rem;
|
||||||
|
margin-right: -1rem;
|
||||||
|
}
|
||||||
|
.log-entry:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
.log-entry.text-success {
|
||||||
|
border-left-color: #198754;
|
||||||
|
}
|
||||||
|
.log-entry.text-warning {
|
||||||
|
border-left-color: #ffc107;
|
||||||
|
}
|
||||||
|
.log-entry.text-danger {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
}
|
||||||
|
.log-entry.text-info {
|
||||||
|
border-left-color: #0dcaf0;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h1 class="display-5 fw-bold text-primary mb-3">Plex Playlist Sync</h1>
|
||||||
|
<p class="lead">Sync your Spotify playlists with Plex Music Library</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h2 class="h4 mb-0">Step 1: Connect to Plex</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data" id="plexConfigForm">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="plex_url" class="form-label fw-bold">Plex Server URL</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-server"></i></span>
|
||||||
|
<input type="url" class="form-control form-control-lg" id="plex_url"
|
||||||
|
name="plex_url" value="{{ config.get('PLEX_BASE_URL', '') }}"
|
||||||
|
placeholder="http://localhost:32400" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">The URL of your Plex server, usually http://localhost:32400 for local servers</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="plex_token" class="form-label fw-bold">Plex Authentication Token</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||||
|
<input type="password" class="form-control form-control-lg" id="plex_token"
|
||||||
|
name="plex_token" value="{{ config.get('PLEX_TOKEN', '') }}" required>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="toggleToken">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-info" type="button" data-bs-toggle="modal" data-bs-target="#tokenHelpModal">
|
||||||
|
<i class="bi bi-question-circle"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
<a href="https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/" target="_blank" rel="noopener noreferrer">
|
||||||
|
How to find your Plex token
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="files" class="form-label fw-bold">Spotify Playlist CSV Files</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-file-earmark-music"></i></span>
|
||||||
|
<input class="form-control form-control-lg" type="file" id="files" name="files" accept=".csv" multiple required>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
Export your Spotify playlists as CSV files. You can select multiple files.
|
||||||
|
<a href="https://exportify.net/" target="_blank">
|
||||||
|
How to export from Spotify
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div id="file-list" class="mt-2">
|
||||||
|
<!-- Selected files will be listed here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="unified-playlist" name="unified_playlist" checked>
|
||||||
|
<label class="form-check-label" for="unified-playlist">
|
||||||
|
Create a single unified playlist from all files
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
If unchecked, a separate playlist will be created for each file.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-arrow-right-circle me-2"></i>Continue to Track Matching
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4 shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h3 class="h5 mb-0">How It Works</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="text-center p-3">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
|
||||||
|
<i class="bi bi-cloud-arrow-up fs-2"></i>
|
||||||
|
</div>
|
||||||
|
<h5>1. Upload CSV</h5>
|
||||||
|
<p class="small text-muted mb-0">Export your Spotify playlist as a CSV file and upload it here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="text-center p-3">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
|
||||||
|
<i class="bi bi-search fs-2"></i>
|
||||||
|
</div>
|
||||||
|
<h5>2. Match Tracks</h5>
|
||||||
|
<p class="small text-muted mb-0">We'll find matching tracks in your Plex library</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="text-center p-3">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
|
||||||
|
<i class="bi bi-music-note-list fs-2"></i>
|
||||||
|
</div>
|
||||||
|
<h5>3. Create Playlist</h5>
|
||||||
|
<p class="small text-muted mb-0">Generate a new playlist in Plex with your matched tracks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token Help Modal -->
|
||||||
|
<div class="modal fade" id="tokenHelpModal" tabindex="-1" aria-labelledby="tokenHelpModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" id="tokenHelpModalLabel">How to Find Your Plex Token</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle-fill me-2"></i>
|
||||||
|
Your Plex token is required to access your Plex server. Here's how to find it:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="mb-4">
|
||||||
|
<li class="mb-2">Sign in to <a href="https://app.plex.tv/desktop" target="_blank">Plex Web</a></li>
|
||||||
|
<li class="mb-2">Click on your profile icon in the top-right corner</li>
|
||||||
|
<li class="mb-2">Select <strong>Account Settings</strong></li>
|
||||||
|
<li class="mb-2">In the left sidebar, click on <strong>Web</strong> under the <em>Account</em> section</li>
|
||||||
|
<li class="mb-2">Look for the <code>X-Plex-Token</code> in the URL. It should look something like: <code>X-Plex-Token=xxxxxxxxxxxxxxxxxxxx</code></li>
|
||||||
|
<li>Copy everything after <code>X-Plex-Token=</code></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<strong>Important:</strong> Keep your token private and never share it with anyone. This token provides access to your Plex server.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-0">
|
||||||
|
<a href="https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/" target="_blank" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-question-circle me-1"></i> More detailed instructions
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Got it!</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Toggle token visibility
|
||||||
|
const toggleTokenBtn = document.getElementById('toggleToken');
|
||||||
|
if (toggleTokenBtn) {
|
||||||
|
toggleTokenBtn.addEventListener('click', function() {
|
||||||
|
const tokenInput = document.getElementById('plex_token');
|
||||||
|
const icon = this.querySelector('i');
|
||||||
|
|
||||||
|
if (tokenInput.type === 'password') {
|
||||||
|
tokenInput.type = 'text';
|
||||||
|
icon.classList.remove('bi-eye');
|
||||||
|
icon.classList.add('bi-eye-slash');
|
||||||
|
} else {
|
||||||
|
tokenInput.type = 'password';
|
||||||
|
icon.classList.remove('bi-eye-slash');
|
||||||
|
icon.classList.add('bi-eye');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const fileInput = document.getElementById('files');
|
||||||
|
const fileList = document.getElementById('file-list');
|
||||||
|
|
||||||
|
if (fileInput && fileList) {
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
const files = Array.from(this.files);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
fileList.innerHTML = '<div class="text-muted">No files selected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'list-group';
|
||||||
|
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
const listItem = document.createElement('li');
|
||||||
|
listItem.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
|
||||||
|
const fileName = document.createElement('span');
|
||||||
|
fileName.textContent = file.name;
|
||||||
|
|
||||||
|
const fileSize = document.createElement('span');
|
||||||
|
fileSize.className = 'badge bg-secondary rounded-pill';
|
||||||
|
fileSize.textContent = formatFileSize(file.size);
|
||||||
|
|
||||||
|
listItem.appendChild(fileName);
|
||||||
|
listItem.appendChild(fileSize);
|
||||||
|
list.appendChild(listItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
fileList.appendChild(list);
|
||||||
|
|
||||||
|
// Update the playlist name to match the first file if unified playlist is enabled
|
||||||
|
const unifiedCheckbox = document.getElementById('unified-playlist');
|
||||||
|
if (unifiedCheckbox && unifiedCheckbox.checked && files.length > 0) {
|
||||||
|
const playlistName = files[0].name.replace(/\.[^/.]+$/, '').replace(/_/g, ' ');
|
||||||
|
document.querySelector('input[name="playlist_name"]').value = playlistName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unified playlist checkbox change
|
||||||
|
const unifiedCheckbox = document.getElementById('unified-playlist');
|
||||||
|
if (unifiedCheckbox) {
|
||||||
|
unifiedCheckbox.addEventListener('change', function() {
|
||||||
|
const playlistNameInput = document.querySelector('input[name="playlist_name"]');
|
||||||
|
if (this.checked && fileInput.files.length > 0) {
|
||||||
|
// If switching to unified, set name to first file's name
|
||||||
|
const playlistName = fileInput.files[0].name.replace(/\.[^/.]+$/, '').replace(/_/g, ' ');
|
||||||
|
playlistNameInput.value = playlistName;
|
||||||
|
}
|
||||||
|
// If switching to separate playlists, we'll use each file's name later
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file size to human readable format
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submission with loading state and validation
|
||||||
|
const form = document.getElementById('plexConfigForm');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
const fileInput = document.getElementById('files');
|
||||||
|
|
||||||
|
// Validate files
|
||||||
|
if (fileInput.files.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please select at least one CSV file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Processing...';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.progress-container {
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-option-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-option-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-option-card.active {
|
||||||
|
border-color: #0d6efd;
|
||||||
|
background-color: #f8f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-option-card.active .form-check-input:checked {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h1 class="display-5 fw-bold text-primary mb-2">Track Matching</h1>
|
||||||
|
|
||||||
|
{% if unified_playlist %}
|
||||||
|
<p class="lead">We'll create <span class="badge bg-primary">1 unified playlist</span> from <span class="badge bg-secondary">{{ uploaded_files|length }} files</span></p>
|
||||||
|
{% else %}
|
||||||
|
<p class="lead">We'll create <span class="badge bg-primary">{{ uploaded_files|length }} playlists</span> (one per file)</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="lead">Found <span class="badge bg-success">{{ found_count }}</span> out of <span class="badge bg-secondary">{{ total_tracks }}</span> tracks in your Plex library</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info d-flex align-items-center mt-3" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill me-2"></i>
|
||||||
|
<div>
|
||||||
|
Need to export your Spotify playlists?
|
||||||
|
<a href="https://www.exportify.net/" target="_blank" class="alert-link">Exportify</a>
|
||||||
|
lets you export your playlists as CSV files that work with this tool.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set total_missing = (total_tracks - found_count) %}
|
||||||
|
{% if total_missing > 0 %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<strong>{{ total_missing }} tracks not found</strong> in your Plex library.
|
||||||
|
You can search for them manually below.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
All tracks were found in your Plex library! Click "Create Playlist" to continue.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set progress_percent = (found_count / total_tracks * 100)|round|int %}
|
||||||
|
<div class="progress mb-4 progress-container">
|
||||||
|
<div id="progressBar" class="progress-bar bg-success"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow="{{ progress_percent }}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
{{ progress_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for group in missing_by_file %}
|
||||||
|
{% set file_id = (group.filename | replace('.', '_') | replace(' ', '_')) %}
|
||||||
|
<div class="card shadow mb-4" id="file-card-{{ file_id }}">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Missing Tracks — {{ group.playlist_name }}</h5>
|
||||||
|
<span class="badge bg-warning text-dark file-missing-badge" data-file-id="{{ file_id }}">{{ group.missing_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if group.missing_tracks %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for track in group.missing_tracks %}
|
||||||
|
<tr id="track-{{ file_id }}-{{ loop.index }}" class="align-middle">
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td class="fw-bold">{{ track.get('Track Name', '') }}</td>
|
||||||
|
<td>{{ track.get('Artist Name(s)', '') }}</td>
|
||||||
|
<td>{{ track.get('Album Name', '') }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary search-track"
|
||||||
|
data-track-name="{{ track.get('Track Name', '') | e }}"
|
||||||
|
data-artist-name="{{ track.get('Artist Name(s)', '') | e }}"
|
||||||
|
data-track-id="{{ loop.index }}"
|
||||||
|
data-file-id="{{ file_id }}">
|
||||||
|
<i class="bi bi-search me-1"></i> Search
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center p-4">
|
||||||
|
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mt-2 mb-0">All tracks were found in this file.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Uploaded Files Summary -->
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Uploaded Files</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for file in uploaded_files %}
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100 file-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="file-icon text-primary">
|
||||||
|
<i class="bi bi-file-earmark-music"></i>
|
||||||
|
</div>
|
||||||
|
<h6 class="card-title text-truncate" title="{{ file.filename }}">
|
||||||
|
{{ file.filename }}
|
||||||
|
</h6>
|
||||||
|
<p class="card-text small text-muted mb-1">
|
||||||
|
{{ file.track_count }} track{% if file.track_count != 1 %}s{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Creation Options -->
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Playlist Creation Options</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="playlistForm" method="post" action="{{ url_for('create_playlist') }}">
|
||||||
|
{% if file_mode %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-secondary">File {{ file_index + 1 }} of {{ total_files }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">You'll be taken to the next file after creating this playlist.</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="file_index" value="{{ file_index }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="playlist_name" class="form-label">Playlist Name</label>
|
||||||
|
<input type="text" class="form-control" id="playlist_name" name="playlist_name"
|
||||||
|
value="{{ missing_by_file[0].playlist_name }}" required>
|
||||||
|
<div class="form-text">You can rename this playlist before creating it.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="onlySelected" name="only_selected">
|
||||||
|
<label class="form-check-label" for="onlySelected">
|
||||||
|
Only add selected matches (skip auto-search for remaining tracks)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% elif unified_playlist %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="playlist_name" class="form-label">Playlist Name</label>
|
||||||
|
<input type="text" class="form-control" id="playlist_name" name="playlist_name"
|
||||||
|
value="{{ config.PLAYLIST_NAME }}" required>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">Each file will be created as a separate playlist with its filename as the playlist name.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> Back
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="startSyncBtn">
|
||||||
|
<i class="bi bi-music-note-list me-1"></i>
|
||||||
|
{% if file_mode %}
|
||||||
|
Create This Playlist
|
||||||
|
{% elif unified_playlist %}
|
||||||
|
Create Unified Playlist
|
||||||
|
{% else %}
|
||||||
|
Create {{ uploaded_files|length }} Playlists
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Modal -->
|
||||||
|
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" id="searchModalLabel">Search for Track</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="searchQuery" class="form-label">Search for track in Plex</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="searchQuery" placeholder="Search by track name, artist, or album">
|
||||||
|
<button class="btn btn-primary" type="button" id="searchButton">
|
||||||
|
<i class="bi bi-search"></i> Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Searching in: <span id="searchingFor" class="fw-bold"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="searchResults" class="mt-3">
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-search" style="font-size: 2rem;"></i>
|
||||||
|
<p class="mt-2">Enter a search term to find matching tracks in your Plex library</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="selectedTrackInfo" class="mt-3 d-none">
|
||||||
|
<hr>
|
||||||
|
<h6>Selected Track</h6>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" id="selectedTrackDetails">
|
||||||
|
<!-- Selected track will be shown here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<input type="hidden" id="currentTrackId">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="confirmTrackBtn" disabled>Confirm Selection</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchModal = new bootstrap.Modal(document.getElementById('searchModal'));
|
||||||
|
let selectedTrack = null;
|
||||||
|
let currentFileId = null;
|
||||||
|
|
||||||
|
// Handle search track button clicks
|
||||||
|
document.querySelectorAll('.search-track').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const trackName = this.getAttribute('data-track-name');
|
||||||
|
const artistName = this.getAttribute('data-artist-name');
|
||||||
|
const trackId = this.getAttribute('data-track-id');
|
||||||
|
const fileId = this.getAttribute('data-file-id');
|
||||||
|
|
||||||
|
document.getElementById('searchQuery').value = trackName + ' ' + artistName;
|
||||||
|
document.getElementById('searchingFor').textContent = '\"' + trackName + '\" by ' + artistName;
|
||||||
|
document.getElementById('currentTrackId').value = trackId;
|
||||||
|
document.getElementById('currentTrackId').setAttribute('data-file-id', fileId || '');
|
||||||
|
currentFileId = fileId || '';
|
||||||
|
document.getElementById('searchResults').innerHTML =
|
||||||
|
'<div class="text-center py-4">' +
|
||||||
|
' <div class="spinner-border text-primary" role="status">' +
|
||||||
|
' <span class="visually-hidden">Loading...</span>' +
|
||||||
|
' </div>' +
|
||||||
|
' <p class="mt-2">Searching for \"' + trackName + '\" by ' + artistName + '...</p>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// Reset selected track
|
||||||
|
selectedTrack = null;
|
||||||
|
document.getElementById('confirmTrackBtn').disabled = true;
|
||||||
|
document.getElementById('selectedTrackInfo').classList.add('d-none');
|
||||||
|
|
||||||
|
// Show modal and trigger search
|
||||||
|
searchModal.show();
|
||||||
|
performSearch(trackName, artistName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle search button click
|
||||||
|
document.getElementById('searchButton').addEventListener('click', function() {
|
||||||
|
const query = document.getElementById('searchQuery').value.trim();
|
||||||
|
if (query) {
|
||||||
|
performSearch(query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle search on Enter key
|
||||||
|
document.getElementById('searchQuery').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const query = this.value.trim();
|
||||||
|
if (query) {
|
||||||
|
performSearch(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle track selection
|
||||||
|
document.getElementById('searchResults').addEventListener('click', function(e) {
|
||||||
|
const resultItem = e.target.closest('.search-result-item');
|
||||||
|
if (resultItem) {
|
||||||
|
// Remove active class from all items
|
||||||
|
document.querySelectorAll('.search-result-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active class to selected item
|
||||||
|
resultItem.classList.add('active');
|
||||||
|
|
||||||
|
// Store selected track data
|
||||||
|
selectedTrack = {
|
||||||
|
title: resultItem.getAttribute('data-title'),
|
||||||
|
artist: resultItem.getAttribute('data-artist'),
|
||||||
|
album: resultItem.getAttribute('data-album'),
|
||||||
|
ratingKey: resultItem.getAttribute('data-rating-key'),
|
||||||
|
thumb: resultItem.getAttribute('data-thumb')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update selected track info
|
||||||
|
const thumbHtml = selectedTrack.thumb ?
|
||||||
|
'<img src="' + selectedTrack.thumb + '" class="img-thumbnail me-3" style="max-height: 80px;">' : '';
|
||||||
|
|
||||||
|
document.getElementById('selectedTrackDetails').innerHTML =
|
||||||
|
'<div class="d-flex align-items-center">' +
|
||||||
|
' ' + thumbHtml +
|
||||||
|
' <div>' +
|
||||||
|
' <h5 class="mb-1">' + selectedTrack.title + '</h5>' +
|
||||||
|
' <p class="mb-1 text-muted">' + selectedTrack.artist + '</p>' +
|
||||||
|
' <p class="mb-0 small">' + (selectedTrack.album || 'No album') + '</p>' +
|
||||||
|
' </div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('selectedTrackInfo').classList.remove('d-none');
|
||||||
|
document.getElementById('confirmTrackBtn').disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle confirm track selection
|
||||||
|
document.getElementById('confirmTrackBtn').addEventListener('click', function() {
|
||||||
|
if (!selectedTrack) return;
|
||||||
|
|
||||||
|
const trackId = document.getElementById('currentTrackId').value;
|
||||||
|
const fileId = document.getElementById('currentTrackId').getAttribute('data-file-id') || '';
|
||||||
|
const trackRow = document.getElementById('track-' + fileId + '-' + trackId);
|
||||||
|
|
||||||
|
if (trackRow) {
|
||||||
|
// Update the row with the selected track
|
||||||
|
trackRow.innerHTML =
|
||||||
|
'<td>' + trackId + '</td>' +
|
||||||
|
'<td class="fw-bold">' + selectedTrack.title + '</td>' +
|
||||||
|
'<td>' + selectedTrack.artist + '</td>' +
|
||||||
|
'<td>' + (selectedTrack.album || '') + '</td>' +
|
||||||
|
'<td>' +
|
||||||
|
' <span class="badge bg-success">' +
|
||||||
|
' <i class="bi bi-check-circle me-1"></i> Matched' +
|
||||||
|
' </span>' +
|
||||||
|
'</td>';
|
||||||
|
|
||||||
|
// Update counters and progress
|
||||||
|
updateMatchCounters(1);
|
||||||
|
|
||||||
|
// Add hidden input for this ratingKey to the form, avoid duplicates
|
||||||
|
const form = document.getElementById('playlistForm');
|
||||||
|
if (form && selectedTrack.ratingKey) {
|
||||||
|
const existing = Array.from(form.querySelectorAll('input[name="track_ratingKey[]"]'))
|
||||||
|
.some(inp => inp.value === selectedTrack.ratingKey);
|
||||||
|
if (!existing) {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'hidden';
|
||||||
|
inp.name = 'track_ratingKey[]';
|
||||||
|
inp.value = selectedTrack.ratingKey;
|
||||||
|
form.appendChild(inp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
searchModal.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// No AJAX submit; rely on form POST for creation
|
||||||
|
|
||||||
|
// Function to perform search
|
||||||
|
function performSearch(query, originalArtist = '') {
|
||||||
|
const resultsContainer = document.getElementById('searchResults');
|
||||||
|
// Show loading state
|
||||||
|
resultsContainer.innerHTML =
|
||||||
|
'<div class="text-center py-4">' +
|
||||||
|
' <div class="spinner-border text-primary" role="status">' +
|
||||||
|
' <span class="visually-hidden">Loading...</span>' +
|
||||||
|
' </div>' +
|
||||||
|
' <p class="mt-2">Searching...</p>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
fetch('{{ url_for("search_plex") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: query,
|
||||||
|
original_artist: originalArtist
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(async response => {
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (!response.ok) {
|
||||||
|
// If redirected to login/index due to missing session
|
||||||
|
if (response.redirected || (!contentType.includes('application/json'))) {
|
||||||
|
throw new Error('Session expired. Please start over.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
throw new Error('Unexpected response.');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
const msg = data.message || 'Search failed. Please try again.';
|
||||||
|
resultsContainer.innerHTML =
|
||||||
|
'<div class="alert alert-danger">' +
|
||||||
|
' <i class="bi bi-exclamation-triangle-fill me-2"></i>' +
|
||||||
|
' ' + msg +
|
||||||
|
'</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.results && data.results.length > 0) {
|
||||||
|
let html = '<div class="list-group list-group-flush">';
|
||||||
|
|
||||||
|
data.results.forEach((track, index) => {
|
||||||
|
const safeTitle = escapeHtml(track.title);
|
||||||
|
const safeArtist = escapeHtml(track.artist);
|
||||||
|
const safeAlbum = escapeHtml(track.album || '');
|
||||||
|
const safeThumb = escapeHtml(track.thumb || '');
|
||||||
|
const safeDuration = escapeHtml(track.duration || '');
|
||||||
|
const safeRatingKey = escapeHtml(String(track.ratingKey || ''));
|
||||||
|
const thumb = safeThumb ?
|
||||||
|
'<img src="' + safeThumb + '" class="rounded me-3" style="width: 40px; height: 40px; object-fit: cover;">' :
|
||||||
|
'<div class="bg-light rounded d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;"><i class="bi bi-music-note"></i></div>';
|
||||||
|
|
||||||
|
html +=
|
||||||
|
'<div class="list-group-item list-group-item-action search-result-item" ' +
|
||||||
|
' data-title="' + safeTitle + '" ' +
|
||||||
|
' data-artist="' + safeArtist + '" ' +
|
||||||
|
' data-album="' + safeAlbum + '" ' +
|
||||||
|
' data-rating-key="' + safeRatingKey + '" ' +
|
||||||
|
' data-thumb="' + safeThumb + '">' +
|
||||||
|
' <div class="d-flex align-items-center">' +
|
||||||
|
' ' + thumb +
|
||||||
|
' <div class="flex-grow-1">' +
|
||||||
|
' <div class="d-flex justify-content-between">' +
|
||||||
|
' <h6 class="mb-1">' + safeTitle + '</h6>' +
|
||||||
|
' <small class="text-muted">' + safeDuration + '</small>' +
|
||||||
|
' </div>' +
|
||||||
|
' <p class="mb-1 small text-muted">' + safeArtist + '</p>' +
|
||||||
|
' <small class="text-muted">' + safeAlbum + '</small>' +
|
||||||
|
' </div>' +
|
||||||
|
' </div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
resultsContainer.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
resultsContainer.innerHTML =
|
||||||
|
'<div class="text-center py-4">' +
|
||||||
|
' <i class="bi bi-music-note-beamed text-muted" style="font-size: 2rem;"></i>' +
|
||||||
|
' <p class="mt-2 mb-0">No results found for "' + escapeHtml(query) + '"</p>' +
|
||||||
|
' <p class="small text-muted">Try a different search term</p>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
const message = (error && error.message) ? error.message : 'An error occurred while searching. Please try again.';
|
||||||
|
resultsContainer.innerHTML =
|
||||||
|
'<div class="alert alert-danger">' +
|
||||||
|
' <i class="bi bi-exclamation-triangle-fill me-2"></i>' +
|
||||||
|
' ' + message +
|
||||||
|
'</div>';
|
||||||
|
if (message.includes('expired')) {
|
||||||
|
setTimeout(() => { window.location.href = '{{ url_for("index") }}'; }, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update match counters and progress
|
||||||
|
function updateMatchCounters(change) {
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const totalTracks = parseInt('{{ total_tracks|default(0) }}', 10) || 1;
|
||||||
|
|
||||||
|
// Update the specific file's missing badge first
|
||||||
|
if (currentFileId) {
|
||||||
|
const fileBadge = document.querySelector('.file-missing-badge[data-file-id="' + currentFileId + '"]');
|
||||||
|
if (fileBadge) {
|
||||||
|
const cur = parseInt(fileBadge.textContent, 10) || 0;
|
||||||
|
fileBadge.textContent = Math.max(0, cur - change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute global missing as sum of all file badges
|
||||||
|
let totalMissing = 0;
|
||||||
|
document.querySelectorAll('.file-missing-badge').forEach(b => {
|
||||||
|
totalMissing += (parseInt(b.textContent, 10) || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update progress bar and found count
|
||||||
|
if (progressBar) {
|
||||||
|
const currentFound = Math.max(0, totalTracks - totalMissing);
|
||||||
|
const newPercent = Math.round((currentFound / totalTracks) * 100);
|
||||||
|
progressBar.style.width = newPercent + '%';
|
||||||
|
progressBar.setAttribute('aria-valuenow', newPercent);
|
||||||
|
progressBar.textContent = newPercent + '%';
|
||||||
|
const foundCountElement = document.querySelector('.lead .badge.bg-success');
|
||||||
|
if (foundCountElement) {
|
||||||
|
foundCountElement.textContent = currentFound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable create playlist button when no missing remain
|
||||||
|
if (totalMissing === 0) {
|
||||||
|
document.getElementById('startSyncBtn').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress bar width on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
if (progressBar) {
|
||||||
|
const progressPercent = '{{ progress_percent }}';
|
||||||
|
progressBar.style.width = progressPercent + '%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to escape HTML
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
return String(unsafe)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.search-result-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item.active {
|
||||||
|
background-color: #e7f1ff;
|
||||||
|
border-left: 3px solid #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -10px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #198754;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
box-shadow: 0 0 0 2px #198754;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar.animate::after {
|
||||||
|
display: block;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(0.8); opacity: 0.8; }
|
||||||
|
70% { transform: scale(1.2); opacity: 0.2; }
|
||||||
|
100% { transform: scale(0.8); opacity: 0.8; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.playlist-card {
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-badge {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-inline-flex align-items-center justify-content-center bg-success bg-opacity-10 rounded-circle p-4 mb-3">
|
||||||
|
<i class="bi bi-check-circle-fill text-success" style="font-size: 3rem;"></i>
|
||||||
|
</div>
|
||||||
|
{% if playlists|length == 1 %}
|
||||||
|
<h1 class="h3 fw-bold text-success mb-3">Playlist Created Successfully!</h1>
|
||||||
|
<p class="lead">Your playlist <span class="fw-bold">{{ playlists[0].name }}</span> has been created with <span class="fw-bold">{{ playlists[0].track_count }}</span> tracks.</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="h3 fw-bold text-success mb-3">{{ playlists|length }} Playlists Created Successfully!</h1>
|
||||||
|
<p class="lead">All playlists have been created with a total of <span class="fw-bold">{{ playlists|sum(attribute='track_count') }}</span> tracks.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Cards -->
|
||||||
|
{% if playlists|length > 1 %}
|
||||||
|
<div class="row g-4 mt-4">
|
||||||
|
{% for playlist in playlists %}
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100 playlist-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="playlist-icon text-primary">
|
||||||
|
<i class="bi bi-music-note-list"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title">{{ playlist.name }}</h5>
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
<span class="badge bg-primary stats-badge">
|
||||||
|
<i class="bi bi-music-note-beamed me-1"></i>{{ playlist.track_count }} track{% if playlist.track_count != 1 %}s{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if playlist.source %}
|
||||||
|
<span class="badge bg-secondary stats-badge ms-1" title="Source file: {{ playlist.source }}">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i>Source
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="#" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-play-circle me-1"></i> Play in Plex
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="d-flex justify-content-center gap-3 mt-5">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-primary px-4">
|
||||||
|
<i class="bi bi-house-door me-2"></i>Back to Home
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('match_tracks') }}" class="btn btn-outline-secondary px-4">
|
||||||
|
<i class="bi bi-arrow-repeat me-2"></i>Sync Another Playlist
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="mt-5 pt-4 border-top">
|
||||||
|
<h5 class="mb-4">What would you like to do next?</h5>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="#" class="card h-100 text-decoration-none text-dark hover-shadow">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
|
||||||
|
<i class="bi bi-music-note-list fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h6 class="card-title mb-0">View in Plex</h6>
|
||||||
|
<small class="text-muted">Open your playlists in Plex</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="#" class="card h-100 text-decoration-none text-dark hover-shadow">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
|
||||||
|
<i class="bi bi-collection-play fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h6 class="card-title mb-0">View All Playlists</h6>
|
||||||
|
<small class="text-muted">See all your Plex playlists</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="{{ url_for('index') }}" class="card h-100 text-decoration-none text-dark hover-shadow">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
|
||||||
|
<i class="bi bi-plus-circle fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h6 class="card-title mb-0">Create Another</h6>
|
||||||
|
<small class="text-muted">Sync another playlist</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Add any client-side interactions if needed
|
||||||
|
|
||||||
|
// Example: Add click handler for the "Play in Plex" buttons
|
||||||
|
document.querySelectorAll('.play-plex-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const playlistName = this.getAttribute('data-playlist-name');
|
||||||
|
// In a real app, this would open the playlist in Plex
|
||||||
|
alert(`Opening "${playlistName}" in Plex...`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hover-shadow {
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-shadow:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user