diff --git a/app/main.py b/app/main.py index 22557f9..b3d71dc 100644 --- a/app/main.py +++ b/app/main.py @@ -267,6 +267,13 @@ def delete_corrupt_files(paths, config_dir, music_dir=None): errors.append({"path": fp, "error": str(e)}) if deleted: + # Capture album IDs before pruning details + affected_album_ids = { + details.get(fp, {}).get("albumId") + for fp in deleted + if details.get(fp, {}).get("albumId") + } + deleted_set = set(deleted) remaining = sorted(allowed - deleted_set) # Rewrite corrupt.txt atomically @@ -290,9 +297,465 @@ def delete_corrupt_files(paths, config_dir, music_dir=None): tracking.pop(fp, None) write_json_atomic(tracking_path, tracking) + if affected_album_ids and lidarr_url and lidarr_key: + _record_pending_redownloads( + config_dir, list(affected_album_ids), + lidarr_url, lidarr_key) + return {"deleted": deleted, "errors": errors, "count": len(deleted)} +# --- Pending re-download tracker --- + +PENDING_REDOWNLOADS_FILE = "pending_redownloads.json" +_PENDING_REDOWNLOAD_TTL = 86400 + + +def _pending_redownloads_path(config_dir): + return os.path.join(config_dir, PENDING_REDOWNLOADS_FILE) + + +def _record_pending_redownloads(config_dir, album_ids, + lidarr_url, lidarr_api_key): + """Track album IDs affected by a Lidarr-backed delete so the next + scan cycle can detect grabbed re-downloads via Lidarr history.""" + if not album_ids: + return + path = _pending_redownloads_path(config_dir) + pending = _load_json(path, default={}) + if not isinstance(pending, dict): + pending = {} + now_iso = time.strftime('%Y-%m-%dT%H:%M:%S') + now_ts = time.time() + for aid in album_ids: + key = str(aid) + entry = pending.get(key, {}) + entry["deletedAt"] = now_iso + entry["deletedAtTs"] = now_ts + if "albumName" not in entry and lidarr_url and lidarr_api_key: + album = _lidarr_get_album(lidarr_url, lidarr_api_key, aid) + if album: + title = album.get("title", "") + artist_obj = album.get("artist", {}) or {} + artist = artist_obj.get("artistName", "") + entry["albumName"] = ( + f"{artist} — {title}" + if artist and title else title or key) + entry["monitored"] = album.get("monitored", True) + pending[key] = entry + try: + write_json_atomic(path, pending) + except OSError: + pass + + +def _poll_pending_redownloads(cfg): + """Check Lidarr history for pending album re-downloads. + Logs grabs to beats_check.log and removes resolved/stale entries. + Called at the top of each scan cycle.""" + if not (cfg.lidarr_url and cfg.lidarr_api_key): + return + path = _pending_redownloads_path(cfg.log_dir) + pending = _load_json(path, default={}) + if not isinstance(pending, dict) or not pending: + return + now_ts = time.time() + changed = False + for aid_str in list(pending.keys()): + entry = pending[aid_str] + try: + aid = int(aid_str) + except (ValueError, TypeError): + pending.pop(aid_str, None) + changed = True + continue + deleted_ts = entry.get("deletedAtTs", 0) + label = entry.get("albumName", aid_str) + if now_ts - deleted_ts > _PENDING_REDOWNLOAD_TTL: + logger.info( + "LIDARR REDOWNLOAD: %s — no grab detected within 24h", + label) + try: + with open(cfg.log_file, 'a', encoding='utf-8') as lf: + lf.write( + f"LIDARR REDOWNLOAD TIMEOUT: {label} " + f"— no grab within 24h\n") + except OSError: + pass + pending.pop(aid_str, None) + changed = True + continue + records = _lidarr_get_album_history( + cfg.lidarr_url, cfg.lidarr_api_key, aid) + if not records: + continue + record = records[0] + rec_date = record.get("date", "") + try: + rec_ts = time.mktime(time.strptime( + rec_date[:19], '%Y-%m-%dT%H:%M:%S')) + except (ValueError, TypeError): + rec_ts = 0 + if rec_ts <= deleted_ts: + continue + source = (record.get("sourceTitle") + or (record.get("data") or {}).get("releaseTitle") + or "") + msg = (f"LIDARR REDOWNLOAD: {label} — " + f"grabbed (1 report)" + + (f" [{source}]" if source else "")) + logger.info(msg) + try: + with open(cfg.log_file, 'a', encoding='utf-8') as lf: + lf.write(msg + "\n") + except OSError: + pass + pending.pop(aid_str, None) + changed = True + if changed: + try: + write_json_atomic(path, pending) + except OSError: + pass + + +# --- Whole-album delete (invoked from the WebUI) --- + +_MAX_DELETE_ALBUMS = 50 +_LIDARR_ALBUM_DELAY_SECONDS = 30 + + +def _list_folder_files(real): + """Return top-level regular-file paths in *real* (excluding symlinks).""" + try: + return [ + os.path.join(real, e) for e in os.listdir(real) + if os.path.isfile(os.path.join(real, e)) + and not os.path.islink(os.path.join(real, e)) + ] + except OSError: + return None + + +def _build_file_to_tfid(all_files, album_ids, corrupt_details, + lidarr_url, lidarr_key): + """Map folder files to Lidarr trackfile IDs via corrupt_details + + suffix-match fallback. Mutates *album_ids* with any new matches.""" + file_to_tfid = { + fp: corrupt_details[fp]["trackfileId"] + for fp in all_files + if "trackfileId" in corrupt_details.get(fp, {}) + } + unmatched = [fp for fp in all_files if fp not in file_to_tfid] + if not unmatched or not album_ids: + return file_to_tfid + album_tfs = [] + for aid in album_ids: + album_tfs.extend(_lidarr_get_trackfiles_by_album( + lidarr_url, lidarr_key, aid)) + for fp in unmatched: + match = _best_suffix_match(fp, album_tfs, min_score=1) + if match: + file_to_tfid[fp] = match["id"] + if match.get("albumId"): + album_ids.add(match["albumId"]) + return file_to_tfid + + +def _lidarr_delete_folder_api(folder, file_to_tfid, album_ids, + lidarr_url, lidarr_key, log): + """Call Lidarr bulk-delete for *file_to_tfid* and wait for + monitored searches. Returns (deleted_paths, error_or_None).""" + tf_ids = list(file_to_tfid.values()) + if not tf_ids: + return ([], None) + max_search_id = 0 + cmds = _lidarr_request( + f"{lidarr_url}/api/v1/command", lidarr_key) + if cmds: + for c in cmds: + if (c.get("name") == "AlbumSearch" + and c.get("id", 0) > max_search_id): + max_search_id = c["id"] + result = _lidarr_delete_trackfiles_bulk( + lidarr_url, lidarr_key, tf_ids) + if result is None: + return ([], "Lidarr bulk delete API failed") + deleted_paths = [fp for fp in file_to_tfid + if not os.path.exists(fp)] + log.write(f"LIDARR DELETE: {folder} — " + f"{len(tf_ids)} track files " + f"({len(album_ids)} albums)\n") + log.flush() + has_monitored = any( + (a := _lidarr_get_album(lidarr_url, lidarr_key, aid)) + and a.get("monitored", True) + for aid in album_ids + ) + if has_monitored: + _lidarr_wait_for_search( + lidarr_url, lidarr_key, since_id=max_search_id) + return (deleted_paths, None) + + +def _rmtree_and_collect(real, folder, log, deleted_paths): + """Walk *real* to record file paths then rmtree the folder. + Returns (deleted_paths, error_or_None). Caller is responsible + for ensuring *real* is safe to remove (not a mount root).""" + for dirpath, _, filenames in os.walk(real): + for fn in filenames: + fp = os.path.join(dirpath, fn) + if os.path.exists(fp) and fp not in deleted_paths: + deleted_paths.append(fp) + try: + shutil.rmtree(real) + log.write(f"REMOVED FOLDER: {folder}\n") + log.flush() + except OSError as e: + log.write(f"ERROR removing folder {folder}: {e}\n") + return (deleted_paths, f"rmtree failed: {e}") + return (deleted_paths, None) + + +def _direct_delete_untracked(all_files, deleted_paths, + corrupt_details, lidarr_url, + lidarr_key, log): + """Direct FS delete for files not already handled by Lidarr.""" + for fp in all_files: + if fp in deleted_paths: + continue + if (lidarr_url and lidarr_key + and "trackfileId" in corrupt_details.get(fp, {})): + continue + try: + os.remove(fp) + deleted_paths.append(fp) + log.write(f"DELETED FILE: {fp}\n") + except OSError as e: + log.write(f"ERROR deleting {fp}: {e}\n") + log.flush() + + +def _delete_one_folder(folder, real, existing_corrupt, corrupt_details, + log, lidarr_url, lidarr_key, lidarr_blocklist, + mode, music_real): + """Delete a single album folder. Returns + (deleted_paths, album_ids, error_message_or_None).""" + if mode == "whole": + all_files = _list_folder_files(real) + if all_files is None: + return ([], [], "listdir failed") + else: + all_files = [cf for cf in existing_corrupt + if os.path.dirname(cf) == folder + and os.path.exists(cf)] + if not all_files: + return ([], [], None) + + album_ids = { + corrupt_details[cf]["albumId"] + for cf in existing_corrupt + if corrupt_details.get(cf, {}).get("albumId") + } + + deleted_paths = [] + if lidarr_url and lidarr_key: + file_to_tfid = _build_file_to_tfid( + all_files, album_ids, corrupt_details, + lidarr_url, lidarr_key) + if file_to_tfid and lidarr_blocklist and album_ids: + _, bl_fail = _lidarr_blocklist_albums( + lidarr_url, lidarr_key, album_ids) + if bl_fail: + return (deleted_paths, list(album_ids), + f"blocklist failed ({bl_fail} albums)") + api_deleted, err = _lidarr_delete_folder_api( + folder, file_to_tfid, album_ids, + lidarr_url, lidarr_key, log) + deleted_paths.extend(api_deleted) + if err: + return (deleted_paths, list(album_ids), err) + + if mode == "whole" and real != music_real and os.path.isdir(real): + deleted_paths, err = _rmtree_and_collect( + real, folder, log, deleted_paths) + if err: + return (deleted_paths, list(album_ids), err) + elif mode == "corrupt": + _direct_delete_untracked( + all_files, deleted_paths, corrupt_details, + lidarr_url, lidarr_key, log) + + return (deleted_paths, list(album_ids), None) + + +def _validate_delete_folders(folders, music_real): + """Return (validated=[(folder, real)], errors=[...]) after safety + checks: mount-root guard, MUSIC_DIR containment, symlink guard, + is-a-directory.""" + validated = [] + errors = [] + for folder in folders: + real = os.path.realpath(folder) + if music_real and real == music_real: + errors.append({"folder": folder, + "error": "cannot delete mount root"}) + continue + if music_real and not real.startswith(music_real + os.sep): + errors.append({"folder": folder, + "error": "outside music directory"}) + continue + if os.path.islink(folder): + errors.append({"folder": folder, + "error": "symlink rejected"}) + continue + if not os.path.isdir(real): + errors.append({"folder": folder, + "error": "not a directory"}) + continue + validated.append((folder, real)) + return validated, errors + + +def _stagger_sleep(cancel_cb): + """Sleep the inter-album delay, checking *cancel_cb* every second. + Returns True if cancellation was requested.""" + for _ in range(_LIDARR_ALBUM_DELAY_SECONDS): + if cancel_cb and cancel_cb(): + return True + time.sleep(1) + return bool(cancel_cb and cancel_cb()) + + +def _finalize_delete_state(config_dir, deleted_files, + corrupt_details): + """After a bulk delete, strip *deleted_files* from corrupt.txt, + corrupt_details.json, and corrupt_tracking.json.""" + if not deleted_files: + return + corrupt_path = os.path.join(config_dir, "corrupt.txt") + details_path = os.path.join(config_dir, "corrupt_details.json") + tracking_path = os.path.join(config_dir, "corrupt_tracking.json") + allowed_corrupt = _load_lines_as_set(corrupt_path) + deleted_set = set(deleted_files) + remaining = sorted(allowed_corrupt - deleted_set) + tmp = corrupt_path + ".tmp" + try: + with open(tmp, 'w', encoding='utf-8') as f: + for p in remaining: + f.write(p + '\n') + os.rename(tmp, corrupt_path) + except OSError: + pass + for fp in deleted_files: + corrupt_details.pop(fp, None) + write_json_atomic(details_path, corrupt_details) + tracking = _load_json(tracking_path) + for fp in deleted_files: + tracking.pop(fp, None) + write_json_atomic(tracking_path, tracking) + + +def delete_album_folders(folders, config_dir, music_dir=None, + mode="whole", progress_cb=None, + cancel_cb=None): + """Delete album folders with staggered Lidarr deletion. + + mode='whole' removes the entire folder (Lidarr-tracked files go + through the API, then shutil.rmtree handles non-tracked files and + subdirectories). mode='corrupt' only removes files currently in + corrupt.txt for the folder. + + Staggered album-by-album with ~30s between albums when Lidarr is + configured. progress_cb(index, total, folder, phase) reports status; + cancel_cb() returning True aborts between albums. + + Returns dict: {deleted, errors, count, albums, cancelled}. + """ + if mode not in ("whole", "corrupt"): + return {"deleted": [], "errors": [ + {"folder": "", "error": "invalid mode"}], + "count": 0, "albums": [], "cancelled": False} + if len(folders) > _MAX_DELETE_ALBUMS: + return {"deleted": [], "errors": [ + {"folder": "", + "error": f"too many folders (>{_MAX_DELETE_ALBUMS})"}], + "count": 0, "albums": [], "cancelled": False} + + music_real = os.path.realpath(music_dir) if music_dir else None + log_file = os.path.join(config_dir, "beats_check.log") + + validated, errors = _validate_delete_folders(folders, music_real) + if not validated: + return {"deleted": [], "errors": errors, "count": 0, + "albums": [], "cancelled": False} + + lidarr_url = os.environ.get("LIDARR_URL", "").rstrip("/") + lidarr_key = _load_lidarr_api_key() + lidarr_blocklist = _parse_env_bool("LIDARR_BLOCKLIST", False) + corrupt_details = _load_corrupt_details(config_dir) + + deleted_files = [] + affected_albums = [] + cancelled = False + total = len(validated) + + with open(log_file, 'a', encoding='utf-8') as log: + log.write(f"\n{'='*60}\n") + log.write(f"Bulk delete ({mode}) started: " + f"{time.strftime('%Y-%m-%d %H:%M:%S')} " + f"— {total} folders\n") + log.write(f"{'='*60}\n") + log.flush() + + for i, (folder, real) in enumerate(validated, 1): + if (cancel_cb and cancel_cb()) or shutdown_requested: + cancelled = True + log.write(f"CANCELLED at folder {i}/{total}\n") + break + + if i > 1 and lidarr_url and lidarr_key: + if progress_cb: + progress_cb(i - 1, total, folder, "waiting") + if _stagger_sleep(cancel_cb): + cancelled = True + log.write(f"CANCELLED at folder {i}/{total}\n") + break + + if progress_cb: + progress_cb(i, total, folder, "deleting") + + existing_corrupt = [ + cf for cf in corrupt_details + if os.path.dirname(cf) == folder + and os.path.exists(cf) + ] + + del_files, album_ids, err = _delete_one_folder( + folder, real, existing_corrupt, corrupt_details, + log, lidarr_url, lidarr_key, lidarr_blocklist, + mode, music_real) + deleted_files.extend(del_files) + affected_albums.extend(album_ids) + if err: + errors.append({"folder": folder, "error": err}) + + log.write(f"Bulk delete ({mode}) complete: " + f"{len(deleted_files)} files, " + f"{len(affected_albums)} albums\n") + log.flush() + + _finalize_delete_state(config_dir, deleted_files, corrupt_details) + + if affected_albums and lidarr_url and lidarr_key: + _record_pending_redownloads( + config_dir, affected_albums, lidarr_url, lidarr_key) + + return {"deleted": deleted_files, "errors": errors, + "count": len(deleted_files), + "albums": affected_albums, "cancelled": cancelled} + + def _rotate_file(path, keep=3): """Rotate path -> path.1 -> path.2 -> ... keeping last N copies.""" oldest = f"{path}.{keep}" @@ -1429,6 +1892,27 @@ def run_delete_mode(corrupt_list_path, log_file, log_dir, # --- Auto-delete --- +def _post_auto_delete_lidarr(log_dir, album_ids, lidarr_url, + lidarr_api_key, lidarr_search): + """After auto-delete, record pending re-downloads and queue + explicit searches for unmonitored albums.""" + if not album_ids: + return + _record_pending_redownloads( + log_dir, album_ids, lidarr_url, lidarr_api_key) + if not lidarr_search: + return + unmonitored = [] + for aid in album_ids: + album = _lidarr_get_album(lidarr_url, lidarr_api_key, aid) + if album and not album.get("monitored", True): + unmonitored.append(aid) + if unmonitored: + _search_queue_add(log_dir, unmonitored) + logger.info(" Queued search for %d unmonitored albums", + len(unmonitored)) + + def run_auto_delete(log_dir, log_file, delete_after_days, max_deletes=50, lidarr_url=None, lidarr_api_key=None, lidarr_search=False, lidarr_blocklist=False): @@ -1501,20 +1985,9 @@ def run_auto_delete(log_dir, log_file, delete_after_days, max_deletes=50, for path in to_delete: if not os.path.exists(path): tracking.pop(path, None) - if lidarr_search and album_ids: - # Only queue search for unmonitored albums — - # Lidarr auto-searches monitored ones after deletion - unmonitored = [] - for aid in album_ids: - album = _lidarr_get_album( - lidarr_url, lidarr_api_key, aid) - if album and not album.get("monitored", True): - unmonitored.append(aid) - if unmonitored: - _search_queue_add(log_dir, unmonitored) - logger.info(" Queued search for %d " - "unmonitored albums", - len(unmonitored)) + _post_auto_delete_lidarr( + log_dir, album_ids, lidarr_url, lidarr_api_key, + lidarr_search) else: logger.error(" Lidarr API failed — auto-delete aborted. " "Files will be retried next run.") @@ -2747,6 +3220,7 @@ def main(): scan_cancelled = False _reload_config(cfg) _maybe_rotate_logs(cfg) + _poll_pending_redownloads(cfg) if not os.path.isdir(cfg.input_folder): logger.error("Music directory not found: %s", diff --git a/app/static/app.js b/app/static/app.js index fb23d1c..42a1881 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -450,13 +450,13 @@ async function loadCorrupt() { : 'Disabled'; const collapsed = localStorage.getItem('beatscheck-info-collapsed') === '1'; const lines = []; - lines.push('Re-download — deletes files via Lidarr' - + (blocklist ? ', blocklists the bad release,' : '') - + ' and waits for each album to finish searching before proceeding.'); + lines.push('Delete — removes corrupt files. Lidarr-tracked files are deleted via the Lidarr API' + + (blocklist ? ', the bad release is blocklisted,' : '') + + ' and BeatsCheck waits for each album to finish searching before moving to the next.'); + lines.push('Delete Album — removes the entire album folder (same Lidarr flow for tracked files).'); lines.push('Monitored albums are automatically re-searched by Lidarr.' + (searchUnmon ? ' Unmonitored albums will also be searched.' : ' Unmonitored albums will not be re-downloaded.')); - lines.push('Delete — permanently removes files' - + (hasAnyLidarr ? ' (Lidarr-tracked files are deleted via API, triggering the same search behavior).' : '.')); + lines.push('Successful re-downloads are logged to beats_check.log on the next scan cycle.'); lines.push('Blocklist: ' + blTag + (blocklist ? ' — bad releases will be blocklisted to prevent re-grabbing the same corrupt version.' : ' — Lidarr may re-grab the same release. Enable in Settings to prevent this.')); infoBanner.innerHTML = '
' @@ -487,6 +487,7 @@ function toggleCorruptView() { const btn = document.getElementById('view-toggle-btn'); if (btn) btn.textContent = corruptView === 'files' ? 'Group by Album' : 'Show All Files'; applyCorruptFilters(); + updateAlbumHelperVisibility(); } function applyCorruptFilters() { @@ -544,14 +545,13 @@ function renderCorruptAlbums(files) { const countLabel = allCorrupt ? `All ${tracks.length} files corrupt` : `${tracks.length} of ${albumTotal} files corrupt`; - const hasLidarr = tracks.some(f => f.has_lidarr_id); html += ` - + ${escHtml(albumName)}
${countLabel} ${formatSize(totalSize)} - ${hasLidarr ? `` : ''} - + + `; @@ -569,6 +569,7 @@ function renderCorruptAlbums(files) { }); }); tbody.innerHTML = html; + updateAlbumHelperVisibility(); } function toggleAlbumExpand(row) { @@ -587,37 +588,175 @@ function toggleAlbumSelect(checkbox) { updateDeleteBtn(); } -async function deleteAlbum(dir) { - const files = document.querySelectorAll(`tr.album-file[data-album="${dir}"] .file-check`); - const paths = Array.from(files).map(c => c.dataset.path).filter(Boolean); - if (!paths.length || !confirm('Permanently delete ' + paths.length + ' corrupt file(s) from this album?')) return; - const btns = document.querySelectorAll(`tr.album-header .btn`); - btns.forEach(b => b.disabled = true); +function updateAlbumHelperVisibility() { + const helper = document.getElementById('album-select-helper'); + const bulkBtn = document.getElementById('delete-albums-btn'); + if (!helper || !bulkBtn) return; + const isAlbums = corruptView === 'albums'; + helper.style.display = isAlbums ? '' : 'none'; + bulkBtn.style.display = isAlbums ? '' : 'none'; +} + +function selectNAlbums() { + const input = document.getElementById('select-n-input'); + let n = parseInt(input.value, 10); + if (isNaN(n) || n < 1) n = 1; + if (n > 50) n = 50; + input.value = n; + const boxes = document.querySelectorAll('tr.album-header:not([style*="display: none"]) .album-check'); + let selected = 0; + boxes.forEach(b => { + if (selected < n) { + if (!b.checked) { + b.checked = true; + toggleAlbumSelect(b); + } + selected++; + } + }); + if (selected < n) { + showToast(`Selected ${selected} album(s) (fewer than requested — only ${selected} visible)`, 'info'); + } else { + showToast(`Selected ${selected} album(s)`, 'success'); + } +} + +async function deleteAlbumWhole(dir, totalFiles) { + const msg = `Delete the entire album folder?\n\n${dir}\n\nThis will delete ALL ${totalFiles || '?'} file(s) in the folder, not just the corrupt ones.`; + if (!confirm(msg)) return; + const res = await startDeleteJob([dir], 'whole'); + if (res) openDeleteProgress(res.job_id, res.total, 'whole'); +} + +async function deleteSelectedAlbums() { + const boxes = document.querySelectorAll('.album-check:checked'); + const dirs = Array.from(boxes).map(b => b.dataset.dir).filter(Boolean); + if (dirs.length === 0) return; + if (dirs.length > 50) { + showToast('Maximum 50 albums per bulk delete', 'error'); + return; + } + const estMin = Math.ceil((dirs.length * 35) / 60); + const msg = `Delete ${dirs.length} whole album folder(s)?\n\n` + + `Albums are processed one at a time with a 30s delay between each to avoid flooding Lidarr and indexers.\n\n` + + `Estimated time: ~${estMin} minute(s).\n\nThis cannot be undone.`; + if (!confirm(msg)) return; + const res = await startDeleteJob(dirs, 'whole'); + if (res) openDeleteProgress(res.job_id, res.total, 'whole'); +} + +async function startDeleteJob(dirs, mode) { try { - const res = await apiPost('delete', { files: paths }); - if (res && res.count > 0) { - showToast('Deleted ' + res.count + ' file(s)', 'success'); - loadCorrupt(); - refreshDashboard(); - } else { - showToast('Delete failed' + (res?.errors?.length ? ': ' + res.errors[0].error : ''), 'error'); + const r = await fetch('/api/delete-albums', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folders: dirs, mode }), + }); + if (r.status === 202) { + return await r.json(); } - } finally { - btns.forEach(b => b.disabled = false); + const err = await r.json().catch(() => ({})); + showToast('Delete failed: ' + (err.error || ('HTTP ' + r.status)), 'error'); + return null; + } catch (e) { + showToast('Delete request failed: ' + e.message, 'error'); + return null; + } +} + +let deleteProgressInterval = null; +let currentDeleteJobId = null; + +function openDeleteProgress(jobId, total, mode) { + currentDeleteJobId = jobId; + const modal = document.getElementById('delete-progress-modal'); + const title = document.getElementById('delete-progress-title'); + if (title) title.textContent = mode === 'whole' ? 'Deleting Albums' : 'Deleting Files'; + document.getElementById('delete-progress-text').textContent = `0 / ${total}`; + document.getElementById('delete-progress-phase').textContent = 'Starting...'; + document.getElementById('delete-progress-current').textContent = ''; + document.getElementById('delete-progress-errors').style.display = 'none'; + document.getElementById('delete-progress-cancel').style.display = ''; + document.getElementById('delete-progress-cancel').disabled = false; + document.getElementById('delete-progress-close').style.display = 'none'; + document.getElementById('delete-progress-fill').style.width = '0%'; + modal.style.display = 'flex'; + if (deleteProgressInterval) clearInterval(deleteProgressInterval); + deleteProgressInterval = setInterval(() => pollDeleteJob(jobId), 2000); + pollDeleteJob(jobId); +} + +async function pollDeleteJob(jobId) { + const r = await fetch('/api/delete-job-status?id=' + encodeURIComponent(jobId)); + if (!r.ok) return; + const job = await r.json(); + const total = job.total || 1; + const done = job.done || 0; + const pct = Math.min(100, Math.round((done / total) * 100)); + document.getElementById('delete-progress-fill').style.width = pct + '%'; + document.getElementById('delete-progress-text').textContent = `${done} / ${total}`; + let phaseLabel = job.phase; + if (job.phase === 'running') phaseLabel = 'Running'; + else if (job.phase === 'waiting') phaseLabel = 'Waiting 30s before next album...'; + else if (job.phase === 'deleting') phaseLabel = 'Deleting current album...'; + else if (job.phase === 'done') phaseLabel = 'Complete'; + else if (job.phase === 'cancelled') phaseLabel = 'Cancelled'; + else if (job.phase === 'error') phaseLabel = 'Error'; + document.getElementById('delete-progress-phase').textContent = phaseLabel; + document.getElementById('delete-progress-current').textContent = job.current ? job.current : ''; + if (job.errors && job.errors.length) { + const errBox = document.getElementById('delete-progress-errors'); + errBox.style.display = ''; + errBox.innerHTML = job.errors.slice(0, 10) + .map(e => `${escHtml(e.folder || '')} — ${escHtml(e.error || '')}`) + .join('
'); + } + if (job.finished) { + clearInterval(deleteProgressInterval); + deleteProgressInterval = null; + document.getElementById('delete-progress-cancel').style.display = 'none'; + document.getElementById('delete-progress-close').style.display = ''; + const verb = job.cancelled ? 'Cancelled' : 'Done'; + showToast(`${verb} — ${job.deleted || 0} file(s) deleted`, job.cancelled ? 'info' : 'success'); + loadCorrupt(); + refreshDashboard(); + } +} + +async function cancelDeleteJob() { + if (!currentDeleteJobId) return; + const btn = document.getElementById('delete-progress-cancel'); + btn.disabled = true; + btn.textContent = 'Cancelling...'; + try { + await fetch('/api/delete-job-cancel?id=' + encodeURIComponent(currentDeleteJobId), { method: 'POST' }); + } catch (e) { + showToast('Cancel request failed', 'error'); } } -async function deleteAlbumRedownload(dir) { +function closeDeleteProgress() { + const modal = document.getElementById('delete-progress-modal'); + modal.style.display = 'none'; + currentDeleteJobId = null; + if (deleteProgressInterval) { + clearInterval(deleteProgressInterval); + deleteProgressInterval = null; + } + const btn = document.getElementById('delete-progress-cancel'); + if (btn) btn.textContent = 'Cancel'; +} + +async function deleteAlbum(dir) { const files = document.querySelectorAll(`tr.album-file[data-album="${dir}"] .file-check`); const paths = Array.from(files).map(c => c.dataset.path).filter(Boolean); - if (!paths.length) return; - if (!confirm('Delete ' + paths.length + ' corrupt file(s) via Lidarr?\n\nLidarr will blocklist the bad release and re-download a clean copy for monitored albums.')) return; + if (!paths.length || !confirm('Permanently delete ' + paths.length + ' corrupt file(s) from this album?')) return; const btns = document.querySelectorAll(`tr.album-header .btn`); btns.forEach(b => b.disabled = true); try { const res = await apiPost('delete', { files: paths }); if (res && res.count > 0) { - showToast('Deleted ' + res.count + ' file(s) — Lidarr will re-download monitored albums', 'success'); + showToast('Deleted ' + res.count + ' file(s)', 'success'); loadCorrupt(); refreshDashboard(); } else { @@ -731,10 +870,13 @@ function updateDeleteBtn() { const checked = document.querySelectorAll('.file-check:checked'); const any = checked.length > 0; document.getElementById('delete-selected-btn').disabled = !any; - const redownloadBtn = document.getElementById('redownload-selected-btn'); - if (redownloadBtn) { - const anyLidarr = Array.from(checked).some(c => c.dataset.lidarr); - redownloadBtn.disabled = !anyLidarr; + const albumsBtn = document.getElementById('delete-albums-btn'); + if (albumsBtn) { + const checkedAlbums = document.querySelectorAll('.album-check:checked').length; + albumsBtn.disabled = checkedAlbums === 0 || checkedAlbums > 50; + albumsBtn.textContent = checkedAlbums > 0 + ? `Delete ${checkedAlbums} Album${checkedAlbums === 1 ? '' : 's'} (Whole)` + : 'Delete Albums (Whole)'; } } @@ -778,30 +920,6 @@ async function deleteSelected() { } } -async function redownloadSelected() { - const checks = document.querySelectorAll('.file-check:checked'); - const paths = Array.from(checks).filter(c => c.dataset.lidarr).map(c => c.dataset.path).filter(Boolean); - if (paths.length === 0) return; - const skipped = checks.length - paths.length; - let msg = 'Delete ' + paths.length + ' file(s) via Lidarr?\n\nLidarr will blocklist the bad release and re-download a clean copy for monitored albums.'; - if (skipped > 0) msg += '\n\n' + skipped + ' file(s) without Lidarr IDs will be skipped.'; - if (!confirm(msg)) return; - const btn = document.getElementById('redownload-selected-btn'); - btn.disabled = true; - try { - const res = await apiPost('delete', { files: paths }); - if (res && res.count > 0) { - showToast('Deleted ' + res.count + ' file(s) — Lidarr will re-download monitored albums', 'success'); - document.getElementById('select-all').checked = false; - loadCorrupt(); - refreshDashboard(); - } else { - showToast('Re-download failed' + (res?.errors?.length ? ': ' + res.errors[0].error : ''), 'error'); - } - } finally { - btn.disabled = false; - } -} // --- Folder Picker --- function createFolderPicker(inputId, currentVal) { diff --git a/app/static/index.html b/app/static/index.html index b73df6a..84be3cd 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -151,8 +151,13 @@

Corrupt Files 0

- + +
@@ -218,6 +223,24 @@

Logs

+ + + diff --git a/app/static/style.css b/app/static/style.css index 26176b0..4cb3d60 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -765,3 +765,52 @@ tbody tr:hover { background: var(--bg-hover); } html { font-size: 14px; } .card-grid { grid-template-columns: 1fr 1fr; } } + +/* --- Bulk-delete modal --- */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: var(--bg-panel, #1e2430); + border: 1px solid var(--border, #33384a); + border-radius: 6px; + padding: 1.5rem; + max-width: 560px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.modal h3 { + margin: 0 0 1rem 0; +} + +.modal .progress-bar { margin-bottom: 0.5rem; } +.modal .progress-text { font-weight: 600; } +.modal .progress-file { + word-break: break-all; + font-size: 0.85rem; + color: var(--text-dim); + margin-top: 0.25rem; +} + +/* --- Select N helper --- */ +.select-n-group { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} +.select-n-label { + color: var(--text-dim); + font-size: 0.9rem; +} +.select-n-input { + width: 4rem !important; + text-align: center; +} diff --git a/app/webui.py b/app/webui.py index 4dc472c..93e50e8 100644 --- a/app/webui.py +++ b/app/webui.py @@ -383,6 +383,134 @@ def _list_dir(path): return [] +# --------------------------------------------------------------------------- +# Bulk-delete job tracker (in-memory; survives only while WebUI is running) +# --------------------------------------------------------------------------- + +_delete_jobs = {} +_delete_jobs_lock = threading.Lock() + + +def _new_delete_job(total, mode): + """Register a new delete job and return its id.""" + job_id = secrets.token_hex(8) + with _delete_jobs_lock: + _delete_jobs[job_id] = { + "id": job_id, + "mode": mode, + "total": total, + "done": 0, + "current": "", + "phase": "queued", + "errors": [], + "deleted": 0, + "finished": False, + "cancelled": False, + "cancel_requested": False, + "started_at": time.time(), + } + return job_id + + +def _update_delete_job(job_id, **kwargs): + with _delete_jobs_lock: + job = _delete_jobs.get(job_id) + if job is not None: + job.update(kwargs) + + +def _get_delete_job(job_id): + with _delete_jobs_lock: + job = _delete_jobs.get(job_id) + if job is None: + return None + return dict(job) + + +def _cancel_delete_job(job_id): + with _delete_jobs_lock: + job = _delete_jobs.get(job_id) + if job is None: + return False + job["cancel_requested"] = True + return True + + +def _prune_delete_jobs(max_age=3600): + """Drop finished jobs older than *max_age* to keep the store bounded.""" + cutoff = time.time() - max_age + with _delete_jobs_lock: + stale = [jid for jid, j in _delete_jobs.items() + if j.get("finished") and j.get("started_at", 0) < cutoff] + for jid in stale: + _delete_jobs.pop(jid, None) + + +def _run_delete_job(job_id, folders, config_dir, music_dir, mode): + """Background worker that runs delete_album_folders under the scan + lock with progress + cancel wired to the in-memory job store.""" + try: + from main import ( + _acquire_scan_lock, delete_album_folders, + ) + except ImportError: + _update_delete_job( + job_id, finished=True, phase="error", + errors=[{"folder": "", "error": "delete not available"}]) + return + + def progress_cb(index, total, folder, phase): + _update_delete_job( + job_id, done=index, total=total, + current=folder, phase=phase) + + def cancel_cb(): + job = _get_delete_job(job_id) + return bool(job and job.get("cancel_requested")) + + lock_fd = None + try: + lock_fd = _acquire_scan_lock(config_dir) + except OSError: + _update_delete_job( + job_id, finished=True, phase="error", + errors=[{"folder": "", + "error": "could not acquire scan lock"}]) + return + + try: + _update_delete_job(job_id, phase="running") + result = delete_album_folders( + folders, config_dir, music_dir=music_dir, + mode=mode, progress_cb=progress_cb, cancel_cb=cancel_cb) + _update_delete_job( + job_id, + deleted=result.get("count", 0), + errors=result.get("errors", []), + cancelled=result.get("cancelled", False), + finished=True, + phase="cancelled" if result.get("cancelled") else "done", + ) + except Exception as e: # noqa: BLE001 + logger.exception("Bulk delete job %s failed", job_id) + _update_delete_job( + job_id, finished=True, phase="error", + errors=[{"folder": "", "error": str(e)}]) + finally: + try: + import fcntl as _fcntl + if lock_fd is not None: + _fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_UN) + lock_fd.close() + try: + os.remove(os.path.join(config_dir, ".scanning")) + except OSError: + pass + except ImportError: + pass + _prune_delete_jobs() + + def _trigger_rescan(config_dir, mode="report", fresh=False): """Trigger a rescan by writing the .rescan file. When *fresh* is True the trigger content is prefixed with ``fresh:`` @@ -539,6 +667,17 @@ def do_GET(self): else: self._json_response({"log": text, "mtime": mtime}) + elif (self.path == '/api/delete-job-status' + or self.path.startswith('/api/delete-job-status?')): + params = urllib.parse.parse_qs( + urllib.parse.urlparse(self.path).query) + job_id = params.get('id', [''])[0] + job = _get_delete_job(job_id) + if job is None: + self._json_response({"error": "job not found"}, 404) + return + self._json_response(job) + elif self.path == '/api/paths' or self.path.startswith('/api/paths?'): params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) parent = params.get('dir', ['/data'])[0] @@ -718,12 +857,73 @@ def do_POST(self): files, config_dir, music_dir=music_dir) self._json_response(result) + elif self._dispatch_bulk_delete(config_dir): + return + elif self.path == '/api/ignore': self._handle_ignore(config_dir) else: self._json_response({"error": "not found"}, 404) + def _dispatch_bulk_delete(self, config_dir): + """Handle bulk-delete-related POSTs. Returns True if the path + matched (response already sent), False otherwise.""" + if self.path == '/api/delete-albums': + self._handle_delete_albums(config_dir) + return True + if (self.path == '/api/delete-job-cancel' + or self.path.startswith('/api/delete-job-cancel?')): + params = urllib.parse.parse_qs( + urllib.parse.urlparse(self.path).query) + ok = _cancel_delete_job(params.get('id', [''])[0]) + self._json_response({"ok": ok}) + return True + return False + + def _handle_delete_albums(self, config_dir): + """POST /api/delete-albums — fire-and-forget bulk delete. + Returns 202 + {job_id}; progress polled via + /api/delete-job-status?id=...""" + body = self._read_body() + if body is None: + return + folders = body.get('folders', []) + mode = body.get('mode', 'whole') + if not isinstance(folders, list) or not folders: + self._json_response( + {"error": "no folders specified"}, 400) + return + if mode not in ('whole', 'corrupt'): + self._json_response( + {"error": "mode must be 'whole' or 'corrupt'"}, 400) + return + try: + from main import _MAX_DELETE_ALBUMS + except ImportError: + _MAX_DELETE_ALBUMS = 50 + if len(folders) > _MAX_DELETE_ALBUMS: + self._json_response( + {"error": f"too many folders " + f"(max {_MAX_DELETE_ALBUMS})"}, 400) + return + # Refuse to start if a scan is running + lock_path = os.path.join(config_dir, ".scanning") + if os.path.exists(lock_path): + self._json_response( + {"error": "scan in progress — cannot delete"}, 409) + return + music_dir = os.environ.get("MUSIC_DIR", "/data") + job_id = _new_delete_job(len(folders), mode) + thread = threading.Thread( + target=_run_delete_job, + args=(job_id, folders, config_dir, music_dir, mode), + daemon=True, + ) + thread.start() + self._json_response( + {"job_id": job_id, "total": len(folders)}, 202) + def _serve_static(self, filename): """Serve a file from the static directory.""" static_dir = self.server.static_dir