From a33c0f0991ca6cb7e662bcaf70ed181344abe995 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 19 Sep 2025 08:19:44 +0200 Subject: [PATCH] first commit --- .gitignore | 3 + app.py | 931 ++++++++++++++++++ .../2029240f6d1128be89ddc32729463129 | Bin 0 -> 9 bytes .../e434c9e38a2ffda8c3c21123d4c942bf | Bin 0 -> 1775 bytes plexsync.py | 408 ++++++++ requirements.txt | 8 + start.bat | 9 + static/css/style.css | 128 +++ templates/base.html | 58 ++ templates/configure.html | 906 +++++++++++++++++ templates/index.html | 280 ++++++ templates/match_tracks.html | 646 ++++++++++++ templates/playlist_created.html | 158 +++ 13 files changed, 3535 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 flask_session/2029240f6d1128be89ddc32729463129 create mode 100644 flask_session/e434c9e38a2ffda8c3c21123d4c942bf create mode 100644 plexsync.py create mode 100644 requirements.txt create mode 100644 start.bat create mode 100644 static/css/style.css create mode 100644 templates/base.html create mode 100644 templates/configure.html create mode 100644 templates/index.html create mode 100644 templates/match_tracks.html create mode 100644 templates/playlist_created.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aaac28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.venv +/venv +/__pycache__ \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..6f248eb --- /dev/null +++ b/app.py @@ -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/') +@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) diff --git a/flask_session/2029240f6d1128be89ddc32729463129 b/flask_session/2029240f6d1128be89ddc32729463129 new file mode 100644 index 0000000000000000000000000000000000000000..8b04914a5e6ad4858df0019a6abe09326d3863de GIT binary patch literal 9 QcmZQzU|?uq^=8xq00XW800000 literal 0 HcmV?d00001 diff --git a/flask_session/e434c9e38a2ffda8c3c21123d4c942bf b/flask_session/e434c9e38a2ffda8c3c21123d4c942bf new file mode 100644 index 0000000000000000000000000000000000000000..b707758cd06aed83adad53110ecd9521643c5564 GIT binary patch literal 1775 zcmbu8U279T6owVrB8t@37K?}-DAYo0QpFp+OWTAl-K`}b)`Bt&yVLHP$xN8pOUAJlc%2kqc-shY-=RMBpkMPUqtk@FwyWe?7{^2Ls$L%w{IM1XBgRp`7T}SRx3kpl>!aqek}H=l&qnS2R=4R6 zp812p)58z^xsuCXujA}P*V$_~+Gp^%;Wd?<_Yx8LO5W=oJ1wVq08iY;nd@}A>T4oz z`C z(8Eyc;6#_zPIQ%UQLg6&5<+kcT8slP1#b|+G>#}%;nLA=xGXnvV3r4U9`sU}=arc& zmbuSU5hDUz)XzXQ4O+9HNf4S~S$*HC4>-DDR2R|@(p_w8@dGO^#md^nT$>Kkn5Ra! zsdX9CY~o=2e_8X;YFda8Gh~N?HXdOvva%am{@9kCq?j#EQp%+4X=Yi6jF9w_u>kaF zfKgzFm~GRvkte`$LYcB;`_X;0h<1T6^15zg)wZF_k0WJWJ~ua_{fL4S#QR3MQ=Z#6 z22E_bmT`(eCuBT(JorUxVla#4+JExt+~4~2KdXc{(k(Y4* literal 0 HcmV?d00001 diff --git a/plexsync.py b/plexsync.py new file mode 100644 index 0000000..f0cb3cc --- /dev/null +++ b/plexsync.py @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b5a06cc --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..9f3c3bc --- /dev/null +++ b/start.bat @@ -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 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..dd888e9 --- /dev/null +++ b/static/css/style.css @@ -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; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..863c1fb --- /dev/null +++ b/templates/base.html @@ -0,0 +1,58 @@ + + + + Plex Playlist Sync + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+
+

Plex Playlist Sync © {{ now.year }}

+
+
+ + + {% block scripts %}{% endblock %} + + diff --git a/templates/configure.html b/templates/configure.html new file mode 100644 index 0000000..2b3c691 --- /dev/null +++ b/templates/configure.html @@ -0,0 +1,906 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Playlist Sync Configuration

+ + Back + +
+
+
+
+
+
Connection Settings
+
+ + +
+
+ +
+ + +
+
+
+
+
Playlist Settings
+
+ + +
The name of your music library in Plex
+
+
+ + +
Name for the new playlist in Plex
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
File Information
+
+
+
    +
  • + CSV File: + {{ session.csv_file|basename }} +
  • +
  • + File Size: + {{ '%0.2f'|format(session.csv_file|filesize / 1024) }} KB +
  • +
  • + Last Modified: + {{ session.csv_file|filemodtime|datetimeformat('%Y-%m-%d %H:%M') }} +
  • +
