#!/usr/bin/env python3 """ Metadata Editor Dialog for neo-pod-desktop Allows editing tags and cover art of audio files (MP3, M4A, FLAC, OGG, OPUS). Supports single-track and batch (multi-track) editing. """ import os import base64 import struct from pathlib import Path from typing import Optional, Dict, Any, List, Tuple from io import BytesIO from PIL import Image from mutagen import File as MutagenFile from mutagen.mp4 import MP4 from mutagen.id3 import ID3 from mutagen.flac import FLAC from mutagen.oggvorbis import OggVorbis from mutagen.oggopus import OggOpus from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QPushButton, QLabel, QLineEdit, QSpinBox, QCheckBox, QFileDialog, QMessageBox, QFrame, QDialogButtonBox, ) from PyQt6.QtCore import Qt, QSize, QTimer from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent from metadata_handler import MetadataHandler from artwork.cache import cover_cache from services.itunes_search import search_itunes from library_cache import get_library_cache, save_library_cache from .star_rating import ClickableStarRating COVER_SIZE = 250 SENTINEL_MULTIPLE = "__MULTIPLE__" class MetadataEditorDialog(QDialog): """Dialog for viewing and editing audio file metadata and cover art.""" def __init__(self, file_paths: List[str], parent=None): super().__init__(parent) self.file_paths = file_paths self._batch_mode = len(file_paths) > 1 self._current_file = file_paths[0] if file_paths else "" self._original_tags: Dict[str, Any] = {} self._mixed_fields: set = set() self._new_cover_path: Optional[str] = None self._cover_removed = False self._existing_cover_data: Optional[bytes] = None title = f"Edit Metadata ({len(file_paths)} files)" if self._batch_mode else "Edit Metadata" self.setWindowTitle(title) self.setMinimumSize(620, 440) self.setModal(True) self._load_tags() self._setup_ui() self._populate_form() if self._batch_mode: self._apply_batch_mode() def _load_tags(self): """Load metadata from file(s). In batch mode, finds common values.""" if not self.file_paths: return first_tags = self._load_single_file(self.file_paths[0]) self._original_tags = dict(first_tags) self._existing_cover_data = self._extract_cover_from_path(self.file_paths[0]) self._load_ratings_from_cache() if self._batch_mode: all_tags = [first_tags] all_different = False for path in self.file_paths[1:]: tags = self._load_single_file(path, skip_cover=True) all_tags.append(tags) if all_different: continue for key in list(self._original_tags.keys()): if tags.get(key) != self._original_tags.get(key): self._mixed_fields.add(key) def _load_ratings_from_cache(self): """Load rating from LibraryCache for all selected files. Stores the rating (0–5 stars) in ``self._original_tags['rating']`` (only when cache has a value; file‑tag rating takes precedence if cache is empty). In batch mode, marks ``rating`` as mixed when values differ. """ cache = get_library_cache() ratings = [] for p in self.file_paths: entry = cache.get(p) r = entry.get("rating", 0) if entry else 0 ratings.append(max(0, min(5, round(r / 20)))) cache_val = ratings[0] if cache_val > 0 or "rating" not in self._original_tags: self._original_tags["rating"] = cache_val if self._batch_mode and len(set(ratings)) > 1: self._mixed_fields.add("rating") self._ratings = {p: r for p, r in zip(self.file_paths, ratings)} else: self._ratings = {} def _load_single_file(self, file_path: str, skip_cover: bool = False) -> Dict[str, Any]: """Load tags from a single file. Returns dict of tag_key -> value.""" result: Dict[str, Any] = {} try: audio = MutagenFile(file_path) if audio is None: return result tags = audio.tags ext = Path(file_path).suffix.lower() field_map = { 'title': ('title', '\xa9nam'), 'artist': ('artist', '\xa9ART'), 'album': ('album', '\xa9alb'), 'album_artist': ('albumartist', 'aART'), 'genre': ('genre', '\xa9gen'), 'date': ('date', '\xa9day'), 'comment': ('comment', '\xa9cmt'), } for our_key, (easy_key, mp4_key) in field_map.items(): val = self._safe_get(tags, easy_key, mp4_key) if val is not None: result[our_key] = val result.update(self._load_track_disc(tags)) result.update(self._load_rating_from_tags(tags, ext)) except Exception: pass return result @staticmethod def _load_rating_from_tags(tags, ext: str) -> dict: """Read rating (0–5 stars) from audio file tags, if present.""" if tags is None: return {} try: if ext in ('.m4a', '.aac', '.mp4') and '\xa9rt' in tags: rt = tags['\xa9rt'] if isinstance(rt, list) and rt: val = int(rt[0]) // 20 # 0-100 → 0-5 return {"rating": max(0, min(5, val))} elif ext == '.mp3': for frame in tags.getall('POPM'): if frame.rating > 0: pops = [0, 64, 128, 192, 224, 255] stars = max(i for i, v in enumerate(pops) if v <= frame.rating) return {"rating": stars} elif ext in ('.flac', '.ogg', '.opus') and 'RATING' in tags: raw = tags['RATING'] if isinstance(raw, list): raw = raw[0] val = int(str(raw)) // 20 return {"rating": max(0, min(5, val))} except Exception: pass return {} def _load_track_disc(self, tags) -> Dict[str, Any]: result = {} if tags is None: return result if 'trkn' in tags: trkn = tags['trkn'] if isinstance(trkn, list) and trkn: t = trkn[0] if isinstance(t, (tuple, list)) and len(t) >= 1: result['track_number'] = int(t[0]) if t[0] else 0 if len(t) >= 2: result['track_total'] = int(t[1]) if t[1] else 0 elif 'TRCK' in tags: tn, tt = self._parse_slash(str(tags['TRCK'])) if tn: result['track_number'] = tn if tt: result['track_total'] = tt elif hasattr(tags, 'getall'): try: frames = tags.getall('TRCK') if frames: tn, tt = self._parse_slash(str(frames[0])) if tn: result['track_number'] = tn if tt: result['track_total'] = tt except Exception: pass for mp4_key, (vorbis_key, num_key, total_key) in ( ('disk', ('DISCNUMBER', 'disc_number', 'disc_total')), ): if mp4_key in tags: disk_val = tags[mp4_key] if isinstance(disk_val, list) and disk_val: d = disk_val[0] if isinstance(d, (tuple, list)) and len(d) >= 1: result[num_key] = int(d[0]) if d[0] else 0 if len(d) >= 2: result[total_key] = int(d[1]) if d[1] else 0 elif vorbis_key in tags: dn, dt = self._parse_slash(str(tags[vorbis_key])) if dn: result[num_key] = dn if dt: result[total_key] = dt elif hasattr(tags, 'getall'): try: frames = tags.getall('TPOS') if frames: dn, dt = self._parse_slash(str(frames[0])) if dn: result[num_key] = dn if dt: result[total_key] = dt except Exception: pass return result def _extract_cover_from_path(self, file_path: str) -> Optional[bytes]: try: audio = MutagenFile(file_path) if audio is None: return None return self._extract_cover_raw(audio, audio.tags) except Exception: return None def _safe_get(self, tags, easy_key: str, mp4_key: str) -> Optional[str]: if tags is None: return None try: if mp4_key in tags: val = tags[mp4_key] if isinstance(val, list) and val: return str(val[0]) return str(val) except (ValueError, KeyError): pass try: easy_lower = easy_key.lower() for k in tags.keys(): if k.lower() == easy_lower: val = tags[k] if isinstance(val, list) and val: return str(val[0]) return str(val) except (ValueError, AttributeError): pass id3_map = { 'title': 'TIT2', 'artist': 'TPE1', 'album': 'TALB', 'albumartist': 'TPE2', 'genre': 'TCON', 'date': 'TDRC', 'comment': 'COMM', } frame_id = id3_map.get(easy_key.lower()) if frame_id and hasattr(tags, 'getall'): try: frames = tags.getall(frame_id) if frames: return str(frames[0]) except Exception: pass return None def _parse_slash(self, val: str): try: parts = str(val).split('/') first = int(parts[0]) if parts[0].strip().isdigit() else 0 second = int(parts[1]) if len(parts) > 1 and parts[1].strip().isdigit() else 0 return first, second except Exception: return 0, 0 def _extract_cover_raw(self, audio, tags) -> Optional[bytes]: if hasattr(tags, 'getall') and tags is not None: try: for frame in tags.getall('APIC'): return frame.data except Exception: pass try: if tags and 'covr' in tags: cover_list = tags['covr'] if isinstance(cover_list, list) and cover_list: return bytes(cover_list[0]) except Exception: pass try: if tags and 'metadata_block_picture' in tags: pic_data = tags['metadata_block_picture'] if isinstance(pic_data, list): pic_data = pic_data[0] pic_data = str(pic_data) decoded = base64.b64decode(pic_data) pos = 0 pos += 4 mime_len = struct.unpack('>I', decoded[pos:pos + 4])[0] pos += 4 + mime_len desc_len = struct.unpack('>I', decoded[pos:pos + 4])[0] pos += 4 + desc_len pos += 20 return decoded[pos:] except Exception: pass if hasattr(audio, 'pictures') and audio.pictures: return audio.pictures[0].data return None def _setup_ui(self): outer = QVBoxLayout(self) outer.setSpacing(12) body = QHBoxLayout() body.setSpacing(16) cover_layout = QVBoxLayout() cover_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.cover_label = QLabel() self.cover_label.setFixedSize(COVER_SIZE, COVER_SIZE) self.cover_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.cover_label.setStyleSheet( "QLabel { border: 2px dashed #888; border-radius: 6px; background: palette(window); }" ) self.cover_label.setAcceptDrops(True) self.cover_label.setText("Drag cover\nhere\nor click") self.cover_label.setToolTip("Click to choose cover image, drag & drop to replace") self.cover_label.mousePressEvent = self._on_cover_clicked cover_layout.addWidget(self.cover_label) self.cover_apply_check = QCheckBox("Apply cover to all selected") self.cover_apply_check.setVisible(False) self.cover_apply_check.setChecked(True) cover_layout.addWidget(self.cover_apply_check) clear_btn = QPushButton("Remove Cover") clear_btn.clicked.connect(self._on_clear_cover) cover_layout.addWidget(clear_btn) cover_layout.addStretch() body.addLayout(cover_layout) form_frame = QFrame() form_layout = QFormLayout(form_frame) form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight) form_layout.setFieldGrowthPolicy( QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow ) self.title_edit = QLineEdit() self.title_edit.setPlaceholderText("Track Title") self.artist_edit = QLineEdit() self.artist_edit.setPlaceholderText("Artist") self.album_edit = QLineEdit() self.album_edit.setPlaceholderText("Album") self.album_artist_edit = QLineEdit() self.album_artist_edit.setPlaceholderText("Album Artist") self.genre_edit = QLineEdit() self.genre_edit.setPlaceholderText("Genre") self.comment_edit = QLineEdit() self.comment_edit.setPlaceholderText("Comment") self.year_spin = QSpinBox() self.year_spin.setRange(0, 2100) self.year_spin.setSpecialValueText("") self.track_num_spin = QSpinBox() self.track_num_spin.setRange(0, 999) self.track_num_spin.setSpecialValueText("") self.track_total_spin = QSpinBox() self.track_total_spin.setRange(0, 999) self.track_total_spin.setSpecialValueText("") self.disc_num_spin = QSpinBox() self.disc_num_spin.setRange(0, 99) self.disc_num_spin.setSpecialValueText("") self.disc_total_spin = QSpinBox() self.disc_total_spin.setRange(0, 99) self.disc_total_spin.setSpecialValueText("") self._title_label = QLabel("Title:") form_layout.addRow(self._title_label, self.title_edit) self._artist_label = QLabel("Artist:") form_layout.addRow(self._artist_label, self.artist_edit) self._lookup_btn = QPushButton("\U0001F50D Lookup in iTunes") self._lookup_btn.setToolTip("Search iTunes Store for metadata by artist and title") self._lookup_btn.clicked.connect(self._on_lookup_clicked) self._lookup_btn.setVisible(not self._batch_mode) self._lookup_status = QLabel("") self._lookup_status.setStyleSheet("color: #888; font-size: 11px; font-style: italic;") self._lookup_status.setVisible(False) lookup_row = QHBoxLayout() lookup_row.addWidget(self._lookup_btn) lookup_row.addWidget(self._lookup_status) lookup_row.addStretch() form_layout.addRow(QLabel(""), lookup_row) self._album_label = QLabel("Album:") form_layout.addRow(self._album_label, self.album_edit) self._album_artist_label = QLabel("Album Artist:") form_layout.addRow(self._album_artist_label, self.album_artist_edit) self._genre_label = QLabel("Genre:") form_layout.addRow(self._genre_label, self.genre_edit) self.rating_widget = ClickableStarRating() self.rating_widget.rating_changed.connect(self._on_rating_changed) self._rating_label = QLabel("Rating:") form_layout.addRow(self._rating_label, self.rating_widget) self._year_label = QLabel("Year:") form_layout.addRow(self._year_label, self.year_spin) self._track_label = QLabel("Track #:") self._track_row_layout = QHBoxLayout() self._track_row_layout.addWidget(self.track_num_spin) self._track_row_layout.addWidget(QLabel("/")) self._track_row_layout.addWidget(self.track_total_spin) self._track_row_layout.addStretch() form_layout.addRow(self._track_label, self._track_row_layout) self._disc_label = QLabel("Disc #:") self._disc_row_layout = QHBoxLayout() self._disc_row_layout.addWidget(self.disc_num_spin) self._disc_row_layout.addWidget(QLabel("/")) self._disc_row_layout.addWidget(self.disc_total_spin) self._disc_row_layout.addStretch() form_layout.addRow(self._disc_label, self._disc_row_layout) self._comment_label = QLabel("Comment:") form_layout.addRow(self._comment_label, self.comment_edit) body.addWidget(form_frame, stretch=1) outer.addLayout(body, stretch=1) self._track_count_label = QLabel("") self._track_count_label.setVisible(False) self._track_count_label.setStyleSheet("color: #888; font-size: 11px;") outer.addWidget(self._track_count_label) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self._on_save) buttons.rejected.connect(self.reject) outer.addWidget(buttons) def _apply_batch_mode(self): title_text = f"\uD83D\uDCCB Editing {len(self.file_paths)} selected tracks" self._track_count_label.setText(title_text) self._track_count_label.setVisible(True) self._title_label.setText("Title: (hidden in batch)") self.title_edit.setVisible(False) for w in (self.track_num_spin, self.track_total_spin): w.setVisible(False) self._track_label.setText("Track #: (hidden in batch)") for widget in self.findChildren(QLabel): if widget.text() == "/" and widget.parent() in (self._track_row_layout.parent(), None): pass self.cover_apply_check.setVisible(True) self._lookup_btn.setVisible(False) self._lookup_status.setVisible(False) for key in self._mixed_fields: self._set_mixed(key) if "rating" in self._mixed_fields: self.rating_widget.set_rating(0) self._rating_label.setText("Rating: (mixed)") def _set_mixed(self, key: str): placeholders = { 'title': "(different values)", 'artist': "(different values)", 'album': "(different values)", 'album_artist': "(different values)", 'genre': "(different values)", 'date': "", 'comment': "(different values)", 'track_number': 0, 'track_total': 0, 'disc_number': 0, 'disc_total': 0, } edit_map = { 'title': self.title_edit, 'artist': self.artist_edit, 'album': self.album_edit, 'album_artist': self.album_artist_edit, 'genre': self.genre_edit, 'comment': self.comment_edit, } spin_map = { 'date': self.year_spin, 'track_number': self.track_num_spin, 'track_total': self.track_total_spin, 'disc_number': self.disc_num_spin, 'disc_total': self.disc_total_spin, } if key in edit_map: edit_map[key].setPlaceholderText(placeholders.get(key, "(different values)")) edit_map[key].setText("") elif key in spin_map: spin_map[key].setValue(0) spin_map[key].setSpecialValueText("\u2014") def _populate_form(self): t = self._original_tags self.title_edit.setText(t.get('title', '')) self.artist_edit.setText(t.get('artist', '')) self.album_edit.setText(t.get('album', '')) self.album_artist_edit.setText(t.get('album_artist', '')) self.genre_edit.setText(t.get('genre', '')) self.comment_edit.setText(t.get('comment', '')) date_str = t.get('date', '') try: year = int(str(date_str)[:4]) except (ValueError, TypeError): year = 0 self.year_spin.setValue(year) self.track_num_spin.setValue(t.get('track_number', 0)) self.track_total_spin.setValue(t.get('track_total', 0)) self.disc_num_spin.setValue(t.get('disc_number', 0)) self.disc_total_spin.setValue(t.get('disc_total', 0)) self.rating_widget.set_rating(t.get("rating", 0)) self._update_cover_display() def _on_rating_changed(self, stars: int) -> None: pass # placeholder for future live-preview def _update_cover_display(self): if self._cover_removed: self.cover_label.setText("No cover") self.cover_label.setPixmap(QPixmap()) return if self._batch_mode and not self._new_cover_path and self._existing_cover_data is None: self.cover_label.setText("Multiple\ncovers\n\nClick to replace") self.cover_label.setPixmap(QPixmap()) return pixmap = None if self._new_cover_path and os.path.exists(self._new_cover_path): pixmap = QPixmap(self._new_cover_path) elif self._existing_cover_data: pixmap = QPixmap() pixmap.loadFromData(self._existing_cover_data) if pixmap and not pixmap.isNull(): scaled = pixmap.scaled( COVER_SIZE - 16, COVER_SIZE - 16, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) self.cover_label.setPixmap(scaled) else: self.cover_label.setText("No cover") self.cover_label.setPixmap(QPixmap()) def _on_cover_clicked(self, event): path, _ = QFileDialog.getOpenFileName( self, "Select Cover Image", "", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.webp);;All Files (*)" ) if path: self._new_cover_path = path self._cover_removed = False self._update_cover_display() def _on_clear_cover(self): self._new_cover_path = None self._cover_removed = True self._update_cover_display() def _on_lookup_clicked(self): artist = self.artist_edit.text().strip() title = self.title_edit.text().strip() if not artist and not title: self._show_lookup_status("Enter artist or title first", is_error=True) return self._lookup_btn.setEnabled(False) self._show_lookup_status("Searching iTunes\u2026") result = search_itunes(artist=artist, title=title) self._lookup_btn.setEnabled(True) if not result: self._show_lookup_status("No results found", is_error=True) return self.title_edit.setText(result.get("title", "")) self.artist_edit.setText(result.get("artist", "")) self.album_edit.setText(result.get("album", "")) self.album_artist_edit.setText(result.get("album_artist", "")) self.genre_edit.setText(result.get("genre", "")) year = result.get("year", 0) if year and year > 0: self.year_spin.setValue(year) track_num = result.get("track_number", 0) if track_num: self.track_num_spin.setValue(track_num) track_total = result.get("track_total", 0) if track_total: self.track_total_spin.setValue(track_total) disc_num = result.get("disc_number", 0) if disc_num: self.disc_num_spin.setValue(disc_num) disc_total = result.get("disc_total", 0) if disc_total: self.disc_total_spin.setValue(disc_total) cover_url = result.get("cover_url", "") if cover_url and not self._existing_cover_data and not self._new_cover_path: try: import tempfile import requests resp = requests.get(cover_url, timeout=10) resp.raise_for_status() fd, tmp_path = tempfile.mkstemp(suffix=".jpg") os.close(fd) with open(tmp_path, "wb") as f: f.write(resp.content) self._new_cover_path = tmp_path self._update_cover_display() except Exception: pass self._show_lookup_status("Metadata loaded from iTunes") def _show_lookup_status(self, text: str, is_error: bool = False): self._lookup_status.setText(text) self._lookup_status.setStyleSheet( "color: #c0392b; font-size: 11px; font-style: italic;" if is_error else "color: #27ae60; font-size: 11px; font-style: italic;" ) self._lookup_status.setVisible(True) QTimer.singleShot(4000, lambda: self._lookup_status.setVisible(False)) def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): event.acceptProposedAction() def dropEvent(self, event: QDropEvent): urls = event.mimeData().urls() if urls: path = urls[0].toLocalFile() if os.path.isfile(path): ext = Path(path).suffix.lower() if ext in ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.webp'): self._new_cover_path = path self._cover_removed = False self._update_cover_display() def _collect_current(self) -> Dict[str, Any]: return { 'title': self.title_edit.text().strip(), 'artist': self.artist_edit.text().strip(), 'album': self.album_edit.text().strip(), 'album_artist': self.album_artist_edit.text().strip(), 'genre': self.genre_edit.text().strip(), 'date': str(self.year_spin.value()) if self.year_spin.value() > 0 else '', 'track_number': self.track_num_spin.value(), 'track_total': self.track_total_spin.value(), 'disc_number': self.disc_num_spin.value(), 'disc_total': self.disc_total_spin.value(), 'comment': self.comment_edit.text().strip(), 'rating': self.rating_widget.rating(), } def _collect_changes(self) -> Dict[str, Any]: if self._batch_mode: return self._collect_batch_changes() return self._collect_single_changes() def _collect_single_changes(self) -> Dict[str, Any]: changes = {} current = self._collect_current() for key, val in current.items(): orig = self._original_tags.get(key) if isinstance(val, int) and isinstance(orig, int): if val != orig: changes[key] = val elif isinstance(val, int) and isinstance(orig, str): try: if int(str(orig)[:4]) != val: changes[key] = val except ValueError: if val: changes[key] = val elif isinstance(val, int) and orig is None: if val != 0: changes[key] = val elif str(val) != str(orig or ''): changes[key] = val return changes def _collect_batch_changes(self) -> Dict[str, Any]: current = self._collect_current() exclude = {'title', 'track_number', 'track_total'} changes = {} for key, val in current.items(): if key in exclude: continue orig = self._original_tags.get(key) if key in self._mixed_fields: if isinstance(val, int) and val == 0: continue if isinstance(val, str) and not val: continue changes[key] = val else: if isinstance(val, int) and isinstance(orig, int): if val != orig: changes[key] = val elif isinstance(val, str) and orig is not None: if str(val) != str(orig): changes[key] = val elif val and not orig: changes[key] = val return changes def _on_save(self): changes = self._collect_changes() cover_arg = None if self._cover_removed: cover_arg = '' elif self._new_cover_path: cover_arg = self._new_cover_path if not changes and cover_arg is None: self.accept() return paths = self.file_paths if self._batch_mode else [self.file_paths[0]] try: handler = MetadataHandler() failed = 0 for path in paths: ok = handler.update_tags(path, changes, cover_arg) if not ok: failed += 1 else: self._invalidate_cover_cache(path, changes, cover_arg) if not failed: self._persist_rating(paths, changes) if failed == len(paths): QMessageBox.warning(self, "Error", "Failed to save metadata changes.") elif failed: QMessageBox.warning( self, "Partial Success", f"Saved {len(paths) - failed}/{len(paths)} files.\n{failed} file(s) failed." ) self.accept() else: self.accept() except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save metadata:\n{e}") def _persist_rating(self, paths: list[str], changes: dict[str, Any]) -> None: if "rating" not in changes: return try: cache = get_library_cache() for p in paths: entry = cache.get(p) if entry is None: continue entry["rating"] = changes["rating"] * 20 cache.put(p, entry) save_library_cache() except Exception: logger.exception("Failed to persist rating to LibraryCache") def _invalidate_cover_cache(self, file_path: str, changes: Dict[str, Any], cover_arg: Optional[str]) -> None: """Update cover cache after successful save.""" try: audio = MutagenFile(file_path) new_cover = self._extract_cover_raw(audio, audio.tags) if audio else None artist = changes.get('artist') or self._original_tags.get('artist', 'Unknown Artist') album = changes.get('album') or self._original_tags.get('album', '') if new_cover: cover_cache.put(artist, album, new_cover) elif cover_arg == '': key = cover_cache._key(artist, album) cache_path = os.path.join(cover_cache.cache_dir, f"{key}.jpg") if os.path.exists(cache_path): os.remove(cache_path) except Exception: pass