Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions deezer_downloader/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
53 changes: 53 additions & 0 deletions deezer_downloader/web/music_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion deezer_downloader/web/static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,17 @@ button.search {
.btn-category.active {
border-bottom: 3px solid #242424;
color: #242424;
}
}

.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; }
175 changes: 141 additions & 34 deletions deezer_downloader/web/static/js/custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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($("<option>").val("").text("Not mapped"));
csvHeaders.forEach(function(header, index) {
select.append($("<option>").val(index).text(header || "Column " + (index + 1)));
});
});
$("#csv-artist-column").val(guessColumn(["artist", "performer", "band"]));
$("#csv-title-column").val(guessColumn(["track", "title", "song"]));
$("#csv-album-column").val(guessColumn(["album", "release"]));
}

function renderCsvPreview() {
var headRow = $("<tr>").append(
$("<th>").append($("<input type='checkbox' id='csv-select-all' checked>"))
);
csvHeaders.forEach(function(header) { headRow.append($("<th>").text(header)); });
$("#csv-table thead").empty().append(headRow);
var body = $("#csv-table tbody").empty();
csvRows.forEach(function(values, index) {
var row = $("<tr>");
row.append($("<td>").append($("<input type='checkbox' class='csv-row-select' checked>").attr("data-row", index)));
csvHeaders.forEach(function(_, column) { row.append($("<td>").text(values[column] || "")); });
body.append(row);
});
$("#csv-summary").text(csvRows.length + " rows loaded");
$("#csv-mapping, #csv-controls").show();
}

$("#csv-file").change(function(event) {
var file = event.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(loadEvent) {
var parsed = parseCsv(loadEvent.target.result);
if (parsed.length < 2) {
$.jGrowl("The CSV needs a header and at least one data row", { life: 5000 });
return;
}
csvHeaders = parsed[0];
csvRows = parsed.slice(1);
populateCsvColumns();
renderCsvPreview();
};
reader.onerror = function() { $.jGrowl("Could not read that CSV file", { life: 5000 }); };
reader.readAsText(file);
});

$(document).on("change", "#csv-select-all", function() {
$(".csv-row-select").prop("checked", this.checked);
});

function csvDownload(type) {
var artistColumn = $("#csv-artist-column").val();
var titleColumn = $("#csv-title-column").val();
var albumColumn = $("#csv-album-column").val();
if ((type === "track" && titleColumn === "") || (type === "album" && albumColumn === "")) {
$.jGrowl("Map the " + (type === "track" ? "track" : "album") + " column first", { life: 5000 });
return;
}
var rows = [];
$(".csv-row-select:checked").each(function() {
var source = csvRows[parseInt($(this).attr("data-row"))];
rows.push({
artist: artistColumn === "" ? "" : (source[parseInt(artistColumn)] || ""),
title: titleColumn === "" ? "" : (source[parseInt(titleColumn)] || ""),
album: albumColumn === "" ? "" : (source[parseInt(albumColumn)] || "")
});
});
if (!rows.length) { $.jGrowl("Select at least one row", { life: 5000 }); return; }
$.ajax({
url: deezer_downloader_api_root + "/import/csv",
method: "POST",
contentType: "application/json",
data: JSON.stringify({
type: type, rows: rows,
add_to_playlist: $("#csv-add-to-playlist").prop("checked"),
create_zip: type === "album" && $("#csv-create-zip").prop("checked")
}),
success: function() {
$.jGrowl("CSV import queued for " + rows.length + " selected rows", { life: 5000 });
},
error: function(xhr) {
var message = xhr.responseJSON && xhr.responseJSON.error ? xhr.responseJSON.error : "Could not queue CSV import";
$.jGrowl(message, { life: 5000 });
}
});
}

$("#csv-download-tracks").click(function() { csvDownload("track"); });
$("#csv-download-albums").click(function() { csvDownload("album"); });

if(!show_mpd_features) {
$("#yt_download_play").hide()
$("#spotify_download_play").hide()
Expand Down Expand Up @@ -310,46 +436,27 @@ $(document).ready(function() {
var bbody = document.getElementById('body');
bbody.onkeydown = function (event) {
if (event.key !== undefined) {
if (event.key === 'Enter' ) {
if (event.key === 'Enter' && $("#nav-songs-albums").hasClass("active")) {
search(search_type);
} else if (event.key === 'm' && event.ctrlKey) {
$("#songs-albums-query")[0].value = "";
$("#songs-albums-query")[0].focus();
}
if (event.ctrlKey && event.shiftKey) {
console.log("pressed ctrl + shift + ..");
if(event.key === '!') {
id_nav = "nav-songs-albums";
id_content = "songs_albums";
}
if(event.key === '"') {
id_nav = "nav-youtubedl";
id_content = "youtubedl";
}
if(event.key === '§') {
id_nav = "nav-spotify-playlists";
id_content = "spotify-playlists";
}
if(event.key === '$') {
id_nav = "nav-deezer-playlists";
id_content = "deezer-playlists";
}
if(event.key === '%') {
id_nav = "nav-songs-albums";
id_content = "songs_albums";
window.open('/downloads/', '_blank');
}
if(event.key === "&") {
id_nav = "nav-debug-log";
id_content = "debug";
show_debug_log();
}
if(event.key === '/') {
id_nav = "nav-task-queue";
id_content = "queue";
}
if(typeof id_nav !== 'undefined') {
show_tab(id_nav, id_content);
var tabs = {
Digit1: ["nav-songs-albums", "songs_albums"],
Digit2: ["nav-youtubedl", "youtubedl"],
Digit3: ["nav-spotify-playlists", "spotify-playlists"],
Digit4: ["nav-deezer", "deezer"],
Digit5: ["nav-csv-import", "csv-import"],
Digit7: ["nav-debug-log", "debug"],
Digit8: ["nav-task-queue", "queue"]
};
if (event.code === "Digit6") window.open('downloads/', '_blank');
else if (tabs[event.code]) {
show_tab(tabs[event.code][0], tabs[event.code][1]);
if (event.code === "Digit7") show_debug_log();
if (event.code === "Digit8") show_task_queue();
}
}
}
Expand Down
Loading