minor bug fixes. known stutter bug in database/meta systems appears to be the delay in connecting to the database. future work: 1. intergrate firefly.dll as server module. 2. ensure full database funcctionality. 3. add toggle to use database or not. (by default we check for firefly if we don't find we default to python systems. if found we automatically use firefly.(so maybe on toggles)) 4. investigate and fix the stutter bug.
908 lines
32 KiB
Python
908 lines
32 KiB
Python
"""File browser implementation with tools and audio playback.
|
|
this includes a midi player"""
|
|
|
|
# flake8: noqa: E501
|
|
# This is the main file this file is the one to run.
|
|
|
|
import sys
|
|
import os
|
|
import warnings
|
|
import argparse
|
|
from PyQt6.QtWidgets import (
|
|
QApplication,
|
|
QMainWindow,
|
|
QWidget,
|
|
QVBoxLayout,
|
|
QPushButton,
|
|
QTreeView,
|
|
QLabel,
|
|
QHBoxLayout,
|
|
QLineEdit,
|
|
QProgressBar,
|
|
QSplitter,
|
|
QMessageBox,
|
|
QSlider,
|
|
QMenu,
|
|
QFileDialog,
|
|
QDialog,
|
|
QToolTip,
|
|
QFrame,
|
|
)
|
|
from PyQt6.QtCore import Qt, QDir, QUrl, QTimer, QEvent, QPoint
|
|
from PyQt6.QtGui import QFileSystemModel, QCursor
|
|
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
|
from MidPlay import MidPlay
|
|
from timer_m import Timer_Ui
|
|
from ScanOrg101 import (
|
|
Organizer,
|
|
FileFilterProxyModel,
|
|
DirectoryFilterProxyModel,
|
|
ArchiveExtractor,
|
|
)
|
|
import mutagen
|
|
from logging_setup import setup_logging
|
|
|
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
|
|
|
|
# Parse command line arguments
|
|
def parse_arguments():
|
|
"""Parse command line arguments"""
|
|
parser = argparse.ArgumentParser(description="File Browser Application")
|
|
parser.add_argument(
|
|
"--debug-on", action="store_true", help="Enable debug logging mode"
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
class MetadataTooltip(QFrame):
|
|
"""Custom styled tooltip for displaying audio metadata"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(
|
|
parent,
|
|
Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint,
|
|
)
|
|
self.setStyleSheet(
|
|
"""
|
|
MetadataTooltip {
|
|
background-color: #2a2a2a;
|
|
border: 1px solid #555555;
|
|
border-radius: 5px;
|
|
color: white;
|
|
padding: 5px;
|
|
}
|
|
QLabel {
|
|
color: white;
|
|
}
|
|
QLabel#title {
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
color: #e0e0e0;
|
|
}
|
|
"""
|
|
)
|
|
self.organizer = Organizer(
|
|
use_db=True,
|
|
db_host="localhost", # Make sure this matches your FireflyDB server
|
|
db_port=6379,
|
|
db_password="", # Add password if needed
|
|
)
|
|
layout = QVBoxLayout(self)
|
|
self.title_label = QLabel("")
|
|
self.title_label.setObjectName("title")
|
|
self.artist_label = QLabel("")
|
|
self.album_label = QLabel("")
|
|
self.genre_label = QLabel("")
|
|
self.year_label = QLabel("")
|
|
self.size_label = QLabel("")
|
|
|
|
layout.addWidget(self.title_label)
|
|
layout.addWidget(self.artist_label)
|
|
layout.addWidget(self.album_label)
|
|
layout.addWidget(self.genre_label)
|
|
layout.addWidget(self.year_label)
|
|
layout.addWidget(self.size_label)
|
|
|
|
self.setLayout(layout)
|
|
self.hide()
|
|
|
|
def set_metadata(self, metadata):
|
|
"""Update the tooltip with metadata"""
|
|
self.title_label.setText(metadata["title"])
|
|
self.artist_label.setText(f"Artist: {metadata['artist']}")
|
|
self.album_label.setText(f"Album: {metadata['album']}")
|
|
self.genre_label.setText(f"Genre: {metadata['genre']}")
|
|
self.year_label.setText(f"Year: {metadata['year']}")
|
|
self.size_label.setText(f"Size: {metadata['size']}")
|
|
|
|
# Adjust size to fit content
|
|
self.adjustSize()
|
|
|
|
|
|
class Fbrowser(QMainWindow):
|
|
"""Fbrowser main class"""
|
|
|
|
def __init__(self, logger):
|
|
super().__init__()
|
|
self.logger = logger
|
|
self.logger.info("Initializing Fbrowser application")
|
|
self.midplay = None
|
|
self.current_path = QDir.homePath()
|
|
self.organizer = Organizer()
|
|
self.player = QMediaPlayer()
|
|
self.audio_output = QAudioOutput()
|
|
self.player.setAudioOutput(self.audio_output)
|
|
self.audio_output.setVolume(0.5) # 50% volume
|
|
self.init_ui()
|
|
self.timer = Timer_Ui()
|
|
self.history = []
|
|
self.history_position = -1
|
|
self.hover_timer = QTimer()
|
|
self.hover_timer.setSingleShot(True)
|
|
self.hover_timer.timeout.connect(self.on_hover_timeout)
|
|
self.hover_position = None
|
|
self.hover_path = None
|
|
self.metadata_cache = {}
|
|
self.logger.debug("Fbrowser initialization complete")
|
|
|
|
def init_ui(self):
|
|
"""initilize UI"""
|
|
self.logger.debug("Initializing UI components")
|
|
self.setWindowTitle("File Browser")
|
|
central_widget = QWidget()
|
|
layout = QVBoxLayout(central_widget)
|
|
|
|
# Address bar
|
|
address_layout = QHBoxLayout()
|
|
self.address_bar = QLineEdit(self.current_path)
|
|
self.address_bar.returnPressed.connect(self.navigate_to_address)
|
|
address_layout.addWidget(self.address_bar)
|
|
|
|
# Navigation buttons
|
|
back_button = QPushButton("Back")
|
|
back_button.clicked.connect(self.go_back)
|
|
address_layout.addWidget(back_button)
|
|
|
|
forward_button = QPushButton("Forward")
|
|
forward_button.clicked.connect(self.go_forward_directory)
|
|
address_layout.addWidget(forward_button)
|
|
|
|
up_button = QPushButton("Up")
|
|
up_button.clicked.connect(self.go_up_directory)
|
|
address_layout.addWidget(up_button)
|
|
|
|
layout.addLayout(address_layout)
|
|
|
|
# Add a progress bar
|
|
self.progress_bar = QProgressBar()
|
|
layout.addWidget(self.progress_bar)
|
|
|
|
# File view with splitter
|
|
splitter = QSplitter()
|
|
|
|
# Directory tree
|
|
self.tree_model = QFileSystemModel()
|
|
self.tree_model.setRootPath(self.current_path)
|
|
self.directory_model = DirectoryFilterProxyModel()
|
|
self.directory_model.setSourceModel(self.tree_model)
|
|
|
|
self.file_tree = QTreeView()
|
|
self.file_tree.setModel(self.directory_model)
|
|
self.file_tree.setRootIndex(
|
|
self.directory_model.mapFromSource(
|
|
self.tree_model.index(self.current_path)
|
|
)
|
|
)
|
|
self.file_tree.setHeaderHidden(True)
|
|
self.file_tree.clicked.connect(self.change_directory)
|
|
splitter.addWidget(self.file_tree)
|
|
|
|
# File list
|
|
self.list_model = QFileSystemModel()
|
|
self.list_model.setRootPath(self.current_path)
|
|
self.file_filter_model = FileFilterProxyModel()
|
|
self.file_filter_model.setSourceModel(self.list_model)
|
|
|
|
self.folder_contents_view = QTreeView()
|
|
self.folder_contents_view.setModel(self.file_filter_model)
|
|
self.folder_contents_view.setRootIndex(
|
|
self.file_filter_model.mapFromSource(
|
|
self.list_model.index(self.current_path)
|
|
)
|
|
)
|
|
self.folder_contents_view.setEditTriggers(
|
|
QTreeView.EditTrigger.NoEditTriggers
|
|
)
|
|
self.folder_contents_view.doubleClicked.connect(
|
|
self.on_item_double_clicked
|
|
)
|
|
splitter.addWidget(self.folder_contents_view)
|
|
|
|
# Set up context menu for both views
|
|
self.folder_contents_view.setContextMenuPolicy(
|
|
Qt.ContextMenuPolicy.CustomContextMenu
|
|
)
|
|
self.folder_contents_view.customContextMenuRequested.connect(
|
|
self.on_folder_contents_context_menu
|
|
)
|
|
|
|
self.file_tree.setContextMenuPolicy(
|
|
Qt.ContextMenuPolicy.CustomContextMenu
|
|
)
|
|
self.file_tree.customContextMenuRequested.connect(
|
|
self.on_file_tree_context_menu
|
|
)
|
|
|
|
layout.addWidget(splitter)
|
|
|
|
# Current directory label
|
|
self.current_dir_label = QLabel(self.current_path)
|
|
layout.addWidget(self.current_dir_label)
|
|
|
|
# Media controls
|
|
media_controls = QHBoxLayout()
|
|
|
|
play_button = QPushButton("Play")
|
|
play_button.clicked.connect(self.player.play)
|
|
media_controls.addWidget(play_button)
|
|
|
|
pause_button = QPushButton("Pause")
|
|
pause_button.clicked.connect(self.player.pause)
|
|
media_controls.addWidget(pause_button)
|
|
|
|
stop_button = QPushButton("Stop")
|
|
stop_button.clicked.connect(self.player.stop)
|
|
media_controls.addWidget(stop_button)
|
|
|
|
volume_slider = QSlider(Qt.Orientation.Horizontal)
|
|
volume_slider.setRange(0, 100)
|
|
volume_slider.setValue(50)
|
|
volume_slider.valueChanged.connect(self.set_volume)
|
|
media_controls.addWidget(volume_slider)
|
|
|
|
layout.addLayout(media_controls)
|
|
|
|
# MidPlay button
|
|
open_midplay_button = QPushButton("Open MidPlay")
|
|
open_midplay_button.clicked.connect(self.open_midplay)
|
|
layout.addWidget(open_midplay_button)
|
|
|
|
# Timer Button
|
|
self.timer_button = QPushButton("Start Timer")
|
|
self.timer_button.clicked.connect(self.open_timer)
|
|
layout.addWidget(self.timer_button)
|
|
|
|
# Exit button
|
|
exit_button = QPushButton("Exit")
|
|
exit_button.clicked.connect(self.show_exit_popup)
|
|
layout.addWidget(exit_button)
|
|
|
|
self.setCentralWidget(central_widget)
|
|
self.resize(800, 600)
|
|
self.folder_contents_view.setMouseTracking(True)
|
|
self.folder_contents_view.viewport().setMouseTracking(True) # type: ignore
|
|
self.folder_contents_view.viewport().installEventFilter(self) # type: ignore
|
|
self.logger.debug("UI initialization complete")
|
|
|
|
def set_volume(self, volume):
|
|
"""Set Volume"""
|
|
self.audio_output.setVolume(volume / 100.0)
|
|
self.logger.debug(f"Volume set to {volume}%")
|
|
|
|
def show_exit_popup(self):
|
|
"""Exit Prompt"""
|
|
self.logger.debug("Exit prompt displayed")
|
|
reply = QMessageBox.question(
|
|
self,
|
|
"Exit",
|
|
"Are you sure you want to exit?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.No,
|
|
)
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
self.logger.info("User confirmed exit, shutting down application")
|
|
sys.exit()
|
|
|
|
def navigate_to_address(self):
|
|
"""Navigation Via Address Bar"""
|
|
new_path = self.address_bar.text()
|
|
self.logger.debug(f"Navigating to address: {new_path}")
|
|
if os.path.isdir(new_path):
|
|
self.add_to_history(self.current_path)
|
|
self.current_path = new_path
|
|
self.update_file_views(new_path)
|
|
else:
|
|
self.logger.warning(f"Invalid directory path: {new_path}")
|
|
self.address_bar.setText(self.current_path)
|
|
|
|
def add_to_history(self, path):
|
|
"""History"""
|
|
self.logger.debug(f"Adding path to history: {path}")
|
|
# If we're not at the end of the history, truncate it
|
|
if self.history_position < len(self.history) - 1:
|
|
self.history = self.history[: self.history_position + 1]
|
|
|
|
self.history.append(path)
|
|
self.history_position = len(self.history) - 1
|
|
|
|
def change_directory(self, index):
|
|
"""Change dir to user dir"""
|
|
index = self.directory_model.mapToSource(index)
|
|
try:
|
|
file_path = self.tree_model.filePath(index)
|
|
self.logger.debug(f"Changing directory to: {file_path}")
|
|
if os.path.isdir(file_path):
|
|
self.add_to_history(self.current_path)
|
|
self.current_path = file_path
|
|
self.update_file_views(file_path)
|
|
except (OSError, IOError) as e:
|
|
self.logger.error(f"Error changing directory: {e}")
|
|
logger.debug(f"Error Changing Dirs.: {e}")
|
|
|
|
def update_file_views(self, path):
|
|
"""Updates the File trees"""
|
|
self.address_bar.setText(path)
|
|
self.current_dir_label.setText(path)
|
|
|
|
# Update tree view
|
|
self.file_tree.setRootIndex(
|
|
self.directory_model.mapFromSource(self.tree_model.index(path))
|
|
)
|
|
|
|
# Update file list view
|
|
self.list_model.setRootPath(path)
|
|
self.folder_contents_view.setRootIndex(
|
|
self.file_filter_model.mapFromSource(self.list_model.index(path))
|
|
)
|
|
|
|
# Automatically scan the directory and extract metadata
|
|
self.auto_scan_directory(path)
|
|
|
|
def on_item_double_clicked(self, index):
|
|
"""on double click do stuff"""
|
|
source_index = self.file_filter_model.mapToSource(index)
|
|
path = self.list_model.filePath(source_index)
|
|
|
|
if os.path.isdir(path):
|
|
self.add_to_history(self.current_path)
|
|
self.current_path = path
|
|
self.update_file_views(path)
|
|
else:
|
|
self.play_file(path)
|
|
|
|
def play_file(self, file_path):
|
|
"""Play a Audio file if midi load midi player"""
|
|
if file_path.lower().endswith((".mid", ".midi")):
|
|
if not self.midplay:
|
|
self.midplay = MidPlay()
|
|
self.midplay.add_to_playlist(file_path)
|
|
self.midplay.load_midi(file_path)
|
|
self.midplay.play_midi(file_path)
|
|
else:
|
|
# For audio files
|
|
self.player.setSource(QUrl.fromLocalFile(file_path))
|
|
self.player.play()
|
|
|
|
def go_back(self):
|
|
"""Go back a dir"""
|
|
if self.history_position > 0:
|
|
self.history_position -= 1
|
|
path = self.history[self.history_position]
|
|
self.current_path = path
|
|
self.update_file_views(path)
|
|
|
|
def go_forward_directory(self):
|
|
"""Go forward a dir"""
|
|
if self.history_position < len(self.history) - 1:
|
|
self.history_position += 1
|
|
path = self.history[self.history_position]
|
|
self.current_path = path
|
|
self.update_file_views(path)
|
|
|
|
def go_up_directory(self):
|
|
"""Similar to back"""
|
|
parent_dir = os.path.dirname(self.current_path)
|
|
if parent_dir != self.current_path:
|
|
self.add_to_history(self.current_path)
|
|
self.current_path = parent_dir
|
|
self.update_file_views(parent_dir)
|
|
|
|
def open_midplay(self):
|
|
"""Opens the midi player"""
|
|
if not self.midplay:
|
|
self.midplay = MidPlay()
|
|
self.midplay.showUI()
|
|
|
|
def open_timer(self):
|
|
"""Opens the Timer"""
|
|
self.timer.showUI()
|
|
|
|
def create_context_menu(self, position, view):
|
|
"""Create and display a context menu for files and folders"""
|
|
# Get the index at position
|
|
if view == self.folder_contents_view:
|
|
index = self.folder_contents_view.indexAt(position)
|
|
if not index.isValid():
|
|
return
|
|
|
|
# Map to source model to get the actual file path
|
|
source_index = self.file_filter_model.mapToSource(index)
|
|
path = self.list_model.filePath(source_index)
|
|
else: # Directory tree
|
|
index = self.file_tree.indexAt(position)
|
|
if not index.isValid():
|
|
return
|
|
|
|
source_index = self.directory_model.mapToSource(index)
|
|
path = self.tree_model.filePath(source_index)
|
|
|
|
# Create menu
|
|
menu = QMenu()
|
|
|
|
if os.path.isdir(path):
|
|
# Directory options
|
|
scan_action = menu.addAction("Scan Directory")
|
|
scan_action.triggered.connect(lambda: self.scan_directory(path)) # type: ignore
|
|
|
|
extract_metadata_action = menu.addAction("Extract Audio Metadata")
|
|
extract_metadata_action.triggered.connect( # type: ignore
|
|
lambda: self.extract_audio_metadata(path)
|
|
)
|
|
|
|
menu.addSeparator()
|
|
|
|
elif os.path.isfile(path):
|
|
# File options
|
|
if path.lower().endswith((".zip", ".rar", ".7z")):
|
|
# Archive options
|
|
extract_here_action = menu.addAction("Extract Here")
|
|
extract_here_action.triggered.connect( # type: ignore
|
|
lambda: self.extract_archive(path, self.current_path)
|
|
)
|
|
|
|
extract_to_action = menu.addAction("Extract To...")
|
|
extract_to_action.triggered.connect( # type: ignore
|
|
lambda: self.extract_archive_to(path)
|
|
)
|
|
|
|
if path.lower().endswith(
|
|
(".mp3", ".wav", ".flac", ".m4a", ".wma", ".mid", ".midi")
|
|
):
|
|
# Audio file options
|
|
play_action = menu.addAction("Play")
|
|
play_action.triggered.connect(lambda: self.play_file(path)) # type: ignore
|
|
|
|
extract_metadata_action = menu.addAction("Extract Metadata")
|
|
extract_metadata_action.triggered.connect( # type: ignore
|
|
lambda: self.extract_file_metadata(path)
|
|
)
|
|
|
|
# Add general options
|
|
menu.addSeparator()
|
|
refresh_action = menu.addAction("Refresh")
|
|
refresh_action.triggered.connect( # type: ignore
|
|
lambda: self.update_file_views(self.current_path)
|
|
)
|
|
|
|
# Show menu
|
|
menu.exec(view.viewport().mapToGlobal(position))
|
|
|
|
def on_folder_contents_context_menu(self, position):
|
|
"""Handle context menu in folder contents view"""
|
|
self.create_context_menu(position, self.folder_contents_view)
|
|
|
|
def on_file_tree_context_menu(self, position):
|
|
"""Handle context menu in file tree view"""
|
|
self.create_context_menu(position, self.file_tree)
|
|
|
|
def scan_directory(self, path):
|
|
"""Scan a directory using ScanOrg101"""
|
|
self.progress_bar.setValue(0)
|
|
|
|
# Connect organizer signals
|
|
self.organizer.on_progress_update = self.update_progress # type: ignore
|
|
self.organizer.on_scan_complete = lambda: self.show_scan_results(path) # type: ignore
|
|
|
|
# Start scan
|
|
self.organizer.start_scan(path)
|
|
|
|
def extract_archive(self, archive_path, extract_dir):
|
|
"""Extract an archive to specified directory"""
|
|
self.progress_bar.setValue(0)
|
|
|
|
# Create an extractor instance
|
|
self.extract_dir = extract_dir
|
|
self.archive_extractor = self.organizer.archive_extractor = (
|
|
ArchiveExtractor(archive_path, extract_dir)
|
|
)
|
|
|
|
# Connect signals
|
|
self.archive_extractor.extraction_progress.connect(
|
|
self.update_progress
|
|
)
|
|
self.archive_extractor.extraction_complete.connect(
|
|
self.extraction_complete
|
|
)
|
|
self.archive_extractor.extraction_error.connect(
|
|
self.show_error_message
|
|
)
|
|
|
|
# Start extraction
|
|
self.archive_extractor.start()
|
|
|
|
def extract_archive_to(self, archive_path):
|
|
"""Extract an archive to a user-selected directory"""
|
|
extract_dir = QFileDialog.getExistingDirectory(
|
|
self, "Select Extraction Directory", self.current_path
|
|
)
|
|
|
|
if extract_dir:
|
|
self.extract_archive(archive_path, extract_dir)
|
|
|
|
def extraction_complete(self, extracted_files):
|
|
"""Handle archive extraction completion"""
|
|
self.progress_bar.setValue(100)
|
|
self.update_file_views(self.extract_dir)
|
|
self.show_message(
|
|
f"Extraction complete: {len(extracted_files)} files extracted"
|
|
)
|
|
|
|
def extract_audio_metadata(self, directory_path):
|
|
"""Extract metadata from audio files in a directory"""
|
|
self.progress_bar.setValue(0)
|
|
|
|
# Scan directory first to get file list
|
|
self.organizer.on_progress_update = self.update_progress # type: ignore
|
|
self.organizer.on_scan_complete = ( # type: ignore
|
|
lambda: self.start_metadata_extraction(directory_path)
|
|
)
|
|
|
|
self.organizer.start_scan(directory_path)
|
|
|
|
def start_metadata_extraction(self, directory_path):
|
|
"""Start metadata extraction after scanning is complete"""
|
|
self.organizer.on_progress_update = self.update_progress # type: ignore
|
|
self.organizer.on_metadata_complete = ( # type: ignore
|
|
lambda: self.show_metadata_results()
|
|
)
|
|
|
|
self.organizer.extract_metadata()
|
|
|
|
def extract_file_metadata(self, file_path):
|
|
"""Extract metadata from a single audio file"""
|
|
self.progress_bar.setValue(0)
|
|
|
|
# Create a temporary file list with just this file
|
|
self.organizer.file_list = [file_path]
|
|
|
|
# Set up extraction
|
|
self.organizer.on_progress_update = self.update_progress # type: ignore
|
|
self.organizer.on_metadata_complete = ( # type: ignore
|
|
lambda: self.show_metadata_results(single_file=True)
|
|
)
|
|
|
|
# Start extraction
|
|
self.organizer.extract_metadata()
|
|
|
|
def update_progress(self, value):
|
|
"""Update the progress bar"""
|
|
self.progress_bar.setValue(value)
|
|
|
|
def show_scan_results(self, path):
|
|
"""Display scan results"""
|
|
self.progress_bar.setValue(100)
|
|
file_count = len(self.organizer.file_list)
|
|
dir_count = len(self.organizer.dir_list)
|
|
self.show_message(
|
|
f"Scan complete: {file_count} files, {dir_count} directories found"
|
|
)
|
|
|
|
def show_metadata_results(self, single_file=False):
|
|
"""Display metadata extraction results"""
|
|
self.progress_bar.setValue(100)
|
|
|
|
if single_file:
|
|
self.show_message("Metadata extraction complete")
|
|
# dialog to show metadata in a context menu
|
|
self.show_metadata_dialog()
|
|
|
|
else:
|
|
artists = len(self.organizer.artists)
|
|
albums = len(self.organizer.albums)
|
|
genres = len(self.organizer.genres)
|
|
self.show_message(
|
|
f"Metadata extraction complete: {artists} artists, {albums} albums, {genres} genres found"
|
|
)
|
|
|
|
def show_metadata_dialog(self):
|
|
"""Show a dialog with metadata information"""
|
|
# Create a dialog with metadata information
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("Metadata Information")
|
|
# layout = QVBoxLayout()
|
|
|
|
def show_message(self, message):
|
|
"""Show a message in a message box"""
|
|
QMessageBox.information(self, "Information", message)
|
|
|
|
def show_error_message(self, error):
|
|
"""Show an error message"""
|
|
QMessageBox.critical(self, "Error", error)
|
|
|
|
def eventFilter(self, obj, event):
|
|
"""Handle hover events for metadata tooltips"""
|
|
if obj == self.folder_contents_view.viewport():
|
|
if event.type() == QEvent.Type.HoverMove:
|
|
pos = event.position().toPoint()
|
|
# Get the item under the cursor
|
|
index = self.folder_contents_view.indexAt(pos)
|
|
if index.isValid():
|
|
source_index = self.file_filter_model.mapToSource(index)
|
|
path = self.list_model.filePath(source_index)
|
|
|
|
# Only proceed for audio files
|
|
if path.lower().endswith(
|
|
(
|
|
".mp3",
|
|
".wav",
|
|
".flac",
|
|
".m4a",
|
|
".wma",
|
|
".mid",
|
|
".midi",
|
|
)
|
|
):
|
|
# If we've moved to a new position or new file, restart the timer
|
|
if self.hover_path != path or (
|
|
self.hover_position
|
|
and (
|
|
abs(self.hover_position.x() - pos.x()) > 5
|
|
or abs(self.hover_position.y() - pos.y()) > 5
|
|
)
|
|
):
|
|
self.hover_timer.stop()
|
|
self.hover_position = pos
|
|
self.hover_path = path
|
|
self.hover_timer.start(1200)
|
|
return True
|
|
else:
|
|
# Not an audio file, stop any running timer
|
|
self.hover_timer.stop()
|
|
self.hover_path = None
|
|
|
|
else:
|
|
# No valid item under cursor
|
|
self.hover_timer.stop()
|
|
self.hover_path = None
|
|
|
|
elif event.type() == QEvent.Type.Leave:
|
|
# Mouse left the widget, stop the timer
|
|
self.hover_timer.stop()
|
|
self.hover_path = None
|
|
QToolTip.hideText()
|
|
|
|
return super().eventFilter(obj, event)
|
|
|
|
def on_hover_timeout(self):
|
|
"""Called when the hover timer expires, extract and show metadata"""
|
|
if not self.hover_path:
|
|
return
|
|
|
|
# Check if we already have metadata for this file in cache
|
|
if self.hover_path in self.metadata_cache:
|
|
self.show_metadata_tooltip(self.metadata_cache[self.hover_path])
|
|
else:
|
|
# Extract metadata in background
|
|
self.extract_hover_metadata(self.hover_path)
|
|
|
|
def extract_hover_metadata(self, file_path):
|
|
"""Extract metadata for the hovered file"""
|
|
# First check if we have this metadata in the database
|
|
if hasattr(self.organizer, "db") and self.organizer.db:
|
|
db_metadata = self.organizer.get_metadata_from_db(file_path)
|
|
if db_metadata:
|
|
# Add the size field which might not be in the database
|
|
try:
|
|
db_metadata["size"] = (
|
|
f"{os.path.getsize(file_path) / (1024*1024):.2f} MB"
|
|
)
|
|
except:
|
|
db_metadata["size"] = "Unknown"
|
|
|
|
# Cache the metadata
|
|
self.metadata_cache[file_path] = db_metadata
|
|
|
|
# Show the tooltip if we're still hovering over the same file
|
|
if self.hover_path == file_path:
|
|
self.show_metadata_tooltip(db_metadata)
|
|
return
|
|
|
|
# If not in database, extract it directly
|
|
try:
|
|
# Use mutagen directly for speed
|
|
|
|
audio = mutagen.File(file_path)
|
|
if not audio:
|
|
return
|
|
|
|
# Extract basic metadata
|
|
metadata = {
|
|
"file_path": file_path,
|
|
"filename": os.path.basename(file_path),
|
|
"size": f"{os.path.getsize(file_path) / (1024*1024):.2f} MB",
|
|
"artist": self._get_tag(audio, "artist", "Unknown Artist"),
|
|
"album": self._get_tag(audio, "album", "Unknown Album"),
|
|
"title": self._get_tag(
|
|
audio, "title", os.path.basename(file_path)
|
|
),
|
|
"genre": self._get_tag(audio, "genre", "Unknown Genre"),
|
|
"year": self._get_tag(audio, "date", "Unknown Year"),
|
|
}
|
|
|
|
# Cache the metadata
|
|
self.metadata_cache[file_path] = metadata
|
|
|
|
# Store in database for future use
|
|
if hasattr(self.organizer, "db") and self.organizer.db:
|
|
self.organizer.store_metadata(metadata)
|
|
|
|
# Show the tooltip if we're still hovering over the same file
|
|
if self.hover_path == file_path:
|
|
self.show_metadata_tooltip(metadata)
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error extracting metadata for hover: {e}")
|
|
|
|
def _get_tag(self, audio, tag_name, default_value):
|
|
"""Helper method to safely extract tags from audio files"""
|
|
try:
|
|
if tag_name in audio:
|
|
value = audio[tag_name]
|
|
if isinstance(value, list) and len(value) > 0:
|
|
return str(value[0])
|
|
return str(value)
|
|
except Exception:
|
|
pass
|
|
return default_value
|
|
|
|
def show_metadata_tooltip(self, metadata):
|
|
"""Display metadata in a stylish custom tooltip"""
|
|
# Create or get the tooltip
|
|
if not hasattr(self, "metadata_tooltip"):
|
|
self.metadata_tooltip = MetadataTooltip()
|
|
|
|
# Update tooltip content
|
|
self.metadata_tooltip.set_metadata(metadata)
|
|
|
|
# Position near but not under cursor
|
|
cursor_pos = QCursor.pos()
|
|
tooltip_pos = QPoint(cursor_pos.x() + 15, cursor_pos.y() + 15)
|
|
|
|
# Show the tooltip
|
|
self.metadata_tooltip.move(tooltip_pos)
|
|
self.metadata_tooltip.show()
|
|
|
|
# Auto-hide after 8 seconds
|
|
QTimer.singleShot(8000, self.metadata_tooltip.hide)
|
|
|
|
def closeEvent(self, event):
|
|
"""Handle application close event"""
|
|
self.logger.info("Application closing")
|
|
# Close database connection
|
|
if hasattr(self, "organizer") and self.organizer:
|
|
self.organizer.close()
|
|
event.accept()
|
|
|
|
def auto_scan_directory(self, path):
|
|
"""Automatically scan a directory and extract metadata when opened"""
|
|
# Only scan directories that are likely to contain audio files
|
|
# to avoid unnecessary scanning of system folders
|
|
if not self.should_auto_scan(path):
|
|
return
|
|
|
|
self.logger.info(f"Auto-scanning directory: {path}")
|
|
|
|
# Initialize organizer with database connection if not already done
|
|
if not hasattr(self.organizer, "db") or self.organizer.db is None:
|
|
self.organizer = Organizer(
|
|
use_db=True, db_host="localhost", db_port=6379, db_password=""
|
|
)
|
|
|
|
# Connect organizer signals
|
|
self.organizer.on_progress_update = self.update_progress
|
|
|
|
# Custom scan handler that checks for existing metadata
|
|
def scan_and_extract():
|
|
# Filter files that don't have metadata in DB
|
|
files_to_process = []
|
|
for file_path in self.organizer.file_list:
|
|
if file_path.lower().endswith(
|
|
(".mp3", ".wav", ".flac", ".m4a", ".wma", ".mid", ".midi")
|
|
):
|
|
if not self.organizer.has_metadata_in_db(file_path):
|
|
files_to_process.append(file_path)
|
|
|
|
if files_to_process:
|
|
self.logger.debug(
|
|
f"Found {len(files_to_process)} files without metadata, extracting..."
|
|
)
|
|
self.organizer.file_list = files_to_process
|
|
self.organizer.extract_metadata()
|
|
else:
|
|
self.logger.debug(
|
|
"All files already have metadata in database, skipping extraction"
|
|
)
|
|
self.progress_bar.setValue(100)
|
|
|
|
# When scan completes, check and extract metadata as needed
|
|
self.organizer.on_scan_complete = scan_and_extract
|
|
|
|
# Start scan
|
|
self.organizer.start_scan(path)
|
|
|
|
def auto_extract_metadata(self):
|
|
"""Automatically extract metadata after scanning"""
|
|
if not self.organizer.file_list:
|
|
logger.debug("No audio files found to extract metadata from")
|
|
return
|
|
|
|
self.logger.debug(
|
|
f"Auto-extracting metadata for {len(self.organizer.file_list)} files"
|
|
)
|
|
|
|
# Connect signals
|
|
self.organizer.on_progress_update = self.update_progress # type: ignore
|
|
self.organizer.on_metadata_complete = lambda: logger.debug("Metadata extraction complete") # type: ignore
|
|
|
|
# Start extraction
|
|
self.organizer.extract_metadata()
|
|
|
|
def should_auto_scan(self, path):
|
|
"""Determine if a directory should be automatically scanned"""
|
|
# Skip system directories and very large directories
|
|
if any(
|
|
system_dir in path
|
|
for system_dir in [
|
|
"/Windows",
|
|
"C:\\Windows",
|
|
"/System",
|
|
"C:\\Program Files",
|
|
"C:\\Program Files (x86)",
|
|
"/usr/bin",
|
|
"/bin",
|
|
]
|
|
):
|
|
return False
|
|
|
|
# Check if the directory has a reasonable number of files
|
|
try:
|
|
file_count = len(os.listdir(path))
|
|
if file_count > 1000: # Skip very large directories
|
|
return False
|
|
except (PermissionError, OSError):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def main():
|
|
# Parse command line arguments
|
|
args = parse_arguments()
|
|
|
|
# Setup logging
|
|
logger = setup_logging(args)
|
|
|
|
logger.info("Starting Fbrowser application")
|
|
|
|
app = QApplication(sys.argv)
|
|
ex = Fbrowser(logger)
|
|
ex.show()
|
|
|
|
logger.info("Application UI displayed")
|
|
sys.exit(app.exec())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|