"""Library browser widget — browse saved files on disk.""" from __future__ import annotations import logging import os import threading from pathlib import Path from PySide6.QtCore import Qt, Signal, QObject, QTimer from PySide6.QtGui import QPixmap from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QComboBox, QMenu, QMessageBox, QInputDialog, QApplication, ) from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS, thumbnails_dir from .grid import ThumbnailGrid log = logging.getLogger("booru") LIBRARY_THUMB_SIZE = 180 class _LibThumbSignals(QObject): thumb_ready = Signal(int, str) video_thumb_request = Signal(int, str, str) # index, source, dest class LibraryView(QWidget): """Browse files saved to the library on disk.""" file_selected = Signal(str) file_activated = Signal(str) files_deleted = Signal(list) # list of post IDs that were deleted def __init__(self, db=None, parent: QWidget | None = None) -> None: super().__init__(parent) self._db = db self._files: list[Path] = [] self._signals = _LibThumbSignals() self._signals.thumb_ready.connect( self._on_thumb_ready, Qt.ConnectionType.QueuedConnection ) self._signals.video_thumb_request.connect( self._capture_video_thumb, Qt.ConnectionType.QueuedConnection ) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) # --- Top bar --- # 4px right margin so the rightmost widget doesn't sit flush # against the preview splitter handle. top = QHBoxLayout() top.setContentsMargins(0, 0, 4, 0) # Compact horizontal padding matches the rest of the app's narrow # toolbar buttons. Vertical padding (2px) + global min-height # (16px) gives a 22px total height — lines up with the inputs/ # combos in the same row. _btn_style = "padding: 2px 6px;" self._folder_combo = QComboBox() self._folder_combo.setMinimumWidth(140) self._folder_combo.currentTextChanged.connect(lambda _: self.refresh()) top.addWidget(self._folder_combo) self._sort_combo = QComboBox() self._sort_combo.addItems(["Date", "Post ID", "Size"]) # 75 is the tight floor: 68 clipped the trailing D under the # bundled themes (font metrics ate more than the math suggested). self._sort_combo.setFixedWidth(75) self._sort_combo.currentTextChanged.connect(lambda _: self.refresh()) top.addWidget(self._sort_combo) refresh_btn = QPushButton("Refresh") refresh_btn.setFixedWidth(75) refresh_btn.setStyleSheet(_btn_style) refresh_btn.clicked.connect(self.refresh) top.addWidget(refresh_btn) self._search_input = QLineEdit() self._search_input.setPlaceholderText("Search tags") # Enter still triggers an immediate refresh. self._search_input.returnPressed.connect(self.refresh) # Live search via debounced timer. Library refresh is heavier # than bookmarks (filesystem scan + DB query + thumbnail repop) # so use a slightly longer 250ms debounce so the user has to pause # a bit more between keystrokes before the work happens. self._search_debounce = QTimer(self) self._search_debounce.setSingleShot(True) self._search_debounce.setInterval(250) self._search_debounce.timeout.connect(self.refresh) self._search_input.textChanged.connect( lambda _: self._search_debounce.start() ) top.addWidget(self._search_input, stretch=1) layout.addLayout(top) # --- Count label --- self._count_label = QLabel() layout.addWidget(self._count_label) # --- Grid --- self._grid = ThumbnailGrid() self._grid.post_selected.connect(self._on_selected) self._grid.post_activated.connect(self._on_activated) self._grid.context_requested.connect(self._on_context_menu) self._grid.multi_context_requested.connect(self._on_multi_context_menu) layout.addWidget(self._grid) # ------------------------------------------------------------------ # Public # ------------------------------------------------------------------ def _set_count(self, text: str, state: str = "normal") -> None: """Update the count label's text and visual state. state ∈ {normal, empty, error}. The state is exposed as a Qt dynamic property `libraryCountState` so themes can target it via `QLabel[libraryCountState="error"]` selectors. Re-polishes the widget so a property change at runtime takes effect immediately. """ self._count_label.setText(text) # Clear any inline stylesheet from earlier code paths so the # theme's QSS rules can take over. self._count_label.setStyleSheet("") self._count_label.setProperty("libraryCountState", state) st = self._count_label.style() st.unpolish(self._count_label) st.polish(self._count_label) def refresh(self) -> None: """Scan the selected folder, sort, display thumbnails.""" root = saved_dir() if not root.exists() or not os.access(root, os.R_OK): self._set_count("Library directory unreachable", "error") self._grid.set_posts(0) self._files = [] return self._refresh_folders() self._files = self._scan_files() self._sort_files() # Filter by tag search if query entered query = self._search_input.text().strip() if query and self._db: matching_ids = self._db.search_library_meta(query) if matching_ids: def _file_matches(f: Path) -> bool: # Templated filenames: look up post_id via library_meta.filename pid = self._db.get_library_post_id_by_filename(f.name) if pid is not None: return pid in matching_ids # Legacy digit-stem fallback if f.stem.isdigit(): return int(f.stem) in matching_ids return False self._files = [f for f in self._files if _file_matches(f)] else: self._files = [] if self._files: self._set_count(f"{len(self._files)} files", "normal") elif query: # Search returned nothing — not an error, just no matches. self._set_count("No items match search", "empty") else: # The library is genuinely empty (the directory exists and is # readable, it just has no files in this folder selection). self._set_count("Library is empty", "empty") thumbs = self._grid.set_posts(len(self._files)) lib_thumb_dir = thumbnails_dir() / "library" lib_thumb_dir.mkdir(parents=True, exist_ok=True) for i, (filepath, thumb) in enumerate(zip(self._files, thumbs)): thumb._cached_path = str(filepath) thumb.setToolTip(filepath.name) thumb.set_saved_locally(True) # Thumbnails are stored by post_id (from _copy_library_thumb), # not by filename stem. Resolve post_id so templated filenames # like artist_12345.jpg find their thumbnail correctly. thumb_name = filepath.stem # default: digit-stem fallback if self._db: pid = self._db.get_library_post_id_by_filename(filepath.name) if pid is not None: thumb_name = str(pid) elif filepath.stem.isdigit(): thumb_name = filepath.stem cached_thumb = lib_thumb_dir / f"{thumb_name}.jpg" if cached_thumb.exists(): pix = QPixmap(str(cached_thumb)) if not pix.isNull(): thumb.set_pixmap(pix) continue self._generate_thumb_async(i, filepath, cached_thumb) # ------------------------------------------------------------------ # Folder list # ------------------------------------------------------------------ def _refresh_folders(self) -> None: current = self._folder_combo.currentText() self._folder_combo.blockSignals(True) self._folder_combo.clear() self._folder_combo.addItem("All Files") self._folder_combo.addItem("Unfiled") root = saved_dir() if root.is_dir(): for entry in sorted(root.iterdir()): if entry.is_dir(): self._folder_combo.addItem(entry.name) idx = self._folder_combo.findText(current) if idx >= 0: self._folder_combo.setCurrentIndex(idx) self._folder_combo.blockSignals(False) # ------------------------------------------------------------------ # File scanning # ------------------------------------------------------------------ def _scan_files(self) -> list[Path]: root = saved_dir() folder_text = self._folder_combo.currentText() if folder_text == "All Files": return self._collect_recursive(root) elif folder_text == "Unfiled": return self._collect_top_level(root) else: sub = root / folder_text if sub.is_dir(): return self._collect_top_level(sub) return [] @staticmethod def _collect_recursive(directory: Path) -> list[Path]: files: list[Path] = [] for dirpath, _dirnames, filenames in os.walk(directory): for name in filenames: p = Path(dirpath) / name if p.suffix.lower() in MEDIA_EXTENSIONS: files.append(p) return files @staticmethod def _collect_top_level(directory: Path) -> list[Path]: if not directory.is_dir(): return [] return [ p for p in directory.iterdir() if p.is_file() and p.suffix.lower() in MEDIA_EXTENSIONS ] # ------------------------------------------------------------------ # Sorting # ------------------------------------------------------------------ def _sort_files(self) -> None: mode = self._sort_combo.currentText() if mode == "Post ID": # Numeric sort by post id (filename stem). Library files are # named {post_id}.{ext} in normal usage; anything with a # non-digit stem (someone manually dropped a file in) sorts # to the end alphabetically so the numeric ordering of real # posts isn't disrupted by stray names. def _key(p: Path) -> tuple: stem = p.stem return (0, int(stem)) if stem.isdigit() else (1, stem.lower()) self._files.sort(key=_key) elif mode == "Size": self._files.sort(key=lambda p: p.stat().st_size, reverse=True) else: # Date — newest first self._files.sort(key=lambda p: p.stat().st_mtime, reverse=True) # ------------------------------------------------------------------ # Async thumbnail generation # ------------------------------------------------------------------ _VIDEO_EXTS = {".mp4", ".webm", ".mkv", ".avi", ".mov"} def _generate_thumb_async( self, index: int, source: Path, dest: Path ) -> None: if source.suffix.lower() in self._VIDEO_EXTS: # Video thumbnails must run on main thread (Qt requirement) self._signals.video_thumb_request.emit(index, str(source), str(dest)) return def _work() -> None: try: from PIL import Image with Image.open(source) as img: img.thumbnail( (LIBRARY_THUMB_SIZE, LIBRARY_THUMB_SIZE), Image.LANCZOS ) if img.mode in ("RGBA", "P"): img = img.convert("RGB") img.save(str(dest), "JPEG", quality=85) if dest.exists(): self._signals.thumb_ready.emit(index, str(dest)) except Exception as e: log.warning("Library thumb %d (%s) failed: %s", index, source.name, e) threading.Thread(target=_work, daemon=True).start() def _capture_video_thumb(self, index: int, source: str, dest: str) -> None: """Grab first frame from video. Tries ffmpeg, falls back to placeholder.""" def _work(): try: import subprocess result = subprocess.run( ["ffmpeg", "-y", "-i", source, "-vframes", "1", "-vf", f"scale={LIBRARY_THUMB_SIZE}:{LIBRARY_THUMB_SIZE}:force_original_aspect_ratio=decrease", "-q:v", "5", dest], capture_output=True, timeout=10, ) if Path(dest).exists(): self._signals.thumb_ready.emit(index, dest) return except (FileNotFoundError, Exception): pass # Fallback: generate a placeholder from PySide6.QtGui import QPainter, QColor, QFont from PySide6.QtGui import QPolygon from PySide6.QtCore import QPoint as QP pix = QPixmap(LIBRARY_THUMB_SIZE - 4, LIBRARY_THUMB_SIZE - 4) pix.fill(QColor(40, 40, 40)) painter = QPainter(pix) painter.setPen(QColor(180, 180, 180)) painter.setFont(QFont(painter.font().family(), 9)) ext = Path(source).suffix.upper().lstrip(".") painter.drawText(pix.rect(), Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, ext) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor(180, 180, 180, 150)) cx, cy = pix.width() // 2, pix.height() // 2 - 10 painter.drawPolygon(QPolygon([QP(cx - 15, cy - 20), QP(cx - 15, cy + 20), QP(cx + 20, cy)])) painter.end() pix.save(dest, "JPEG", 85) if Path(dest).exists(): self._signals.thumb_ready.emit(index, dest) threading.Thread(target=_work, daemon=True).start() def _on_thumb_ready(self, index: int, path: str) -> None: thumbs = self._grid._thumbs if 0 <= index < len(thumbs): pix = QPixmap(path) if not pix.isNull(): thumbs[index].set_pixmap(pix) # ------------------------------------------------------------------ # Selection signals # ------------------------------------------------------------------ def _on_selected(self, index: int) -> None: if 0 <= index < len(self._files): self.file_selected.emit(str(self._files[index])) def _on_activated(self, index: int) -> None: if 0 <= index < len(self._files): self.file_activated.emit(str(self._files[index])) def _move_files_to_folder( self, files: list[Path], target_folder: str | None ) -> None: """Move library files into target_folder (None = Unfiled root). Uses Path.rename for an atomic same-filesystem move. That matters here because the bug we're fixing is "move produces a duplicate" — a copy-then-delete sequence can leave both files behind if the delete fails or the process is killed mid-step. rename() is one syscall and either fully succeeds or doesn't happen at all. If the rename crosses filesystems (rare — only if the user pointed the library at a different mount than its parent), Python raises OSError(EXDEV) and we fall back to shutil.move which copies-then- unlinks; in that path the unlink failure is the only window for a duplicate, and it's logged. """ import shutil try: if target_folder: dest_dir = saved_folder_dir(target_folder) else: dest_dir = saved_dir() except ValueError as e: QMessageBox.warning(self, "Invalid Folder Name", str(e)) return dest_resolved = dest_dir.resolve() moved = 0 skipped_same = 0 collisions: list[str] = [] errors: list[str] = [] for src in files: if not src.exists(): continue if src.parent.resolve() == dest_resolved: skipped_same += 1 continue target = dest_dir / src.name if target.exists(): collisions.append(src.name) continue try: src.rename(target) moved += 1 except OSError: # Cross-device move — fall back to copy + delete. try: shutil.move(str(src), str(target)) moved += 1 except Exception as e: log.warning("Failed to move %s → %s: %s", src, target, e) errors.append(f"{src.name}: {e}") self.refresh() if collisions: sample = "\n".join(collisions[:10]) more = f"\n... and {len(collisions) - 10} more" if len(collisions) > 10 else "" QMessageBox.warning( self, "Move Conflicts", f"Skipped {len(collisions)} file(s) — destination already " f"contains a file with the same name:\n\n{sample}{more}", ) if errors: sample = "\n".join(errors[:10]) QMessageBox.warning(self, "Move Errors", sample) def _on_context_menu(self, index: int, pos) -> None: if index < 0 or index >= len(self._files): return filepath = self._files[index] from PySide6.QtGui import QDesktopServices from PySide6.QtCore import QUrl menu = QMenu(self) open_default = menu.addAction("Open in Default App") open_folder = menu.addAction("Open Containing Folder") menu.addSeparator() copy_file = menu.addAction("Copy File to Clipboard") copy_path = menu.addAction("Copy File Path") menu.addSeparator() # Move to Folder submenu — atomic rename, no copy step, so a # crash mid-move can never leave a duplicate behind. The current # location is included in the list (no-op'd in the move helper) # so the menu shape stays predictable for the user. move_menu = menu.addMenu("Move to Folder") move_unsorted = move_menu.addAction("Unfiled") move_menu.addSeparator() move_folder_actions: dict[int, str] = {} root = saved_dir() if root.is_dir(): for entry in sorted(root.iterdir()): if entry.is_dir(): a = move_menu.addAction(entry.name) move_folder_actions[id(a)] = entry.name move_menu.addSeparator() move_new = move_menu.addAction("+ New Folder...") menu.addSeparator() delete_action = menu.addAction("Delete from Library") action = menu.exec(pos) if not action: return if action == open_default: QDesktopServices.openUrl(QUrl.fromLocalFile(str(filepath))) elif action == open_folder: QDesktopServices.openUrl(QUrl.fromLocalFile(str(filepath.parent))) elif action == move_unsorted: self._move_files_to_folder([filepath], None) elif action == move_new: name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") if ok and name.strip(): self._move_files_to_folder([filepath], name.strip()) elif id(action) in move_folder_actions: self._move_files_to_folder([filepath], move_folder_actions[id(action)]) elif action == copy_file: from PySide6.QtCore import QMimeData from PySide6.QtGui import QPixmap as _QP mime_data = QMimeData() mime_data.setUrls([QUrl.fromLocalFile(str(filepath.resolve()))]) pix = _QP(str(filepath)) if not pix.isNull(): mime_data.setImageData(pix.toImage()) QApplication.clipboard().setMimeData(mime_data) elif action == copy_path: QApplication.clipboard().setText(str(filepath)) elif action == delete_action: reply = QMessageBox.question( self, "Confirm", f"Delete {filepath.name} from library?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: post_id = self._db.get_library_post_id_by_filename(filepath.name) if post_id is None and filepath.stem.isdigit(): post_id = int(filepath.stem) filepath.unlink(missing_ok=True) thumb_key = str(post_id) if post_id is not None else filepath.stem lib_thumb = thumbnails_dir() / "library" / f"{thumb_key}.jpg" lib_thumb.unlink(missing_ok=True) if post_id is not None: self._db.remove_library_meta(post_id) self.refresh() if post_id is not None: self.files_deleted.emit([post_id]) def _on_multi_context_menu(self, indices: list, pos) -> None: files = [self._files[i] for i in indices if 0 <= i < len(self._files)] if not files: return menu = QMenu(self) move_menu = menu.addMenu(f"Move {len(files)} files to Folder") move_unsorted = move_menu.addAction("Unfiled") move_menu.addSeparator() move_folder_actions: dict[int, str] = {} root = saved_dir() if root.is_dir(): for entry in sorted(root.iterdir()): if entry.is_dir(): a = move_menu.addAction(entry.name) move_folder_actions[id(a)] = entry.name move_menu.addSeparator() move_new = move_menu.addAction("+ New Folder...") menu.addSeparator() delete_all = menu.addAction(f"Delete {len(files)} files from Library") action = menu.exec(pos) if not action: return if action == move_unsorted: self._move_files_to_folder(files, None) elif action == move_new: name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") if ok and name.strip(): self._move_files_to_folder(files, name.strip()) elif id(action) in move_folder_actions: self._move_files_to_folder(files, move_folder_actions[id(action)]) elif action == delete_all: reply = QMessageBox.question( self, "Confirm", f"Delete {len(files)} files from library?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: deleted_ids = [] for f in files: post_id = self._db.get_library_post_id_by_filename(f.name) if post_id is None and f.stem.isdigit(): post_id = int(f.stem) f.unlink(missing_ok=True) thumb_key = str(post_id) if post_id is not None else f.stem lib_thumb = thumbnails_dir() / "library" / f"{thumb_key}.jpg" lib_thumb.unlink(missing_ok=True) if post_id is not None: self._db.remove_library_meta(post_id) deleted_ids.append(post_id) self.refresh() if deleted_ids: self.files_deleted.emit(deleted_ids)