Files
random-file-picker/random_file_picker/tui.py
T

275 lines
10 KiB
Python

"""
A script to randomly select files from a directory, open them, and allow the user to upload them to a Discord webhook, delete them, or skip to the next file.
You can specify a file size limit, whether to descend into subdirectories, and provide blacklist/whitelist patterns for file selection.
Here's an example usage:
./rand.py -s 5 -d /path/to/directory --descend --blacklist "*.png" "*.mp4"
"""
import pathlib
import random
from typing import override
import typed_argparse as tap
from textual.app import App, ComposeResult
from textual.binding import BindingType
from textual.widgets import Footer, Header, Label
from random_file_picker.core import IMG_EXTENSIONS, collect_files, move_file_to_move_folder, trash_file, upload_file
from random_file_picker.openers import (
is_geeqie_available,
open_file_in_dolphin,
open_file_with_default_app,
open_file_with_geeqie,
)
GEEQIE_AVAILABLE = is_geeqie_available()
class Args(tap.TypedArgs):
file_size: int = tap.arg("-s", default=10, help="Maximum file size in MB")
directory: str = tap.arg("-d", default=".", help="Directory to search for files")
recursive: bool = tap.arg("-r", "--recursive", "--descend", default=False, help="Whether to recurse into subdirectories")
use_geeqie: bool = tap.arg("--use-geeqie", default=False, help="Whether to use Geeqie as the image viewer")
no_trash: bool = tap.arg("--no-trash", default=False, help="Delete files immediately without moving to trash")
blacklist: list[str] | None = tap.arg("--blacklist", nargs="*", help="List of glob patterns to blacklist files")
whitelist: list[str] | None = tap.arg("--whitelist", nargs="*", help="List of glob patterns to whitelist files")
class MenuApp(App[None]):
# This defines the keys shown in the footer
BINDINGS: list[BindingType] = [
("left", "prev_file", "Previous File"),
("right", "next_file", "Next File"),
("r", "reopen_file", "Reopen"),
("u", "upload", "Upload"),
("d", "delete", "Delete"),
("m", "move_to_folder", "Move File"),
("f", "open_folder", "Open Folder"),
("q", "quit", "Quit"),
]
def __init__(
self,
directory: str = ".",
file_limit: int = 10,
recurse: bool = False,
blacklist: list[str] | None = None,
whitelist: list[str] | None = None,
use_geeqie: bool = False,
use_trash: bool = True,
):
super().__init__()
self.directory: pathlib.Path = pathlib.Path(directory)
self.file_limit: int = file_limit
self.recurse: bool = recurse
self.blacklist_patterns: list[str] = blacklist or []
self.whitelist_patterns: list[str] = whitelist or []
self.use_geeqie: bool = use_geeqie
self.use_trash: bool = use_trash
self.usable_files: list[pathlib.Path] = []
self.current_index: int = 0
self.current_file: pathlib.Path | None = None
@override
def compose(self) -> ComposeResult:
yield Header()
yield Label("Loading files...", id="status")
yield Label("", id="current-file")
yield Footer()
def on_mount(self) -> None:
self.gather_files()
if self.usable_files:
self.current_index = 0
self.display_current_file()
def assert_current_file_exists(self) -> bool:
"""Check if the current file exists and update the status label accordingly."""
if not self.current_file or not self.current_file.exists():
self.query_one("#status", Label).update(f"❌ File '{self.current_file}' not found")
return False
return True
def display_current_file(self) -> None:
"""Display the current file based on current_index."""
if not self.usable_files or self.current_index >= len(self.usable_files):
self.query_one("#status", Label).update("❌ No files available")
self.current_file = None
return
self.current_file = self.usable_files[self.current_index]
file_count_str = f"({self.current_index + 1}/{len(self.usable_files)})"
self.query_one("#current-file", Label).update(f"📄 {self.current_file} {file_count_str}")
self.open_file()
def gather_files(self) -> None:
"""Collect files from the directory based on filters."""
self.usable_files = []
if not self.directory.exists() or not self.directory.is_dir():
self.query_one("#status", Label).update(f"❌ Directory '{self.directory}' not found")
return
self.usable_files = collect_files(
search_path=self.directory,
search_subdirectories=self.recurse,
file_size_limit=self.file_limit,
blacklist_patterns=self.blacklist_patterns,
whitelist_patterns=self.whitelist_patterns,
)
if not self.usable_files:
self.query_one("#status", Label).update("❌ No usable files found")
else:
self.query_one("#status", Label).update(f"✅ Found {len(self.usable_files)} files")
random.shuffle(self.usable_files)
def open_file(self) -> None:
"""Open the current file - display images in terminal, play others externally."""
if not self.current_file:
return
if not self.assert_current_file_exists():
return
self.query_one("#status", Label).update(f"📝 Open {self.current_file}")
try:
if self.use_geeqie and str(self.current_file).lower().endswith(tuple(IMG_EXTENSIONS)):
open_file_with_geeqie(self.current_file)
else:
ok = open_file_with_default_app(self.current_file)
if not ok:
self.query_one("#status", Label).update("❌ Failed to open file with default app")
except Exception as e:
self.query_one("#status", Label).update(f"❌ Failed to open file: {str(e)}")
def send_file(self) -> None:
"""Upload the current file to Discord webhook."""
if not self.current_file:
return
if not self.assert_current_file_exists():
return
try:
self.query_one("#status", Label).update(f"⏳ Uploading {self.current_file.name} to webhook...")
upload_file(self.current_file)
self.query_one("#status", Label).update(f"✅ Uploaded {self.current_file.name} to webhook")
except Exception as e:
self.query_one("#status", Label).update(f"❌ Upload failed: {str(e)}")
def delete_file(self) -> None:
"""Delete the current file."""
if not self.current_file or self.current_index >= len(self.usable_files):
return
if not self.assert_current_file_exists():
return
try:
if self.use_trash:
trash_file(self.current_file)
else:
self.current_file.unlink()
_ = self.usable_files.pop(self.current_index)
self.query_one("#status", Label).update("✅ Deleted file")
# Adjust index if we deleted the last file
if self.current_index >= len(self.usable_files) and self.usable_files:
self.current_index = len(self.usable_files) - 1
if self.usable_files:
self.display_current_file()
else:
self.query_one("#status", Label).update("❌ No more files")
except Exception as e:
self.query_one("#status", Label).update(f"❌ Delete failed: {str(e)}")
def move_to_move_folder(self) -> None:
"""Move the current file to the move folder."""
if not self.current_file or self.current_index >= len(self.usable_files):
return
if not self.assert_current_file_exists():
return
try:
move_file_to_move_folder(self.current_file)
_ = self.usable_files.pop(self.current_index)
self.query_one("#status", Label).update("✅ Moved to move folder")
# Adjust index if we removed the last file
if self.current_index >= len(self.usable_files) and self.usable_files:
self.current_index = len(self.usable_files) - 1
if self.usable_files:
self.display_current_file()
else:
self.query_one("#status", Label).update("❌ No more files")
except Exception as e:
self.query_one("#status", Label).update(f"❌ Move failed: {str(e)}")
def open_in_folder(self) -> None:
"""Open the file's folder in file manager."""
if not self.current_file:
return
if not self.assert_current_file_exists():
return
open_file_in_dolphin(self.current_file)
self.query_one("#status", Label).update("📂 Opened file in folder")
def action_upload(self) -> None:
self.send_file()
def action_delete(self) -> None:
self.delete_file()
def action_move_to_folder(self) -> None:
self.move_to_move_folder()
def action_open_folder(self) -> None:
self.open_in_folder()
def action_next_file(self) -> None:
if not self.usable_files:
return
self.current_index = (self.current_index + 1) % len(self.usable_files)
self.display_current_file()
def action_prev_file(self) -> None:
if not self.usable_files:
return
self.current_index = (self.current_index - 1) % len(self.usable_files)
self.display_current_file()
def action_reopen_file(self) -> None:
if not self.current_file:
return
self.open_file()
self.query_one("#status", Label).update("🔄 Reopened file")
def main_with_tap(args: Args) -> None:
if args.use_geeqie and not GEEQIE_AVAILABLE:
print("❌ Geeqie is not available on this system. Please install it or run without --use-geeqie.")
return
app = MenuApp(
directory=args.directory,
file_limit=args.file_size,
recurse=args.recursive,
blacklist=args.blacklist or [],
whitelist=args.whitelist or [],
use_geeqie=args.use_geeqie,
use_trash=not args.no_trash,
)
app.run()
def main() -> None:
tap.Parser(Args).bind(main_with_tap).run()
if __name__ == "__main__":
main()