Initial commit
This commit is contained in:
commit
3b909bb756
113
.gitignore
vendored
Normal file
113
.gitignore
vendored
Normal file
@ -0,0 +1,113 @@
|
||||
# Python ignores
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.hypothesis/
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.pytype/
|
||||
.mypy_cache/
|
||||
|
||||
# C# ignores
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Dd]ebug/
|
||||
[Rr]elease/
|
||||
x64/
|
||||
x86/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]uild/
|
||||
msbuild.log
|
||||
msbuild.err
|
||||
msbuild.wrn
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.userprefs
|
||||
.vs/
|
||||
Generated\ Files/
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
.localhistory/
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
*.nupkg
|
||||
**/packages/*
|
||||
!**/packages/build/
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Specific file exclusion
|
||||
maintest.py
|
||||
|
||||
# SoundFonts can be large - optional
|
||||
*.sf2
|
||||
|
||||
# Generated MIDI files - optional
|
||||
*.mid
|
||||
*.midi
|
||||
736
Fbrowser.py
Normal file
736
Fbrowser.py
Normal file
@ -0,0 +1,736 @@
|
||||
# flake8: noqa: E501
|
||||
|
||||
"""File browser implementation with tools and audio playback.
|
||||
this includes a midi player"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import warnings
|
||||
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,
|
||||
mutagen,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
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):
|
||||
super().__init__()
|
||||
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 = {}
|
||||
|
||||
def init_ui(self):
|
||||
"""initilize UI"""
|
||||
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
|
||||
|
||||
def set_volume(self, volume):
|
||||
"""Set Volume"""
|
||||
self.audio_output.setVolume(volume / 100.0)
|
||||
|
||||
def show_exit_popup(self):
|
||||
"""Exit Prompt"""
|
||||
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:
|
||||
sys.exit()
|
||||
|
||||
def navigate_to_address(self):
|
||||
"""Navigation Via Address Bar"""
|
||||
new_path = self.address_bar.text()
|
||||
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.address_bar.setText(self.current_path)
|
||||
|
||||
def add_to_history(self, path):
|
||||
"""History"""
|
||||
# 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)
|
||||
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:
|
||||
print(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))
|
||||
)
|
||||
|
||||
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"""
|
||||
try:
|
||||
# Use mutagen directly for speed
|
||||
audio = mutagen.File(file_path) # type: ignore
|
||||
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
|
||||
|
||||
# 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:
|
||||
print(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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
ex = Fbrowser()
|
||||
ex.show()
|
||||
sys.exit(app.exec())
|
||||
524
MidPlay.py
Normal file
524
MidPlay.py
Normal file
@ -0,0 +1,524 @@
|
||||
# Path: MidPlay.py
|
||||
# Name: MidPlay
|
||||
# Author: Stanton
|
||||
# Description: This file contains the class MidPlay
|
||||
# which is used to play MIDI files.
|
||||
# It includes FluidSynth initialization, MIDI playback controls.
|
||||
# playlist management, volume control, and MIDI file validation.
|
||||
# The script handles MIDI file loading, threading for playback,
|
||||
# and soundfont configuration.
|
||||
|
||||
# flake8: noqa: E501
|
||||
# Imports
|
||||
import fluidsynth # typed: ignore
|
||||
import time
|
||||
import mido # typed: ignore
|
||||
import os
|
||||
import platform
|
||||
from queue import Queue
|
||||
from threading import Lock
|
||||
import threading
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QPushButton,
|
||||
QLabel,
|
||||
QFileDialog,
|
||||
QSlider,
|
||||
QListWidget,
|
||||
QHBoxLayout,
|
||||
QProgressBar,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
|
||||
|
||||
def find_default_soundfont():
|
||||
"""Find the default soundfont based on the operating system"""
|
||||
system = platform.system()
|
||||
|
||||
# List of possible soundfont paths by OS
|
||||
if system == "Windows":
|
||||
possible_paths = [
|
||||
# Common FluidSynth installation locations
|
||||
os.path.expandvars(
|
||||
r"%PROGRAMFILES%\FluidSynth\share\soundfonts\FluidR3_GM.sf2"
|
||||
),
|
||||
# Common user locations
|
||||
os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
"Documents",
|
||||
"SoundFonts",
|
||||
"FluidR3_GM.sf2",
|
||||
),
|
||||
# Application directory
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"SoundFonts",
|
||||
"FluidR3_GM.sf2",
|
||||
),
|
||||
# Development fallback
|
||||
"C:\\Users\\East\\Documents\\Dev\\Tests\\MidPlay\\SoundFonts\\tales_of_phantasia.sf2",
|
||||
]
|
||||
|
||||
elif system == "Darwin": # macOS
|
||||
possible_paths = [
|
||||
"/Library/Audio/Sounds/SF2/FluidR3_GM.sf2",
|
||||
"/usr/local/share/soundfonts/FluidR3_GM.sf2",
|
||||
"/usr/share/soundfonts/FluidR3_GM.sf2",
|
||||
os.path.expanduser("~/Library/Audio/Sounds/SF2/FluidR3_GM.sf2"),
|
||||
]
|
||||
else: # Linux and others
|
||||
possible_paths = [
|
||||
"/usr/share/soundfonts/FluidR3_GM.sf2",
|
||||
"/usr/share/sounds/sf2/FluidR3_GM.sf2", # Common on many distros
|
||||
"/usr/local/share/soundfonts/FluidR3_GM.sf2",
|
||||
]
|
||||
|
||||
# Check if files exist and return the first one found
|
||||
for path in possible_paths:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
# If no soundfont found, look for a soundfont in the current directory
|
||||
# or in a "SoundFonts" subdirectory
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
soundfont_dir = os.path.join(current_dir, "SoundFonts")
|
||||
|
||||
if os.path.isdir(soundfont_dir):
|
||||
for file in os.listdir(soundfont_dir):
|
||||
if file.lower().endswith(".sf2"):
|
||||
return os.path.join(soundfont_dir, file)
|
||||
|
||||
# Last check - any .sf2 file in the current directory
|
||||
for file in os.listdir(current_dir):
|
||||
if file.lower().endswith(".sf2"):
|
||||
return os.path.join(current_dir, file)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# --- Fluidsynth Initialization --- #
|
||||
try:
|
||||
default_soundfont = find_default_soundfont()
|
||||
if default_soundfont:
|
||||
print(f"Using soundfont: {default_soundfont}")
|
||||
else:
|
||||
print("No soundfont found. Please select one manually.")
|
||||
except Exception as e:
|
||||
print(f"Error finding soundfont: {e}")
|
||||
default_soundfont = None
|
||||
|
||||
current_sf = "No soundfont loaded"
|
||||
fs = fluidsynth.Synth()
|
||||
sfid = None
|
||||
|
||||
if default_soundfont:
|
||||
try:
|
||||
sfid = fs.sfload(default_soundfont)
|
||||
fs.program_select(0, sfid, 0, 0)
|
||||
current_sf = default_soundfont
|
||||
except Exception as e:
|
||||
print(f"Error loading soundfont: {e}")
|
||||
|
||||
settings = fluidsynth.new_fluid_settings() # typed: ignore
|
||||
|
||||
# Use appropriate audio driver based on OS
|
||||
if platform.system() == "Windows":
|
||||
fs.start(driver="dsound")
|
||||
elif platform.system() == "Darwin": # macOS
|
||||
fs.start(driver="coreaudio")
|
||||
else: # Linux
|
||||
fs.start(driver="alsa")
|
||||
|
||||
|
||||
class MidPlay:
|
||||
"""The Heart of Midi Playback"""
|
||||
|
||||
def __init__(self):
|
||||
self.queue = Queue()
|
||||
self.thread = threading.Thread(target=self.midi_threading)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
self.playback_lock = Lock()
|
||||
self.playlist = []
|
||||
self.current_midi = None
|
||||
self.playing = False
|
||||
self.current_index = 0
|
||||
self.midi_start_time = 0
|
||||
self.current_volume = 100
|
||||
self.midi_length = 0
|
||||
self.ui = None
|
||||
|
||||
@staticmethod
|
||||
def is_valid_midi(filepath):
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
header = f.read(4)
|
||||
return header == b"MThd"
|
||||
except IOError:
|
||||
return False
|
||||
|
||||
def showUI(self):
|
||||
if not self.ui:
|
||||
self.ui = MidPlayGUI(self)
|
||||
self.ui.show()
|
||||
|
||||
def set_volume(self, value):
|
||||
self.current_volume = value
|
||||
for channel in range(16):
|
||||
fs.cc(channel, 7, int(value * 127))
|
||||
|
||||
def load_midi(self, filepath: str) -> None:
|
||||
if not self.is_valid_midi(filepath):
|
||||
print(f"Debug: Invalid MIDI file: {filepath}")
|
||||
return
|
||||
try:
|
||||
self.current_midi = mido.MidiFile(filepath)
|
||||
self.midi_length = self.current_midi.length
|
||||
if filepath in self.playlist:
|
||||
self.current_index = self.playlist.index(filepath)
|
||||
except Exception as e:
|
||||
print(f"Error loading MIDI file {filepath}: {e}")
|
||||
|
||||
def add_to_playlist(self, filepath: str) -> None:
|
||||
if filepath not in self.playlist:
|
||||
self.playlist.append(filepath)
|
||||
|
||||
def stop_midi(self):
|
||||
fs.all_notes_off(-1) # -1 means all channels in MIDI CC
|
||||
fs.all_sounds_off(-1)
|
||||
fs.system_reset()
|
||||
self.playing = False
|
||||
self.current_midi = None
|
||||
|
||||
def _play_midi(self, filepath):
|
||||
try:
|
||||
self.stop_midi()
|
||||
mid = mido.MidiFile(filepath)
|
||||
self.playing = True
|
||||
self.midi_start_time = time.time()
|
||||
tempo = mido.bpm2tempo(120)
|
||||
|
||||
for msg in mido.merge_tracks(mid.tracks):
|
||||
if not self.playing:
|
||||
break
|
||||
if msg.is_meta:
|
||||
if msg.type == "set_tempo":
|
||||
# print(f"Set Tempo: {mido.tempo2bpm(msg.tempo)} BPM")
|
||||
tempo = msg.tempo
|
||||
|
||||
else:
|
||||
time.sleep(
|
||||
mido.tick2second(msg.time, mid.ticks_per_beat, tempo)
|
||||
)
|
||||
if msg.type == "note_on":
|
||||
fs.noteon(0, msg.note, msg.velocity)
|
||||
|
||||
elif msg.type == "note_off":
|
||||
fs.noteoff(0, msg.note)
|
||||
|
||||
if self.playing:
|
||||
self.next_song()
|
||||
|
||||
finally:
|
||||
self.playback_lock.release()
|
||||
self.playing = False
|
||||
|
||||
def pause(self):
|
||||
if self.playing:
|
||||
fs.all_notes_off(-1)
|
||||
self.playing = False
|
||||
|
||||
def previous_song(self) -> None:
|
||||
if not self.playlist:
|
||||
return
|
||||
self.stop_midi()
|
||||
self.current_index = (self.current_index - 1) % len(self.playlist)
|
||||
prev_file = self.playlist[self.current_index]
|
||||
self.load_midi(prev_file)
|
||||
self.play_midi(prev_file)
|
||||
|
||||
def next_song(self) -> None:
|
||||
if not self.playlist:
|
||||
return
|
||||
self.stop_midi()
|
||||
self.current_index = (self.current_index + 1) % len(self.playlist)
|
||||
filepath = self.playlist[self.current_index]
|
||||
self.load_midi(filepath)
|
||||
self.set_volume(self.current_volume)
|
||||
self.play_midi(filepath)
|
||||
|
||||
def play_midi(self, filepath):
|
||||
if self.playback_lock.acquire(blocking=False):
|
||||
try:
|
||||
self.stop_midi()
|
||||
thread = threading.Thread(
|
||||
target=self._play_midi, args=(filepath,)
|
||||
)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
except IOError:
|
||||
self.playback_lock.release()
|
||||
|
||||
def midi_threading(self):
|
||||
while True:
|
||||
file_path = self.queue.get()
|
||||
try:
|
||||
self._play_midi(file_path)
|
||||
self.playing = True
|
||||
except Exception as e:
|
||||
print(f"Error playing MIDI file {file_path}: {e}")
|
||||
self.queue.task_done()
|
||||
self.next_song()
|
||||
|
||||
def stop(self):
|
||||
self.stop_midi()
|
||||
self.playing = False
|
||||
self.current_midi = None
|
||||
fs.all_notes_off(-1)
|
||||
fs.all_sounds_off(-1)
|
||||
fs.program_unset(0)
|
||||
|
||||
|
||||
# MidPlayGUI class
|
||||
class MidPlayGUI(QWidget):
|
||||
def __init__(self, player=None):
|
||||
super().__init__()
|
||||
self.setWindowTitle("MidPlay")
|
||||
self.status_label = QLabel(f"SoundFont: {current_sf}")
|
||||
|
||||
if player:
|
||||
self.player = player
|
||||
else:
|
||||
self.player = MidPlay()
|
||||
|
||||
self.midi_length = 0
|
||||
self.playing = False
|
||||
self.current_index = 0
|
||||
self.init_ui()
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self.update_progress)
|
||||
self.timer.start(100)
|
||||
|
||||
# If no soundfont is loaded, prompt the user immediately
|
||||
if current_sf == "No soundfont loaded":
|
||||
QTimer.singleShot(500, self.change_soundfont)
|
||||
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self.update_progress)
|
||||
self.timer.start(100)
|
||||
|
||||
def set_volume(self, value):
|
||||
volume = value / 100.0
|
||||
for channel in range(16):
|
||||
fs.cc(channel, 7, int(volume * 127))
|
||||
self.player.current_volume = value
|
||||
|
||||
def update_progress(self):
|
||||
if self.playing and self.player.midi_length > 0:
|
||||
elapsed_time = time.time() - self.player.midi_start_time
|
||||
if elapsed_time >= self.player.midi_length:
|
||||
self.handle_song_end()
|
||||
else:
|
||||
progress = min(
|
||||
(elapsed_time / self.player.midi_length) * 100, 100
|
||||
)
|
||||
self.progress_bar.setValue(int(progress))
|
||||
|
||||
if self.player.playing:
|
||||
progress = min(
|
||||
(elapsed_time / self.player.midi_length) * 100, 100
|
||||
)
|
||||
self.progress_bar.setValue(int(progress))
|
||||
if progress >= 100:
|
||||
self.player.next_song()
|
||||
|
||||
def change_soundfont(self):
|
||||
new_sf_path, _ = QFileDialog.getOpenFileName(
|
||||
None, "Select SoundFont", filter="SoundFont (*.sf2)"
|
||||
)
|
||||
if new_sf_path: # If a file was chosen
|
||||
global sfid, current_sf
|
||||
try:
|
||||
# Only try to unload if sfid is not None
|
||||
if sfid is not None:
|
||||
fs.sfunload(sfid)
|
||||
|
||||
# Load the new soundfont
|
||||
sfid = fs.sfload(new_sf_path)
|
||||
fs.program_select(0, sfid, 0, 0)
|
||||
current_sf = new_sf_path
|
||||
self.status_label.setText(f"SoundFont: {current_sf}")
|
||||
except Exception as e:
|
||||
self.status_label.setText(f"Error loading SoundFont: {e}")
|
||||
|
||||
def init_ui(self):
|
||||
# Widgets
|
||||
self.progress_bar = QProgressBar()
|
||||
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.volume_slider.setMinimum(0)
|
||||
self.volume_slider.setMaximum(100)
|
||||
self.volume_slider.setValue(100)
|
||||
self.volume_slider.valueChanged.connect(self.set_volume)
|
||||
self.current_midi_label = QLabel("Current MIDI: None")
|
||||
self.playlist_widget = QListWidget()
|
||||
self.playlist_widget.itemDoubleClicked.connect(self.play_selected_song)
|
||||
|
||||
# Buttons
|
||||
play_button = QPushButton("Play")
|
||||
play_button.clicked.connect(self.handle_play)
|
||||
pause_button = QPushButton("Pause")
|
||||
pause_button.clicked.connect(self.handle_pause)
|
||||
stop_button = QPushButton("Stop")
|
||||
stop_button.clicked.connect(self.handle_stop)
|
||||
next_button = QPushButton("Next")
|
||||
next_button.clicked.connect(self.handle_next)
|
||||
back_button = QPushButton("Back")
|
||||
back_button.clicked.connect(self.handle_previous)
|
||||
add_button = QPushButton("Add to Playlist")
|
||||
add_button.clicked.connect(self.load_midi_file)
|
||||
add_folder_button = QPushButton("Add Folder to Playlist")
|
||||
add_folder_button.clicked.connect(self.load_folder)
|
||||
clear_button = QPushButton("Clear Playlist")
|
||||
clear_button.clicked.connect(self.clear_playlist)
|
||||
change_sf_button = QPushButton("Change SoundFont")
|
||||
change_sf_button.clicked.connect(self.change_soundfont)
|
||||
|
||||
# Window Layout
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.current_midi_label)
|
||||
layout.addWidget(self.playlist_widget)
|
||||
|
||||
# Progress and volume layout
|
||||
progress_volume_layout = QHBoxLayout()
|
||||
progress_volume_layout.addWidget(self.progress_bar)
|
||||
progress_volume_layout.addWidget(self.volume_slider)
|
||||
layout.addLayout(progress_volume_layout)
|
||||
|
||||
# Playback controls
|
||||
layout.addWidget(play_button)
|
||||
layout.addWidget(pause_button)
|
||||
layout.addWidget(stop_button)
|
||||
layout.addWidget(next_button)
|
||||
layout.addWidget(back_button)
|
||||
|
||||
# Playlist management
|
||||
layout.addWidget(add_button)
|
||||
layout.addWidget(add_folder_button)
|
||||
layout.addWidget(clear_button)
|
||||
layout.addWidget(change_sf_button)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# Event handlers
|
||||
def handle_play(self):
|
||||
if self.playlist_widget.currentItem():
|
||||
self.play_selected_song(self.playlist_widget.currentItem())
|
||||
|
||||
def handle_pause(self):
|
||||
if self.playing:
|
||||
self.player.pause()
|
||||
self.playing = False
|
||||
else:
|
||||
if self.current_index < len(self.player.playlist):
|
||||
self.player.play_midi(self.player.playlist[self.current_index])
|
||||
self.playing = True
|
||||
|
||||
def handle_stop(self):
|
||||
self.player.stop_midi()
|
||||
self.playing = False
|
||||
self.progress_bar.setValue(0)
|
||||
|
||||
def handle_next(self):
|
||||
if not self.player.playlist:
|
||||
return
|
||||
|
||||
self.playing = False
|
||||
self.progress_bar.setValue(0)
|
||||
self.player.next_song()
|
||||
|
||||
next_index = (self.current_index + 1) % len(self.player.playlist)
|
||||
self.current_index = next_index
|
||||
self.playlist_widget.setCurrentRow(next_index)
|
||||
|
||||
# Update the current MIDI label
|
||||
filename = os.path.basename(self.player.playlist[next_index])
|
||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||
self.playing = True
|
||||
|
||||
def handle_previous(self):
|
||||
if not self.player.playlist:
|
||||
return
|
||||
|
||||
self.playing = False
|
||||
self.progress_bar.setValue(0)
|
||||
self.player.previous_song()
|
||||
|
||||
prev_index = (self.current_index - 1) % len(self.player.playlist)
|
||||
self.current_index = prev_index
|
||||
self.playlist_widget.setCurrentRow(prev_index)
|
||||
|
||||
# Update the current MIDI label
|
||||
filename = os.path.basename(self.player.playlist[prev_index])
|
||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||
self.playing = True
|
||||
|
||||
def handle_song_end(self):
|
||||
self.handle_next()
|
||||
|
||||
def play_selected_song(self, item):
|
||||
if not item or not self.player.playlist:
|
||||
return
|
||||
|
||||
self.playing = False
|
||||
index = self.playlist_widget.row(item)
|
||||
|
||||
if 0 <= index < len(self.player.playlist):
|
||||
self.current_index = index
|
||||
filepath = self.player.playlist[self.current_index]
|
||||
self.player.load_midi(filepath)
|
||||
self.player.play_midi(filepath)
|
||||
filename = os.path.basename(filepath)
|
||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||
self.playing = True
|
||||
self.progress_bar.setValue(0)
|
||||
self.player.midi_start_time = time.time()
|
||||
|
||||
def load_midi_file(self):
|
||||
filepath, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select MIDI File", filter="MIDI files (*.mid *.midi)"
|
||||
)
|
||||
if filepath:
|
||||
filename = os.path.basename(filepath)
|
||||
self.player.add_to_playlist(filepath)
|
||||
self.playlist_widget.addItem(filename)
|
||||
|
||||
# If this is the first file added, load it
|
||||
if len(self.player.playlist) == 1:
|
||||
self.player.load_midi(filepath)
|
||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||
|
||||
def load_folder(self):
|
||||
folder_path = QFileDialog.getExistingDirectory(self, "Select Folder")
|
||||
if folder_path:
|
||||
filenames = [
|
||||
f
|
||||
for f in os.listdir(folder_path)
|
||||
if f.lower().endswith(".mid") or f.lower().endswith(".midi")
|
||||
]
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(folder_path, filename)
|
||||
self.player.add_to_playlist(filepath)
|
||||
self.playlist_widget.addItem(filename)
|
||||
|
||||
# If this is the first file added, load it
|
||||
if len(self.player.playlist) == 1:
|
||||
self.player.load_midi(filepath)
|
||||
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||
|
||||
def clear_playlist(self):
|
||||
self.player.playlist = []
|
||||
self.playlist_widget.clear()
|
||||
self.current_midi_label.setText("Current MIDI: None")
|
||||
self.current_index = -1
|
||||
651
ScanOrg101.py
Normal file
651
ScanOrg101.py
Normal file
@ -0,0 +1,651 @@
|
||||
"""
|
||||
ScanOrg101.py - Enhanced file scanning and organization module
|
||||
"""
|
||||
|
||||
# flake8: noqa: E501
|
||||
import os
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
import py7zr
|
||||
import rarfile # typed: ignore
|
||||
import mutagen
|
||||
from PyQt6.QtCore import Qt, QThread, QSortFilterProxyModel, pyqtSignal
|
||||
|
||||
|
||||
# Directory Filter Proxy Model
|
||||
class DirectoryFilterProxyModel(QSortFilterProxyModel):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
self.setFilterKeyColumn(0)
|
||||
|
||||
def filterAcceptsRow(self, source_row, source_parent):
|
||||
source_model = self.sourceModel()
|
||||
if source_model is None:
|
||||
return False
|
||||
|
||||
index = source_model.index(source_row, 0, source_parent)
|
||||
|
||||
if hasattr(source_model, "isDir"):
|
||||
return source_model.isDir(index) # type: ignore
|
||||
return False
|
||||
|
||||
|
||||
# File Filter Proxy Model
|
||||
class FileFilterProxyModel(QSortFilterProxyModel):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
self.setFilterKeyColumn(0)
|
||||
self.allowed_extensions = [
|
||||
".zip",
|
||||
".mp3",
|
||||
".wav",
|
||||
".flac",
|
||||
".mid",
|
||||
".midi",
|
||||
".aiff",
|
||||
".aif",
|
||||
".aifc",
|
||||
".au",
|
||||
".snd",
|
||||
".wv",
|
||||
".wma",
|
||||
".m4a",
|
||||
".7z",
|
||||
".rar",
|
||||
]
|
||||
|
||||
def filterAcceptsRow(self, source_row, source_parent):
|
||||
source_model = self.sourceModel()
|
||||
if source_model is None:
|
||||
return False
|
||||
|
||||
index = source_model.index(source_row, 0, source_parent)
|
||||
|
||||
if hasattr(source_model, "isDir") and source_model.isDir(index): # type: ignore
|
||||
return True
|
||||
|
||||
if hasattr(source_model, "fileName"):
|
||||
return source_model.fileName(index).endswith( # type: ignore
|
||||
tuple(self.allowed_extensions)
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Enhanced File Scanner with optimizations
|
||||
class FileScanner(QThread):
|
||||
items_found = pyqtSignal(list) # Now emits batches of items
|
||||
scan_complete = pyqtSignal()
|
||||
progress_update = pyqtSignal(int)
|
||||
directory_scanned = pyqtSignal(str) # New signal for lazy loading
|
||||
|
||||
def __init__(self, path, batch_size=500, max_workers=4):
|
||||
"""
|
||||
Initialize the file scanner with performance optimizations
|
||||
|
||||
Args:
|
||||
path: Starting path to scan
|
||||
batch_size: Number of items to collect before emitting a batch
|
||||
max_workers: Maximum number of parallel scanning threads
|
||||
"""
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.stop_requested = False
|
||||
self.cache = {}
|
||||
self.scanned_directories = (
|
||||
set()
|
||||
) # Track which directories have been scanned
|
||||
self.batch_size = batch_size
|
||||
self.max_workers = max_workers
|
||||
self.allowed_extensions = {
|
||||
".mid",
|
||||
".midi",
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".flac",
|
||||
".aac",
|
||||
".m4a",
|
||||
".wma",
|
||||
".flp",
|
||||
".als",
|
||||
".logic",
|
||||
".logicx",
|
||||
".ptx",
|
||||
".pts",
|
||||
".cpr",
|
||||
".rpp",
|
||||
".reason",
|
||||
".sng",
|
||||
".ardour",
|
||||
".bwproject",
|
||||
".zip",
|
||||
".7z",
|
||||
".rar",
|
||||
}
|
||||
|
||||
def run(self):
|
||||
"""Main thread run method - only scans the root path initially"""
|
||||
# Check cache first
|
||||
if self.path in self.cache:
|
||||
self.items_found.emit(self.cache[self.path])
|
||||
self.scan_complete.emit()
|
||||
return
|
||||
|
||||
# Only scan the top level directory initially (lazy loading)
|
||||
self.scan_single_directory(self.path)
|
||||
self.scan_complete.emit()
|
||||
|
||||
def scan_directory_recursive(self, path):
|
||||
"""
|
||||
Recursively scan a directory - used when explicitly requesting
|
||||
a full scan of all subdirectories
|
||||
"""
|
||||
if path in self.cache:
|
||||
return self.cache[path]
|
||||
|
||||
items = []
|
||||
batch = []
|
||||
dirs_to_scan = deque([path]) # type: ignore
|
||||
|
||||
# For progress estimation
|
||||
start_time = time.time() # type: ignore
|
||||
progress_update_interval = 0.2 # seconds
|
||||
last_update_time = start_time
|
||||
entries_processed = 0
|
||||
|
||||
# Estimate total number of items
|
||||
try:
|
||||
sample_count = len(list(os.scandir(path)))
|
||||
estimated_total = sample_count * 10 # Simple heuristic
|
||||
except (PermissionError, OSError):
|
||||
estimated_total = 1000 # Fallback estimate
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=self.max_workers
|
||||
) as executor:
|
||||
futures = {}
|
||||
|
||||
while dirs_to_scan and not self.stop_requested:
|
||||
# Process directories in parallel
|
||||
while dirs_to_scan and len(futures) < self.max_workers:
|
||||
dir_path = dirs_to_scan.popleft()
|
||||
if dir_path not in self.scanned_directories:
|
||||
futures[
|
||||
executor.submit(
|
||||
self.scan_single_directory_helper, dir_path
|
||||
)
|
||||
] = dir_path
|
||||
|
||||
# Process completed directories
|
||||
for future in list(
|
||||
concurrent.futures.as_completed(futures.keys())
|
||||
):
|
||||
dir_path = futures.pop(future)
|
||||
|
||||
try:
|
||||
dir_items, subdirs = future.result()
|
||||
entries_processed += len(dir_items)
|
||||
|
||||
# Add results to our list
|
||||
items.extend(dir_items)
|
||||
batch.extend(dir_items)
|
||||
|
||||
# Add subdirectories to our queue
|
||||
dirs_to_scan.extend(subdirs)
|
||||
|
||||
# Mark directory as scanned
|
||||
self.scanned_directories.add(dir_path)
|
||||
self.directory_scanned.emit(dir_path)
|
||||
|
||||
# Emit batch if it's full
|
||||
if len(batch) >= self.batch_size:
|
||||
self.items_found.emit(batch)
|
||||
batch = []
|
||||
|
||||
# Update progress periodically
|
||||
current_time = time.time() # type: ignore
|
||||
if (
|
||||
current_time - last_update_time
|
||||
> progress_update_interval
|
||||
):
|
||||
# Simple progress estimation
|
||||
progress = min(
|
||||
99,
|
||||
int(entries_processed / estimated_total * 100),
|
||||
)
|
||||
self.progress_update.emit(progress)
|
||||
last_update_time = current_time
|
||||
except Exception as e:
|
||||
print(f"Error scanning directory {dir_path}: {e}")
|
||||
|
||||
# Emit any remaining items in the final batch
|
||||
if batch and not self.stop_requested:
|
||||
self.items_found.emit(batch)
|
||||
|
||||
# Store in cache
|
||||
self.cache[path] = items
|
||||
self.progress_update.emit(100) # Final update
|
||||
return items
|
||||
|
||||
def scan_single_directory(self, path):
|
||||
"""
|
||||
Scan a single directory without recursion - supports lazy loading
|
||||
"""
|
||||
if self.stop_requested:
|
||||
return []
|
||||
|
||||
if path in self.cache:
|
||||
items = self.cache[path]
|
||||
self.items_found.emit(items)
|
||||
return items
|
||||
|
||||
try:
|
||||
items = []
|
||||
with os.scandir(path) as entries:
|
||||
for entry in entries:
|
||||
if self.stop_requested:
|
||||
break
|
||||
|
||||
if entry.is_dir():
|
||||
# For directories, just add them to the list
|
||||
# but don't scan them yet (lazy loading)
|
||||
items.append((entry.path, True))
|
||||
elif entry.is_file() and entry.name.lower().endswith(
|
||||
tuple(self.allowed_extensions)
|
||||
):
|
||||
items.append((entry.path, False))
|
||||
|
||||
# Store in cache and emit
|
||||
self.cache[path] = items
|
||||
self.items_found.emit(items)
|
||||
self.scanned_directories.add(path)
|
||||
self.directory_scanned.emit(path)
|
||||
self.progress_update.emit(100) # Show complete for this directory
|
||||
return items
|
||||
except PermissionError:
|
||||
print(f"Permission denied: {path}")
|
||||
return []
|
||||
except OSError as e:
|
||||
print(f"Error accessing {path}: {e}")
|
||||
return []
|
||||
|
||||
def scan_single_directory_helper(self, path):
|
||||
"""Helper method for parallel directory scanning"""
|
||||
items = []
|
||||
subdirs = []
|
||||
|
||||
try:
|
||||
with os.scandir(path) as entries:
|
||||
for entry in entries:
|
||||
if self.stop_requested:
|
||||
break
|
||||
|
||||
if entry.is_dir():
|
||||
items.append((entry.path, True))
|
||||
subdirs.append(entry.path)
|
||||
elif entry.is_file() and entry.name.lower().endswith(
|
||||
tuple(self.allowed_extensions)
|
||||
):
|
||||
items.append((entry.path, False))
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"Error accessing {path}: {e}")
|
||||
|
||||
return items, subdirs
|
||||
|
||||
def request_directory_scan(self, path):
|
||||
"""Request a scan of a specific directory (for lazy loading)"""
|
||||
if path in self.scanned_directories:
|
||||
return
|
||||
|
||||
items = self.scan_single_directory(path)
|
||||
return items
|
||||
|
||||
def request_full_scan(self):
|
||||
"""Request a full recursive scan of all subdirectories"""
|
||||
items = self.scan_directory_recursive(self.path)
|
||||
self.scan_complete.emit()
|
||||
return items
|
||||
|
||||
def stop(self):
|
||||
self.stop_requested = True
|
||||
|
||||
|
||||
# Metadata Extractor
|
||||
class MetadataExtractor(QThread):
|
||||
metadata_extracted = pyqtSignal(dict)
|
||||
extraction_complete = pyqtSignal()
|
||||
progress_update = pyqtSignal(int)
|
||||
|
||||
def __init__(self, file_list):
|
||||
super().__init__()
|
||||
self.file_list = file_list
|
||||
self.stop_requested = False
|
||||
self.metadata_cache = {}
|
||||
|
||||
def run(self):
|
||||
total_files = len(self.file_list)
|
||||
processed_files = 0
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
futures = []
|
||||
for file_path in self.file_list:
|
||||
if self.stop_requested:
|
||||
break
|
||||
if file_path in self.metadata_cache:
|
||||
self.metadata_extracted.emit(
|
||||
self.metadata_cache[file_path]
|
||||
)
|
||||
processed_files += 1
|
||||
self.progress_update.emit(
|
||||
int(processed_files / total_files * 100)
|
||||
)
|
||||
else:
|
||||
futures.append(
|
||||
executor.submit(self.extract_metadata, file_path)
|
||||
)
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
if self.stop_requested:
|
||||
break
|
||||
try:
|
||||
metadata = future.result()
|
||||
if metadata:
|
||||
self.metadata_extracted.emit(metadata)
|
||||
except Exception as e:
|
||||
print(f"Error extracting metadata: {e}")
|
||||
|
||||
processed_files += 1
|
||||
self.progress_update.emit(
|
||||
int(processed_files / total_files * 100)
|
||||
)
|
||||
|
||||
self.extraction_complete.emit()
|
||||
|
||||
def extract_metadata(self, file_path):
|
||||
try:
|
||||
if not os.path.isfile(file_path):
|
||||
return None
|
||||
|
||||
# Skip non-audio files
|
||||
if not file_path.lower().endswith(
|
||||
(".mp3", ".wav", ".flac", ".m4a", ".wma", ".mid", ".midi")
|
||||
):
|
||||
return None
|
||||
|
||||
audio = mutagen.File(file_path) # type: ignore
|
||||
if not audio:
|
||||
return None
|
||||
|
||||
metadata = {
|
||||
"file_path": file_path,
|
||||
"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 result
|
||||
self.metadata_cache[file_path] = metadata
|
||||
return metadata
|
||||
except Exception as e:
|
||||
print(f"Error processing {file_path}: {e}")
|
||||
return None
|
||||
|
||||
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 stop(self):
|
||||
self.stop_requested = True
|
||||
|
||||
|
||||
# Archive Extractor not fully tested or implemented
|
||||
class ArchiveExtractor(QThread):
|
||||
extraction_progress = pyqtSignal(int)
|
||||
extraction_complete = pyqtSignal(list) # Emits list of extracted files
|
||||
extraction_error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, archive_path, extraction_dir):
|
||||
super().__init__()
|
||||
self.archive_path = archive_path
|
||||
self.extraction_dir = extraction_dir
|
||||
self.stop_requested = False
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
extracted_files = []
|
||||
|
||||
if self.archive_path.lower().endswith(".zip"):
|
||||
extracted_files = self._extract_zip()
|
||||
elif self.archive_path.lower().endswith(".7z"):
|
||||
extracted_files = self._extract_7z()
|
||||
elif self.archive_path.lower().endswith(".rar"):
|
||||
extracted_files = self._extract_rar()
|
||||
else:
|
||||
self.extraction_error.emit(
|
||||
f"Unsupported archive format: {self.archive_path}"
|
||||
)
|
||||
return
|
||||
|
||||
self.extraction_complete.emit(extracted_files)
|
||||
except Exception as e:
|
||||
self.extraction_error.emit(f"Extraction error: {str(e)}")
|
||||
|
||||
def _extract_zip(self):
|
||||
extracted_files = []
|
||||
try:
|
||||
with zipfile.ZipFile(self.archive_path, "r") as zip_ref:
|
||||
file_list = zip_ref.namelist()
|
||||
total_files = len(file_list)
|
||||
|
||||
for i, file in enumerate(file_list):
|
||||
if self.stop_requested:
|
||||
break
|
||||
zip_ref.extract(file, self.extraction_dir)
|
||||
extracted_files.append(
|
||||
os.path.join(self.extraction_dir, file)
|
||||
)
|
||||
self.extraction_progress.emit(
|
||||
int((i + 1) / total_files * 100)
|
||||
)
|
||||
except Exception as e:
|
||||
self.extraction_error.emit(f"ZIP extraction error: {str(e)}")
|
||||
|
||||
return extracted_files
|
||||
|
||||
def _extract_7z(self):
|
||||
extracted_files = []
|
||||
try:
|
||||
with py7zr.SevenZipFile(self.archive_path, mode="r") as z:
|
||||
file_list = z.getnames()
|
||||
total_files = len(file_list)
|
||||
|
||||
for i, file in enumerate(file_list):
|
||||
if self.stop_requested:
|
||||
break
|
||||
z.extract(self.extraction_dir, [file])
|
||||
extracted_files.append(
|
||||
os.path.join(self.extraction_dir, file)
|
||||
)
|
||||
self.extraction_progress.emit(
|
||||
int((i + 1) / total_files * 100)
|
||||
)
|
||||
except Exception as e:
|
||||
self.extraction_error.emit(f"7Z extraction error: {str(e)}")
|
||||
|
||||
return extracted_files
|
||||
|
||||
def _extract_rar(self):
|
||||
extracted_files = []
|
||||
try:
|
||||
with rarfile.RarFile(self.archive_path) as rf:
|
||||
file_list = rf.namelist()
|
||||
total_files = len(file_list)
|
||||
|
||||
for i, file in enumerate(file_list):
|
||||
if self.stop_requested:
|
||||
break
|
||||
rf.extract(file, self.extraction_dir)
|
||||
extracted_files.append(
|
||||
os.path.join(self.extraction_dir, file)
|
||||
)
|
||||
self.extraction_progress.emit(
|
||||
int((i + 1) / total_files * 100)
|
||||
)
|
||||
except Exception as e:
|
||||
self.extraction_error.emit(f"RAR extraction error: {str(e)}")
|
||||
|
||||
return extracted_files
|
||||
|
||||
def stop(self):
|
||||
self.stop_requested = True
|
||||
|
||||
|
||||
# Main Organizer class
|
||||
class Organizer:
|
||||
def __init__(self):
|
||||
self.file_list = []
|
||||
self.dir_list = []
|
||||
self.scanner = None
|
||||
self.metadata_extractor = None
|
||||
self.archive_extractor = None
|
||||
|
||||
# Metadata organization
|
||||
self.artists = set()
|
||||
self.albums = set()
|
||||
self.genres = set()
|
||||
self.years = set()
|
||||
|
||||
# Signals for UI updates
|
||||
self.on_scan_complete = None
|
||||
self.on_progress_update = None
|
||||
self.on_metadata_complete = None
|
||||
|
||||
def start_scan(self, path):
|
||||
"""Start scanning a directory for files"""
|
||||
self.file_list.clear()
|
||||
self.dir_list.clear()
|
||||
|
||||
self.scanner = FileScanner(path)
|
||||
self.scanner.items_found.connect(self.add_items)
|
||||
self.scanner.scan_complete.connect(self.scan_finished)
|
||||
|
||||
# Connect progress signal if handler exists
|
||||
if self.on_progress_update:
|
||||
self.scanner.progress_update.connect(self.on_progress_update)
|
||||
|
||||
self.scanner.start()
|
||||
|
||||
def add_items(self, items):
|
||||
"""Process items found during scanning"""
|
||||
for path, is_dir in items:
|
||||
if is_dir:
|
||||
self.dir_list.append(path)
|
||||
else:
|
||||
self.file_list.append(path)
|
||||
|
||||
def scan_finished(self):
|
||||
"""Handle scan completion"""
|
||||
print(
|
||||
f"Scan complete. Found {len(self.dir_list)} directories\
|
||||
and {len(self.file_list)} files."
|
||||
)
|
||||
if self.on_scan_complete:
|
||||
self.on_scan_complete()
|
||||
|
||||
def stop_scan(self):
|
||||
"""Stop the current scan operation"""
|
||||
if self.scanner:
|
||||
self.scanner.stop()
|
||||
self.scanner.wait()
|
||||
|
||||
def extract_metadata(self):
|
||||
"""Extract metadata from audio files"""
|
||||
if not self.file_list:
|
||||
print("No files to extract metadata from")
|
||||
return
|
||||
|
||||
self.metadata_extractor = MetadataExtractor(self.file_list)
|
||||
self.metadata_extractor.metadata_extracted.connect(
|
||||
self.process_metadata
|
||||
)
|
||||
self.metadata_extractor.extraction_complete.connect(
|
||||
self.metadata_extraction_complete
|
||||
)
|
||||
|
||||
# Connect progress signal if handler exists
|
||||
if self.on_progress_update:
|
||||
self.metadata_extractor.progress_update.connect(
|
||||
self.on_progress_update
|
||||
)
|
||||
|
||||
self.metadata_extractor.start()
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
"""Process extracted metadata"""
|
||||
if "artist" in metadata and metadata["artist"]:
|
||||
self.artists.add(metadata["artist"])
|
||||
if "album" in metadata and metadata["album"]:
|
||||
self.albums.add(metadata["album"])
|
||||
if "genre" in metadata and metadata["genre"]:
|
||||
self.genres.add(metadata["genre"])
|
||||
if "year" in metadata and metadata["year"]:
|
||||
self.years.add(metadata["year"])
|
||||
|
||||
def metadata_extraction_complete(self):
|
||||
"""Handle metadata extraction completion"""
|
||||
print(
|
||||
f"Metadata extraction complete. Artists: {len(self.artists)},\
|
||||
Albums: {len(self.albums)}, Genres: {len(self.genres)},\
|
||||
Years: {len(self.years)}"
|
||||
)
|
||||
if self.on_metadata_complete:
|
||||
self.on_metadata_complete()
|
||||
|
||||
def extract_archives(self):
|
||||
"""Extract archives"""
|
||||
if not self.file_list:
|
||||
print("No files to extract archives from")
|
||||
return
|
||||
|
||||
self.archive_extractor = ArchiveExtractor(
|
||||
self.file_list, extraction_dir=None
|
||||
)
|
||||
self.archive_extractor.extraction_complete.connect(
|
||||
self.archive_extraction_complete
|
||||
)
|
||||
|
||||
# Connect progress signal if handler exists
|
||||
if self.on_progress_update:
|
||||
self.archive_extractor.extraction_progress.connect(
|
||||
self.on_progress_update
|
||||
)
|
||||
|
||||
self.archive_extractor.start()
|
||||
|
||||
def archive_extraction_complete(self):
|
||||
"""Handle archive extraction completion"""
|
||||
print("Archive extraction complete.")
|
||||
|
||||
if self.on_progress_update:
|
||||
self.on_progress_update(100)
|
||||
|
||||
def stop_extraction(self):
|
||||
"""Stop the current extraction operation"""
|
||||
if self.archive_extractor:
|
||||
self.archive_extractor.stop()
|
||||
self.archive_extractor.wait()
|
||||
127
readme.md
Normal file
127
readme.md
Normal file
@ -0,0 +1,127 @@
|
||||
# FBrowser - Enhanced File Browser with Audio Tools
|
||||
|
||||
A powerful file browser application with special focus on audio file handling, MIDI playback, and archive management.
|
||||
|
||||
## Features
|
||||
|
||||
- **Advanced File Browser**: Browse directories with tree view and file list
|
||||
- **Audio Playback**: Built-in player for various audio formats
|
||||
- **MIDI Player**: Full-featured MIDI player with playlist management
|
||||
- **Metadata Extraction**: View and explore audio file metadata
|
||||
- **Archive Management**: Extract ZIP, RAR, and 7z archives
|
||||
- **Timer Utility**: Built-in countdown timer with audio alerts
|
||||
- **Hover Tooltips**: Quick metadata preview by hovering over audio files
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.6+
|
||||
- PyQt6
|
||||
- FluidSynth (for MIDI playback)
|
||||
- Mido (MIDI handling library)
|
||||
- Mutagen (audio metadata extraction)
|
||||
- py7zr and rarfile (archive handling)
|
||||
- SoundFont (.sf2) files for MIDI playback
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/yourusername/fbroswer-master.git
|
||||
cd fbroswer-master
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
```bash
|
||||
pip install pyqt6 fluidsynth-midi mido mutagen py7zr rarfile numpy sounddevice
|
||||
```
|
||||
|
||||
### 3. Install FluidSynth
|
||||
|
||||
- **Windows**: Download from [FluidSynth website](https://www.fluidsynth.org/)
|
||||
- **macOS**: `brew install fluid-synth`
|
||||
- **Linux**: `sudo apt-get install fluidsynth`
|
||||
|
||||
### 4. Download a SoundFont
|
||||
|
||||
- Download a .sf2 file (like FluidR3_GM.sf2)
|
||||
- Place it in a "SoundFonts" directory in the application folder
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the Application
|
||||
```bash
|
||||
python Fbrowser.py
|
||||
```
|
||||
|
||||
### Basic Navigation
|
||||
- Use the tree view on the left to navigate directories
|
||||
- Double-click files in the right panel to open/play them
|
||||
- Use address bar for direct navigation
|
||||
- Back/Forward/Up buttons for quick navigation
|
||||
|
||||
### Audio and MIDI Playback
|
||||
- Double-click audio files to play them
|
||||
- Use media controls at the bottom for playback
|
||||
- Click "Open MidPlay" for advanced MIDI playback
|
||||
|
||||
### Advanced Features
|
||||
- Right-click on files/folders for context menu options
|
||||
- Extract archives directly from the browser
|
||||
- Scan directories to find audio files
|
||||
- Extract and view metadata
|
||||
|
||||
## Components Overview
|
||||
|
||||
### Fbrowser.py
|
||||
The main application file implementing the file browser interface with audio playback capabilities.
|
||||
|
||||
### MidPlay.py
|
||||
A specialized MIDI player with:
|
||||
- Playlist management
|
||||
- SoundFont selection
|
||||
- Volume control
|
||||
- Playback controls (play, pause, next, previous)
|
||||
|
||||
### ScanOrg101.py
|
||||
Handles file scanning, metadata extraction, and archive operations:
|
||||
- Recursive directory scanning
|
||||
- Audio metadata extraction using Mutagen
|
||||
- Archive extraction (ZIP, RAR, 7z)
|
||||
|
||||
### timer_m.py
|
||||
A countdown timer with:
|
||||
- Customizable hours, minutes, seconds
|
||||
- Audio alerts on completion
|
||||
- Start/stop/reset functionality
|
||||
|
||||
## Customization
|
||||
|
||||
### Changing Default SoundFont
|
||||
The application will attempt to find a suitable SoundFont file automatically. If none is found, you'll be prompted to select one. You can change it anytime via the MidPlay interface.
|
||||
|
||||
### File Type Filtering
|
||||
Edit the `allowed_extensions` list in `FileFilterProxyModel` class in ScanOrg101.py to change which file types are displayed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Sound in MIDI Playback
|
||||
- Ensure FluidSynth is properly installed
|
||||
- Verify a valid SoundFont (.sf2) file is loaded
|
||||
- Check system audio settings
|
||||
|
||||
### Archive Extraction Issues
|
||||
- Ensure you have proper permissions in the target directory
|
||||
- For RAR files, make sure UnRAR is installed on your system
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## Credits
|
||||
- FluidSynth for MIDI synthesis
|
||||
- PyQt6 for the user interface
|
||||
- Mutagen for audio metadata handling
|
||||
- Stanton for Project fbrowser, Timer_m, and MidPlay the fbrowser is the foundation of this project.
|
||||
---
|
||||
|
||||
*Note: This project is under development. Feedback and contributions are welcome!*
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
PyQt6
|
||||
matplotlib
|
||||
rarfile
|
||||
py7zr
|
||||
mido
|
||||
numpy
|
||||
pyFluidSynth
|
||||
mutagen
|
||||
sounddevice
|
||||
204
timer_m.py
Normal file
204
timer_m.py
Normal file
@ -0,0 +1,204 @@
|
||||
# flake8: noqa: E501
|
||||
import time
|
||||
import threading
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QPushButton,
|
||||
QLabel,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
)
|
||||
from PyQt6.QtCore import QTimer
|
||||
import numpy as np
|
||||
import sounddevice as sd # type: ignore
|
||||
|
||||
|
||||
class Timer:
|
||||
def __init__(self):
|
||||
self.remaining = 0
|
||||
self.running = False
|
||||
self.timer_thread = None
|
||||
|
||||
def set(self, hours, minutes, seconds):
|
||||
self.remaining = hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
def start(self):
|
||||
if self.remaining > 0:
|
||||
self.running = True
|
||||
self.timer_thread = threading.Thread(target=self._run_timer)
|
||||
self.timer_thread.start()
|
||||
|
||||
def _run_timer(self):
|
||||
while self.running and self.remaining > 0:
|
||||
time.sleep(1)
|
||||
self.remaining -= 1
|
||||
if self.running:
|
||||
self._alarm()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.timer_thread:
|
||||
self.timer_thread.join()
|
||||
|
||||
def get_remaining_time(self):
|
||||
return self.remaining
|
||||
|
||||
def _alarm(self):
|
||||
print("Time's up!")
|
||||
try:
|
||||
self._play_tone()
|
||||
# sleep = 1.2
|
||||
self._play_tone2()
|
||||
# sleep = 1
|
||||
self._play_tone3()
|
||||
# sleep = 1
|
||||
self._play_tone4()
|
||||
# sleep = 1
|
||||
self._play_tone5()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating alarm tone: {e}")
|
||||
|
||||
def _play_tone(self, frequency=587, duration=0.2, repeat=4):
|
||||
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||
sample_rate = 44100
|
||||
t = np.linspace(
|
||||
0, duration, int(sample_rate * duration), endpoint=False
|
||||
)
|
||||
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||
for _ in range(repeat):
|
||||
sd.play(tone, samplerate=sample_rate)
|
||||
sd.wait()
|
||||
|
||||
def _play_tone2(self, frequency=698, duration=0.2, repeat=3):
|
||||
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||
sample_rate = 44100
|
||||
t = np.linspace(
|
||||
0, duration, int(sample_rate * duration), endpoint=False
|
||||
)
|
||||
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||
for _ in range(repeat):
|
||||
sd.play(tone, samplerate=sample_rate)
|
||||
sd.wait()
|
||||
|
||||
def _play_tone3(self, frequency=659, duration=0.2, repeat=3):
|
||||
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||
sample_rate = 44100
|
||||
t = np.linspace(
|
||||
0, duration, int(sample_rate * duration), endpoint=False
|
||||
)
|
||||
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||
for _ in range(repeat):
|
||||
sd.play(tone, samplerate=sample_rate)
|
||||
sd.wait()
|
||||
|
||||
def _play_tone4(self, frequency=554, duration=0.4, repeat=1):
|
||||
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||
sample_rate = 44100
|
||||
t = np.linspace(
|
||||
0, duration, int(sample_rate * duration), endpoint=False
|
||||
)
|
||||
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||
for _ in range(repeat):
|
||||
sd.play(tone, samplerate=sample_rate)
|
||||
sd.wait()
|
||||
|
||||
def _play_tone5(self, frequency=554, duration=0.8, repeat=1):
|
||||
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||
sample_rate = 44100
|
||||
t = np.linspace(
|
||||
0, duration, int(sample_rate * duration), endpoint=False
|
||||
)
|
||||
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||
for _ in range(repeat):
|
||||
sd.play(tone, samplerate=sample_rate)
|
||||
sd.wait()
|
||||
|
||||
|
||||
class Timer_Ui(QWidget):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Timer")
|
||||
self.setFixedWidth(400)
|
||||
self.setFixedHeight(200)
|
||||
self.timer = Timer()
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
self.timer_label = QLabel("Timer: Not Set")
|
||||
layout.addWidget(self.timer_label)
|
||||
|
||||
timer_input_layout = QHBoxLayout()
|
||||
self.hours_input = QLineEdit()
|
||||
self.minutes_input = QLineEdit()
|
||||
self.seconds_input = QLineEdit()
|
||||
timer_input_layout.addWidget(QLabel("Hours:"))
|
||||
timer_input_layout.addWidget(self.hours_input)
|
||||
timer_input_layout.addWidget(QLabel("Minutes:"))
|
||||
timer_input_layout.addWidget(self.minutes_input)
|
||||
timer_input_layout.addWidget(QLabel("Seconds:"))
|
||||
timer_input_layout.addWidget(self.seconds_input)
|
||||
layout.addLayout(timer_input_layout)
|
||||
|
||||
self.set_timer_button = QPushButton("Set Timer")
|
||||
self.set_timer_button.clicked.connect(self.set_timer)
|
||||
layout.addWidget(self.set_timer_button)
|
||||
|
||||
self.start_timer_button = QPushButton("Start Timer")
|
||||
self.start_timer_button.clicked.connect(self.start_timer)
|
||||
layout.addWidget(self.start_timer_button)
|
||||
|
||||
self.stop_timer_button = QPushButton("Stop Timer")
|
||||
self.stop_timer_button.clicked.connect(self.stop_timer)
|
||||
layout.addWidget(self.stop_timer_button)
|
||||
|
||||
self.update_timer = QTimer()
|
||||
self.update_timer.timeout.connect(self.update_timer_display)
|
||||
self.update_timer.start(1000)
|
||||
|
||||
def set_timer(self):
|
||||
try:
|
||||
hours = int(self.hours_input.text() or 0)
|
||||
minutes = int(self.minutes_input.text() or 0)
|
||||
seconds = int(self.seconds_input.text() or 0)
|
||||
self.timer.set(hours, minutes, seconds)
|
||||
self.timer_label.setText(
|
||||
f"Timer: Set to {hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
)
|
||||
except ValueError:
|
||||
self.timer_label.setText("Timer: Invalid input")
|
||||
|
||||
def start_timer(self):
|
||||
if self.timer.get_remaining_time() > 0:
|
||||
self.timer.start()
|
||||
else:
|
||||
self.timer_label.setText("Timer: Not set")
|
||||
|
||||
def stop_timer(self):
|
||||
self.timer.stop()
|
||||
self.timer_label.setText("Timer: Stopped")
|
||||
|
||||
def update_timer_display(self):
|
||||
if self.timer.running:
|
||||
remaining = self.timer.get_remaining_time()
|
||||
hours, remainder = divmod(remaining, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
self.timer_label.setText(
|
||||
f"Timer: {hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
)
|
||||
elif (
|
||||
self.timer.get_remaining_time() == 0
|
||||
and self.timer_label.text() != "Timer: Not Set"
|
||||
):
|
||||
self.timer_label.setText("Timer: Time's up!")
|
||||
|
||||
def showUI(self):
|
||||
self.setWindowTitle("Timer")
|
||||
self.setGeometry(100, 100, 300, 200)
|
||||
super().show()
|
||||
Loading…
x
Reference in New Issue
Block a user