From e46efb112b44842b285123d41cbf62f4fe47cbd7 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Wed, 22 Jul 2026 22:30:54 -0500 Subject: [PATCH] Add CSV downloading to the WebUI --- deezer_downloader/web/app.py | 37 +++++ deezer_downloader/web/music_backend.py | 53 ++++++ deezer_downloader/web/static/css/custom.css | 15 +- deezer_downloader/web/static/js/custom.js | 175 ++++++++++++++++---- deezer_downloader/web/templates/index.html | 48 +++++- 5 files changed, 287 insertions(+), 41 deletions(-) diff --git a/deezer_downloader/web/app.py b/deezer_downloader/web/app.py index 75b9695..0325e57 100644 --- a/deezer_downloader/web/app.py +++ b/deezer_downloader/web/app.py @@ -203,6 +203,43 @@ def deezer_download_song_or_album(): return jsonify({"task_id": id(task), }) +@app.route('/import/csv', methods=['POST']) +def csv_import_download(): + """Queue tracks or albums described by rows imported from a CSV file.""" + user_input = request.get_json(silent=True) + if not isinstance(user_input, dict): + return jsonify({"error": "request body must be JSON"}), 400 + + required = {"type", "rows", "add_to_playlist", "create_zip"} + if set(user_input) != required: + return jsonify({"error": "required fields: type, rows, add_to_playlist, create_zip"}), 400 + if user_input["type"] not in ("track", "album"): + return jsonify({"error": "type must be track or album"}), 400 + if not isinstance(user_input["add_to_playlist"], bool) or not isinstance(user_input["create_zip"], bool): + return jsonify({"error": "add_to_playlist and create_zip must be booleans"}), 400 + rows = user_input["rows"] + if not isinstance(rows, list) or not rows or len(rows) > 10000: + return jsonify({"error": "rows must contain between 1 and 10000 entries"}), 400 + for row in rows: + if not isinstance(row, dict) or set(row) != {"artist", "title", "album"}: + return jsonify({"error": "each row must contain artist, title and album"}), 400 + if any(not isinstance(value, str) for value in row.values()): + return jsonify({"error": "row values must be strings"}), 400 + needed = "title" if user_input["type"] == "track" else "album" + if not row[needed].strip(): + return jsonify({"error": f"every row must have a {needed}"}), 400 + + task = sched.enqueue_task( + f"Downloading CSV {user_input['type']} import ({len(rows)} rows)", + "download_csv_import", + rows=rows, + download_type=user_input["type"], + add_to_playlist=user_input["add_to_playlist"], + create_zip=user_input["create_zip"], + ) + return jsonify({"task_id": id(task), "row_count": len(rows)}) + + @app.route('/youtubedl', methods=['POST']) @validate_schema("url", "add_to_playlist") def youtubedl_download(): diff --git a/deezer_downloader/web/music_backend.py b/deezer_downloader/web/music_backend.py index 3ff8ade..f40d634 100644 --- a/deezer_downloader/web/music_backend.py +++ b/deezer_downloader/web/music_backend.py @@ -177,6 +177,59 @@ def download_deezer_album_and_queue_and_zip(album_id, add_to_playlist, create_zi return make_song_paths_relative_to_mpd_root(songs_absolute_location) +def _normalized(value): + return " ".join((value or "").casefold().split()) + + +def _best_csv_search_result(results, row, download_type): + """Prefer exact CSV metadata matches while retaining Deezer's first result fallback.""" + if not results: + return None + artist = _normalized(row.get("artist")) + wanted = _normalized(row.get("title") if download_type == TYPE_TRACK else row.get("album")) + + def score(result): + result_name = result.get("title") if download_type == TYPE_TRACK else result.get("album") + return (2 if artist and _normalized(result.get("artist")) == artist else 0) + \ + (1 if wanted and _normalized(result_name) == wanted else 0) + + return max(results, key=score) + + +@sched.register_command() +def download_csv_import(rows, download_type, add_to_playlist, create_zip): + """Resolve CSV metadata against Deezer and download each unique requested item.""" + unique_rows = [] + seen = set() + for row in rows: + key = (_normalized(row.get("artist")), + _normalized(row.get("title") if download_type == TYPE_TRACK else row.get("album"))) + if key not in seen: + seen.add(key) + unique_rows.append(row) + + results = [] + for i, row in enumerate(unique_rows): + report_progress(i, len(unique_rows)) + name = row.get("title") if download_type == TYPE_TRACK else row.get("album") + query = " ".join(value.strip() for value in (row.get("artist", ""), name) if value.strip()) + try: + match = _best_csv_search_result(deezer_search(query, download_type), row, download_type) + if not match: + print(f"Warning: no Deezer {download_type} found for CSV row: {query}") + continue + if download_type == TYPE_TRACK: + result = download_deezer_song_and_queue(match["id"], add_to_playlist) + else: + result = download_deezer_album_and_queue_and_zip(match["id"], add_to_playlist, create_zip) + if result: + results.extend(result) + except Exception as error: + print(f"Warning: could not download CSV {download_type} ({query}): {error}") + report_progress(len(unique_rows), len(unique_rows)) + return results + + @sched.register_command() def download_deezer_playlist_and_queue_and_zip(playlist_id, add_to_playlist, create_zip): playlist_name, songs = parse_deezer_playlist(playlist_id) diff --git a/deezer_downloader/web/static/css/custom.css b/deezer_downloader/web/static/css/custom.css index ac5bfc4..22e48a3 100644 --- a/deezer_downloader/web/static/css/custom.css +++ b/deezer_downloader/web/static/css/custom.css @@ -44,4 +44,17 @@ button.search { .btn-category.active { border-bottom: 3px solid #242424; color: #242424; -} \ No newline at end of file +} + +.csv-mapping { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} + +.csv-mapping label { min-width: 190px; } +.csv-actions { margin: 14px 0; } +.csv-actions .btn { margin-right: 6px; } +.csv-preview { max-height: 55vh; } +.csv-preview thead th { position: sticky; top: 0; background: white; z-index: 1; } diff --git a/deezer_downloader/web/static/js/custom.js b/deezer_downloader/web/static/js/custom.js index 2a849ed..a663d3d 100644 --- a/deezer_downloader/web/static/js/custom.js +++ b/deezer_downloader/web/static/js/custom.js @@ -31,6 +31,132 @@ function play_preview(src) { $(document).ready(function() { + var csvHeaders = []; + var csvRows = []; + + function parseCsv(text) { + var rows = [], row = [], field = "", quoted = false; + text = text.replace(/^\uFEFF/, ""); + for (var i = 0; i < text.length; i++) { + var character = text[i]; + if (quoted) { + if (character === '"' && text[i + 1] === '"') { field += '"'; i++; } + else if (character === '"') quoted = false; + else field += character; + } else if (character === '"') quoted = true; + else if (character === ',') { row.push(field); field = ""; } + else if (character === '\n' || character === '\r') { + if (character === '\r' && text[i + 1] === '\n') i++; + row.push(field); field = ""; + if (row.some(function(value) { return value.length; })) rows.push(row); + row = []; + } else field += character; + } + row.push(field); + if (row.some(function(value) { return value.length; })) rows.push(row); + return rows; + } + + function guessColumn(words) { + for (var i = 0; i < csvHeaders.length; i++) { + var header = csvHeaders[i].toLowerCase(); + if (words.some(function(word) { return header.indexOf(word) !== -1; })) return i.toString(); + } + return ""; + } + + function populateCsvColumns() { + ["#csv-artist-column", "#csv-title-column", "#csv-album-column"].forEach(function(id) { + var select = $(id).empty().append($("