+
+
+
+
+
+
+
Sync Status
+
+
+
+
+
0%
+
+
+ Ready to start sync +
+
+ + 0 found + + + 0 missing + +
+
+
+ +
+
+
+
+
+
+
+ + + + + + + +
+
+

+ Sync Log +

+
+ + +
+
+
+
+
+
+ +

Waiting to start sync...

+
+
+
+
+ +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..d3cd70c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,280 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Plex Playlist Sync

+

Sync your Spotify playlists with Plex Music Library

+
+ +
+
+

Step 1: Connect to Plex

+
+
+
+
+ +
+ + +
+
The URL of your Plex server, usually http://localhost:32400 for local servers
+
+ +
+ +
+ + + + +
+ +
+ +
+ +
+ +
+ + +
+
+ Export your Spotify playlists as CSV files. You can select multiple files. + + How to export from Spotify + +
+
+ +
+
+ +
+ + +
+ If unchecked, a separate playlist will be created for each file. +
+
+ +
+ +
+
+
+
+ +
+
+

How It Works

+
+
+
+
+
+
+ +
+
1. Upload CSV
+

Export your Spotify playlist as a CSV file and upload it here

+
+
+
+
+
+ +
+
2. Match Tracks
+

We'll find matching tracks in your Plex library

+
+
+
+
+
+ +
+
3. Create Playlist
+

Generate a new playlist in Plex with your matched tracks

+
+
+
+
+
+
+
+ + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/match_tracks.html b/templates/match_tracks.html new file mode 100644 index 0000000..fdf7c81 --- /dev/null +++ b/templates/match_tracks.html @@ -0,0 +1,646 @@ +{% extends "base.html" %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+
+

Track Matching

+ + {% if unified_playlist %} +

We'll create 1 unified playlist from {{ uploaded_files|length }} files

+ {% else %} +

We'll create {{ uploaded_files|length }} playlists (one per file)

+ {% endif %} + +

Found {{ found_count }} out of {{ total_tracks }} tracks in your Plex library

+ + + + {% set total_missing = (total_tracks - found_count) %} + {% if total_missing > 0 %} +
+ + {{ total_missing }} tracks not found in your Plex library. + You can search for them manually below. +
+ {% else %} +
+ + All tracks were found in your Plex library! Click "Create Playlist" to continue. +
+ {% endif %} + + {% set progress_percent = (found_count / total_tracks * 100)|round|int %} +
+
+ {{ progress_percent }}% +
+
+
+ + {% for group in missing_by_file %} + {% set file_id = (group.filename | replace('.', '_') | replace(' ', '_')) %} +
+
+
Missing Tracks — {{ group.playlist_name }}
+ {{ group.missing_count }} +
+
+ {% if group.missing_tracks %} +
+ + + + + + + + + + + + {% for track in group.missing_tracks %} + + + + + + + + {% endfor %} + +
#TrackArtistAlbumAction
{{ loop.index }}{{ track.get('Track Name', '') }}{{ track.get('Artist Name(s)', '') }}{{ track.get('Album Name', '') }} + +
+
+ {% else %} +
+ +

All tracks were found in this file.

+
+ {% endif %} +
+
+ {% endfor %} + + +
+
+
Uploaded Files
+
+
+
+ {% for file in uploaded_files %} +
+
+
+
+ +
+
+ {{ file.filename }} +
+

+ {{ file.track_count }} track{% if file.track_count != 1 %}s{% endif %} +

+
+
+
+ {% endfor %} +
+
+
+ + +
+
+
Playlist Creation Options
+
+
+
+ {% if file_mode %} +
+
+ File {{ file_index + 1 }} of {{ total_files }} +
+
You'll be taken to the next file after creating this playlist.
+
+ +
+ + +
You can rename this playlist before creating it.
+
+
+ + +
+ {% elif unified_playlist %} +
+ + +
+ {% else %} +

Each file will be created as a separate playlist with its filename as the playlist name.

+ {% endif %} + +
+ + Back + +
+ +
+
+
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/templates/playlist_created.html b/templates/playlist_created.html new file mode 100644 index 0000000..86cd0c5 --- /dev/null +++ b/templates/playlist_created.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+
+
+
+
+ +
+ {% if playlists|length == 1 %} +

Playlist Created Successfully!

+

Your playlist {{ playlists[0].name }} has been created with {{ playlists[0].track_count }} tracks.

+ {% else %} +

{{ playlists|length }} Playlists Created Successfully!

+

All playlists have been created with a total of {{ playlists|sum(attribute='track_count') }} tracks.

+ {% endif %} +
+ + + {% if playlists|length > 1 %} +
+ {% for playlist in playlists %} +
+
+
+
+ +
+
{{ playlist.name }}
+

+ + {{ playlist.track_count }} track{% if playlist.track_count != 1 %}s{% endif %} + + {% if playlist.source %} + + Source + + {% endif %} +

+ +
+
+
+ {% endfor %} +
+ {% endif %} + + + + + + +
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %}