Compare commits

...

No commits in common. "master" and "migration-tauri-desktop" have entirely different histories.

82 changed files with 21452 additions and 3344 deletions

141
.gitignore vendored
View File

@ -1,123 +1,22 @@
# Python ignores
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
node_modules/
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
*~
src-tauri/target/
target/
.DS_Store
# Specific file exclusion
maintest.py
# SoundFonts can be large - optional
*.sf2
# Generated MIDI files - optional
*.mid
*.midi
# Firefly Related Files
Firefly.exe
Firefly.XML
libfirefly.XML
FireflyClient.exe
Firefly.exe
Firefly.dll
redis_viewer.py
/templates/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Fbrowser.7z
baseline_zip.zip
python-src/Dockerfile
python-src/docker-compose.yml
python-src/docker-compose.debug.yml
migrations/0001_init.sql
__pycache__/
test.png
paq9a.cpp
src-paq-next/output.paqg++
output_v2.paq
output.paq
pypaqtest.paq
src-paq-next/

6739
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
Cargo.toml Normal file
View File

@ -0,0 +1,36 @@
[workspace]
members = [
"crates/fbrowser-core",
"crates/fbrowser-audio",
"crates/fbrowser-midi",
"crates/fbrowser-archive",
"crates/fbrowser-plugin-core",
"src-tauri"
]
resolver = "2"
[workspace.package]
edition = "2021"
license = "MIT"
version = "0.1.0"
[workspace.dependencies]
anyhow = "1.0.98"
chrono = { version = "0.4.41", features = ["serde"] }
flate2 = "1.1.1"
ignore = "0.4.23"
lofty = "0.22.4"
midir = "0.10.1"
midly = "0.5.3"
rayon = "1.10.0"
rodio = { version = "0.22.2", default-features = true }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.4", features = ["sqlite", "runtime-tokio-rustls", "chrono"] }
symphonia = { version = "0.5.4", features = ["aac", "aiff", "alac", "flac", "isomp4", "mp3", "ogg", "pcm", "vorbis", "wav"] }
tar = "0.4.44"
tauri = { version = "2.5.1", features = [] }
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread", "sync", "time"] }
uuid = { version = "1.17.0", features = ["serde", "v4"] }
walkdir = "2.5.0"
zip = { version = "2.4.1", default-features = false, features = ["deflate"] }

View File

@ -1,37 +0,0 @@
fbroswer Contributor License Agreement (CLA)
This Contributor License Agreement ("Agreement") is between you ("Contributor") and [Stan ] ("Project") and governs your contributions to the fbroswer Community Edition (the "Project").
1. Definitions
1.1. "Contribution" means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by Contributor to the Project for inclusion in, or documentation of, any of the products owned or managed by the Project (the "Work").
1.2. "Contributor" means the individual or legal entity who submits a Contribution to the Project.
1.3. "Project" means the open-source fbroswer Community Edition and its associated repositories, documentation, and websites.
2. Grant of Rights
2.1. Subject to the terms of this Agreement, Contributor hereby grants to Project and to recipients of software distributed by Project a perpetual, worldwide, non-exclusive, royalty-free, irrevocable, sublicensable, and transferable license to:
a) reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute the Contribution and such derivative works;
b) incorporate the Contribution into any form, medium, or technology now known or later developed.
3. Moral Rights
3.1. To the extent Contributor has moral rights in the Contribution, Contributor hereby irrevocably transfers and waives such moral rights to the fullest extent permitted by applicable law.
4. Warranties and Representations
4.1. Contributor represents that:
a) Contributor is entitled to grant the rights to the Contribution under this Agreement;
b) the Contribution is Contributor's original creation and does not violate any third-party rights;
c) if the Contribution includes material from third parties, Contributor has obtained any necessary permissions and clearly identified such material.
4.2. Contributor provides the Contribution "AS IS," without warranty of any kind, and Project disclaims all warranties, express or implied.
5. No Compensation
5.1. Contributor agrees that the rights granted hereunder are granted without expectation of monetary compensation.
6. Contribution Process
6.1. To make a Contribution, Contributor may submit a pull request, patch, or other submission through the Project's contribution process. By doing so, Contributor agrees that the Contribution is subject to this Agreement.
7. Termination
7.1. This Agreement and the licenses granted herein will terminate automatically if Contributor fails to comply with any term of this Agreement. Upon termination, Project may cease to distribute the Contribution but retains the rights granted prior to termination.
8. General 8.1. This Agreement is governed by the laws of [United States], without regard to conflict-of-law principles. 8.2. If any provision of this Agreement is held invalid or unenforceable, the remaining provisions will remain in full force and effect. 8.3. This Agreement constitutes the entire agreement between the parties regarding the Contribution and supersedes all prior or contemporaneous understandings.
By submitting a Contribution, you accept and agree to the terms of this Agreement.

View File

@ -1,907 +0,0 @@
"""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()

View File

@ -1,524 +0,0 @@
# 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

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) [Year] [Your Name or Organization]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,62 +0,0 @@
fbroswer Pro Edition License Agreement
======================================
This License Agreement (“Agreement”) is a binding contract between you (“Licensee”)
and [Your Name or Organization] (“Licensor”) for the licensed software product
known as “fbroswer Pro Edition” including all updates, addons, and accompanying
documentation (collectively, the “Software”).
1. GRANT OF LICENSE
Licensor hereby grants Licensee a nonexclusive, nontransferable, revocable
license to install and use one copy of the Software on up to [N] machines
owned or controlled by Licensee, solely for Licensees internal business or
personal use, subject to payment of the license fee described below.
2. LICENSE FEE & PAYMENT
a. Licensee agrees to pay Licensor a onetime license fee of USD $[Amount].
b. All payments are nonrefundable. Licensor may suspend license rights for
nonpayment or late payment beyond [30] days after invoice date.
3. RESTRICTIONS
Licensee shall not, and shall not permit others to:
- Reverseengineer, decompile, or disassemble the Software except as expressly
permitted by law.
- Rent, lease, sublicense, or distribute the Software to third parties.
- Remove or alter any proprietary notices or labels on the Software.
4. SUPPORT & MAINTENANCE
Licensor will provide free updates and bugfix releases for a period of [12]
months from the date of purchase. Extended maintenance contracts are available
separately.
5. OWNERSHIP
The Software is licensed, not sold. Licensor and its suppliers retain all
rights, title, and interest in and to the Software, including all intellectual
property rights.
6. WARRANTY DISCLAIMER
THE SOFTWARE IS PROVIDED “AS IS” WITHOUT WARRANTY OF ANY KIND. LICENSOR
DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS, IMPLIED, OR STATUTORY, INCLUDING
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
7. LIMITATION OF LIABILITY
IN NO EVENT SHALL LICENSOR BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL,
OR CONSEQUENTIAL DAMAGES ARISING OUT OF OR IN CONNECTION WITH THE SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
8. TERMINATION
This Agreement will terminate automatically if Licensee fails to comply with
any term herein. Upon termination, Licensee must cease all use of the Software
and destroy all copies in its possession.
9. GOVERNING LAW
This Agreement shall be governed by and construed in accordance with the laws
of [Your State/Country], without regard to its conflict of law principles.
10. ENTIRE AGREEMENT
This Agreement constitutes the entire agreement between the parties
concerning the Software and supersedes all prior or contemporaneous
understandings or agreements.
By installing, copying, or otherwise using the Software, Licensee agrees to be
bound by the terms of this Agreement.

28
README.md Normal file
View File

@ -0,0 +1,28 @@
# Fbrowser
Fbrowser is being migrated from a Python/PyQt desktop app to a Rust + Tauri + React desktop application.
## Repository Layout
- `src/`: React frontend for the new desktop app.
- `src-tauri/`: Tauri host application and IPC command layer.
- `crates/`: Shared Rust workspace crates for catalog, audio, MIDI, archives, and future plugin-facing APIs.
- `migrations/`: SQLite schema migrations for the new local catalog.
- `python-src/`: Legacy Python implementation kept as a feature reference during migration.
## Current Status
- The new Tauri/Rust/React application builds successfully.
- The legacy Python code has been moved out of the repository root to keep the migration boundary clear.
- The desktop shell, catalog model, scan pipeline, archive utilities, timer, waveform generation, and transport scaffolding are in place.
- Some areas are still scaffold-level rather than production-complete, especially MIDI playback internals and broader archive-format support.
## Verification
- Frontend production build: `npm run build`
- Rust workspace checks/tests: `cargo check --manifest-path src-tauri/Cargo.toml` and `cargo test --workspace`
## Notes
- The Python code under `python-src/` is no longer the primary application entrypoint.
- The new root-level app is the active migration target.

View File

@ -1,624 +0,0 @@
"""
ScanOrg101.py - Enhanced file scanning and organization module
"""
# flake8: noqa: E501
import os
import logging
import concurrent.futures
from collections import deque
import time
from PyQt6.QtCore import Qt, QThread, QSortFilterProxyModel, pyqtSignal
from dbman import FireflyDB
from metaextract import MetadataExtractor, mutagen
from archiver import ArchiveExtractor
# Get the logger
logger = logging.getLogger("fbroswer")
# Directory Filter Proxy Model
class DirectoryFilterProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.setFilterKeyColumn(0)
logger.debug("DirectoryFilterProxyModel initialized")
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",
]
logger.debug(
"FileFilterProxyModel initialized with allowed extensions"
)
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:
logger.debug(
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:
logger.debug(f"Permission denied: {path}")
return []
except OSError as e:
logger.debug(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:
logger.debug(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
# Main Organizer class
class Organizer:
def __init__(
self, use_db=False, db_host="localhost", db_port=6379, db_password=None
):
self.file_list = []
self.dir_list = []
self.scanner = None
self.metadata_extractor = None
self.archive_extractor = None
# Signals for UI updates
self.on_scan_complete = None
self.on_progress_update = None
self.on_metadata_complete = None
# Database integration - use FireflyDB from dbman.py
self.use_db = use_db
self.db_manager = FireflyDB()
if use_db:
self.db_manager.connect_to(use_db, db_host, db_port, db_password)
self.db = self.db_manager.db
else:
self.db = None
def close(self):
"""Close database connection when done"""
if hasattr(self, "db_manager"):
self.db_manager.close()
self.db = 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"""
logger.debug(
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 using MetadataExtractor from metaextract.py"""
if not self.file_list:
logger.debug("No files to extract metadata from")
return
# Verify database connection if enabled
if self.use_db and self.db:
if not self.db_manager.verify_database_connection():
logger.debug(
"Warning: Database verification failed, continuing without database"
)
self.use_db = False
self.db = None
# Use MetadataExtractor from metaextract.py
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
)
# Set the callback for metadata completion
self.metadata_extractor.on_metadata_complete = (
self.on_metadata_complete
)
self.metadata_extractor.start()
def process_metadata(self, metadata):
"""Process extracted metadata using FireflyDB from dbman.py"""
# Use the database manager to process metadata
if hasattr(self, "db_manager"):
self.db_manager.process_metadata(metadata)
# Also update local sets for UI display
if "artist" in metadata and metadata["artist"]:
if not hasattr(self, "artists"):
self.artists = set()
self.artists.add(metadata["artist"])
if "album" in metadata and metadata["album"]:
if not hasattr(self, "albums"):
self.albums = set()
self.albums.add(metadata["album"])
if "genre" in metadata and metadata["genre"]:
if not hasattr(self, "genres"):
self.genres = set()
self.genres.add(metadata["genre"])
if "year" in metadata and metadata["year"]:
if not hasattr(self, "years"):
self.years = set()
self.years.add(metadata["year"])
def metadata_extraction_complete(self):
"""Handle metadata extraction completion"""
logger.debug(
f"Metadata extraction complete. Artists: {len(getattr(self, 'artists', []))}, "
f"Albums: {len(getattr(self, 'albums', []))}, Genres: {len(getattr(self, 'genres', []))}, "
f"Years: {len(getattr(self, 'years', []))}"
)
def extract_archive(self, archive_path, extraction_dir):
"""Extract an archive to specified directory using ArchiveExtractor from archiver.py"""
if not os.path.isfile(archive_path):
logger.debug(f"Error: Archive file {archive_path} does not exist")
return False
if not os.path.isdir(extraction_dir):
logger.debug(
f"Error: Extraction directory {extraction_dir} does not exist"
)
return False
logger.debug(f"Extracting archive {archive_path} to {extraction_dir}")
# Create an ArchiveExtractor instance from archiver.py
self.archive_extractor = ArchiveExtractor(archive_path, extraction_dir)
# Connect signals
if self.on_progress_update:
self.archive_extractor.extraction_progress.connect(
self.on_progress_update
)
# Define completion handler
def on_extraction_complete(extracted_files):
logger.debug(
f"Archive extraction complete. Extracted {len(extracted_files)} files."
)
# Add extracted files to our file list if they match our criteria
for file_path in extracted_files:
if os.path.isfile(file_path) and any(
file_path.lower().endswith(ext)
for ext in [
".mp3",
".wav",
".flac",
".m4a",
".wma",
".mid",
".midi",
]
):
self.file_list.append(file_path)
# Automatically extract metadata from audio files if enabled
audio_files = [
f
for f in extracted_files
if any(
f.lower().endswith(ext)
for ext in [
".mp3",
".wav",
".flac",
".m4a",
".wma",
".mid",
".midi",
]
)
]
if audio_files and self.use_db:
logger.debug(
f"Found {len(audio_files)} audio files in archive, extracting metadata..."
)
temp_extractor = MetadataExtractor(audio_files)
temp_extractor.metadata_extracted.connect(
self.process_metadata
)
temp_extractor.start()
# Connect completion signal
self.archive_extractor.extraction_complete.connect(
on_extraction_complete
)
# Define error handler
def on_extraction_error(error_message):
logger.debug(f"Archive extraction error: {error_message}")
# Connect error signal
self.archive_extractor.extraction_error.connect(on_extraction_error)
# Start extraction
self.archive_extractor.start()
return True
def extract_archive_to_directory(
self, archive_path, target_directory=None
):
"""Extract an archive to a specified directory or to a subdirectory in the same location"""
if not os.path.isfile(archive_path):
logger.debug(f"Error: Archive file {archive_path} does not exist")
return False
# If no target directory is specified, create one based on the archive name
if not target_directory:
archive_name = os.path.splitext(os.path.basename(archive_path))[0]
target_directory = os.path.join(
os.path.dirname(archive_path), archive_name
)
# Create the directory if it doesn't exist
if not os.path.exists(target_directory):
try:
os.makedirs(target_directory)
logger.debug(f"Created directory {target_directory}")
except OSError as e:
logger.debug(
f"Error creating directory {target_directory}: {e}"
)
return False
return self.extract_archive(archive_path, target_directory)
def has_metadata_in_db(self, file_path):
"""Check if metadata for a file already exists in the database"""
if not self.use_db or not self.db:
return False
try:
# Delegate to the db_manager
return self.db_manager.has_metadata_in_db(file_path)
except Exception as e:
logger.debug(f"Error checking metadata in database: {e}")
return False
def get_metadata_from_db(self, file_path):
"""Retrieve metadata for a file from the database"""
if not self.use_db or not self.db:
return None
try:
# Delegate to the db_manager
return self.db_manager.get_metadata_from_db(file_path)
except Exception as e:
logger.debug(f"Error retrieving metadata from database: {e}")
return None
def store_metadata(self, metadata):
"""Store audio file metadata in the database"""
if not self.use_db or not self.db:
logger.debug("Database usage is disabled, not storing metadata")
return False
try:
# Delegate to the db_manager
return self.db_manager.store_metadata(metadata)
except Exception as e:
logger.debug(f"Error storing metadata in database: {e}")
import traceback
traceback.print_exc()
return False

View File

@ -1,153 +0,0 @@
import os
import logging
import zipfile
import py7zr
import rarfile # typed: ignore
from PyQt6.QtCore import QThread, pyqtSignal
# Get the logger
logger = logging.getLogger("fbroswer")
# 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
self.file_list = []
# Signals for UI updates
self.on_scan_complete = None
self.on_progress_update = None
self.on_metadata_complete = None
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
def extract_archives(self):
"""Extract archives"""
if not self.file_list:
logger.debug("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"""
logger.debug("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()

View File

@ -0,0 +1,13 @@
[package]
name = "fbrowser-archive"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
flate2.workspace = true
serde.workspace = true
tar.workspace = true
tokio.workspace = true
zip.workspace = true

View File

@ -0,0 +1,345 @@
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use serde::{Deserialize, Serialize};
use tar::{Archive as TarArchive, Builder as TarBuilder};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveJobSpec {
pub source: String,
pub destination: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveJobResult {
pub output_path: String,
pub processed_entries: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArchiveFormat {
Zip,
Tar,
TarGz,
}
pub fn extract(spec: &ArchiveJobSpec) -> Result<ArchiveJobResult> {
let source = Path::new(&spec.source);
let destination = Path::new(&spec.destination);
fs::create_dir_all(destination)?;
match detect_archive_format(source) {
Some(ArchiveFormat::Zip) => extract_zip(source, destination),
Some(ArchiveFormat::Tar) => extract_tar(File::open(source)?, destination),
Some(ArchiveFormat::TarGz) => extract_tar(GzDecoder::new(File::open(source)?), destination),
None => bail!(
"unsupported archive format for extract: {} (supported: .zip, .tar, .tar.gz, .tgz)",
source.display()
),
}
}
pub fn compress(spec: &ArchiveJobSpec) -> Result<ArchiveJobResult> {
let source = Path::new(&spec.source);
let destination = Path::new(&spec.destination);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
match detect_archive_format(destination) {
Some(ArchiveFormat::Zip) => compress_zip(source, destination),
Some(ArchiveFormat::Tar) => compress_tar(source, destination, false),
Some(ArchiveFormat::TarGz) => compress_tar(source, destination, true),
None => bail!(
"unsupported archive format for compress: {} (supported: .zip, .tar, .tar.gz, .tgz)",
destination.display()
),
}
}
fn detect_archive_format(path: &Path) -> Option<ArchiveFormat> {
let lower_name = path.file_name()?.to_str()?.to_ascii_lowercase();
if lower_name.ends_with(".tar.gz") || lower_name.ends_with(".tgz") {
Some(ArchiveFormat::TarGz)
} else if lower_name.ends_with(".tar") {
Some(ArchiveFormat::Tar)
} else if lower_name.ends_with(".zip") {
Some(ArchiveFormat::Zip)
} else {
None
}
}
fn compress_zip(source: &Path, destination: &Path) -> Result<ArchiveJobResult> {
let file = File::create(destination)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let mut processed = 0_usize;
if source.is_file() {
let name = source
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("item")
.to_string();
add_file_to_zip(&mut zip, source, &name, options)?;
processed += 1;
} else {
for entry in walk(source)? {
let relative = entry.strip_prefix(source)?.to_string_lossy().replace('\\', "/");
add_file_to_zip(&mut zip, &entry, &relative, options)?;
processed += 1;
}
}
zip.finish()?;
Ok(ArchiveJobResult {
output_path: destination.display().to_string(),
processed_entries: processed,
})
}
fn compress_tar(source: &Path, destination: &Path, gzip: bool) -> Result<ArchiveJobResult> {
let mut processed = 0_usize;
if gzip {
let file = File::create(destination)?;
let writer = GzEncoder::new(file, Compression::default());
let mut builder = TarBuilder::new(writer);
append_to_tar(&mut builder, source, &mut processed)?;
builder.finish()?;
} else {
let file = File::create(destination)?;
let mut builder = TarBuilder::new(file);
append_to_tar(&mut builder, source, &mut processed)?;
builder.finish()?;
}
Ok(ArchiveJobResult {
output_path: destination.display().to_string(),
processed_entries: processed,
})
}
fn extract_zip(source: &Path, destination: &Path) -> Result<ArchiveJobResult> {
let file = File::open(source)?;
let mut archive = ZipArchive::new(file)?;
let mut processed = 0_usize;
for index in 0..archive.len() {
let mut entry = archive.by_index(index)?;
let Some(relative_path) = entry.enclosed_name().map(|path| path.to_owned()) else {
continue;
};
let out_path = destination.join(relative_path);
if entry.is_dir() {
fs::create_dir_all(&out_path)?;
continue;
}
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
let mut out = File::create(&out_path)?;
io::copy(&mut entry, &mut out)?;
processed += 1;
}
Ok(ArchiveJobResult {
output_path: destination.display().to_string(),
processed_entries: processed,
})
}
fn extract_tar<R: Read>(reader: R, destination: &Path) -> Result<ArchiveJobResult> {
let mut archive = TarArchive::new(reader);
let mut processed = 0_usize;
for entry in archive.entries()? {
let mut entry = entry?;
if entry.header().entry_type().is_dir() {
entry.unpack_in(destination)?;
continue;
}
entry.unpack_in(destination)?;
processed += 1;
}
Ok(ArchiveJobResult {
output_path: destination.display().to_string(),
processed_entries: processed,
})
}
fn add_file_to_zip(
zip: &mut ZipWriter<File>,
source: &Path,
archive_path: &str,
options: SimpleFileOptions,
) -> Result<()> {
let mut file = File::open(source)?;
zip.start_file(archive_path, options)?;
io::copy(&mut file, zip)?;
Ok(())
}
fn append_to_tar<W: io::Write>(
builder: &mut TarBuilder<W>,
source: &Path,
processed: &mut usize,
) -> Result<()> {
if source.is_file() {
let name = source
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("item")
.to_string();
builder.append_path_with_name(source, name)?;
*processed += 1;
return Ok(());
}
let root_name = source
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("archive-root")
.to_string();
for entry in walk(source)? {
let relative = entry.strip_prefix(source)?.to_path_buf();
let archive_path = Path::new(&root_name).join(relative);
builder.append_path_with_name(&entry, archive_path)?;
*processed += 1;
}
Ok(())
}
fn walk(root: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
files.extend(walk(&path)?);
} else if path.is_file() {
files.push(path);
}
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::{compress, extract, ArchiveJobSpec};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(name: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
std::env::temp_dir().join(format!("fbrowser-{name}-{suffix}"))
}
#[test]
fn zip_roundtrip_preserves_nested_file_contents() {
let source_dir = temp_path("archive-source");
let nested_dir = source_dir.join("nested");
let archive_path = temp_path("archive-output").with_extension("zip");
let extract_dir = temp_path("archive-extract");
fs::create_dir_all(&nested_dir).expect("create source dir");
fs::write(source_dir.join("root.txt"), "root-data").expect("write root file");
fs::write(nested_dir.join("child.txt"), "nested-data").expect("write nested file");
let compress_result = compress(&ArchiveJobSpec {
source: source_dir.display().to_string(),
destination: archive_path.display().to_string(),
})
.expect("compress directory");
assert_eq!(compress_result.processed_entries, 2);
assert!(archive_path.exists());
let extract_result = extract(&ArchiveJobSpec {
source: archive_path.display().to_string(),
destination: extract_dir.display().to_string(),
})
.expect("extract archive");
assert_eq!(extract_result.processed_entries, 2);
assert_eq!(
fs::read_to_string(extract_dir.join("root.txt")).expect("read extracted root file"),
"root-data"
);
assert_eq!(
fs::read_to_string(extract_dir.join("nested").join("child.txt")).expect("read extracted nested file"),
"nested-data"
);
let _ = fs::remove_dir_all(&source_dir);
let _ = fs::remove_file(&archive_path);
let _ = fs::remove_dir_all(&extract_dir);
}
#[test]
fn extract_rejects_unsupported_archive_types() {
let source_path = temp_path("archive-unsupported").with_extension("rar");
let destination = temp_path("archive-unsupported-out");
fs::write(&source_path, b"not-a-real-rar").expect("write unsupported source");
let error = extract(&ArchiveJobSpec {
source: source_path.display().to_string(),
destination: destination.display().to_string(),
})
.expect_err("unsupported format should fail");
assert!(error.to_string().contains("unsupported archive format"));
let _ = fs::remove_file(&source_path);
}
#[test]
fn tar_gz_roundtrip_preserves_directory_structure() {
let source_dir = temp_path("archive-source-targz");
let nested_dir = source_dir.join("drums").join("kicks");
let archive_path = temp_path("archive-output-targz").join("samples.tar.gz");
let extract_dir = temp_path("archive-extract-targz");
fs::create_dir_all(&nested_dir).expect("create nested source dir");
fs::write(nested_dir.join("kick.txt"), "four-on-the-floor").expect("write nested file");
let compress_result = compress(&ArchiveJobSpec {
source: source_dir.display().to_string(),
destination: archive_path.display().to_string(),
})
.expect("compress tar.gz directory");
assert_eq!(compress_result.processed_entries, 1);
assert!(archive_path.exists());
let extract_result = extract(&ArchiveJobSpec {
source: archive_path.display().to_string(),
destination: extract_dir.display().to_string(),
})
.expect("extract tar.gz archive");
assert_eq!(extract_result.processed_entries, 1);
let extracted_file = extract_dir
.join(source_dir.file_name().expect("source dir name"))
.join("drums")
.join("kicks")
.join("kick.txt");
assert_eq!(
fs::read_to_string(extracted_file).expect("read extracted tar.gz file"),
"four-on-the-floor"
);
let _ = fs::remove_dir_all(&source_dir);
let _ = fs::remove_file(&archive_path);
let _ = fs::remove_dir_all(&extract_dir);
}
}

View File

@ -0,0 +1,12 @@
[package]
name = "fbrowser-audio"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
rodio.workspace = true
serde.workspace = true
symphonia.workspace = true
zip.workspace = true

View File

@ -0,0 +1,287 @@
use std::fs::{self, File};
use std::io::{self, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::default::{get_codecs, get_probe};
use zip::ZipArchive;
const PREVIEWABLE_ARCHIVE_EXTENSIONS: &[&str] = &[
"wav", "wave", "mp3", "flac", "aif", "aiff", "aifc", "ogg", "m4a", "aac",
];
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoopRegion {
pub start_ms: u64,
pub end_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackState {
pub loaded_path: Option<String>,
pub is_playing: bool,
pub volume: f32,
pub position_ms: u64,
pub duration_ms: u64,
pub loop_region: Option<LoopRegion>,
pub output_device: Option<String>,
pub media_kind: String,
}
impl Default for PlaybackState {
fn default() -> Self {
Self {
loaded_path: None,
is_playing: false,
volume: 0.8,
position_ms: 0,
duration_ms: 0,
loop_region: None,
output_device: Some("Default".into()),
media_kind: "audio".into(),
}
}
}
struct PlaybackInner {
_stream: MixerDeviceSink,
player: Player,
state: PlaybackState,
temp_preview_path: Option<PathBuf>,
}
#[derive(Clone)]
pub struct AudioEngine {
inner: Arc<Mutex<PlaybackInner>>,
}
impl AudioEngine {
pub fn new() -> Result<Self> {
let stream = DeviceSinkBuilder::open_default_sink()?;
let player = Player::connect_new(&stream.mixer());
player.set_volume(0.8);
Ok(Self {
inner: Arc::new(Mutex::new(PlaybackInner {
_stream: stream,
player,
state: PlaybackState::default(),
temp_preview_path: None,
})),
})
}
pub fn load(&self, path: &str, media_kind: &str) -> Result<PlaybackState> {
let (source_path, display_path, temp_preview_path) = prepare_playback_source(path, media_kind)?;
let file = BufReader::new(File::open(&source_path)?);
let decoder = Decoder::try_from(file)?;
let duration_ms = decoder
.total_duration()
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
let mut inner = self.inner.lock().expect("audio engine poisoned");
cleanup_temp_preview(inner.temp_preview_path.take());
inner.player.stop();
inner.player.clear();
inner.player.append(decoder);
inner.player.pause();
inner.player.set_volume(inner.state.volume);
inner.temp_preview_path = temp_preview_path;
inner.state.loaded_path = Some(display_path);
inner.state.duration_ms = duration_ms;
inner.state.position_ms = 0;
inner.state.media_kind = media_kind.to_string();
inner.state.is_playing = false;
Ok(inner.state.clone())
}
pub fn play(&self) -> PlaybackState {
let mut inner = self.inner.lock().expect("audio engine poisoned");
inner.player.play();
inner.state.is_playing = true;
inner.state.clone()
}
pub fn pause(&self) -> PlaybackState {
let mut inner = self.inner.lock().expect("audio engine poisoned");
inner.state.position_ms = current_position_locked(&inner);
inner.player.pause();
inner.state.is_playing = false;
inner.state.clone()
}
pub fn stop(&self) -> PlaybackState {
let mut inner = self.inner.lock().expect("audio engine poisoned");
inner.player.stop();
inner.player.clear();
inner.state.position_ms = 0;
inner.state.is_playing = false;
inner.state.clone()
}
pub fn seek(&self, position_ms: u64) -> Result<PlaybackState> {
let mut inner = self.inner.lock().expect("audio engine poisoned");
inner
.player
.try_seek(Duration::from_millis(position_ms))
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
inner.state.position_ms = position_ms;
Ok(inner.state.clone())
}
pub fn set_volume(&self, volume: f32) -> PlaybackState {
let mut inner = self.inner.lock().expect("audio engine poisoned");
inner.player.set_volume(volume);
inner.state.volume = volume;
inner.state.clone()
}
pub fn set_loop_region(&self, region: Option<LoopRegion>) -> PlaybackState {
let mut inner = self.inner.lock().expect("audio engine poisoned");
inner.state.loop_region = region;
inner.state.clone()
}
pub fn state(&self) -> PlaybackState {
let mut inner = self.inner.lock().expect("audio engine poisoned");
let position = current_position_locked(&inner);
inner.state.position_ms = position;
if let Some(region) = inner.state.loop_region.clone() {
if inner.state.is_playing && position >= region.end_ms {
let _ = inner.player.try_seek(Duration::from_millis(region.start_ms));
inner.state.position_ms = region.start_ms;
}
}
inner.state.clone()
}
}
pub fn generate_waveform(path: &str, bars: usize) -> Result<Vec<f32>> {
let file = File::open(Path::new(path))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let probe = get_probe().format(
&Default::default(),
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)?;
let mut format = probe.format;
let track = format
.default_track()
.ok_or_else(|| anyhow::anyhow!("no default audio track"))?;
let mut decoder = get_codecs().make(&track.codec_params, &DecoderOptions::default())?;
let mut peaks = Vec::new();
while let Ok(packet) = format.next_packet() {
let decoded = decoder.decode(&packet)?;
let channels = decoded.spec().channels.count();
let frames = decoded.frames();
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, *decoded.spec());
samples.copy_interleaved_ref(decoded);
let chunk = samples.samples();
if chunk.is_empty() {
continue;
}
let stride = channels.max(1);
for frame in 0..frames {
let idx = frame * stride;
if let Some(sample) = chunk.get(idx) {
peaks.push(sample.abs());
}
}
}
if peaks.is_empty() {
return Ok(vec![0.0; bars.max(1)]);
}
let bucket_size = (peaks.len() / bars.max(1)).max(1);
let mut output = Vec::with_capacity(bars.max(1));
for chunk in peaks.chunks(bucket_size).take(bars.max(1)) {
output.push(chunk.iter().copied().fold(0.0_f32, f32::max));
}
while output.len() < bars.max(1) {
output.push(0.0);
}
Ok(output)
}
fn prepare_playback_source(path: &str, media_kind: &str) -> Result<(PathBuf, String, Option<PathBuf>)> {
if media_kind != "archive" {
let source_path = PathBuf::from(path);
return Ok((source_path, path.to_string(), None));
}
let archive_path = Path::new(path);
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.unwrap_or_default();
if extension != "zip" {
bail!("archive preview is currently supported for .zip files only");
}
let (temp_path, entry_name) = extract_preview_from_zip(archive_path)?;
Ok((
temp_path.clone(),
format!("{} :: {}", archive_path.display(), entry_name),
Some(temp_path),
))
}
fn extract_preview_from_zip(archive_path: &Path) -> Result<(PathBuf, String)> {
let file = File::open(archive_path).with_context(|| format!("failed to open archive {}", archive_path.display()))?;
let mut archive = ZipArchive::new(file)?;
for index in 0..archive.len() {
let mut entry = archive.by_index(index)?;
if entry.is_dir() {
continue;
}
let entry_name = entry.name().to_string();
let extension = Path::new(&entry_name)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.unwrap_or_default();
if !PREVIEWABLE_ARCHIVE_EXTENSIONS.contains(&extension.as_str()) {
continue;
}
let temp_path = unique_preview_path(&extension);
let mut output = File::create(&temp_path)?;
io::copy(&mut entry, &mut output)?;
output.flush()?;
return Ok((temp_path, entry_name));
}
bail!("no previewable audio files found inside {}", archive_path.display())
}
fn unique_preview_path(extension: &str) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!("fbrowser-preview-{stamp}.{extension}"))
}
fn cleanup_temp_preview(path: Option<PathBuf>) {
if let Some(path) = path {
let _ = fs::remove_file(path);
}
}
fn current_position_locked(inner: &PlaybackInner) -> u64 {
inner.player.get_pos().as_millis() as u64
}

View File

@ -0,0 +1,17 @@
[package]
name = "fbrowser-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
ignore.workspace = true
lofty.workspace = true
rayon.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tokio.workspace = true
walkdir.workspace = true

View File

@ -0,0 +1,558 @@
use std::str::FromStr;
use anyhow::{Context, Result};
use chrono::Utc;
use lofty::config::WriteOptions;
use lofty::file::{AudioFile, TaggedFileExt};
use lofty::probe::Probe;
use lofty::tag::{Accessor, ItemKey, ItemValue, Tag, TagItem};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool};
use crate::models::{
AnnotationUpdate, CollectionItemsMutation, CollectionMutation, CollectionRecord, LibraryRoot,
MediaItemDetail, MediaItemSummary, MetadataPatch, NewMediaItem, ReorderCollectionMutation,
SearchRequest, SearchResponse,
};
#[derive(Clone)]
pub struct AppDatabase {
pool: SqlitePool,
}
impl AppDatabase {
pub async fn connect(database_path: &str) -> Result<Self> {
let options = SqliteConnectOptions::from_str(database_path)?
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(options)
.await?;
Ok(Self { pool })
}
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
pub async fn list_roots(&self) -> Result<Vec<LibraryRoot>> {
let roots = sqlx::query_as::<_, LibraryRoot>(
"SELECT id, path, enabled, platform, created_at, updated_at, item_count FROM library_roots ORDER BY path ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(roots)
}
pub async fn add_root(&self, path: &str) -> Result<LibraryRoot> {
let now = Utc::now().to_rfc3339();
sqlx::query(
r#"
INSERT INTO library_roots(path, enabled, platform, created_at, updated_at, item_count)
VALUES(?1, 1, ?2, ?3, ?4, 0)
ON CONFLICT(path) DO UPDATE SET updated_at = excluded.updated_at
"#,
)
.bind(path)
.bind(std::env::consts::OS)
.bind(&now)
.bind(&now)
.execute(&self.pool)
.await?;
let root = sqlx::query_as::<_, LibraryRoot>(
"SELECT id, path, enabled, platform, created_at, updated_at, item_count FROM library_roots WHERE path = ?1",
)
.bind(path)
.fetch_one(&self.pool)
.await?;
Ok(root)
}
pub async fn remove_root(&self, root_id: i64) -> Result<()> {
sqlx::query("DELETE FROM library_roots WHERE id = ?1")
.bind(root_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn clear_root_media(&self, root_id: i64) -> Result<()> {
sqlx::query("DELETE FROM media_items WHERE root_id = ?1")
.bind(root_id)
.execute(&self.pool)
.await?;
sqlx::query("UPDATE library_roots SET item_count = 0, updated_at = ?2 WHERE id = ?1")
.bind(root_id)
.bind(Utc::now().to_rfc3339())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn insert_media_item(&self, item: &NewMediaItem) -> Result<()> {
sqlx::query(
r#"
INSERT INTO media_items(
root_id, absolute_path, file_name, extension, media_kind, size_bytes, mtime_unix,
duration_ms, sample_rate, channels, bpm, musical_key, waveform_cache_key,
title, artist, album, genre, year, comment, embedded_bpm
)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
ON CONFLICT(absolute_path) DO UPDATE SET
root_id=excluded.root_id,
file_name=excluded.file_name,
extension=excluded.extension,
media_kind=excluded.media_kind,
size_bytes=excluded.size_bytes,
mtime_unix=excluded.mtime_unix,
duration_ms=excluded.duration_ms,
sample_rate=excluded.sample_rate,
channels=excluded.channels,
bpm=excluded.bpm,
musical_key=excluded.musical_key,
waveform_cache_key=excluded.waveform_cache_key,
title=excluded.title,
artist=excluded.artist,
album=excluded.album,
genre=excluded.genre,
year=excluded.year,
comment=excluded.comment,
embedded_bpm=excluded.embedded_bpm
"#,
)
.bind(item.root_id)
.bind(&item.absolute_path)
.bind(&item.file_name)
.bind(&item.extension)
.bind(&item.media_kind)
.bind(item.size_bytes)
.bind(item.mtime_unix)
.bind(item.duration_ms)
.bind(item.sample_rate)
.bind(item.channels)
.bind(item.bpm)
.bind(&item.musical_key)
.bind(&item.waveform_cache_key)
.bind(&item.title)
.bind(&item.artist)
.bind(&item.album)
.bind(&item.genre)
.bind(&item.year)
.bind(&item.comment)
.bind(item.embedded_bpm)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn finalize_root_scan(&self, root_id: i64) -> Result<()> {
sqlx::query(
r#"
UPDATE library_roots
SET item_count = (SELECT COUNT(*) FROM media_items WHERE root_id = ?1),
updated_at = ?2
WHERE id = ?1
"#,
)
.bind(root_id)
.bind(Utc::now().to_rfc3339())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn search_library(&self, req: SearchRequest) -> Result<SearchResponse> {
let mut select = QueryBuilder::<Sqlite>::new(
r#"
SELECT
mi.id, mi.root_id, mi.absolute_path, mi.file_name, mi.extension, mi.media_kind,
mi.size_bytes, mi.mtime_unix, mi.duration_ms, mi.sample_rate, mi.channels,
mi.bpm, mi.musical_key, mi.title, mi.artist, mi.album, mi.genre, mi.year,
mi.comment, mi.embedded_bpm,
COALESCE(ua.favorite, 0) AS favorite,
ua.rating,
ua.note,
ua.custom_tags_json,
ua.color,
(SELECT MAX(ph.played_at) FROM play_history ph WHERE ph.media_item_id = mi.id) AS last_played_at
FROM media_items mi
LEFT JOIN user_annotations ua ON ua.media_item_id = mi.id
"#,
);
self.apply_filters(&mut select, &req);
self.apply_sort(&mut select, req.sort.as_deref());
let offset = (req.page.saturating_mul(req.page_size)) as i64;
select.push(" LIMIT ").push_bind(req.page_size as i64);
select.push(" OFFSET ").push_bind(offset);
let items = select
.build_query_as::<MediaItemSummary>()
.fetch_all(&self.pool)
.await?;
let mut count = QueryBuilder::<Sqlite>::new(
"SELECT COUNT(*) AS count FROM media_items mi LEFT JOIN user_annotations ua ON ua.media_item_id = mi.id ",
);
self.apply_filters(&mut count, &req);
let total = count
.build()
.fetch_one(&self.pool)
.await?
.get::<i64, _>("count");
Ok(SearchResponse {
items,
total,
page: req.page,
page_size: req.page_size,
})
}
pub async fn get_item(&self, item_id: i64) -> Result<MediaItemDetail> {
let summary = sqlx::query_as::<_, MediaItemSummary>(
r#"
SELECT
mi.id, mi.root_id, mi.absolute_path, mi.file_name, mi.extension, mi.media_kind,
mi.size_bytes, mi.mtime_unix, mi.duration_ms, mi.sample_rate, mi.channels,
mi.bpm, mi.musical_key, mi.title, mi.artist, mi.album, mi.genre, mi.year,
mi.comment, mi.embedded_bpm,
COALESCE(ua.favorite, 0) AS favorite,
ua.rating,
ua.note,
ua.custom_tags_json,
ua.color,
(SELECT MAX(ph.played_at) FROM play_history ph WHERE ph.media_item_id = mi.id) AS last_played_at
FROM media_items mi
LEFT JOIN user_annotations ua ON ua.media_item_id = mi.id
WHERE mi.id = ?1
"#,
)
.bind(item_id)
.fetch_one(&self.pool)
.await?;
let custom_tags = summary
.custom_tags_json
.as_ref()
.and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
.unwrap_or_default();
Ok(MediaItemDetail { summary, custom_tags })
}
pub async fn update_annotations(&self, update: AnnotationUpdate) -> Result<MediaItemDetail> {
let existing = sqlx::query(
"SELECT favorite, rating, note, custom_tags_json, color FROM user_annotations WHERE media_item_id = ?1",
)
.bind(update.item_id)
.fetch_optional(&self.pool)
.await?;
let favorite = update.favorite.unwrap_or_else(|| {
existing
.as_ref()
.and_then(|row| row.try_get::<i64, _>("favorite").ok())
.unwrap_or(0)
!= 0
});
let rating = update
.rating
.or_else(|| existing.as_ref().and_then(|row| row.try_get::<Option<i64>, _>("rating").ok().flatten()));
let note = update
.note
.or_else(|| existing.as_ref().and_then(|row| row.try_get::<Option<String>, _>("note").ok().flatten()));
let custom_tags_json = update
.custom_tags
.map(|tags| serde_json::to_string(&tags))
.transpose()?
.or_else(|| existing.as_ref().and_then(|row| row.try_get::<Option<String>, _>("custom_tags_json").ok().flatten()));
let color = update
.color
.or_else(|| existing.as_ref().and_then(|row| row.try_get::<Option<String>, _>("color").ok().flatten()));
sqlx::query(
r#"
INSERT INTO user_annotations(media_item_id, favorite, rating, note, custom_tags_json, color, updated_at)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(media_item_id) DO UPDATE SET
favorite=excluded.favorite,
rating=excluded.rating,
note=excluded.note,
custom_tags_json=excluded.custom_tags_json,
color=excluded.color,
updated_at=excluded.updated_at
"#,
)
.bind(update.item_id)
.bind(if favorite { 1 } else { 0 })
.bind(rating)
.bind(note)
.bind(custom_tags_json)
.bind(color)
.bind(Utc::now().to_rfc3339())
.execute(&self.pool)
.await?;
self.get_item(update.item_id).await
}
pub async fn record_play_history(&self, item_id: i64, source_context: &str) -> Result<()> {
sqlx::query("INSERT INTO play_history(media_item_id, played_at, source_context) VALUES(?1, ?2, ?3)")
.bind(item_id)
.bind(Utc::now().to_rfc3339())
.bind(source_context)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn find_item_id_by_path(&self, path: &str) -> Result<Option<i64>> {
let row = sqlx::query("SELECT id FROM media_items WHERE absolute_path = ?1")
.bind(path)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|row| row.get::<i64, _>("id")))
}
pub async fn get_path_for_item(&self, item_id: i64) -> Result<String> {
let row = sqlx::query("SELECT absolute_path FROM media_items WHERE id = ?1")
.bind(item_id)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<String, _>("absolute_path"))
}
pub async fn write_metadata(&self, item_id: i64, patch: MetadataPatch) -> Result<MediaItemDetail> {
let path = self.get_path_for_item(item_id).await?;
let mut tagged = Probe::open(&path)
.with_context(|| format!("failed to open media file for metadata write: {path}"))?
.read()
.with_context(|| format!("failed to read media file for metadata write: {path}"))?;
let primary = tagged.primary_tag_type();
if tagged.primary_tag_mut().is_none() {
tagged.insert_tag(Tag::new(primary));
}
let tag = if let Some(tag) = tagged.primary_tag_mut() {
tag
} else {
tagged
.first_tag_mut()
.context("no writable metadata tag available")?
};
if let Some(value) = &patch.title {
tag.set_title(value.clone());
}
if let Some(value) = &patch.artist {
tag.set_artist(value.clone());
}
if let Some(value) = &patch.album {
tag.set_album(value.clone());
}
if let Some(value) = &patch.genre {
tag.set_genre(value.clone());
}
if let Some(value) = &patch.year {
tag.insert(TagItem::new(ItemKey::RecordingDate, ItemValue::Text(value.clone())));
}
if let Some(value) = &patch.comment {
tag.insert(TagItem::new(ItemKey::Comment, ItemValue::Text(value.clone())));
}
tagged.save_to_path(&path, WriteOptions::default())?;
sqlx::query(
r#"
UPDATE media_items
SET title = COALESCE(?2, title),
artist = COALESCE(?3, artist),
album = COALESCE(?4, album),
genre = COALESCE(?5, genre),
year = COALESCE(?6, year),
comment = COALESCE(?7, comment)
WHERE id = ?1
"#,
)
.bind(item_id)
.bind(patch.title)
.bind(patch.artist)
.bind(patch.album)
.bind(patch.genre)
.bind(patch.year)
.bind(patch.comment)
.execute(&self.pool)
.await?;
self.get_item(item_id).await
}
pub async fn list_collections(&self) -> Result<Vec<CollectionRecord>> {
let collections = sqlx::query_as::<_, CollectionRecord>(
"SELECT id, name, kind, rules_json, created_at FROM collections ORDER BY created_at DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(collections)
}
pub async fn create_collection(&self, payload: CollectionMutation) -> Result<CollectionRecord> {
let now = Utc::now().to_rfc3339();
let result =
sqlx::query("INSERT INTO collections(name, kind, rules_json, created_at) VALUES(?1, ?2, ?3, ?4)")
.bind(payload.name)
.bind(payload.kind)
.bind(payload.rules_json)
.bind(&now)
.execute(&self.pool)
.await?;
let id = result.last_insert_rowid();
let collection = sqlx::query_as::<_, CollectionRecord>(
"SELECT id, name, kind, rules_json, created_at FROM collections WHERE id = ?1",
)
.bind(id)
.fetch_one(&self.pool)
.await?;
Ok(collection)
}
pub async fn update_collection(&self, id: i64, payload: CollectionMutation) -> Result<CollectionRecord> {
sqlx::query("UPDATE collections SET name = ?2, kind = ?3, rules_json = ?4 WHERE id = ?1")
.bind(id)
.bind(payload.name)
.bind(payload.kind)
.bind(payload.rules_json)
.execute(&self.pool)
.await?;
let collection = sqlx::query_as::<_, CollectionRecord>(
"SELECT id, name, kind, rules_json, created_at FROM collections WHERE id = ?1",
)
.bind(id)
.fetch_one(&self.pool)
.await?;
Ok(collection)
}
pub async fn delete_collection(&self, id: i64) -> Result<()> {
sqlx::query("DELETE FROM collections WHERE id = ?1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn add_items_to_collection(&self, payload: CollectionItemsMutation) -> Result<()> {
for (position, item_id) in payload.item_ids.into_iter().enumerate() {
sqlx::query(
r#"
INSERT INTO collection_items(collection_id, media_item_id, position)
VALUES(?1, ?2, ?3)
ON CONFLICT(collection_id, media_item_id) DO UPDATE SET position = excluded.position
"#,
)
.bind(payload.collection_id)
.bind(item_id)
.bind(position as i64)
.execute(&self.pool)
.await?;
}
Ok(())
}
pub async fn remove_items_from_collection(&self, payload: CollectionItemsMutation) -> Result<()> {
for item_id in payload.item_ids {
sqlx::query("DELETE FROM collection_items WHERE collection_id = ?1 AND media_item_id = ?2")
.bind(payload.collection_id)
.bind(item_id)
.execute(&self.pool)
.await?;
}
Ok(())
}
pub async fn reorder_collection(&self, payload: ReorderCollectionMutation) -> Result<()> {
for (position, item_id) in payload.item_ids.into_iter().enumerate() {
sqlx::query(
"UPDATE collection_items SET position = ?3 WHERE collection_id = ?1 AND media_item_id = ?2",
)
.bind(payload.collection_id)
.bind(item_id)
.bind(position as i64)
.execute(&self.pool)
.await?;
}
Ok(())
}
pub async fn get_setting_json<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
let row = sqlx::query("SELECT value FROM settings WHERE key = ?1")
.bind(key)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = row {
let value = row.get::<String, _>("value");
Ok(Some(serde_json::from_str(&value)?))
} else {
Ok(None)
}
}
pub async fn set_setting_json<T: serde::Serialize>(&self, key: &str, value: &T) -> Result<()> {
let raw = serde_json::to_string(value)?;
sqlx::query(
r#"
INSERT INTO settings(key, value)
VALUES(?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
"#,
)
.bind(key)
.bind(raw)
.execute(&self.pool)
.await?;
Ok(())
}
fn apply_filters<'a>(&self, builder: &mut QueryBuilder<'a, Sqlite>, req: &SearchRequest) {
builder.push(" WHERE 1=1 ");
if let Some(root_id) = req.root_id {
builder.push(" AND mi.root_id = ").push_bind(root_id);
}
if let Some(media_kind) = req.media_kind.as_deref() {
builder.push(" AND mi.media_kind = ").push_bind(media_kind.to_string());
}
if let Some(query) = req.query.as_deref() {
let like = format!("%{}%", query.trim());
builder.push(" AND (mi.file_name LIKE ");
builder.push_bind(like.clone());
builder.push(" OR COALESCE(mi.title, '') LIKE ");
builder.push_bind(like.clone());
builder.push(" OR COALESCE(mi.artist, '') LIKE ");
builder.push_bind(like.clone());
builder.push(" OR COALESCE(mi.album, '') LIKE ");
builder.push_bind(like);
builder.push(")");
}
if let Some(section) = req.section.as_deref() {
match section {
"favorites" => {
builder.push(" AND COALESCE(ua.favorite, 0) = 1");
}
"recent" => {
builder.push(" AND EXISTS (SELECT 1 FROM play_history ph WHERE ph.media_item_id = mi.id)");
}
_ => {}
}
}
if let Some(collection_id) = req.collection_id {
builder.push(" AND EXISTS (SELECT 1 FROM collection_items ci WHERE ci.collection_id = ");
builder.push_bind(collection_id);
builder.push(" AND ci.media_item_id = mi.id)");
}
}
fn apply_sort<'a>(&self, builder: &mut QueryBuilder<'a, Sqlite>, sort: Option<&str>) {
match sort.unwrap_or("name") {
"recent" => builder.push(" ORDER BY last_played_at DESC NULLS LAST, mi.file_name ASC "),
"rating" => builder.push(" ORDER BY ua.rating DESC NULLS LAST, mi.file_name ASC "),
"duration" => builder.push(" ORDER BY mi.duration_ms DESC NULLS LAST, mi.file_name ASC "),
_ => builder.push(" ORDER BY mi.file_name COLLATE NOCASE ASC "),
};
}
}

View File

@ -0,0 +1,5 @@
pub mod db;
pub mod models;
pub mod scanner;
pub use db::AppDatabase;

View File

@ -0,0 +1,151 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct LibraryRoot {
pub id: i64,
pub path: String,
pub enabled: bool,
pub platform: String,
pub created_at: String,
pub updated_at: String,
pub item_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct MediaItemSummary {
pub id: i64,
pub root_id: i64,
pub absolute_path: String,
pub file_name: String,
pub extension: String,
pub media_kind: String,
pub size_bytes: i64,
pub mtime_unix: i64,
pub duration_ms: Option<i64>,
pub sample_rate: Option<i64>,
pub channels: Option<i64>,
pub bpm: Option<f64>,
pub musical_key: Option<String>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<String>,
pub comment: Option<String>,
pub embedded_bpm: Option<f64>,
pub favorite: bool,
pub rating: Option<i64>,
pub note: Option<String>,
pub custom_tags_json: Option<String>,
pub color: Option<String>,
pub last_played_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItemDetail {
pub summary: MediaItemSummary,
pub custom_tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchRequest {
pub query: Option<String>,
pub section: Option<String>,
pub sort: Option<String>,
pub page: u32,
pub page_size: u32,
pub root_id: Option<i64>,
pub collection_id: Option<i64>,
pub media_kind: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResponse {
pub items: Vec<MediaItemSummary>,
pub total: i64,
pub page: u32,
pub page_size: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnotationUpdate {
pub item_id: i64,
pub favorite: Option<bool>,
pub rating: Option<i64>,
pub note: Option<String>,
pub custom_tags: Option<Vec<String>>,
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataPatch {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<String>,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct CollectionRecord {
pub id: i64,
pub name: String,
pub kind: String,
pub rules_json: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionMutation {
pub name: String,
pub kind: String,
pub rules_json: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionItemsMutation {
pub collection_id: i64,
pub item_ids: Vec<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReorderCollectionMutation {
pub collection_id: i64,
pub item_ids: Vec<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScanStatus {
pub active: bool,
pub current_root: Option<String>,
pub indexed: u64,
pub discovered: u64,
pub last_error: Option<String>,
pub roots: Vec<LibraryRoot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewMediaItem {
pub root_id: i64,
pub absolute_path: String,
pub file_name: String,
pub extension: String,
pub media_kind: String,
pub size_bytes: i64,
pub mtime_unix: i64,
pub duration_ms: Option<i64>,
pub sample_rate: Option<i64>,
pub channels: Option<i64>,
pub bpm: Option<f64>,
pub musical_key: Option<String>,
pub waveform_cache_key: Option<String>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<String>,
pub comment: Option<String>,
pub embedded_bpm: Option<f64>,
}

View File

@ -0,0 +1,173 @@
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Result;
use ignore::WalkBuilder;
use lofty::file::{AudioFile, TaggedFileExt};
use lofty::probe::Probe;
use lofty::tag::Accessor;
use rayon::prelude::*;
use crate::db::AppDatabase;
use crate::models::{LibraryRoot, NewMediaItem};
const AUDIO_EXTENSIONS: &[&str] = &[
"wav", "wave", "mp3", "flac", "aif", "aiff", "aifc", "ogg", "m4a", "aac", "wma",
];
const MIDI_EXTENSIONS: &[&str] = &["mid", "midi"];
const ARCHIVE_EXTENSIONS: &[&str] = &["zip", "tar", "gz", "tgz", "7z"];
const DISCOVERY_PROGRESS_INTERVAL: usize = 250;
const INDEX_PROGRESS_INTERVAL: usize = 100;
#[derive(Debug, Clone)]
pub struct ScanProgress {
pub discovered: u64,
pub indexed: u64,
pub current_path: Option<String>,
}
pub async fn scan_root<F>(db: &AppDatabase, root: &LibraryRoot, mut on_progress: F) -> Result<u64>
where
F: FnMut(ScanProgress) + Send,
{
db.clear_root_media(root.id).await?;
let mut candidate_paths = Vec::<(PathBuf, String)>::new();
let walker = WalkBuilder::new(&root.path)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.build();
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
if !entry.file_type().map(|file_type| file_type.is_file()).unwrap_or(false) {
continue;
}
let path = entry.into_path();
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.unwrap_or_default();
if !AUDIO_EXTENSIONS.contains(&extension.as_str())
&& !MIDI_EXTENSIONS.contains(&extension.as_str())
&& !ARCHIVE_EXTENSIONS.contains(&extension.as_str())
{
continue;
}
candidate_paths.push((path.clone(), extension));
if candidate_paths.len() == 1 || candidate_paths.len() % DISCOVERY_PROGRESS_INTERVAL == 0 {
on_progress(ScanProgress {
discovered: candidate_paths.len() as u64,
indexed: 0,
current_path: Some(path.display().to_string()),
});
}
}
let discovered = candidate_paths.len() as u64;
let items = candidate_paths
.par_iter()
.filter_map(|(path, extension)| match build_item(root.id, path, extension) {
Ok(item) => item,
Err(_) => None,
})
.collect::<Vec<_>>();
let mut indexed = 0_u64;
for item in items {
db.insert_media_item(&item).await?;
indexed += 1;
if indexed == 1 || indexed % INDEX_PROGRESS_INTERVAL as u64 == 0 {
on_progress(ScanProgress {
discovered,
indexed,
current_path: Some(item.absolute_path.clone()),
});
}
}
db.finalize_root_scan(root.id).await?;
on_progress(ScanProgress {
discovered,
indexed,
current_path: None,
});
Ok(indexed)
}
fn build_item(root_id: i64, path: &Path, extension: &str) -> Result<Option<NewMediaItem>> {
let metadata = fs::metadata(path)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
.unwrap_or_else(|| path.display().to_string());
let absolute_path = path.canonicalize()?.display().to_string();
let mtime_unix = metadata
.modified()?
.duration_since(std::time::UNIX_EPOCH)?
.as_secs() as i64;
let media_kind = if AUDIO_EXTENSIONS.contains(&extension) {
"audio"
} else if MIDI_EXTENSIONS.contains(&extension) {
"midi"
} else {
"archive"
};
let mut item = NewMediaItem {
root_id,
absolute_path,
file_name,
extension: extension.to_string(),
media_kind: media_kind.to_string(),
size_bytes: metadata.len() as i64,
mtime_unix,
duration_ms: None,
sample_rate: None,
channels: None,
bpm: None,
musical_key: None,
waveform_cache_key: None,
title: None,
artist: None,
album: None,
genre: None,
year: None,
comment: None,
embedded_bpm: None,
};
if media_kind == "audio" {
if let Ok(tagged) = Probe::open(path)?.read() {
item.duration_ms = tagged
.properties()
.duration()
.as_millis()
.try_into()
.ok();
item.sample_rate = tagged.properties().sample_rate().map(i64::from);
item.channels = tagged.properties().channels().map(i64::from);
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
item.title = tag.title().map(|value| value.into_owned());
item.artist = tag.artist().map(|value| value.into_owned());
item.album = tag.album().map(|value| value.into_owned());
item.genre = tag.genre().map(|value| value.into_owned());
item.comment = tag.comment().map(|value| value.into_owned());
item.year = tag.year().map(|value: u32| value.to_string());
}
}
}
Ok(Some(item))
}

View File

@ -0,0 +1,12 @@
[package]
name = "fbrowser-midi"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
fbrowser-audio = { path = "../fbrowser-audio" }
midir.workspace = true
midly.workspace = true
serde.workspace = true

View File

@ -0,0 +1,366 @@
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use fbrowser_audio::{LoopRegion, PlaybackState};
use midir::MidiOutput;
use midly::{MetaMessage, MidiMessage, Smf, Timing, TrackEventKind};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MidiBackendInfo {
pub id: String,
pub label: String,
pub supports_soundfont: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MidiBackendConfig {
pub backend_id: String,
pub soundfont_path: Option<String>,
}
impl Default for MidiBackendConfig {
fn default() -> Self {
Self {
backend_id: "system".into(),
soundfont_path: None,
}
}
}
#[derive(Clone)]
pub struct MidiEngine {
inner: Arc<Mutex<MidiPlaybackInner>>,
}
struct MidiPlaybackInner {
state: PlaybackState,
events: Vec<ScheduledMidiEvent>,
task: Option<MidiPlaybackTask>,
play_started_at: Option<Instant>,
paused_position_ms: u64,
}
struct MidiPlaybackTask {
stop: Arc<AtomicBool>,
handle: JoinHandle<()>,
}
#[derive(Debug, Clone)]
struct ScheduledMidiEvent {
timestamp_ms: u64,
data: Vec<u8>,
}
impl MidiEngine {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(MidiPlaybackInner {
state: PlaybackState {
media_kind: "midi".into(),
..PlaybackState::default()
},
events: Vec::new(),
task: None,
play_started_at: None,
paused_position_ms: 0,
})),
}
}
pub fn load(&self, path: &str, config: &MidiBackendConfig) -> Result<PlaybackState> {
ensure_supported_backend(config)?;
let bytes = fs::read(path).with_context(|| format!("failed to read MIDI file {path}"))?;
let (events, duration_ms) = parse_midi_events(&bytes)?;
let mut inner = self.inner.lock().expect("midi engine poisoned");
stop_task_locked(&mut inner);
inner.events = events;
inner.paused_position_ms = 0;
inner.play_started_at = None;
inner.state.loaded_path = Some(path.to_string());
inner.state.duration_ms = duration_ms;
inner.state.position_ms = 0;
inner.state.volume = 0.8;
inner.state.loop_region = None;
inner.state.output_device = Some("System MIDI".into());
inner.state.media_kind = "midi".into();
inner.state.is_playing = false;
Ok(inner.state.clone())
}
pub fn play(&self, config: &MidiBackendConfig) -> Result<PlaybackState> {
ensure_supported_backend(config)?;
let mut inner = self.inner.lock().expect("midi engine poisoned");
if inner.state.loaded_path.is_none() {
bail!("no MIDI file loaded");
}
stop_task_locked(&mut inner);
let stop = Arc::new(AtomicBool::new(false));
let stop_for_thread = stop.clone();
let events = inner.events.clone();
let start_offset_ms = inner.paused_position_ms;
let state = self.inner.clone();
let handle = thread::spawn(move || {
let midi_out = match MidiOutput::new("Fbrowser MIDI") {
Ok(output) => output,
Err(_) => return,
};
let ports = midi_out.ports();
let Some(port) = ports.first() else {
return;
};
let mut connection = match midi_out.connect(port, "fbrowser-system-midi") {
Ok(connection) => connection,
Err(_) => return,
};
let start = Instant::now();
for event in events.into_iter().filter(|event| event.timestamp_ms >= start_offset_ms) {
let target_offset = event.timestamp_ms.saturating_sub(start_offset_ms);
while !stop_for_thread.load(Ordering::Relaxed) {
let elapsed = start.elapsed().as_millis() as u64;
if elapsed >= target_offset {
break;
}
thread::sleep(Duration::from_millis(2));
}
if stop_for_thread.load(Ordering::Relaxed) {
break;
}
let _ = connection.send(&event.data);
if let Ok(mut inner) = state.lock() {
inner.state.position_ms = event.timestamp_ms;
}
}
if let Ok(mut inner) = state.lock() {
if !stop_for_thread.load(Ordering::Relaxed) {
inner.state.position_ms = inner.state.duration_ms;
inner.paused_position_ms = inner.state.duration_ms;
}
inner.state.is_playing = false;
inner.play_started_at = None;
inner.task = None;
}
});
inner.task = Some(MidiPlaybackTask { stop, handle });
inner.play_started_at = Some(Instant::now());
inner.state.is_playing = true;
Ok(inner.state.clone())
}
pub fn pause(&self) -> PlaybackState {
let mut inner = self.inner.lock().expect("midi engine poisoned");
inner.paused_position_ms = current_position_locked(&inner);
stop_task_locked(&mut inner);
inner.state.position_ms = inner.paused_position_ms;
inner.state.is_playing = false;
inner.state.clone()
}
pub fn stop(&self) -> PlaybackState {
let mut inner = self.inner.lock().expect("midi engine poisoned");
stop_task_locked(&mut inner);
inner.paused_position_ms = 0;
inner.state.position_ms = 0;
inner.state.is_playing = false;
inner.state.clone()
}
pub fn seek(&self, position_ms: u64, config: &MidiBackendConfig) -> Result<PlaybackState> {
ensure_supported_backend(config)?;
let was_playing = {
let mut inner = self.inner.lock().expect("midi engine poisoned");
let was_playing = inner.state.is_playing;
stop_task_locked(&mut inner);
let clamped = position_ms.min(inner.state.duration_ms);
inner.paused_position_ms = clamped;
inner.state.position_ms = clamped;
inner.state.is_playing = false;
was_playing
};
if was_playing {
return self.play(config);
}
Ok(self.state())
}
pub fn set_volume(&self, volume: f32) -> PlaybackState {
let mut inner = self.inner.lock().expect("midi engine poisoned");
inner.state.volume = volume;
inner.state.clone()
}
pub fn set_loop_region(&self, region: Option<LoopRegion>) -> PlaybackState {
let mut inner = self.inner.lock().expect("midi engine poisoned");
inner.state.loop_region = region;
inner.state.clone()
}
pub fn state(&self) -> PlaybackState {
let mut inner = self.inner.lock().expect("midi engine poisoned");
inner.state.position_ms = current_position_locked(&inner);
inner.state.clone()
}
}
pub fn available_backends() -> Vec<MidiBackendInfo> {
vec![
MidiBackendInfo {
id: "system".into(),
label: "System MIDI Output".into(),
supports_soundfont: false,
},
MidiBackendInfo {
id: "soundfont".into(),
label: "User SoundFont".into(),
supports_soundfont: true,
},
]
}
fn ensure_supported_backend(config: &MidiBackendConfig) -> Result<()> {
match config.backend_id.as_str() {
"system" => Ok(()),
"soundfont" => bail!("SoundFont MIDI playback backend is not implemented yet"),
other => bail!("unsupported MIDI backend: {other}"),
}
}
fn parse_midi_events(bytes: &[u8]) -> Result<(Vec<ScheduledMidiEvent>, u64)> {
let smf = Smf::parse(bytes)?;
let ticks_per_beat = match smf.header.timing {
Timing::Metrical(ticks) => u64::from(ticks.as_int()),
Timing::Timecode(_, _) => bail!("timecode-based MIDI timing is not supported yet"),
};
let mut raw_events = Vec::<(u64, RawMidiEvent)>::new();
for track in &smf.tracks {
let mut tick_position = 0_u64;
for event in track {
tick_position += u64::from(event.delta.as_int());
match event.kind {
TrackEventKind::Midi { channel, message } => {
if let Some(data) = midi_message_to_bytes(channel.as_int(), message) {
raw_events.push((tick_position, RawMidiEvent::Message(data)));
}
}
TrackEventKind::Meta(MetaMessage::Tempo(tempo)) => {
raw_events.push((tick_position, RawMidiEvent::Tempo(tempo.as_int())));
}
_ => {}
}
}
}
raw_events.sort_by_key(|(tick, _)| *tick);
let mut events = Vec::new();
let mut current_tempo_us_per_beat = 500_000_u64;
let mut previous_tick = 0_u64;
let mut elapsed_us = 0_u64;
for (tick, raw_event) in raw_events {
let delta_ticks = tick.saturating_sub(previous_tick);
elapsed_us = elapsed_us.saturating_add(
delta_ticks
.saturating_mul(current_tempo_us_per_beat)
.checked_div(ticks_per_beat)
.unwrap_or(0),
);
previous_tick = tick;
match raw_event {
RawMidiEvent::Message(data) => events.push(ScheduledMidiEvent {
timestamp_ms: elapsed_us / 1000,
data,
}),
RawMidiEvent::Tempo(next_tempo) => {
current_tempo_us_per_beat = u64::from(next_tempo);
}
}
}
let duration_ms = events.last().map(|event| event.timestamp_ms).unwrap_or(0);
Ok((events, duration_ms))
}
fn midi_message_to_bytes(channel: u8, message: MidiMessage) -> Option<Vec<u8>> {
let status_base = match message {
MidiMessage::NoteOff { .. } => 0x80,
MidiMessage::NoteOn { .. } => 0x90,
MidiMessage::Aftertouch { .. } => 0xA0,
MidiMessage::Controller { .. } => 0xB0,
MidiMessage::ProgramChange { .. } => 0xC0,
MidiMessage::ChannelAftertouch { .. } => 0xD0,
MidiMessage::PitchBend { .. } => 0xE0,
};
let status = status_base | (channel & 0x0F);
Some(match message {
MidiMessage::NoteOff { key, vel } => vec![status, key.as_int(), vel.as_int()],
MidiMessage::NoteOn { key, vel } => vec![status, key.as_int(), vel.as_int()],
MidiMessage::Aftertouch { key, vel } => vec![status, key.as_int(), vel.as_int()],
MidiMessage::Controller { controller, value } => vec![status, controller.as_int(), value.as_int()],
MidiMessage::ProgramChange { program } => vec![status, program.as_int()],
MidiMessage::ChannelAftertouch { vel } => vec![status, vel.as_int()],
MidiMessage::PitchBend { bend } => {
let value = bend.as_int();
vec![status, (value & 0x7F) as u8, ((value >> 7) & 0x7F) as u8]
}
})
}
fn stop_task_locked(inner: &mut MidiPlaybackInner) {
if let Some(task) = inner.task.take() {
task.stop.store(true, Ordering::Relaxed);
let _ = task.handle.join();
}
inner.play_started_at = None;
}
fn current_position_locked(inner: &MidiPlaybackInner) -> u64 {
if inner.state.is_playing {
if let Some(started_at) = inner.play_started_at {
return (inner.paused_position_ms + started_at.elapsed().as_millis() as u64)
.min(inner.state.duration_ms);
}
}
inner.paused_position_ms.min(inner.state.duration_ms)
}
enum RawMidiEvent {
Message(Vec<u8>),
Tempo(u32),
}
#[cfg(test)]
mod tests {
use super::{available_backends, MidiBackendConfig};
#[test]
fn default_config_uses_system_backend_without_soundfont() {
let config = MidiBackendConfig::default();
assert_eq!(config.backend_id, "system");
assert_eq!(config.soundfont_path, None);
}
#[test]
fn available_backends_include_system_and_soundfont_modes() {
let backends = available_backends();
assert!(backends.iter().any(|backend| backend.id == "system" && !backend.supports_soundfont));
assert!(backends.iter().any(|backend| backend.id == "soundfont" && backend.supports_soundfont));
}
}

View File

@ -0,0 +1,8 @@
[package]
name = "fbrowser-plugin-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true

View File

@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginPreviewRequest {
pub path: String,
pub start_ms: u64,
pub end_ms: Option<u64>,
}
pub trait PreviewHost {
fn preview(&self, request: PluginPreviewRequest);
}

288
dbman.py
View File

@ -1,288 +0,0 @@
import logging
from datetime import datetime
from ifireflylib import IFireflyClient as FireflyDatabase
# Get the logger
logger = logging.getLogger("fbroswer")
class FireflyDB:
def __init__(self):
# Initialize with default values
self.use_db = False
self.db = None
self.artists = set()
self.albums = set()
self.genres = set()
self.years = set()
logger.debug("FireflyDB instance initialized")
def connect_to(
self, use_db=False, db_host="localhost", db_port=6379, db_password=None
):
# Set the use_db flag
self.use_db = use_db
logger.info(f"Database usage set to: {use_db}")
# Metadata organization
self.artists = set()
self.albums = set()
self.genres = set()
self.years = set()
if use_db:
try:
logger.info(f"Connecting to FireflyDB at {db_host}:{db_port}")
self.db = FireflyDatabase(
host=db_host, port=db_port, password=db_password
)
# Test connection
if not self.db.ping():
logger.warning(
"Could not connect to FireflyDB. Continuing without database."
)
self.use_db = False
self.db = None
else:
logger.info("Successfully connected to FireflyDB")
# Store connection timestamp
timestamp = str(datetime.now())
logger.debug(
f"Setting last_connection timestamp: {timestamp}"
)
self.db.string_ops.string_set("last_connection", timestamp)
logger.debug("Connection timestamp stored successfully")
except Exception as e:
logger.error(
f"Error connecting to FireflyDB: {e}", exc_info=True
)
import traceback
traceback.print_exc()
self.use_db = False
self.db = None
def close(self):
"""Close database connection when done"""
if self.db:
logger.info("Closing database connection")
self.db.close()
self.db = None
def has_metadata_in_db(self, file_path):
"""Check if metadata for a file already exists in the database"""
if not self.use_db or not self.db:
return False
try:
key = f"audio:{file_path}"
# Use hash_field_exists to check if the key exists in the database
exists = self.db.hash_ops.hash_field_exists(key, "title")
logger.debug(
f"Metadata check for {file_path}: {'exists' if exists else 'not found'}"
)
return exists
except Exception as e:
logger.error(f"Error checking metadata in database: {e}")
return False
def store_metadata(self, metadata):
"""Store audio file metadata in the database"""
if not self.use_db or not self.db:
logger.info("Database usage is disabled, not storing metadata")
return False
try:
# Use the file path as a unique key
file_path = metadata["file_path"]
key = f"audio:{file_path}"
# Log the storage attempt
logger.info(f"Storing metadata for {file_path} in FireflyDB")
logger.debug(f"Metadata fields: {list(metadata.keys())}")
# Store as a hash with all metadata fields
success = True
for field, value in metadata.items():
if field != "file_path": # Skip using file_path as a field
logger.debug(f" Setting field {field}={value}")
# Use the direct hash_set method instead of hash_ops
result = self.db.hash_ops.hash_set(key, field, value)
if not result:
logger.warning(
f" Failed to store field {field} for {file_path}"
)
success = False
else:
logger.debug(f" Successfully stored field {field}")
# Add to index lists for quick lookup
if "artist" in metadata and metadata["artist"]:
artist_key = f"index:artist:{metadata['artist']}"
logger.debug(f" Adding to artist index: {artist_key}")
self.db.list_ops.list_right_push(artist_key, file_path)
if "album" in metadata and metadata["album"]:
album_key = f"index:album:{metadata['album']}"
logger.debug(f" Adding to album index: {album_key}")
self.db.list_ops.list_right_push(album_key, file_path)
if "genre" in metadata and metadata["genre"]:
genre_key = f"index:genre:{metadata['genre']}"
logger.debug(f" Adding to genre index: {genre_key}")
self.db.list_ops.list_right_push(genre_key, file_path)
# Add to a master list of all audio files for easy retrieval
logger.debug(" Adding to master audio files list")
self.db.list_ops.list_right_push("all_audio_files", file_path)
# Store timestamp of when metadata was added
self.db.hash_ops.hash_set(key, "timestamp", str(datetime.now()))
# Verify storage by retrieving one field
if "title" in metadata:
retrieved_title = self.db.hash_ops.hash_get(key, "title")
logger.debug(
f" Verification - Retrieved title: {retrieved_title}"
)
# Strip quotes if present in the retrieved value
if retrieved_title and isinstance(retrieved_title, str):
if retrieved_title.startswith(
'"'
) and retrieved_title.endswith('"'):
retrieved_title = retrieved_title[1:-1]
# Compare the cleaned value
if retrieved_title != metadata["title"]:
logger.warning(
f" Verification failed: expected '{metadata['title']}', got '{retrieved_title}'"
)
# Don't mark as failure if it's just a quoting issue
if retrieved_title.strip('"') == metadata["title"]:
logger.info(
" Verification passed after stripping quotes"
)
else:
success = False
logger.info(
f"Metadata storage {'successful' if success else 'partially failed'} for {file_path}"
)
return success
except Exception as e:
logger.error(
f"Error storing metadata in database: {e}", exc_info=True
)
import traceback
traceback.print_exc()
return False
def get_metadata_from_db(self, file_path):
"""Retrieve metadata for a file from the database"""
if not self.use_db or not self.db:
return None
try:
key = f"audio:{file_path}"
metadata = self.db.hash_ops.hash_get_all(key)
if metadata:
# Add the file path to the metadata
metadata["file_path"] = file_path
logger.debug(
f"Retrieved metadata for {file_path} from database"
)
return metadata
logger.debug(f"No metadata found for {file_path} in database")
return None
except Exception as e:
logger.error(f"Error retrieving metadata from database: {e}")
return None
def search_by_artist(self, artist):
"""Search for files by artist"""
if not self.use_db or not self.db:
return []
try:
key = f"index:artist:{artist}"
logger.debug(f"Searching for files by artist: {artist}")
return self.db.list_ops.list_range(key, 0, -1)
except Exception as e:
logger.error(f"Error searching by artist: {e}")
return []
# Similar methods for album and genre searches
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"])
# Store in database if enabled
if self.use_db and self.db:
logger.debug(
f"Storing metadata for {metadata.get('file_path', 'unknown file')} in database"
)
db_success = self.store_metadata(metadata)
if db_success:
logger.info(
f"Successfully stored metadata for {metadata.get('file_path', 'unknown file')} in database"
)
else:
logger.warning(
f"Failed to store metadata for {metadata.get('file_path', 'unknown file')} in database"
)
def verify_database_connection(self):
"""Verify that the database connection is working properly"""
if not self.use_db or not self.db:
logger.info("Database usage is disabled")
return False
try:
# Test ping
logger.debug("Testing database connection with ping")
ping_result = self.db.ping()
if not ping_result:
logger.warning("Database ping failed")
return False
# Test basic operations
test_key = "test:connection"
test_value = "connection_test"
# Test string operations
logger.debug("Testing string operations")
string_set_result = self.db.string_ops.string_set(
test_key, test_value
)
if not string_set_result:
logger.warning("Failed to set test string in database")
return False
string_get_result = self.db.string_get(test_key)
if string_get_result != test_value:
logger.warning(
f"String get test failed. Expected '{test_value}', got '{string_get_result}'"
)
return False
# Clean up
self.db.delete(test_key)
logger.info("Database connection verified successfully")
return True
except Exception as e:
logger.error(
f"Database verification failed with error: {e}", exc_info=True
)
return False

View File

@ -1,40 +0,0 @@
from typing import Dict, List, Optional
class IFireflyClient:
def __init__(
self,
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
) -> None: ...
def ping(self) -> bool: ...
def close(self) -> None: ...
# String operations
def string_ops(self, key: str) -> Dict[str, str]: ...
def string_set(self, key: str, value: str) -> bool: ...
def string_get(self, key: str) -> Optional[str]: ...
def string_delete(self, key: str) -> bool: ...
# List operations
def list_right_push(self, key: str, value: str) -> int: ...
def list_left_push(self, key: str, value: str) -> int: ...
def list_right_pop(self, key: str) -> Optional[str]: ...
def list_left_pop(self, key: str) -> Optional[str]: ...
def list_range(self, key: str, start: int, end: int) -> List[str]: ...
def list_length(self, key: str) -> int: ...
def list_ops(self, key: str) -> List[str]: ...
# Hash operations
def hash_ops(self, key: str) -> Dict[str, str]: ...
def hash_set(self, key: str, field: str, value: str) -> bool: ...
def hash_get(self, key: str, field: str) -> Optional[str]: ...
def hash_delete(self, key: str, field: str) -> bool: ...
def hash_get_all(self, key: str) -> Dict[str, str]: ...
def hash_exists(self, key: str, field: str) -> bool: ...
# Connection management
def execute_command(self, command: str, *args) -> Optional[str]: ...
def on_progress_update(self, progress: int) -> None: ...
def on_metadata_complete(self) -> None: ...
def on_scan_complete(self) -> None: ...

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fbrowser</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<script type="module" src="/src/main.tsx"></script>
</head>
<body class="bg-canvas text-text">
<div id="root"></div>
</body>
</html>

View File

@ -1,44 +0,0 @@
import logging
import argparse
def setup_logging(args=None):
"""
Configure logging based on command line arguments
Args:
args: The parsed command line arguments
Returns:
A configured logger instance
"""
# Create logger
logger = logging.getLogger("fbroswer")
# Set level based on debug flag
if args and args.debug_on:
logger.setLevel(logging.DEBUG)
# Create more verbose formatter for debug mode
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
)
else:
logger.setLevel(logging.CRITICAL)
# Create simpler formatter for normal operation
formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s"
)
# Create console handler
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
# Add handler to logger
logger.addHandler(console_handler)
# Optionally add file handler for persistent logs
file_handler = logging.FileHandler("fbroswer.log")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger

View File

@ -1,186 +0,0 @@
import os
import logging
import concurrent.futures
import mutagen
from datetime import datetime
from PyQt6.QtCore import QThread, pyqtSignal
# Get the logger
logger = logging.getLogger("fbroswer")
# 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 = {}
self.on_metadata_complete = None
# Metadata organization
self.artists = set()
self.albums = set()
self.genres = set()
self.years = set()
logger.debug(
f"MetadataExtractor initialized with {len(file_list)} files"
)
def run(self):
total_files = len(self.file_list)
processed_files = 0
logger.info(f"Starting metadata extraction for {total_files} files")
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for file_path in self.file_list:
if self.stop_requested:
logger.debug("Metadata extraction stopped by user request")
break
if file_path in self.metadata_cache:
logger.debug(f"Using cached metadata for {file_path}")
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:
logger.debug(
"Metadata extraction stopped during processing"
)
break
try:
metadata = future.result()
if metadata:
self.metadata_extracted.emit(metadata)
except Exception as e:
logger.error(
f"Error extracting metadata: {e}", exc_info=True
)
processed_files += 1
progress = int(processed_files / total_files * 100)
logger.debug(f"Metadata extraction progress: {progress}%")
self.progress_update.emit(progress)
logger.info("Metadata extraction complete")
self.extraction_complete.emit()
def extract_metadata(self, file_path):
try:
if not os.path.isfile(file_path):
logger.warning(f"File does not exist: {file_path}")
return None
# Skip non-audio files
if not file_path.lower().endswith(
(".mp3", ".wav", ".flac", ".m4a", ".wma", ".mid", ".midi")
):
logger.debug(f"Skipping non-audio file: {file_path}")
return None
logger.info(f"Extracting metadata for {file_path}")
# Add additional error handling for mutagen
try:
audio = mutagen.File(file_path) # type: ignore
except (OSError, IOError) as e:
logger.error(f"Mutagen error reading file {file_path}: {e}")
# Return basic metadata without audio tags
file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
return {
"file_path": file_path,
"artist": "Unknown Artist",
"album": "Unknown Album",
"title": os.path.basename(file_path),
"genre": "Unknown Genre",
"year": "Unknown Year",
"size_mb": f"{file_size_mb:.2f}",
"filename": os.path.basename(file_path),
"extension": os.path.splitext(file_path)[1].lower(),
"last_modified": str(
datetime.fromtimestamp(os.path.getmtime(file_path))
),
"extracted_at": str(datetime.now()),
"error": str(e),
}
if not audio:
logger.warning(f"No metadata found for {file_path}")
return None
# Get file size in MB
file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
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"),
"size_mb": f"{file_size_mb:.2f}",
"filename": os.path.basename(file_path),
"extension": os.path.splitext(file_path)[1].lower(),
"last_modified": str(
datetime.fromtimestamp(os.path.getmtime(file_path))
),
"extracted_at": str(datetime.now()),
}
logger.debug(f"Extracted metadata: {metadata}")
# Cache the result
self.metadata_cache[file_path] = metadata
return metadata
except Exception as e:
logger.error(f"Error processing {file_path}: {e}", exc_info=True)
import traceback
traceback.print_exc()
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 as e:
logger.debug(f"Error extracting tag {tag_name}: {e}")
return default_value
def stop(self):
logger.info("Stopping metadata extraction")
self.stop_requested = True
def metadata_extraction_complete(self):
"""Handle metadata extraction completion"""
logger.info(
f"Metadata extraction complete. Artists: {len(self.artists)}, "
f"Albums: {len(self.albums)}, Genres: {len(self.genres)}, "
f"Years: {len(self.years)}"
)
if self.on_metadata_complete:
self.on_metadata_complete()

3596
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "fbrowser-desktop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 1420",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"tauri": "tauri"
},
"dependencies": {
"@tanstack/react-query": "^5.76.1",
"@tanstack/react-virtual": "^3.13.5",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.3.1",
"clsx": "^2.1.1",
"framer-motion": "^12.9.2",
"lucide-react": "^0.511.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
"vite": "^6.2.5",
"vitest": "^3.1.1"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

0
pypaqtest.paq Normal file
View File

View File

@ -0,0 +1 @@
3.13

216
python-src/Fbrowser.py Normal file
View File

@ -0,0 +1,216 @@
# Path: Fbrowser.py
# Sample Music Browser & Ogranizer: Main.py
# Importing Libraries
import sys
import os
from ScanOrg import organizer, file_scanner, DirectoryFilterProxyModel, FileFilterProxyModel
from PyQt5.QtGui import QStandardItem , QStandardItemModel
from PyQt5.QtWidgets import QApplication, QLabel, QPushButton, QTreeView, QMessageBox, QSlider, QWidget, QFileSystemModel, QSplitter, QHBoxLayout, QFileDialog
from PyQt5.QtMultimedia import QMediaPlaylist, QMediaPlayer, QMediaContent, QAudioFormat, QAudioDeviceInfo, QAudio
from PyQt5.QtCore import QDir, QSortFilterProxyModel, Qt, QUrl #QAbstractItemModel, QAbstractProxyModel, QModelIndex, QItemSelectionModel, QItemSelection, QItemSelectionRange, QItemSelectionModel, QItemSelection, QItemSelectionRange
"""
# Audio Format
audio_format = QAudioFormat()
audio_format.setSampleRate(44100)
audio_format.setChannelCount(2)
audio_format.setSampleSize(16)
audio_format.setCodec('audio/pcm')
audio_format.setByteOrder(QAudioFormat.LittleEndian)
audio_format.setSampleType(QAudioFormat.SignedInt)
# Audio Device Info
device_info = QAudioDeviceInfo.defaultOutputDevice()
if not device_info.isFormatSupported(audio_format):
print('Raw audio format not supported by backend, cannot play audio.')
audio_format = device_info.nearestFormat(audio_format)
"""
# Sample Music Browser Main Class
class SampleMusicBrowser(QWidget):
def __init__(self):
super().__init__()
self.organizer = organizer()
self.file_model = QStandardItemModel()
self.player = QMediaPlayer()
self.playlist = QMediaPlaylist()
self.player.setPlaylist(self.playlist)
self.tree_model = QFileSystemModel()
self.init_ui()
self.folder_contents_view.setEditTriggers(QTreeView.NoEditTriggers)
self.player.error.connect(self.player_error)
self.player.mediaStatusChanged.connect(self.player_media_status_changed)
self.player.setAudioRole(QAudio.MusicRole)
def player_error(self, error):
if error == QMediaPlayer.NoError:
return
print('Error: ' + self.player.errorString())
def player_media_status_changed(self, status):
if status == QMediaPlayer.NoMedia:
return
print('Media Status: ' + str(status))
def init_ui(self):
layout = QHBoxLayout()
label = QLabel('Sample Music Browser')
layout.addWidget(label)
button = QPushButton('Exit')
button.clicked.connect(self.show_exit_popup)
layout.addWidget(button)
self.file_tree = QTreeView()
self.file_tree.setHeaderHidden(True)
self.file_tree.clicked.connect(self.change_directory)
self.folder_contents_view = QTreeView()
self.folder_contents_view.setHeaderHidden(False)
self.folder_contents_view.setRootIsDecorated(False)
self.folder_contents_view.setSortingEnabled(True)
splitter = QSplitter()
splitter.addWidget(self.file_tree)
splitter.addWidget(self.folder_contents_view)
layout.addWidget(splitter)
self.current_dir_label = QLabel()
layout.addWidget(self.current_dir_label)
up_dir_button = QPushButton('Up Directory')
up_dir_button.clicked.connect(self.go_up_directory)
layout.addWidget(up_dir_button)
back_button = QPushButton('Back')
back_button.clicked.connect(self.go_back_directory)
layout.addWidget(back_button)
forward_button = QPushButton('Forward')
forward_button.clicked.connect(self.go_forward_directory)
layout.addWidget(forward_button)
self.setLayout(layout)
self.setWindowTitle('Samples are life!')
path = QFileDialog.getExistingDirectory(self, 'Select Directory')
if path:
self.populate_file_tree(path)
play_button = QPushButton('Play')
play_button.clicked.connect(self.player.play)
layout.addWidget(play_button)
pause_button = QPushButton('Pause')
pause_button.clicked.connect(self.player.pause)
layout.addWidget(pause_button)
stop_button = QPushButton('Stop')
stop_button.clicked.connect(self.player.stop)
layout.addWidget(stop_button)
self.player.stateChanged.connect(self.player_state_changed)
self.player.positionChanged.connect(self.player_position_changed)
self.player.durationChanged.connect(self.player_duration_changed)
self.player.setVolume(50)
volume_slider = QSlider(Qt.Horizontal)
volume_slider.setRange(0, 100)
volume_slider.setValue(50)
volume_slider.valueChanged.connect(self.player.setVolume)
layout.addWidget(volume_slider)
self.playlist.currentIndexChanged.connect(self.playlist_current_index_changed)
self.playlist.currentMediaChanged.connect(self.playlist_current_media_changed)
self.playlist.mediaInserted.connect(self.playlist_media_inserted)
self.playlist.mediaRemoved.connect(self.playlist_media_removed)
self.playlist.setPlaybackMode(QMediaPlaylist.Loop)
self.folder_contents_view.doubleClicked.connect(self.play_file)
def directory_loaded(self, path):
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.model.index(path)))
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
def populate_file_tree(self, path):
try:
self.tree_model.setRootPath(path)
self.file_tree.setModel(self.tree_model)
self.directory_model = DirectoryFilterProxyModel()
self.directory_model.setSourceModel(self.tree_model)
self.file_tree.setModel(self.directory_model)
self.file_tree.setRootIndex(self.directory_model.mapFromSource(self.tree_model.index(path)))
self.list_model = QFileSystemModel()
self.list_model.setRootPath(path)
self.file_filter_model = FileFilterProxyModel()
self.file_filter_model.setSourceModel(self.list_model)
self.folder_contents_view.setModel(self.file_filter_model)
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(path)))
self.current_dir_label.setText(path)
except Exception as e:
print(f"Error Populating File Tree: {e}")
def show_exit_popup(self):
reply = QMessageBox.question(self, 'Exit', 'Are you sure you want to exit?',
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.Yes:
sys.exit()
def play_file(self, index):
index = self.file_filter_model.mapToSource(index)
file_path = self.list_model.filePath(index)
media = QMediaContent(QUrl.fromLocalFile(file_path))
self.playlist.addMedia(media)
self.player.play()
def player_state_changed(self, state):
if state == QMediaPlayer.StoppedState:
self.playlist.setCurrentIndex(0)
def player_position_changed(self, position):
pass
def player_duration_changed(self, duration):
pass
def playlist_current_index_changed(self, index):
pass
def playlist_current_media_changed(self, media):
pass
def playlist_media_inserted(self, start, end):
pass
def playlist_media_removed(self, start, end):
pass
def change_directory(self, index):
index = self.directory_model.mapToSource(index)
try:
file_path = self.tree_model.filePath(index)
self.list_model.setRootPath(file_path)
self.current_dir_label.setText(file_path)
self.folder_contents_view.setRootIndex(self.file_filter_model.mapFromSource(self.list_model.index(file_path)))
except Exception as e:
print(f"Error Changing Dirs.: {e}")
def go_up_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
def go_back_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
def go_forward_directory(self):
index = self.folder_contents_view.rootIndex()
index = self.file_filter_model.mapToSource(index)
index = self.file_model.index(index)
index = index.parent()
index = self.file_filter_model.mapFromSource(index)
self.folder_contents_view.setRootIndex(index)
self.current_dir_label.setText(self.model.filePath(index))
if __name__ == '__main__':
app = QApplication(sys.argv)
sampleMusicBrowser = SampleMusicBrowser()
sampleMusicBrowser.show()
sys.exit(app.exec_())

261
python-src/MidPlay.py Normal file
View File

@ -0,0 +1,261 @@
#Path: MidPlay.py
# Description: A class to play MIDI files and a class to view MIDI files
import pygame
# Imports
import mido
import fluidsynth
import os
from PyQt6.QtWidgets import (QApplication, QLabel, QListWidget, QFileDialog, QMessageBox, QWidget, QPushButton, QHBoxLayout,
QVBoxLayout,
QProgressBar,
QSlider) # structured for readability and to avoid long lines and it annoys my friend XD
from PyQt6.QtCore import QTimer, Qt
import threading
import cProfile # profiler remove for production
#profiler remove for production
def start_profiling():
global pr
pr = cProfile.Profile()
pr.enable()
def stop_profiling():
pr.disable()
pr.dump_stats('midi_profile.out')
# The pygame.mixer.init() call is necessary to initialize the mixer module
# before any sound can be played. The pygame.init() call is necessary maybe.
pygame.mixer.init()
pygame.init()
class MidPlayGUI(QWidget):
def __init__(self):
super().__init__()
self.player = MidPlay()
self.current_midi_label = QLabel()
self.playlist_widget = QListWidget()
self.setWindowTitle("MidPlay - Midi Player")
self.init_ui()
self.timer = QTimer()
self.timer.timeout.connect(self.handle_song_end)
self.timer.start(1000)
def set_volume(self, value):
volume = value / 100
pygame.mixer.music.set_volume(volume)
def update_progress(self):
if self.player.current_midi:
current_time = pygame.mixer.music.get_pos() / 1000 # get_pos returns time in milliseconds NOT SECONDS!
total_time = self.calculate_midi_duration(self.player.current_midi)
progress = current_time / total_time * 100
self.progress_bar.setValue(int(progress))
def calculate_midi_duration(self, midi_file):
total_duration = 0
for track in midi_file.tracks:
track_duration = max([msg.time for msg in track]) if track else 0
total_duration = max(total_duration, track_duration)
return total_duration
def handle_song_end(self):
if self.player.playing and not pygame.mixer.music.get_busy():
self.player.next_song()
if self.player.playlist:
self.player.current_index %= len(self.player.playlist)
filepath = self.player.playlist[self.player.current_index]
filename = os.path.basename(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.update_progress()
def init_ui(self):
#label = QLabel("MidPlay - Midi player")
#label.setStyleSheet("font-size: 20px; font-weight: bold;")
self.progress_bar = QProgressBar()
self.volume_slider = QSlider(Qt.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.setText("Current MIDI: None")
self.playlist_widget.itemDoubleClicked.connect(self.play_selected_song)
pygame.mixer.music.set_endevent(pygame.USEREVENT)
# Buttons
play_button = QPushButton("Play")
play_button.clicked.connect(self.player.play_midi)
pause_button = QPushButton("Pause")
pause_button.clicked.connect(self.player.pause)
stop_button = QPushButton("Stop")
stop_button.clicked.connect(self.player.stop)
next_button = QPushButton("Next")
next_button.clicked.connect(self.player.next_song)
back_button = QPushButton("Back")
back_button.clicked.connect(self.previous_song)
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)
# Window layout
layout = QVBoxLayout()
layout.addWidget(self.current_midi_label)
layout.addWidget(self.playlist_widget)
layout.addWidget(self.progress_bar)
layout.addWidget(self.volume_slider)
layout.addWidget(play_button)
layout.addWidget(pause_button)
layout.addWidget(stop_button)
layout.addWidget(next_button)
layout.addWidget(back_button)
layout.addWidget(add_button)
layout.addWidget(add_folder_button)
layout.addWidget(clear_button)
progress_volume_layout = QHBoxLayout()
progress_volume_layout.addWidget(self.progress_bar)
progress_volume_layout.addWidget(self.volume_slider)
layout.addLayout(progress_volume_layout)
self.setLayout(layout)
# Event handlers
def play_selected_song(self, item):
index = self.playlist_widget.row(item)
self.current_index = index
filepath = self.player.playlist[self.current_index]
self.player.load_midi(filepath)
self.player.play_midi()
pygame.mixer.music.set_endevent(pygame.USEREVENT)
filename = os.path.basename(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
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.load_midi(filepath)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.player.play_midi()
self.playlist_widget.addItem(filename)
self.player.add_to_playlist(filepath)
def load_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Select Folder")
if folder:
for file in os.listdir(folder):
if file.endswith((".midi", ".mid")):
filepath = os.path.join(folder, file)
self.playlist_widget.addItem(file)
self.player.add_to_playlist(filepath) # Only add to playlist, don't load immediately!!!!!!!!!!!!!!!
#probably should be in the MidPlay class
def previous_song(self):
if self.player.playlist:
filepath = self.player.playlist[self.current_index]
filename = os.path.basename(filepath)
self.player.current_index = (self.player.current_index - 1) % len(self.player.playlist)
self.current_midi_label.setText(f"Current MIDI: {filename}")
self.player.play_midi()
def clear_playlist(self):
self.player.clear_playlist()
self.playlist_widget.clear()
def closeEvent(self, event):
confirmation = QMessageBox.question(self, "Exit Confirmation", "Are you sure you want to exit?", QMessageBox.Yes | QMessageBox.No)
if confirmation == QMessageBox.Yes:
pygame.mixer.quit()
pygame.quit()
event.accept()
else:
event.ignore()
class MidPlay:
"""The Heart of Midi Playback"""
def __init__(self):
self.playlist = []
self.current_midi = None
self.playing = False
self.current_index = 0
def load_midi(self, filepath: str) -> None:
def load():
try:
self.current_midi = mido.MidiFile(filepath)
pygame.mixer.music.load(filepath)
except Exception as e:
print(f"Error loading MIDI: {e}")
threading.Thread(target=load).start()
def add_to_playlist(self, filepath: str) -> None:
self.playlist.append(filepath)
def clear_playlist(self) -> None:
self.playlist = []
def play_midi(self) -> None:
def play():
if self.current_midi:
self.current_midi.instruments[0].synthesize()
pygame.mixer.music.play()
self.playing = True
pygame.mixer.music.set_endevent(pygame.USEREVENT)
else:
print("No MIDI file loaded")
threading.Thread(target=play).start()
def pause(self) -> None:
pygame.mixer.music.pause()
self.playing = False
def stop(self) -> None:
pygame.mixer.music.stop()
self.playing = False
def next_song(self) -> None:
#print("Debug: next_song() called", self.playlist) debug line
if self.playlist:
self.current_index = (self.current_index + 1) % len(self.playlist)
filepath = self.playlist[self.current_index]
# If a new MIDI was loaded before the last one ended, respect that as the new playlist start
if self.current_midi and self.playing:
# print("Debug: New MIDI loaded before last one ended") # debug line
self.load_midi(filepath)
self.play_midi()
# print("Debug: Filepath:", filepath) # debug line
# print("Debug: Current MIDI:", self.current_midi) # debug line
"""
if __name__ == '__main__':
app = QApplication([])
player_gui = MidPlayGUI()
player_gui.show()
running = True
while True:
for event in pygame.event.get():
if event.type == pygame.USEREVENT:
player_gui.player.next_song()
if event.type == pygame.QUIT:
running = False
break
app.exec_()
"""

187
python-src/ScanOrg.py Normal file
View File

@ -0,0 +1,187 @@
#Path: ScanOrg.py
# Description: A class to scan and organize music files
import concurrent.futures
import threading
import queue
import zipfile
import py7zr
import rarfile
import os
import mutagen
from PyQt6.QtCore import Qt, 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):
index = self.sourceModel().index(source_row, 0, source_parent)
return self.sourceModel().isDir(index)
# 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']
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
if self.sourceModel().isDir(index):
return True
else:
return self.sourceModel().fileName(index).endswith(tuple(self.allowed_extensions))
# File Scan and Organize
class file_scanner:
def __init__(self):
self.file_list = []
self.cache = {}
def scan(self, path):
def background_scan(self, path):
if path in self.cache:
return self.cache[path]
file_list = []
dirs_queue = queue.Queue()
dirs_queue.put(path)
while not dirs_queue.empty():
current_path = dirs_queue.get()
try:
for root, dirs, files in os.walk(current_path):
for dir in dirs:
dirs_queue.put(os.path.join(root, dir))
for file in files:
if file.endswith(('.mp3', '.wav', '.flac', '.mid', '.midi', '.aiff', '.aif', '.aifc', '.au', '.snd', '.wv', '.wma', '.m4a')):
file_list.append(os.path.join(root, file))
self.cache[current_path] = file_list
except (IOError, PermissionError, FileNotFoundError, OSError) as e:
print(f"Error Scanning Files: {e}")
return file_list
file_list = []
thread = threading.Thread(target=background_scan, args=(path, file_list))
thread.start()
return file_list
def get_file_list(self):
return self.file_list
def clear_file_list(self):
self.file_list = []
class extractor:
def zipviewer(self, index, file_filter_model, list_model, extraction_directory):
if index.isValid() and extraction_directory is not None:
index = file_filter_model.mapToSource(index)
file_path = list_model.filePath(index)
try:
if file_path.endswith(('.zip', '.rar', '.7z')):
with zipfile.ZipFile(file_path, 'r') as zip_ref:
for filename in zip_ref.namelist():
destination = os.path.join(extraction_directory, filename)
zip_ref.extract(filename, extraction_directory)
except (zipfile.BadZipFile, OSError, zipfile.LargeZipFile, zipfile.LargeZipFile) as e:
print(f"Extraction Error: {e}")
return
class organizer:
global metadata_queue
metadata_queue = queue.Queue()
def __init__(self):
self.file_list = []
self.artist_list = []
self.album_list = []
self.genre_list = []
self.year_list = []
self.file_scanner = file_scanner()
self.file_info_cache = {}
def scan(self, path):
if path in self.file_scanner.cache:
self.file_list = self.file_scanner.cache[path]
else:
self.file_list = self.file_scanner.scan(path)
def get_file_list(self):
return self.file_list
def clear_file_list(self):
self.file_list = []
def get_artist_list(self):
return self.artist_list
def get_album_list(self):
return self.album_list
def get_genre_list(self):
return self.genre_list
def get_year_list(self):
return self.year_list
def clear_artist_list(self):
self.artist_list = []
def clear_album_list(self):
self.album_list = []
def clear_genre_list(self):
self.genre_list = []
def clear_year_list(self):
self.year_list = []
def organize(self):
results_queue = queue.Queue()
metadata = pyqtSignal(dict)
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for file in self.file_list:
futures.append(executor.submit(self.get_file_info, file, results_queue))
for future in concurrent.futures.as_completed(futures):
try:
metadata = future.result()
if metadata['artist'] not in self.artist_list:
self.artist_list.append(metadata['artist'])
if metadata['album'] not in self.album_list:
self.album_list.append(metadata['album'])
if metadata['genre'] not in self.genre_list:
self.genre_list.append(metadata['genre'])
if metadata['year'] not in self.year_list:
self.year_list.append(metadata['year'])
except mutagen.mp3.HeaderNotFoundError:
print('Error: ' + file)
continue
while not metadata_queue.put(metadata):
pass
def get_file_info(self, file, results_queue):
try:
audio = mutagen.File(file)
artist = audio['artist'][0]
album = audio['album'][0]
genre = audio['genre'][0]
year = audio['date'][0]
if artist not in self.artist_list:
self.artist_list.append(artist)
if album not in self.album_list:
self.album_list.append(album)
if genre not in self.genre_list:
self.genre_list.append(genre)
if year not in self.year_list:
self.year_list.append(year)
metadata = {
'artist': artist,
'album': album,
'genre': genre,
'year': year
}
self.metadata_extracted.emit(metadata)
except Exception as e:
results_queue.put(None)
print('Error: ' + file)
if os.path.splitext(file)[1] == ('.mp3', '.wav', '.flac', '.m4a', '.wma', 'mid', '.midi'):
self.organize_audio()
audio = mutagen.File(file)

69
python-src/ScanOrg100.py Normal file
View File

@ -0,0 +1,69 @@
import os
from PyQt6.QtCore import QThread, pyqtSignal
class FileScanner(QThread):
items_found = pyqtSignal(list)
scan_complete = pyqtSignal()
def __init__(self, path):
super().__init__()
self.path = path
self.stop_requested = False
self.allowed_extensions = {
'.mid', '.midi', '.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a', '.wma',
'.flp', '.als', '.logic', '.logicx', '.ptx', '.pts', '.cpr', '.rpp',
'.reason', '.sng', '.ardour', '.bwproject'
}
def run(self):
self.scan_directory(self.path)
self.scan_complete.emit()
def scan_directory(self, path):
try:
items = []
with os.scandir(path) as entries:
for entry in entries:
if self.stop_requested:
return
if entry.is_dir():
items.append((entry.path, True))
elif entry.is_file() and entry.name.lower().endswith(tuple(self.allowed_extensions)):
items.append((entry.path, False))
self.items_found.emit(items)
except PermissionError:
print(f"Permission denied: {path}")
except OSError as e:
print(f"Error accessing {path}: {e}")
def stop(self):
self.stop_requested = True
class Organizer:
def __init__(self):
self.file_list = []
self.dir_list = []
self.scanner = None
def start_scan(self, path):
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)
self.scanner.start()
def add_items(self, items):
for path, is_dir in items:
if is_dir:
self.dir_list.append(path)
else:
self.file_list.append(path)
def scan_finished(self):
print(f"Scan complete. Found {len(self.dir_list)} directories and {len(self.file_list)} files.")
def stop_scan(self):
if self.scanner:
self.scanner.stop()
self.scanner.wait()

View File

@ -0,0 +1,94 @@
# imports
import os
import zipfile
import rarfile
import py7zr
import shutil
import tarfile
import argparse
import tqdm
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import pool
# File Compressor
class Compressor:
def __init__(self):
pass
def _compress_folder(self, source_path, archive_file, archive_format):
for root, _, files in os.walk(source_path):
for file in files:
file_path = os.path.join(root, file)
archive_path = os.path.relpath(file_path, source_path)
self._compress_file(file_path, archive_file, archive_path, archive_format)
def _compress_file(self, file_path, archive_file, archive_path, archive_format):
if archive_format == "zip":
with open(file_path, 'rb') as file:
for chunk in iter(lambda: file.read(1024 * 1024), b''):
archive_file.writestr(archive_path, chunk)
elif archive_format == "tar":
archive_file.add(file_path, arcname=archive_path)
elif archive_format == "7z":
archive_file.write(file_path, archive_path)
else:
raise ValueError(f"Unsupported archive format: {archive_format}")
def compress(self, source_path, archive_name, archive_format="zip"):
pbar = tqdm.tqdm(total=100, unit="B", unit_scale=True, desc="Compressing")
supported_formats = ["zip", "tar", "7z"]
if archive_format not in supported_formats:
raise ValueError(f"Unsupported archive format: {archive_format}")
archive_path = os.path.join(os.path.dirname(source_path), f"{archive_name}.{archive_format}")
# Check if source path exists
if not os.path.exists(source_path):
print(f"Source path does not exist: {source_path}")
return
# Check if archive path already exists
if os.path.exists(archive_path):
print(f"Archive path already exists: {archive_path}")
return
# Open archive file based on format
if archive_format == "zip":
archive_file = zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED)
elif archive_format == "tar":
archive_file = tarfile.open(archive_path, mode="w")
elif archive_format == "7z":
archive_file = py7zr.SevenZipFile(archive_path, mode="w")
# Compress the source path
try:
if os.path.isdir(source_path):
self._compress_folder(source_path, archive_file, archive_format)
pbar.update(1)
else:
if os.path.isfile(source_path):
self._compress_file(source_path, archive_file, "", archive_format)
pbar.update(1)
else:
print(f"Source path is not a file or directory: {source_path}")
return
except Exception as e:
print(f"Compressed to: {archive_path} error:{e}")
finally:
archive_file.close() # Ensure closing the archive file
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Compress files")
parser.add_argument("source", help="Path to the file or folder to compress")
parser.add_argument("archive_name", help="Name for the compressed archive")
parser.add_argument("-f", "--format", choices=["zip", "tar", "7z"], default="zip", help="Archive format")
args = parser.parse_args()
compressor = Compressor()
compressor.compress(args.source, args.archive_name, args.format)

172
python-src/bsshpy.py Normal file
View File

@ -0,0 +1,172 @@
import sys
import os
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLineEdit, QPushButton, QTextEdit, QFileDialog, QLabel)
from PyQt6.QtCore import QThread, pyqtSignal
import paramiko
class SSHThread(QThread):
output_received = pyqtSignal(str)
def __init__(self, client, command):
super().__init__()
self.client = client
self.command = command
def run(self):
stdin, stdout, stderr = self.client.exec_command(self.command)
for line in stdout:
self.output_received.emit(line.strip())
for line in stderr:
self.output_received.emit(line.strip())
class SSHWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Enhanced SSH Frontend")
self.setGeometry(100, 100, 800, 600)
self.client = None
central_widget = QWidget()
layout = QVBoxLayout()
connection_layout = QHBoxLayout()
self.host_input = QLineEdit()
self.host_input.setPlaceholderText("Host")
connection_layout.addWidget(self.host_input)
self.user_input = QLineEdit()
self.user_input.setPlaceholderText("Username")
connection_layout.addWidget(self.user_input)
self.password_input = QLineEdit()
self.password_input.setPlaceholderText("Password")
self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
connection_layout.addWidget(self.password_input)
self.connect_button = QPushButton("Connect")
self.connect_button.clicked.connect(self.connect_ssh)
connection_layout.addWidget(self.connect_button)
self.disconnect_button = QPushButton("Disconnect")
self.disconnect_button.clicked.connect(self.disconnect_ssh)
self.disconnect_button.setEnabled(False)
connection_layout.addWidget(self.disconnect_button)
layout.addLayout(connection_layout)
self.output_area = QTextEdit()
self.output_area.setReadOnly(True)
layout.addWidget(self.output_area)
command_layout = QHBoxLayout()
self.command_input = QLineEdit()
self.command_input.setPlaceholderText("Enter command")
command_layout.addWidget(self.command_input)
self.execute_button = QPushButton("Execute")
self.execute_button.clicked.connect(self.execute_command)
command_layout.addWidget(self.execute_button)
layout.addLayout(command_layout)
file_transfer_layout = QHBoxLayout()
self.upload_button = QPushButton("Upload File")
self.upload_button.clicked.connect(self.upload_file)
file_transfer_layout.addWidget(self.upload_button)
self.download_button = QPushButton("Download File")
self.download_button.clicked.connect(self.download_file)
file_transfer_layout.addWidget(self.download_button)
layout.addLayout(file_transfer_layout)
self.status_label = QLabel("Not connected")
layout.addWidget(self.status_label)
central_widget.setLayout(layout)
self.setCentralWidget(central_widget)
def connect_ssh(self):
host = self.host_input.text()
username = self.user_input.text()
password = self.password_input.text()
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(hostname=host, username=username, password=password)
self.status_label.setText(f"Connected to {host}")
self.connect_button.setEnabled(False)
self.disconnect_button.setEnabled(True)
self.output_area.append(f"Connected to {host}\n")
except paramiko.AuthenticationException:
self.output_area.append("Authentication failed.\n")
except Exception as e:
self.output_area.append(f"Error: {str(e)}\n")
def disconnect_ssh(self):
if self.client:
self.client.close()
self.client = None
self.status_label.setText("Not connected")
self.connect_button.setEnabled(True)
self.disconnect_button.setEnabled(False)
self.output_area.append("Disconnected\n")
def execute_command(self):
if not self.client:
self.output_area.append("Not connected. Please connect first.\n")
return
command = self.command_input.text()
self.output_area.append(f"$ {command}\n")
self.command_input.clear()
self.ssh_thread = SSHThread(self.client, command)
self.ssh_thread.output_received.connect(self.update_output)
self.ssh_thread.start()
def update_output(self, output):
self.output_area.append(output + "\n")
def upload_file(self):
if not self.client:
self.output_area.append("Not connected. Please connect first.\n")
return
file_path, _ = QFileDialog.getOpenFileName(self, "Select File to Upload")
if file_path:
try:
sftp = self.client.open_sftp()
remote_path = f"/home/{self.user_input.text()}/{os.path.basename(file_path)}"
sftp.put(file_path, remote_path)
sftp.close()
self.output_area.append(f"Uploaded {file_path} to {remote_path}\n")
except Exception as e:
self.output_area.append(f"Upload failed: {str(e)}\n")
def download_file(self):
if not self.client:
self.output_area.append("Not connected. Please connect first.\n")
return
remote_path, ok = QFileDialog.getText(self, "Download File", "Enter remote file path:")
if ok and remote_path:
try:
sftp = self.client.open_sftp()
local_path, _ = QFileDialog.getSaveFileName(self, "Save File As")
if local_path:
sftp.get(remote_path, local_path)
sftp.close()
self.output_area.append(f"Downloaded {remote_path} to {local_path}\n")
except Exception as e:
self.output_area.append(f"Download failed: {str(e)}\n")
if __name__ == "__main__":
app = QApplication(sys.argv)
window = SSHWindow()
window.show()
sys.exit(app.exec())

74
python-src/extraction.py Normal file
View File

@ -0,0 +1,74 @@
# extractor.py
import os
import zipfile
import rarfile
import py7zr
import argparse
from tqdm import tqdm
from concurrent.futures import ThreadPoolExecutor
class Extractor:
def zipviewer(self, source, destination):
print(f"checking if {source} exists")
if not os.path.exists(source):
print(f"Error: Archive file not found: {source}")
return
try:
print(f"checking if {destination} exists")
if not os.path.exists(destination):
print(f"{destination} does not exist, creating {destination}")
os.makedirs(destination)
print(f"{destination} created")
else:
print(f"{destination} exists")
print(f"checking if {source} is a valid archive file")
if source.endswith(".zip"):
print(f"Extracting all files from {source} to {destination}")
with zipfile.ZipFile(source, 'r') as zip_ref:
zip_ref.extractall(destination)
print(f"Extracted all files from {source} to {destination}")
elif source.endswith(".rar, .tar.gz, .tar.bz2, .tar.xz, .tar.zst"):
with rarfile.RarFile(source, 'r') as rar_ref:
rar_ref.extractall(destination)
print(f"Extracted all files from {source} to {destination}")
elif source.endswith(".7z"):
with py7zr.SevenZipFile(source, 'r') as sevenzip_ref:
sevenzip_ref.extractall(destination)
print(f"Extracted all files from {source} to {destination}")
else:
print(f"Unsupported file format: {source}")
except (zipfile.BadZipFile, zipfile.LargeZipFile) as e:
print(f"ZIP Extraction Error: {e}")
except (rarfile.RarFileException, rarfile.NotRARFile) as e:
print(f"RAR Extraction Error: {e}")
except py7zr.exceptions.SevenZipException as e:
print(f"7z Extraction Error: {e}")
except OSError as e:
print(f"Extraction Error: {e}")
def main():
print("Welcome to the Archive Extractor!")
parser = argparse.ArgumentParser(description="Compress or extract files")
subparsers = parser.add_subparsers(title="Command", dest="command")
# Subparser for extraction
extract_parser = subparsers.add_parser("extract")
extract_parser.add_argument("source", help="Path to the archive file")
extract_parser.add_argument("destination", help="Extraction directory")
args = parser.parse_args()
if args.command == "extract":
print(f"Extracting {args.source} to {args.destination}")
extractor = Extractor()
extractor.zipviewer(args.source, args.destination)
if __name__ == "__main__":
main()

147
python-src/maintest.py Normal file
View File

@ -0,0 +1,147 @@
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
import sys
import os
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QPushButton,
QTreeView, QLabel, QStyle, QFileDialog, QHBoxLayout, QLineEdit, QProgressBar)
from PyQt6.QtCore import Qt, QDir, QTimer
from PyQt6.QtGui import QStandardItemModel, QStandardItem
from testmid import MidPlay
from ScanOrg100 import Organizer
from timer_m import Timer_Ui
class Fbrowser(QMainWindow):
def __init__(self):
super().__init__()
self.midplay = None
self.current_path = QDir.homePath()
self.organizer = Organizer()
self.init_ui()
self.timer = Timer_Ui()
def init_ui(self):
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)
layout.addLayout(address_layout)
# Add a progress bar
self.progress_bar = QProgressBar()
layout.addWidget(self.progress_bar)
# File view
self.file_model = QStandardItemModel()
self.file_tree = QTreeView()
self.file_tree.setModel(self.file_model)
self.file_tree.doubleClicked.connect(self.on_item_double_clicked)
layout.addWidget(self.file_tree)
# MidPlay button
open_midplay_button = QPushButton('Open MidPlay')
open_midplay_button.clicked.connect(self.open_midplay)
layout.addWidget(open_midplay_button)
self.setCentralWidget(central_widget)
self.resize(800, 600)
# Timer Button
self.timer_button = QPushButton('Start Timer')
self.timer_button.clicked.connect(self.open_timer)
layout.addWidget(self.timer_button)
self.scan_current_directory()
def scan_current_directory(self):
self.organizer.start_scan(self.current_path)
self.progress_bar.setRange(0, 0) # Indeterminate progress
self.organizer.scanner.scan_complete.connect(self.scan_finished)
def scan_finished(self):
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(100)
self.update_file_tree()
def navigate_to_address(self):
new_path = self.address_bar.text()
if os.path.isdir(new_path):
self.current_path = new_path
self.scan_current_directory()
else:
self.address_bar.setText(self.current_path)
def update_file_tree(self):
self.file_model.clear()
self.file_model.setHorizontalHeaderLabels(['Name'])
root = self.file_model.invisibleRootItem()
# Add directories
for path in self.organizer.dir_list:
name = os.path.basename(path)
item = QStandardItem(name)
item.setData(path, Qt.ItemDataRole.UserRole)
item.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon))
root.appendRow(item)
# Add files
for path in self.organizer.file_list:
name = os.path.basename(path)
item = QStandardItem(name)
item.setData(path, Qt.ItemDataRole.UserRole)
item.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon))
root.appendRow(item)
self.file_tree.sortByColumn(0, Qt.SortOrder.AscendingOrder)
def on_item_double_clicked(self, index):
item = self.file_model.itemFromIndex(index)
path = item.data(Qt.ItemDataRole.UserRole)
if os.path.isdir(path):
self.current_path = path
self.address_bar.setText(path)
self.scan_current_directory()
else:
self.play_file(path)
def playingcheck_and_stop(self):
if self.midplay.is_playing():
self.midplay.stop()
def play_file(self, file_path):
if not self.midplay:
self.midplay = MidPlay()
#self.midplay.showUI()
self.midplay.add_to_playlist(file_path)
def go_back(self):
parent_dir = os.path.dirname(self.current_path)
if parent_dir != self.current_path:
self.current_path = parent_dir
self.address_bar.setText(parent_dir)
self.scan_current_directory()
def open_midplay(self):
if not self.midplay:
self.midplay = MidPlay()
self.midplay.showUI()
def open_timer(self):
self.timer.showUI()
if __name__ == '__main__':
app = QApplication(sys.argv)
ex = Fbrowser()
ex.show()
sys.exit(app.exec())

178
python-src/paqtest.py Normal file
View File

@ -0,0 +1,178 @@
import os
import argparse
import subprocess
from multiprocessing import Pool
from sys import platform
def set_archive_filename(output: str, paq8l_version: str) -> str:
basename, ext = os.path.splitext(output)
if ext == 'paq8l{}'.format(paq8l_version):
return output
if ext == 'paq8l':
return output + paq8l_version
else:
return output + '.paq8l' + paq8l_version
def compress_file(file: str, output: str, exe_filename: str, compression_arg: str, paq8l_version: str) -> None:
output = set_archive_filename(output, paq8l_version)
if platform == "win32":
cmd = [exe_filename, compression_arg, file, output]
else:
cmd = "{} {} \"{}\" \"{}\"".format(exe_filename, compression_arg, file, output)
print(cmd)
subprocess.run(cmd, shell=True)
def test_archive(input_location: str, archive: str, exe_filename: str, paq8l_version: str) -> None:
archive = set_archive_filename(archive, paq8l_version)
if platform == "win32":
cmd = [exe_filename, '-t', archive]
else:
cmd = "{} -t \"{}\" \"{}\"".format(exe_filename, archive, input_location)
print(cmd)
subprocess.run(cmd, shell=True)
def create_text_file(filelist: list, input_location: str, filename: str) -> str:
if filelist:
filelist_path = os.path.join(input_location, filename + '.txt')
print("Writing filelist.txt")
txt_file = open(filelist_path, 'w')
txt_file.write('\n')
for file in filelist:
if not os.path.isdir(file):
txt_file.write(file + '\n')
txt_file.close()
return '@' + filelist_path
else:
return input_location
def compression_args(args: argparse) -> str:
if not args.level:
level = '9'
else:
level = args.level
def get_output_location(args: argparse) -> str:
if not args.output:
output_location = args.input
else:
output_location = args.output
return output_location
def parse_action(args: argparse) -> tuple:
action = "compress"
action_finished = "Compression"
if args.test and not args.test_only:
action += " and test"
action_finished += " and testing"
if args.test_only:
action = "test"
action_finished = "Testing"
return action, action_finished
def single_threaded_compression(args: argparse, input_location: str, output_location: str, filename: str,
exe_filename: str, paq8l_version: str, compression_args: str) -> None:
filelist = []
action, _ = parse_action(args)
if os.path.isdir(input_location):
print("Listing files to {}".format(action))
for dir_, _, files in os.walk(input_location):
for fileName in sorted(files):
rel_file = os.path.join(fileName)
filelist.append(rel_file)
print(rel_file)
single_file = False
else:
print("file to {}".format(action), filename)
single_file = True
if (filelist or single_file) and not args.test_only:
filename = create_text_file(filelist, input_location, filename)
print("\nStarting compression...\n")
compress_file(filename, output_location, exe_filename, compression_args)
if args.test or args.test_only:
print("\nVerifying archive...\n")
test_archive(input_location, output_location, exe_filename)
def multithreaded_compression(args: argparse, input_location: str, output_location: str, filename: str,
exe_filename: str, compression_args: str) -> None:
if os.path.isdir(input_location):
print("Compressing each file separately")
pool = Pool()
for file in sorted(os.listdir(input_location)):
file_path = os.path.join(input_location, file)
pool.apply_async(compress_file, (file_path, file_path, exe_filename, compression_args))
pool.close()
pool.join()
else:
print("file to compress:", filename)
print("\nStarting compression...\n")
compress_file(input_location, output_location, exe_filename, compression_args)
if args.test or args.test_only:
print("\nVerifying archive is not yet implemented for multi-threaded individual file compression...\n")
def main() -> None:
parser = argparse.ArgumentParser(description='This script will generate a filelist file which will be used by '
'paq8l_v207 for compressing. It is also used for testing if you '
'use the -t or -to argument')
required = parser.add_argument_group('required arguments')
optional = parser.add_argument_group('optional arguments')
required.add_argument('-i', '--input', help="Input file or folder to compress. REQUIRED", required=True)
optional.add_argument('-v', '--version', help='Version of paq8l to use. Example: 207. Default is 207',
required=False, default='207')
optional.add_argument('-l', '--level', help="Compression level and switches. Example: 9a to compress using level 9 "
"and with the 'Adaptive learning rate' switch. Default is 9",
required=False, default='9')
optional.add_argument('-o', '--output', help="Output file to use. If not used, the archive will be saved at the "
"root of the parent folder where the file/folder to compress is "
"located. Do not provide extension", required=False, default=None)
optional.add_argument('-t', '--test', help="Optional flag to test the archive after compressing it. It is "
"recommended to use this option. Default is not to test",
required=False, action='store_true')
optional.add_argument('-to', '--test-only', help="Skip compression and just test the archive.",
required=False, action='store_true')
optional.add_argument('-r', '--remove', help="Deletes the filelist text file. Not recommended unless you plan not "
"to test the archive later. Default is not to remove", required=False,
default=False, action='store_true')
optional.add_argument('-mt', '--multithread', help="Compresses each file on a separate thread. This creates "
"individual archives with just one file", required=False,
default=False, action='store_true')
optional.add_argument('-n', '--nativecpu', help="Use the native CPU version. "
"These versions usually ends with _nativecpu and may provide "
"performane improvements on your machine over the generic version",
required=False,
default=False, action='store_true')
args = parser.parse_args()
# Variables:
exe_filename = "/home/stan/Documents/Dev/Fbroswer/paq8l"
compression_args = '-' + args.level
input_location = args.input
output_location = get_output_location(args)
filename = os.path.basename(input_location)
# Compression
if not args.multithread:
single_threaded_compression(args, input_location, output_location, filename,
exe_filename, compression_args)
else:
multithreaded_compression(args, input_location, output_location, filename,
exe_filename)
# Remove file list if not in multithreaded mode.
if args.remove and not args.multithread:
print("\nRemoving the filelist file")
os.remove(os.path.join(input_location, filename + '.txt'))
_, action_finished = parse_action(args)
print("\n{} finished!".format(action_finished))
if __name__ == "__main__":
main()

9
python-src/readme.txt Normal file
View File

@ -0,0 +1,9 @@
<<<<<<< HEAD
FBroswer is a sample and loop organizer and browser with extra features
such as the ability to sample that audio you're checking out
full midi play back support with soundfont controls
a timer to help balance work life and personal life
a way to keep track of what you're listening to
=======
fbrowser is a sample and loops audio organizer and browser
>>>>>>> 1ee9caf82243dd45a72a97bf6c5de681139670e2

View File

@ -1,10 +1,12 @@
PyQT5
PyQt6
matplotlib
rarfile
py7zr
pygame
mido
numpy
pyFluidSynth
mutagen
sounddevice
ifireflylib
numpy
crypto
django
pyFluidSynth

152
python-src/stanzip.py Normal file
View File

@ -0,0 +1,152 @@
# stanzip.py
# Description: Can Compress and Extract files using Various libraries more compression methods are going to be added
#
import os
import zipfile
import rarfile
import py7zr
import shutil
import argparse
import tqdm
from concurrent.futures import ThreadPoolExecutor
# File Extractor
class Extractor:
def zipviewer(self, source, destination):
if not os.path.exists(source):
print(f"Error: Archive file not found: {source}")
return
try:
pbar = tqdm.tqdm(total=100, desc="Extracting Archive file")
if not os.path.exists(destination):
os.makedirs(destination)
pbar.update(1)
if source.endswith(".zip"):
with zipfile.ZipFile(source, 'r') as zip_ref:
with tqdm.tqdm(total=len(zipfile.ZipFile(source).namelist()), desc="Extracting ZIP files") as pbar:
for filename in zip_ref.namelist():
zip_ref.extract(filename, destination)
pbar.update(1)
print(f"Extracted all files from {source} to {destination}")
elif source.endswith(".rar, .tar.gz, .tar.bz2, .tar.xz, .tar.zst"):
with rarfile.RarFile(source, 'r') as rar_ref:
with tqdm.tqdm(total=len(rar_ref.namelist()), desc="Extracting RAR files") as pbar:
for filename in rar_ref.namelist():
rar_ref.extractall(filename, destination)
pbar.update(1)
print(f"Extracted all files from {source} to {destination}")
elif source.endswith(".7z"):
with py7zr.SevenZipFile(source, 'r') as sevenzip_ref:
with tqdm.tqdm(total=len(sevenzip_ref.namelist()), desc="Extracting 7z files") as pbar:
for filename in sevenzip_ref.namelist():
sevenzip_ref.extractall(filename, destination)
pbar.update(1)
print(f"Extracted all files from {source} to {destination}")
else:
print(f"Unsupported file format: {source}")
except (zipfile.BadZipFile, zipfile.LargeZipFile) as e:
print(f"ZIP Extraction Error: {e}")
except (rarfile.RarFileException, rarfile.NotRARFile) as e:
print(f"RAR Extraction Error: {e}")
except py7zr.exceptions.SevenZipException as e:
print(f"7z Extraction Error: {e}")
except OSError as e:
print(f"Extraction Error: {e}")
# File Compressor
class Compressor:
def __init__(self):
pass
def _compress_folder(self, source_path, zip_file):
for root, _, files in os.walk(source_path):
for file in files:
file_path = os.path.join(root, file)
archive_path = os.path.relpath(file_path, source_path)
self._compress_file(file_path, zip_file, archive_path)
def _compress_file(self, file_path, zip_file, archive_path=None):
if not archive_path:
archive_path = os.path.basename(file_path)
if archive_path.endswith(".zip"):
return
with open(file_path, 'rb') as file:
for chunk in iter(lambda: file.read(1024 * 1024), b''):
zip_file.writestr(archive_path, chunk)
def compress(self, source_path, archive_name, archive_format="zip"):
if archive_format != "zip":
raise ValueError(f"Unsupported archive format: {archive_format}")
archive_path = os.path.join(os.path.dirname(source_path), f"{archive_name}.{archive_format}")
# Check if source path exists
if not os.path.exists(source_path):
print(f"Source path does not exist: {source_path}")
return
# Compress the source path
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zip_file:
if os.path.isdir(source_path):
print(f"Compressing folder: {source_path}")
self._compress_folder(source_path, zip_file)
else:
print(f"Compressing file: {source_path}")
self._compress_file(source_path, zip_file)
print(f"Compressed to: {archive_path}")
if os.path.isdir(source_path):
file_list = []
for root, _, files in os.walk(source_path):
for file in files:
file_path = os.path.join(root, file)
file_list.append(file_path)
# Use thread pool
with ThreadPoolExecutor(max_workers=4) as executor:
for file_path in file_list:
executor.submit(self._compress_file, file_path, zip_file)
executor.shutdown(wait=True)
def main():
parser = argparse.ArgumentParser(description="Compress or extract files")
subparsers = parser.add_subparsers(title="Command", dest="command")
# Subparser for extraction
extract_parser = subparsers.add_parser("extract")
extract_parser.add_argument("source", help="Path to the archive file")
extract_parser.add_argument("destination", help="Extraction directory")
# Subparser for compression
compress_parser = subparsers.add_parser("compress")
compress_parser.add_argument("source", help="Path to the file or folder to compress")
compress_parser.add_argument("archive_name", help="Name for the compressed archive")
compress_parser.add_argument("-f", "--format", choices=["zip"], default="zip", help="Archive format (default: zip)")
args = parser.parse_args()
if args.command == "extract":
extractor = Extractor()
extractor.zipviewer(args.source, args.destination)
if args.command == "compress":
compressor = Compressor()
compressor.compress(args.source, args.archive_name, args.format)
else:
print("Invalid command. Use 'extract' or 'compress'")
if __name__ == "__main__":
main()

168
python-src/test.py Normal file
View File

@ -0,0 +1,168 @@
import mido as pretty_midi
import random
import tkinter as tk
from tkinter import ttk, filedialog
import pygame
import pypianoroll # type: ignore
from icecream import ic # type: ignore
class midgen:
def __init__(self, status_label: ttk.Label):
self.status_label = status_label
self.scales = self.scales()
def scales(self):
scales = {
"Major": [0, 2, 4, 5, 7, 9, 11],
"Minor": [0, 2, 3, 5, 7, 8, 10],
"Pentatonic": [0, 2, 4, 7, 9],
"Blues": [0, 3, 5, 6, 7, 10],
"Whole Tone": [0, 2, 4, 6, 8, 10],
"Chromatic": [i for i in range(12)],
"Octatonic": [0, 1, 3, 4, 6, 7, 9, 10],
"Harmonic Minor": [0, 2, 3, 5, 7, 8, 11],
"Melodic Minor": [0, 2, 3, 5, 7, 9, 11],
"Dorian": [0, 2, 3, 5, 7, 9, 10],
"Phrygian": [0, 1, 3, 5, 7, 8, 10],
"Lydian": [0, 2, 4, 6, 7, 9, 11],
"Mixolydian": [0, 2, 4, 5, 7, 9, 10],
"Locrian": [0, 1, 3, 5, 6, 8, 10],
"Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
"Whole Half Diminished": [0, 2, 3, 5, 6, 8, 9, 11],
"Arabian": [0, 2, 4, 5, 6, 8, 10],
"Hungarian Minor": [0, 2, 3, 6, 7, 8, 11],
"Enigmatic": [0, 1, 4, 6, 8, 10, 11],
"Neapolitan Major": [0, 1, 3, 5, 7, 9, 11],
"Neapolitan Minor": [0, 1, 3, 5, 7, 8, 11],
"Bluesy": [0, 3, 5, 6, 7, 10],
"Hawaiian": [0, 2, 3, 7, 9],
"Japanese": [0, 1, 5, 7, 8],
"Chinese": [0, 4, 6, 7, 11],
"Gypsy": [0, 2, 3, 6, 7, 8, 10],
"Hirojoshi": [0, 2, 3, 7, 8],
"In Sen": [0, 1, 5, 7, 10],
"Iwato": [0, 1, 5, 6, 10],
"Kumoi": [0, 2, 3, 7, 9],
"Pelog": [0, 1, 3, 7, 8],
"Ryukyu": [0, 4, 5, 7, 11],
"Spanish": [0, 1, 3, 4, 5, 6, 8, 10],
"Todi": [0, 1, 3, 6, 7, 8, 11],
"Yo": [0, 2, 5, 7, 9]
}
return scales
def generate_midi(self):
self.status_label.config(text='Generating MIDI...')
try:
midi = pretty_midi.PrettyMIDI()
instrument = pretty_midi.Instrument(0)
scale = random.choice(list(self.scales.keys()))
scale_notes = self.scales[scale]
ic(f"Using scale: {scale}")
ic(f"Using notes: {scale_notes}")
for start, end in zip(range(0, 100, 10), range(10, 110, 10)):
note = pretty_midi.Note(
velocity=100, pitch=random.choice(scale_notes),
start=start, end=end
)
instrument.notes.append(note)
midi.instruments.append(instrument)
filepath = filedialog.asksaveasfilename(defaultextension='.mid')
if filepath:
midi.write(filepath)
track = pypianoroll.Multitrack(filepath)
track.plot()
self.status_label.config(text='MIDI generated successfully!')
except Exception as e:
self.status_label.config(text=f"Error generating MIDI: {e}")
class MidPlay:
"""A class to handle MIDI file playback."""
def __init__(self):
self.playlist = []
self.current_midi = None
self.playing = False
pygame.mixer.init()
def load_midi(self, filepath: str) -> None:
try:
self.current_midi = pretty_midi.PrettyMIDI(filepath)
pygame.mixer.music.load(filepath)
except Exception as e:
print(f"Error loading MIDI: {e}")
def add_to_playlist(self, filepath: str) -> None:
"""Adds a MIDI file to the playlist.
Args:
filepath: The path to the MIDI file.
"""
self.playlist.append(filepath)
def clear_playlist(self) -> None:
"""Clears the playlist."""
self.playlist = []
def play_midi(self) -> None:
"""Starts or resumes playback of the current MIDI file."""
if self.current_midi:
self.current_midi.instruments[0].synthesize()
pygame.mixer.music.play()
self.playing = True
else:
print("No MIDI file loaded")
def pause(self) -> None:
"""Pauses playback."""
pygame.mixer.music.pause()
self.playing = False
def stop(self) -> None:
"""Stops playback."""
pygame.mixer.music.stop()
self.playing = False
class UserInterface:
def __init__(self):
self.root = tk.Tk()
self.root.title("MIDI Generator")
self.root.geometry("400x200")
self.root.resizable(True, True)
self.status_label = ttk.Label(self.root, text="")
self.status_label.pack()
self.midi_generator = midgen(self.status_label)
self.midi_player = MidPlay()
self.filepath = None
self.midi = None
self.generate_button = ttk.Button(self.root, text="Generate MIDI", command=self.midi_generator.generate_midi)
self.generate_button.pack()
self.load_button = ttk.Button(self.root, text="Load MIDI", command=lambda: self.midi_player.load_midi(self.filepath))
self.load_button.pack()
self.play_button = ttk.Button(self.root, text="Play MIDI", command=lambda: self.midi_player.play_midi())
self.play_button.pack()
self.exit_button = ttk.Button(self.root, text="Exit", command=self.root.quit)
self.exit_button.pack()
window = tk.Tk()
window.title("MIDI Generator")
self.root.mainloop()
if __name__ == "__main__":
ui = UserInterface()

View File

@ -0,0 +1,37 @@
import unittest
from unittest.mock import MagicMock
from PyQt5.QtWidgets import QApplication
from Fbrowser import SampleMusicBrowser
class TestSampleMusicBrowser(unittest.TestCase):
def setUp(self):
self.app = QApplication([])
self.browser = SampleMusicBrowser()
def tearDown(self):
self.app.quit()
def test_player_error(self):
# Mock QMediaPlayer and set error code
self.browser.player.error = MagicMock(return_value=1)
self.browser.player.errorString = MagicMock(return_value="Test Error")
self.browser.player_error(1)
# Assert that the error message is printed
self.assertIn("An error occurred: Code:1 Test Error", self.browser.console_output)
def test_player_media_status_changed(self):
# Mock QMediaPlayer and set media status
self.browser.player_media_status_changed(2)
# Assert that the media status is printed
self.assertIn("Media Status: 2", self.browser.console_output)
def test_play_file(self):
# Mock QFileSystemModel and set file path
self.browser.list_model.filePath = MagicMock(return_value="/path/to/file.mp3")
# Call play_file method
self.browser.play_file(None)
# Assert that the player is playing the correct media
self.assertEqual(self.browser.playlist.media(0).canonicalUrl().toString(), "file:///path/to/file.mp3")
if __name__ == '__main__':
unittest.main()

136
python-src/testmid.py Normal file
View File

@ -0,0 +1,136 @@
import os
import subprocess
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QLabel, QFileDialog, QSlider, QListWidget
from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
DEFAULT_SOUNDFONT = '/home/stan/Documents/Dev/Tests/MidPlay/SoundFonts/FinalFantasyVI.sf2'
class MidPlay(QWidget):
def __init__(self):
super().__init__()
self.current_file = None
self.current_soundfont = DEFAULT_SOUNDFONT
self.process = None
self.media_player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output)
self.playlist = []
self.current_index = 0
self.current_volume = 50
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
self.playlist_widget = QListWidget()
self.playlist_widget.itemDoubleClicked.connect(self.play_selected)
layout.addWidget(self.playlist_widget)
self.play_button = QPushButton("Play")
self.play_button.clicked.connect(self.play)
layout.addWidget(self.play_button)
self.stop_button = QPushButton("Stop")
self.stop_button.clicked.connect(self.stop)
layout.addWidget(self.stop_button)
self.next_button = QPushButton("Next")
self.next_button.clicked.connect(self.play_next)
layout.addWidget(self.next_button)
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
self.volume_slider.setRange(0, 100)
self.volume_slider.setValue(self.current_volume)
self.volume_slider.valueChanged.connect(self.set_volume)
layout.addWidget(self.volume_slider)
self.status_label = QLabel("No file loaded")
layout.addWidget(self.status_label)
self.setLayout(layout)
def add_to_playlist(self, file_path):
self.playlist.append(file_path)
self.playlist_widget.addItem(os.path.basename(file_path))
if not self.current_file:
self.current_file = file_path
self.play()
def play_selected(self, item):
index = self.playlist_widget.row(item)
self.current_index = index
self.current_file = self.playlist[index]
self.play()
def play(self):
if not self.current_file:
self.status_label.setText("No file selected")
return
self.stop() # Stop any current playback
if self.current_file.lower().endswith(('.mid', '.midi')):
self.play_midi()
else:
self.play_audio()
def play_midi(self):
self.media_player.stop()
command = [
"fluidsynth",
"-a", "pulseaudio",
"-g", str(self.current_volume / 50),
self.current_soundfont,
self.current_file
]
self.process = subprocess.Popen(command)
self.status_label.setText(f"Playing MIDI: {os.path.basename(self.current_file)}")
def play_audio(self):
if self.process:
self.process.terminate()
self.process = None
self.media_player.setSource(QUrl.fromLocalFile(self.current_file))
self.media_player.play()
self.status_label.setText(f"Playing Audio: {os.path.basename(self.current_file)}")
self.media_player.mediaStatusChanged.connect(self.handle_media_status_change)
def stop(self):
if self.process:
self.process.terminate()
self.process = None
self.media_player.stop()
self.status_label.setText("Playback stopped")
def set_volume(self, value):
self.current_volume = value
self.audio_output.setVolume(value / 50)
if self.process and self.current_file.lower().endswith(('.mid', '.midi')):
self.update_midi_volume()
def update_midi_volume(self):
pass
def play_next(self):
if self.playlist:
self.current_index = (self.current_index + 1) % len(self.playlist)
self.current_file = self.playlist[self.current_index]
self.play()
def handle_media_status_change(self, status):
if status == QMediaPlayer.MediaStatus.EndOfMedia:
self.play_next()
def closeEvent(self, event):
self.stop()
super().closeEvent(event)
def showUI(self):
self.setWindowTitle("MidPlay")
super().show()
def close(self):
self.stop()
super().close()

159
python-src/testmidi.py Normal file
View File

@ -0,0 +1,159 @@
import sys
import os
from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QFileSystemModel, QTreeView, QLabel, QComboBox
from PyQt5.QtCore import QDir, Qt, QThread, pyqtSignal
import mido
import pygame
import numpy as np
from mingus.midi import fluidsynth
from mingus.containers import Note
import soundfile as sf
class MidiPlayerThread(QThread):
update_signal = pyqtSignal(str)
def __init__(self, file_path):
super().__init__()
self.file_path = file_path
self.playing = True
def run(self):
midi_file = mido.MidiFile(self.file_path)
for msg in midi_file.play():
if not self.playing:
break
if not msg.is_meta:
if msg.type == 'note_on':
fluidsynth.play_Note(Note(msg.note), msg.channel, msg.velocity)
elif msg.type == 'note_off':
fluidsynth.stop_Note(Note(msg.note), msg.channel)
elif msg.type == 'control_change':
fluidsynth.control_change(msg.channel, msg.control, msg.value)
self.update_signal.emit(f"Playing: {msg}")
def stop(self):
self.playing = False
class AudioPlayerThread(QThread):
update_signal = pyqtSignal(str)
def __init__(self, file_path):
super().__init__()
self.file_path = file_path
self.playing = True
def run(self):
pygame.mixer.music.load(self.file_path)
pygame.mixer.music.play()
while pygame.mixer.music.get_busy() and self.playing:
pygame.time.Clock().tick(10)
self.update_signal.emit(f"Playing audio: {pygame.mixer.music.get_pos() / 1000:.2f} seconds")
def stop(self):
self.playing = False
pygame.mixer.music.stop()
class MidiPlayer(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("MIDI Player and Audio File Browser")
self.setGeometry(100, 100, 800, 600)
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.layout = QHBoxLayout(self.central_widget)
# File Browser
self.model = QFileSystemModel()
self.model.setRootPath(QDir.rootPath())
self.model.setNameFilters(["*.mid", "*.midi", "*.mp3", "*.wav", "*.sf2"])
self.model.setNameFilterDisables(False)
self.tree = QTreeView()
self.tree.setModel(self.model)
self.tree.setRootIndex(self.model.index(QDir.homePath()))
self.tree.setColumnWidth(0, 250)
self.tree.setAnimated(False)
self.tree.setIndentation(20)
self.tree.setSortingEnabled(True)
self.tree.setWindowTitle("File Browser")
self.tree.clicked.connect(self.on_file_clicked)
# Player controls
self.player_widget = QWidget()
self.player_layout = QVBoxLayout(self.player_widget)
self.file_label = QLabel("No file selected")
self.player_layout.addWidget(self.file_label)
self.play_button = QPushButton("Play")
self.play_button.clicked.connect(self.play_file)
self.player_layout.addWidget(self.play_button)
self.stop_button = QPushButton("Stop")
self.stop_button.clicked.connect(self.stop_file)
self.player_layout.addWidget(self.stop_button)
self.soundfont_combo = QComboBox()
self.soundfont_combo.currentIndexChanged.connect(self.change_soundfont)
self.player_layout.addWidget(self.soundfont_combo)
self.status_label = QLabel("")
self.player_layout.addWidget(self.status_label)
# Add widgets to main layout
self.layout.addWidget(self.tree)
self.layout.addWidget(self.player_widget)
# Initialize pygame mixer
pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=1024)
# Initialize FluidSynth
fluidsynth.init(sf2="/path/to/default/soundfont.sf2") # Adjust this path as needed
self.player_thread = None
def on_file_clicked(self, index):
file_path = self.model.filePath(index)
self.file_label.setText(os.path.basename(file_path))
if file_path.lower().endswith('.sf2'):
self.load_soundfont(file_path)
def load_soundfont(self, sf2_path):
try:
fluidsynth.init(sf2=sf2_path)
self.soundfont_combo.clear()
self.soundfont_combo.addItems([f"Instrument {i}" for i in range(128)]) # MIDI has 128 standard instruments
except Exception as e:
print(f"Error loading soundfont: {e}")
def change_soundfont(self, index):
fluidsynth.set_instrument(0, index) # Set instrument for channel 0
def play_file(self):
file_path = self.model.filePath(self.tree.currentIndex())
if file_path.lower().endswith(('.mid', '.midi')):
self.player_thread = MidiPlayerThread(file_path)
elif file_path.lower().endswith(('.mp3', '.wav')):
self.player_thread = AudioPlayerThread(file_path)
else:
return
self.player_thread.update_signal.connect(self.update_status)
self.player_thread.start()
def stop_file(self):
if self.player_thread and self.player_thread.isRunning():
self.player_thread.stop()
self.player_thread.wait()
pygame.mixer.stop()
fluidsynth.stop_everything()
def update_status(self, status):
self.status_label.setText(status)
if __name__ == "__main__":
app = QApplication(sys.argv)
player = MidiPlayer()
player.show()
sys.exit(app.exec_())

141
python-src/timer_m.py Normal file
View File

@ -0,0 +1,141 @@
import time
import threading
import subprocess
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QLabel, QHBoxLayout, QLineEdit
from PyQt6.QtCore import QTimer, QUrl
from PyQt6.QtMultimedia import QSoundEffect
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:
for _ in range(3):
subprocess.run(["aplay", "/usr/share/sounds/sound-icons/glass.wav"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True)
except subprocess.CalledProcessError:
print("Error playing alarm sound")
self._fallback_alarm()
def _fallback_alarm(self):
sound = QSoundEffect()
sound.setSource(QUrl.fromLocalFile("/usr/share/sounds/sound-icons/glass.wav"))
sound.play()
class Timer_Ui(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Timer")
self.width = 400
self.height = 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, self.width, self.height)
super().show()
if __name__ == "__main__":
import sys
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv)
Timerui = Timer_Ui()
Timerui.showUI()
sys.exit(app.exec())

129
readme.md
View File

@ -1,129 +0,0 @@
# 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 (Via Qt Media powered by ffmpeg)
- **MIDI Player**: Full-featured MIDI player with playlist management (synthesis of midi's powered by fluidsynth)
- **Metadata Extraction**: View and explore audio file metadata
- **Archive Management**: Extract ZIP, RAR, and 7z archives # Not fully implemented no way to use this outside of a custom debugger tool. (not included)
- **Timer Utility**: Built-in countdown timer with audio alerts
- **Hover Tooltips**: Quick metadata preview by hovering over audio files
## Requirements
- Python 3.6+ (maybe 3.8 or newer)
- PyQt6
- FluidSynth (for MIDI playback)
- Mido (MIDI handling library)
- Mutagen (audio metadata extraction)
- py7zr and rarfile (archive handling)
- SoundFont (.sf2) files for MIDI playback
- ifireflylib (Database System)
## Installation
### 1. Clone the repository
```bash
git clone https://gitea.innovativedevsolutions.org/stan44/Fbrowser.git
cd fbroswer
```
### 2. Install dependencies
```bash
pip install pyqt6 fluidsynth-midi mido mutagen py7zr rarfile numpy sounddevice ifireflylib
```
### 3. Install FluidSynth
- **Windows**: Download from [FluidSynth website](https://www.fluidsynth.org/) (you can also use choco [chocolatey website](https://chocolatey.org/))
- **Windows Choco**: `choco install fluidsynth`
- **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!*

22
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "fbrowser-desktop"
version.workspace = true
edition.workspace = true
license.workspace = true
[build-dependencies]
tauri-build = { version = "2.2.0", features = [] }
[dependencies]
anyhow.workspace = true
chrono.workspace = true
fbrowser-archive = { path = "../crates/fbrowser-archive" }
fbrowser-audio = { path = "../crates/fbrowser-audio" }
fbrowser-core = { path = "../crates/fbrowser-core" }
fbrowser-midi = { path = "../crates/fbrowser-midi" }
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tauri.workspace = true
tauri-plugin-dialog = "2.3.1"
tokio.workspace = true

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop capability for Fbrowser.",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open",
"dialog:allow-save"
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default desktop capability for Fbrowser.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","dialog:allow-save"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

496
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,496 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::fs;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::Utc;
use fbrowser_archive::{compress, extract, ArchiveJobResult, ArchiveJobSpec};
use fbrowser_audio::{generate_waveform, AudioEngine, LoopRegion, PlaybackState};
use fbrowser_core::models::{
AnnotationUpdate, CollectionItemsMutation, CollectionMutation, CollectionRecord, MediaItemDetail,
MetadataPatch, ScanStatus, SearchRequest, SearchResponse,
};
use fbrowser_core::scanner::{scan_root, ScanProgress};
use fbrowser_core::AppDatabase;
use fbrowser_midi::{available_backends, MidiBackendConfig, MidiBackendInfo, MidiEngine};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::task::JoinHandle;
use tokio::time::sleep;
#[derive(Clone)]
struct DesktopState {
db: AppDatabase,
audio: AudioEngine,
midi: MidiEngine,
scan_status: Arc<Mutex<ScanStatus>>,
timer: TimerManager,
midi_config: Arc<Mutex<MidiBackendConfig>>,
active_media_kind: Arc<Mutex<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct TimerState {
running: bool,
remaining_ms: u64,
started_at: Option<String>,
}
#[derive(Clone, Default)]
struct TimerManager {
state: Arc<Mutex<TimerState>>,
task: Arc<Mutex<Option<JoinHandle<()>>>>,
}
impl TimerManager {
fn state(&self) -> TimerState {
self.state.lock().expect("timer state poisoned").clone()
}
fn start(&self, app: AppHandle, duration_ms: u64) -> TimerState {
self.stop();
{
let mut state = self.state.lock().expect("timer state poisoned");
state.running = true;
state.remaining_ms = duration_ms;
state.started_at = Some(Utc::now().to_rfc3339());
}
let state = self.state.clone();
let task = tokio::spawn(async move {
loop {
sleep(Duration::from_secs(1)).await;
let snapshot = {
let mut state = state.lock().expect("timer state poisoned");
if !state.running {
break;
}
if state.remaining_ms <= 1000 {
state.remaining_ms = 0;
state.running = false;
} else {
state.remaining_ms -= 1000;
}
state.clone()
};
let _ = app.emit("timer:tick", &snapshot);
if !snapshot.running {
break;
}
}
});
*self.task.lock().expect("timer task poisoned") = Some(task);
self.state()
}
fn stop(&self) -> TimerState {
if let Some(task) = self.task.lock().expect("timer task poisoned").take() {
task.abort();
}
let mut state = self.state.lock().expect("timer state poisoned");
state.running = false;
state.clone()
}
}
fn database_url(app: &AppHandle) -> anyhow::Result<String> {
let dir = app.path().app_data_dir()?;
fs::create_dir_all(&dir)?;
let db_path = dir.join("fbrowser.sqlite");
Ok(format!("sqlite://{}", db_path.display()))
}
fn desktop_state<'a>(app_state: &'a State<'_, DesktopState>) -> &'a DesktopState {
app_state.inner()
}
#[tauri::command]
async fn scan_add_root(app: AppHandle, state: State<'_, DesktopState>, path: String) -> Result<fbrowser_core::models::LibraryRoot, String> {
let root = desktop_state(&state).db.add_root(&path).await.map_err(|err| err.to_string())?;
start_scan_job(app, desktop_state(&state).clone(), vec![root.clone()]);
Ok(root)
}
#[tauri::command]
async fn scan_remove_root(state: State<'_, DesktopState>, root_id: i64) -> Result<(), String> {
desktop_state(&state).db.remove_root(root_id).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn scan_rescan(app: AppHandle, state: State<'_, DesktopState>, root_id: Option<i64>, all: Option<bool>) -> Result<(), String> {
let roots = desktop_state(&state).db.list_roots().await.map_err(|err| err.to_string())?;
let targets = if all.unwrap_or(false) || root_id.is_none() {
roots
} else {
roots.into_iter().filter(|root| Some(root.id) == root_id).collect()
};
start_scan_job(app, desktop_state(&state).clone(), targets);
Ok(())
}
#[tauri::command]
async fn scan_get_status(state: State<'_, DesktopState>) -> Result<ScanStatus, String> {
let mut status = desktop_state(&state).scan_status.lock().expect("scan status poisoned").clone();
status.roots = desktop_state(&state).db.list_roots().await.map_err(|err| err.to_string())?;
Ok(status)
}
#[tauri::command]
async fn library_search(state: State<'_, DesktopState>, request: SearchRequest) -> Result<SearchResponse, String> {
desktop_state(&state).db.search_library(request).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn library_get_item(state: State<'_, DesktopState>, item_id: i64) -> Result<MediaItemDetail, String> {
desktop_state(&state).db.get_item(item_id).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn library_update_annotations(state: State<'_, DesktopState>, payload: AnnotationUpdate) -> Result<MediaItemDetail, String> {
desktop_state(&state).db.update_annotations(payload).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn library_write_metadata(state: State<'_, DesktopState>, item_id: i64, patch: MetadataPatch) -> Result<MediaItemDetail, String> {
desktop_state(&state).db.write_metadata(item_id, patch).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn collection_list(state: State<'_, DesktopState>) -> Result<Vec<CollectionRecord>, String> {
desktop_state(&state).db.list_collections().await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn collection_create(state: State<'_, DesktopState>, payload: CollectionMutation) -> Result<CollectionRecord, String> {
desktop_state(&state).db.create_collection(payload).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn collection_update(state: State<'_, DesktopState>, id: i64, payload: CollectionMutation) -> Result<CollectionRecord, String> {
desktop_state(&state).db.update_collection(id, payload).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn collection_delete(state: State<'_, DesktopState>, id: i64) -> Result<(), String> {
desktop_state(&state).db.delete_collection(id).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn collection_add_items(state: State<'_, DesktopState>, payload: CollectionItemsMutation) -> Result<(), String> {
desktop_state(&state).db.add_items_to_collection(payload).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn collection_remove_items(state: State<'_, DesktopState>, payload: CollectionItemsMutation) -> Result<(), String> {
desktop_state(&state).db.remove_items_from_collection(payload).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn collection_reorder(state: State<'_, DesktopState>, payload: fbrowser_core::models::ReorderCollectionMutation) -> Result<(), String> {
desktop_state(&state).db.reorder_collection(payload).await.map_err(|err| err.to_string())
}
#[tauri::command]
async fn playback_load(state: State<'_, DesktopState>, path: String, media_kind: String) -> Result<PlaybackState, String> {
let app_state = desktop_state(&state);
let playback = if media_kind == "midi" {
let config = app_state.midi_config.lock().expect("midi config poisoned").clone();
app_state.audio.stop();
app_state.midi.load(&path, &config).map_err(|err| err.to_string())?
} else {
app_state.midi.stop();
app_state.audio.load(&path, &media_kind).map_err(|err| err.to_string())?
};
*app_state.active_media_kind.lock().expect("active media kind poisoned") = media_kind;
Ok(playback)
}
#[tauri::command]
async fn playback_play(state: State<'_, DesktopState>) -> Result<PlaybackState, String> {
let app_state = desktop_state(&state);
let current = if current_media_kind(app_state) == "midi" {
let config = app_state.midi_config.lock().expect("midi config poisoned").clone();
app_state.midi.play(&config).map_err(|err| err.to_string())?
} else {
app_state.audio.play()
};
if let Some(path) = &current.loaded_path {
if let Some(item_id) = app_state.db.find_item_id_by_path(path).await.map_err(|err| err.to_string())? {
let _ = app_state.db.record_play_history(item_id, "transport").await;
}
}
Ok(current)
}
#[tauri::command]
async fn playback_pause(state: State<'_, DesktopState>) -> Result<PlaybackState, String> {
let app_state = desktop_state(&state);
Ok(if current_media_kind(app_state) == "midi" {
app_state.midi.pause()
} else {
app_state.audio.pause()
})
}
#[tauri::command]
async fn playback_stop(state: State<'_, DesktopState>) -> Result<PlaybackState, String> {
let app_state = desktop_state(&state);
Ok(if current_media_kind(app_state) == "midi" {
app_state.midi.stop()
} else {
app_state.audio.stop()
})
}
#[tauri::command]
async fn playback_seek(state: State<'_, DesktopState>, position_ms: u64) -> Result<PlaybackState, String> {
let app_state = desktop_state(&state);
if current_media_kind(app_state) == "midi" {
let config = app_state.midi_config.lock().expect("midi config poisoned").clone();
app_state.midi.seek(position_ms, &config).map_err(|err| err.to_string())
} else {
app_state.audio.seek(position_ms).map_err(|err| err.to_string())
}
}
#[tauri::command]
async fn playback_set_volume(state: State<'_, DesktopState>, volume: f32) -> Result<PlaybackState, String> {
let app_state = desktop_state(&state);
Ok(if current_media_kind(app_state) == "midi" {
app_state.midi.set_volume(volume)
} else {
app_state.audio.set_volume(volume)
})
}
#[tauri::command]
async fn playback_set_loop_region(state: State<'_, DesktopState>, loop_start_ms: Option<u64>, loop_end_ms: Option<u64>) -> Result<PlaybackState, String> {
let app_state = desktop_state(&state);
let region = match (loop_start_ms, loop_end_ms) {
(Some(start_ms), Some(end_ms)) => Some(LoopRegion { start_ms, end_ms }),
_ => None,
};
Ok(if current_media_kind(app_state) == "midi" {
app_state.midi.set_loop_region(region)
} else {
app_state.audio.set_loop_region(region)
})
}
#[tauri::command]
async fn playback_get_state(state: State<'_, DesktopState>) -> Result<PlaybackState, String> {
let app_state = desktop_state(&state);
Ok(if current_media_kind(app_state) == "midi" {
app_state.midi.state()
} else {
app_state.audio.state()
})
}
#[tauri::command]
async fn waveform_get(state: State<'_, DesktopState>, item_id: i64) -> Result<Vec<f32>, String> {
let path = desktop_state(&state).db.get_path_for_item(item_id).await.map_err(|err| err.to_string())?;
generate_waveform(&path, 128).map_err(|err| err.to_string())
}
#[tauri::command]
async fn midi_get_backends() -> Result<Vec<MidiBackendInfo>, String> {
Ok(available_backends())
}
#[tauri::command]
async fn midi_get_config(state: State<'_, DesktopState>) -> Result<MidiBackendConfig, String> {
Ok(desktop_state(&state).midi_config.lock().expect("midi config poisoned").clone())
}
#[tauri::command]
async fn midi_set_backend(state: State<'_, DesktopState>, config: MidiBackendConfig) -> Result<MidiBackendConfig, String> {
desktop_state(&state)
.db
.set_setting_json("midi_config", &config)
.await
.map_err(|err| err.to_string())?;
*desktop_state(&state).midi_config.lock().expect("midi config poisoned") = config.clone();
Ok(config)
}
#[tauri::command]
async fn archive_extract(app: AppHandle, spec: ArchiveJobSpec) -> Result<ArchiveJobResult, String> {
let _ = app.emit("archive:progress", serde_json::json!({ "stage": "extracting", "source": spec.source }));
let result = extract(&spec).map_err(|err| err.to_string())?;
let _ = app.emit("archive:progress", serde_json::json!({ "stage": "done", "output": result.output_path }));
Ok(result)
}
#[tauri::command]
async fn archive_compress(app: AppHandle, spec: ArchiveJobSpec) -> Result<ArchiveJobResult, String> {
let _ = app.emit("archive:progress", serde_json::json!({ "stage": "compressing", "source": spec.source }));
let result = compress(&spec).map_err(|err| err.to_string())?;
let _ = app.emit("archive:progress", serde_json::json!({ "stage": "done", "output": result.output_path }));
Ok(result)
}
#[tauri::command]
async fn timer_start(app: AppHandle, state: State<'_, DesktopState>, duration_ms: u64) -> Result<TimerState, String> {
Ok(desktop_state(&state).timer.start(app, duration_ms))
}
#[tauri::command]
async fn timer_stop(state: State<'_, DesktopState>) -> Result<TimerState, String> {
Ok(desktop_state(&state).timer.stop())
}
#[tauri::command]
async fn timer_get_state(state: State<'_, DesktopState>) -> Result<TimerState, String> {
Ok(desktop_state(&state).timer.state())
}
fn start_scan_job(app: AppHandle, state: DesktopState, roots: Vec<fbrowser_core::models::LibraryRoot>) {
tauri::async_runtime::spawn(async move {
{
let mut status = state.scan_status.lock().expect("scan status poisoned");
status.active = true;
status.indexed = 0;
status.discovered = 0;
status.last_error = None;
}
for root in roots {
let root_path = root.path.clone();
{
let mut status = state.scan_status.lock().expect("scan status poisoned");
status.current_root = Some(root_path.clone());
}
let app_for_progress = app.clone();
let state_for_progress = state.clone();
let progress_root_path = root_path.clone();
let result = scan_root(&state.db, &root, move |progress: ScanProgress| {
let mut status = state_for_progress.scan_status.lock().expect("scan status poisoned");
status.discovered = progress.discovered;
status.indexed = progress.indexed;
let _ = app_for_progress.emit("scan:progress", serde_json::json!({
"root": progress_root_path,
"discovered": progress.discovered,
"indexed": progress.indexed,
"currentPath": progress.current_path
}));
})
.await;
match result {
Ok(count) => {
let _ = app.emit("scan:item-indexed", serde_json::json!({ "root": root_path, "count": count }));
}
Err(err) => {
let mut status = state.scan_status.lock().expect("scan status poisoned");
status.last_error = Some(err.to_string());
}
}
}
let roots = state.db.list_roots().await.unwrap_or_default();
let final_status = {
let mut status = state.scan_status.lock().expect("scan status poisoned");
status.active = false;
status.current_root = None;
status.roots = roots;
status.clone()
};
let _ = app.emit("scan:completed", &final_status);
});
}
fn spawn_playback_emitter(app: AppHandle, desktop_state: DesktopState) {
tauri::async_runtime::spawn(async move {
loop {
sleep(Duration::from_millis(250)).await;
let snapshot = if current_media_kind(&desktop_state) == "midi" {
desktop_state.midi.state()
} else {
desktop_state.audio.state()
};
let _ = app.emit("playback:state", &snapshot);
let _ = app.emit(
"playback:position",
serde_json::json!({ "positionMs": snapshot.position_ms, "durationMs": snapshot.duration_ms }),
);
}
});
}
fn build_state(app: &AppHandle) -> anyhow::Result<DesktopState> {
let db_url = database_url(app)?;
let db = tauri::async_runtime::block_on(async {
let db = AppDatabase::connect(&db_url).await?;
sqlx::migrate!("../migrations").run(db.pool()).await?;
Ok::<_, anyhow::Error>(db)
})?;
let midi_config = tauri::async_runtime::block_on(async {
db.get_setting_json::<MidiBackendConfig>("midi_config").await
})?
.unwrap_or_default();
Ok(DesktopState {
db,
audio: AudioEngine::new()?,
midi: MidiEngine::new(),
scan_status: Arc::new(Mutex::new(ScanStatus::default())),
timer: TimerManager::default(),
midi_config: Arc::new(Mutex::new(midi_config)),
active_media_kind: Arc::new(Mutex::new("audio".into())),
})
}
fn current_media_kind(state: &DesktopState) -> String {
state
.active_media_kind
.lock()
.expect("active media kind poisoned")
.clone()
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let desktop_state = build_state(&app.handle())?;
spawn_playback_emitter(app.handle().clone(), desktop_state.clone());
app.manage(desktop_state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
scan_add_root,
scan_remove_root,
scan_rescan,
scan_get_status,
library_search,
library_get_item,
library_update_annotations,
library_write_metadata,
collection_list,
collection_create,
collection_update,
collection_delete,
collection_add_items,
collection_remove_items,
collection_reorder,
playback_load,
playback_play,
playback_pause,
playback_stop,
playback_seek,
playback_set_volume,
playback_set_loop_region,
playback_get_state,
waveform_get,
midi_get_backends,
midi_get_config,
midi_set_backend,
archive_extract,
archive_compress,
timer_start,
timer_stop,
timer_get_state
])
.run(tauri::generate_context!())
.expect("failed to run Fbrowser desktop");
}

32
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,32 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"productName": "Fbrowser",
"version": "0.1.0",
"identifier": "com.fbrowser.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Fbrowser",
"width": 1600,
"height": 1040,
"minWidth": 1280,
"minHeight": 840,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": []
}
}

298
src/App.tsx Normal file
View File

@ -0,0 +1,298 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Sidebar } from "./components/Sidebar";
import { LibraryPanel } from "./components/LibraryPanel";
import { InspectorPanel } from "./components/InspectorPanel";
import { TransportBar } from "./components/TransportBar";
import { TimerPanel } from "./components/TimerPanel";
import { ArchivePanel } from "./components/ArchivePanel";
import { SettingsPanel } from "./components/SettingsPanel";
import { api } from "./lib/api";
import { useUIStore } from "./store/ui";
import type {
MediaItemSummary,
MidiBackendConfig,
PlaybackState,
SearchRequest,
TimerState,
} from "./lib/types";
const initialPlaybackState: PlaybackState = {
loaded_path: null,
is_playing: false,
volume: 0.8,
position_ms: 0,
duration_ms: 0,
loop_region: null,
output_device: "Default",
media_kind: "audio",
};
const initialTimerState: TimerState = {
running: false,
remaining_ms: 0,
started_at: null,
};
export default function App() {
const queryClient = useQueryClient();
const { section, setSection, query, setQuery, selectedItem, setSelectedItem } = useUIStore();
const [selectedCollectionId, setSelectedCollectionId] = useState<number | null>(null);
const [playback, setPlayback] = useState<PlaybackState>(initialPlaybackState);
const [timer, setTimer] = useState<TimerState>(initialTimerState);
useEffect(() => {
let mounted = true;
const unsubscribers: Array<() => void> = [];
api.playbackGetState().then((value) => mounted && setPlayback(value));
api.timerGetState().then((value) => mounted && setTimer(value));
Promise.all([
api.on<PlaybackState>("playback:state", (payload) => mounted && setPlayback(payload)),
api.on<TimerState>("timer:tick", (payload) => mounted && setTimer(payload)),
api.on("scan:completed", () => {
queryClient.invalidateQueries({ queryKey: ["scan-status"] });
queryClient.invalidateQueries({ queryKey: ["library-search"] });
}),
api.on("scan:progress", () => {
queryClient.invalidateQueries({ queryKey: ["scan-status"] });
}),
]).then((handlers) => {
handlers.forEach((unlisten) => unsubscribers.push(unlisten));
});
return () => {
mounted = false;
unsubscribers.forEach((unsubscribe) => unsubscribe());
};
}, [queryClient]);
const scanStatusQuery = useQuery({
queryKey: ["scan-status"],
queryFn: api.scanGetStatus,
refetchInterval: 5000,
});
const collectionsQuery = useQuery({
queryKey: ["collections"],
queryFn: api.collectionList,
});
const midiBackendsQuery = useQuery({
queryKey: ["midi-backends"],
queryFn: api.midiGetBackends,
});
const midiConfigQuery = useQuery({
queryKey: ["midi-config"],
queryFn: api.midiGetConfig,
});
const searchRequest = useMemo<SearchRequest>(
() => ({
query,
section: section === "library" || section === "collections" ? undefined : section,
page: 0,
page_size: 250,
collection_id: section === "collections" ? selectedCollectionId ?? undefined : undefined,
}),
[query, section, selectedCollectionId],
);
const libraryQuery = useQuery({
queryKey: ["library-search", searchRequest],
queryFn: () => api.librarySearch(searchRequest),
});
const selectedItemDetailQuery = useQuery({
queryKey: ["library-item", selectedItem?.id],
enabled: Boolean(selectedItem?.id),
queryFn: () => api.libraryGetItem(selectedItem!.id),
});
const waveformQuery = useQuery({
queryKey: ["waveform", selectedItem?.id],
enabled: Boolean(selectedItem?.id && selectedItem.media_kind === "audio"),
queryFn: () => api.waveformGet(selectedItem!.id),
});
const addRootMutation = useMutation({
mutationFn: api.scanAddRoot,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["scan-status"] });
queryClient.invalidateQueries({ queryKey: ["library-search"] });
},
});
const removeRootMutation = useMutation({
mutationFn: api.scanRemoveRoot,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["scan-status"] });
queryClient.invalidateQueries({ queryKey: ["library-search"] });
},
});
const createCollectionMutation = useMutation({
mutationFn: api.collectionCreate,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["collections"] }),
});
const saveAnnotationsMutation = useMutation({
mutationFn: api.libraryUpdateAnnotations,
onSuccess: (detail) => {
queryClient.setQueryData(["library-item", detail.summary.id], detail);
queryClient.invalidateQueries({ queryKey: ["library-search"] });
},
});
const writeMetadataMutation = useMutation({
mutationFn: ({ itemId, patch }: { itemId: number; patch: Record<string, string | null> }) =>
api.libraryWriteMetadata(itemId, patch),
onSuccess: (detail) => {
queryClient.setQueryData(["library-item", detail.summary.id], detail);
queryClient.invalidateQueries({ queryKey: ["library-search"] });
},
});
const addToCollectionMutation = useMutation({
mutationFn: api.collectionAddItems,
});
const title =
section === "favorites"
? "Favorites"
: section === "recent"
? "Recently previewed"
: section === "collections"
? "Collections"
: "Indexed Library";
const selectedSummary = selectedItemDetailQuery.data?.summary ?? selectedItem;
async function loadItem(item: MediaItemSummary) {
setSelectedItem(item);
const loaded = await api.playbackLoad(item.absolute_path, item.media_kind);
setPlayback(loaded);
return loaded;
}
async function loadAndPlay(item: MediaItemSummary) {
await loadItem(item);
const playing = await api.playbackPlay();
setPlayback(playing);
}
function createCollection() {
createCollectionMutation.mutate({
name: `Collection ${new Date().toLocaleTimeString()}`,
kind: "playlist",
rules_json: null,
});
}
return (
<div className="flex h-full flex-col p-4">
<div className="flex min-h-0 flex-1 gap-4">
<Sidebar
section={section}
onSectionChange={setSection}
collections={collectionsQuery.data ?? []}
scanStatus={scanStatusQuery.data}
selectedCollectionId={selectedCollectionId}
onSelectCollection={setSelectedCollectionId}
onCreateCollection={createCollection}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4">
{section === "archives" ? (
<ArchivePanel
onExtract={(source, destination) => api.archiveExtract({ source, destination })}
onCompress={(source, destination) => api.archiveCompress({ source, destination })}
/>
) : section === "timer" ? (
<TimerPanel
timer={timer}
onStart={(durationMs) => api.timerStart(durationMs).then(setTimer)}
onStop={() => api.timerStop().then(setTimer)}
/>
) : section === "settings" ? (
<SettingsPanel
roots={scanStatusQuery.data?.roots ?? []}
midiBackends={midiBackendsQuery.data ?? []}
midiConfig={midiConfigQuery.data ?? null}
onAddRoot={(path) => addRootMutation.mutate(path)}
onRemoveRoot={(rootId) => removeRootMutation.mutate(rootId)}
onRescanAll={() => api.scanRescan(undefined, true)}
onMidiConfigChange={(config: MidiBackendConfig) =>
api.midiSetBackend(config).then(() => {
queryClient.invalidateQueries({ queryKey: ["midi-config"] });
})
}
/>
) : (
<LibraryPanel
title={title}
section={section}
query={query}
onQueryChange={setQuery}
items={libraryQuery.data?.items ?? []}
total={libraryQuery.data?.total ?? 0}
selectedItemId={selectedSummary?.id ?? null}
onSelectItem={(item) => void loadItem(item)}
onActivateItem={loadAndPlay}
/>
)}
<TransportBar
playback={playback}
onPlay={() =>
selectedSummary
? playback.loaded_path?.startsWith(selectedSummary.absolute_path)
? api.playbackPlay().then(setPlayback)
: loadAndPlay(selectedSummary)
: api.playbackPlay().then(setPlayback)
}
onPause={() => api.playbackPause().then(setPlayback)}
onStop={() => api.playbackStop().then(setPlayback)}
onSeek={(value) => api.playbackSeek(value).then(setPlayback)}
onVolume={(value) => api.playbackSetVolume(value).then(setPlayback)}
onToggleLoop={() => {
const nextRegion = playback.loop_region
? [undefined, undefined]
: [0, Math.max(1000, playback.duration_ms)];
return api.playbackSetLoopRegion(nextRegion[0], nextRegion[1]).then(setPlayback);
}}
/>
</div>
<InspectorPanel
detail={selectedItemDetailQuery.data}
waveform={waveformQuery.data ?? []}
collections={collectionsQuery.data ?? []}
onSaveAnnotations={(payload) => {
if (!selectedSummary) return;
saveAnnotationsMutation.mutate({
item_id: selectedSummary.id,
favorite: payload.favorite,
rating: payload.rating,
note: payload.note,
custom_tags: payload.customTags,
color: payload.color,
});
}}
onWriteMetadata={(patch) => {
if (!selectedSummary) return;
writeMetadataMutation.mutate({ itemId: selectedSummary.id, patch });
}}
onAddToCollection={(collectionId) => {
if (!selectedSummary) return;
addToCollectionMutation.mutate({
collection_id: collectionId,
item_ids: [selectedSummary.id],
});
}}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,156 @@
import { useState } from "react";
import { open, save } from "@tauri-apps/plugin-dialog";
import type { ArchiveJobResult } from "../lib/types";
interface ArchivePanelProps {
onExtract: (source: string, destination: string) => Promise<ArchiveJobResult>;
onCompress: (source: string, destination: string) => Promise<ArchiveJobResult>;
}
type ArchiveAction = "extract" | "compress" | null;
export function ArchivePanel({ onExtract, onCompress }: ArchivePanelProps) {
const [source, setSource] = useState("");
const [destination, setDestination] = useState("");
const [lastResult, setLastResult] = useState<ArchiveJobResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<ArchiveAction>(null);
async function runAction(action: Exclude<ArchiveAction, null>) {
if (!source || !destination) {
setError("Choose both a source and destination before running an archive job.");
return;
}
setPendingAction(action);
setError(null);
try {
const result = action === "extract" ? await onExtract(source, destination) : await onCompress(source, destination);
setLastResult(result);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Archive job failed.");
} finally {
setPendingAction(null);
}
}
return (
<div className="glass rounded-[32px] p-6">
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Archive utilities</div>
<h2 className="mt-2 text-2xl font-semibold text-white">Extract and package sample crates</h2>
<p className="mt-3 max-w-2xl text-sm text-textMuted">
Supports <span className="text-text">ZIP</span>, <span className="text-text">TAR</span>, and{" "}
<span className="text-text">TAR.GZ / TGZ</span>. Extraction expects an archive file and a target
folder. Compression accepts either a file or directory and writes a new archive.
</p>
<div className="mt-6 grid gap-4">
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Source</div>
<div className="mt-3 flex gap-3">
<input
value={source}
onChange={(event) => setSource(event.target.value)}
className="flex-1 rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none"
placeholder="Archive file to extract, or file/folder to compress"
/>
<button
type="button"
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
onClick={async () => {
const picked = await open({
directory: false,
multiple: false,
filters: [{ name: "Archives", extensions: ["zip", "tar", "gz", "tgz"] }],
});
if (typeof picked === "string") setSource(picked);
}}
>
File
</button>
<button
type="button"
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
onClick={async () => {
const picked = await open({ directory: true, multiple: false });
if (typeof picked === "string") setSource(picked);
}}
>
Folder
</button>
</div>
</div>
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Destination</div>
<div className="mt-3 flex gap-3">
<input
value={destination}
onChange={(event) => setDestination(event.target.value)}
className="flex-1 rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none"
placeholder="Target folder for extract, or archive path like crate.tar.gz"
/>
<button
type="button"
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
onClick={async () => {
const picked = await open({ directory: true, multiple: false });
if (typeof picked === "string") setDestination(picked);
}}
>
Folder
</button>
<button
type="button"
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
onClick={async () => {
const picked = await save({
filters: [
{ name: "ZIP", extensions: ["zip"] },
{ name: "TAR", extensions: ["tar"] },
{ name: "TAR.GZ", extensions: ["tar.gz", "tgz"] },
],
});
if (typeof picked === "string") setDestination(picked);
}}
>
Save as
</button>
</div>
</div>
</div>
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={() => void runAction("extract")}
disabled={pendingAction !== null}
className="rounded-2xl bg-accent px-5 py-3 font-semibold text-black disabled:cursor-not-allowed disabled:opacity-50"
>
{pendingAction === "extract" ? "Extracting..." : "Extract"}
</button>
<button
type="button"
onClick={() => void runAction("compress")}
disabled={pendingAction !== null}
className="rounded-2xl border border-line/45 px-5 py-3 text-text disabled:cursor-not-allowed disabled:opacity-50"
>
{pendingAction === "compress" ? "Compressing..." : "Create archive"}
</button>
</div>
{error ? (
<div className="mt-5 rounded-2xl border border-red-400/35 bg-red-500/10 p-4 text-sm text-red-100">
{error}
</div>
) : null}
{lastResult ? (
<div className="mt-5 rounded-2xl border border-line/35 bg-white/5 p-4 text-sm text-text">
Output: {lastResult.output_path}
<div className="mt-2 text-textMuted">{lastResult.processed_entries} items processed</div>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,229 @@
import { useEffect, useMemo, useState } from "react";
import { Disc3, FileAudio, Music3, Star } from "lucide-react";
import type { CollectionRecord, MediaItemDetail } from "../lib/types";
import { formatDuration, formatFileSize } from "../lib/format";
interface InspectorPanelProps {
detail: MediaItemDetail | null | undefined;
waveform: number[];
collections: CollectionRecord[];
onSaveAnnotations: (payload: {
favorite: boolean;
rating: number | null;
note: string;
customTags: string[];
color: string;
}) => void;
onWriteMetadata: (patch: Record<string, string | null>) => void;
onAddToCollection: (collectionId: number) => void;
}
export function InspectorPanel({
detail,
waveform,
collections,
onSaveAnnotations,
onWriteMetadata,
onAddToCollection,
}: InspectorPanelProps) {
const summary = detail?.summary;
const [favorite, setFavorite] = useState(false);
const [rating, setRating] = useState<number | null>(null);
const [note, setNote] = useState("");
const [tagsText, setTagsText] = useState("");
const [color, setColor] = useState("#24c4ff");
const [title, setTitle] = useState("");
const [artist, setArtist] = useState("");
const [album, setAlbum] = useState("");
const [genre, setGenre] = useState("");
const [year, setYear] = useState("");
const [comment, setComment] = useState("");
useEffect(() => {
setFavorite(summary?.favorite ?? false);
setRating(summary?.rating ?? null);
setNote(summary?.note ?? "");
setTagsText(detail?.custom_tags.join(", ") ?? "");
setColor(summary?.color ?? "#24c4ff");
setTitle(summary?.title ?? "");
setArtist(summary?.artist ?? "");
setAlbum(summary?.album ?? "");
setGenre(summary?.genre ?? "");
setYear(summary?.year ?? "");
setComment(summary?.comment ?? "");
}, [detail, summary]);
const statRows = useMemo(
() => [
{ label: "Type", value: summary?.media_kind ?? "--" },
{ label: "Length", value: formatDuration(summary?.duration_ms) },
{ label: "Size", value: formatFileSize(summary?.size_bytes) },
{ label: "Rate", value: summary?.sample_rate ? `${summary.sample_rate} Hz` : "--" },
],
[summary],
);
return (
<aside className="glass flex h-full w-[360px] flex-col gap-4 rounded-[32px] p-5">
<div className="rounded-[28px] border border-line/40 bg-black/15 p-5">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accentSoft/80 text-accent">
{summary?.media_kind === "midi" ? <Music3 className="h-5 w-5" /> : <FileAudio className="h-5 w-5" />}
</div>
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">
{summary ? summary.title || summary.file_name : "No selection"}
</div>
<div className="truncate text-sm text-textMuted">
{summary ? summary.absolute_path : "Select a media item to inspect metadata and notes."}
</div>
</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
{statRows.map((row) => (
<div key={row.label} className="rounded-2xl border border-line/35 bg-white/5 p-3">
<div className="text-[11px] uppercase tracking-[0.22em] text-textMuted">{row.label}</div>
<div className="mt-2 text-sm text-white">{row.value}</div>
</div>
))}
</div>
<div className="mt-5 h-24 rounded-2xl border border-line/35 bg-panel/60 p-3">
<div className="flex h-full items-end gap-[3px]">
{waveform.length === 0
? Array.from({ length: 48 }).map((_, index) => (
<div
key={index}
className="flex-1 rounded-full bg-white/10"
style={{ height: `${12 + (index % 8) * 6}%` }}
/>
))
: waveform.map((value, index) => (
<div
key={`${value}-${index}`}
className="flex-1 rounded-full bg-accent/80"
style={{ height: `${Math.max(8, value * 100)}%` }}
/>
))}
</div>
</div>
</div>
<div className="scrollbar-thin flex-1 space-y-4 overflow-auto pr-1">
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium text-white">Catalog annotations</div>
<button
type="button"
onClick={() =>
onSaveAnnotations({
favorite,
rating,
note,
customTags: tagsText
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
color,
})
}
disabled={!summary}
className="rounded-xl bg-accent px-3 py-2 text-xs font-medium text-black disabled:cursor-not-allowed disabled:opacity-40"
>
Save local annotations
</button>
</div>
<label className="mb-3 flex items-center gap-3 rounded-2xl border border-line/35 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={favorite}
onChange={(event) => setFavorite(event.target.checked)}
/>
Favorite this item
</label>
<div className="mb-3">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-textMuted">Rating</div>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
onClick={() => setRating(value)}
className={value <= (rating ?? 0) ? "text-accent" : "text-textMuted"}
>
<Star className="h-5 w-5 fill-current" />
</button>
))}
</div>
</div>
<div className="space-y-3">
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={4}
placeholder="Session notes, pack notes, vibe, mix reminders"
className="w-full rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none"
/>
<input
value={tagsText}
onChange={(event) => setTagsText(event.target.value)}
placeholder="custom tags, comma separated"
className="w-full rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none"
/>
<label className="flex items-center justify-between rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text">
Accent
<input value={color} onChange={(event) => setColor(event.target.value)} type="color" />
</label>
</div>
</div>
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-white">
<Disc3 className="h-4 w-4 text-accent" />
Write metadata to file
</div>
<div className="grid grid-cols-1 gap-3">
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Title" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
<input value={artist} onChange={(event) => setArtist(event.target.value)} placeholder="Artist" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
<input value={album} onChange={(event) => setAlbum(event.target.value)} placeholder="Album" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
<div className="grid grid-cols-2 gap-3">
<input value={genre} onChange={(event) => setGenre(event.target.value)} placeholder="Genre" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
<input value={year} onChange={(event) => setYear(event.target.value)} placeholder="Year" className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
</div>
<textarea value={comment} onChange={(event) => setComment(event.target.value)} placeholder="Comment" rows={3} className="rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-sm text-text outline-none" />
<button
type="button"
onClick={() => onWriteMetadata({ title, artist, album, genre, year, comment })}
disabled={!summary}
className="rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black disabled:cursor-not-allowed disabled:opacity-40"
>
Write metadata to source file
</button>
</div>
</div>
<div className="rounded-3xl border border-line/35 bg-white/5 p-4">
<div className="mb-3 text-sm font-medium text-white">Collections</div>
<div className="space-y-2">
{collections.map((collection) => (
<button
key={collection.id}
type="button"
onClick={() => onAddToCollection(collection.id)}
disabled={!summary}
className="flex w-full items-center justify-between rounded-2xl border border-line/35 bg-panel/70 px-3 py-3 text-left text-sm text-text transition hover:border-line/60 disabled:cursor-not-allowed disabled:opacity-40"
>
<span>{collection.name}</span>
<span className="text-xs uppercase tracking-[0.18em] text-textMuted">{collection.kind}</span>
</button>
))}
</div>
</div>
</div>
</aside>
);
}

View File

@ -0,0 +1,124 @@
import { useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import clsx from "clsx";
import { Search, Waves } from "lucide-react";
import type { MediaItemSummary, SectionKey } from "../lib/types";
import { formatDuration, formatFileSize } from "../lib/format";
interface LibraryPanelProps {
title: string;
section: SectionKey;
query: string;
onQueryChange: (value: string) => void;
items: MediaItemSummary[];
total: number;
selectedItemId: number | null;
onSelectItem: (item: MediaItemSummary) => void;
onActivateItem: (item: MediaItemSummary) => void;
}
export function LibraryPanel({
title,
section,
query,
onQueryChange,
items,
total,
selectedItemId,
onSelectItem,
onActivateItem,
}: LibraryPanelProps) {
const parentRef = useRef<HTMLDivElement | null>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 78,
overscan: 12,
});
const description = useMemo(() => {
switch (section) {
case "favorites":
return "Pinned sounds, cues, and go-to preview targets.";
case "recent":
return "Recent playback history across the indexed library.";
case "collections":
return "Curated crates and smart bins for retrieval speed.";
default:
return "High-speed indexed browsing with low-latency transport controls.";
}
}, [section]);
return (
<section className="glass flex h-full min-w-0 flex-1 flex-col rounded-[32px] p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.24em] text-textMuted">{section}</div>
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-white">{title}</h1>
<p className="mt-2 max-w-2xl text-sm text-textMuted">{description}</p>
</div>
<div className="min-w-[280px] max-w-[360px] flex-1">
<label className="flex items-center gap-3 rounded-2xl border border-line/45 bg-white/5 px-4 py-3 text-sm text-textMuted">
<Search className="h-4 w-4" />
<input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
className="w-full bg-transparent outline-none placeholder:text-textMuted/70"
placeholder="Search file name, title, artist, album"
/>
</label>
</div>
</div>
<div className="mb-3 flex items-center justify-between rounded-2xl border border-line/35 bg-black/10 px-4 py-3 text-xs uppercase tracking-[0.2em] text-textMuted">
<span>{total.toLocaleString()} indexed items</span>
<span className="flex items-center gap-2">
<Waves className="h-4 w-4" />
Preview-ready browser
</span>
</div>
<div ref={parentRef} className="scrollbar-thin flex-1 overflow-auto pr-1">
<div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index];
return (
<button
key={item.id}
type="button"
onClick={() => onSelectItem(item)}
onDoubleClick={() => onActivateItem(item)}
className={clsx(
"absolute left-0 top-0 flex w-full items-center gap-4 rounded-3xl border px-4 py-4 text-left transition",
selectedItemId === item.id
? "border-accent/50 bg-accentSoft/70 shadow-glow"
: "border-transparent bg-white/0 hover:border-line/40 hover:bg-white/5",
)}
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-panelAlt text-sm font-semibold uppercase text-accent">
{item.extension}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-medium text-white">
{item.title || item.file_name}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-textMuted">
<span className="truncate">{item.artist || item.album || item.absolute_path}</span>
<span>{formatDuration(item.duration_ms)}</span>
<span>{formatFileSize(item.size_bytes)}</span>
<span className="uppercase">{item.media_kind}</span>
</div>
</div>
<div className="text-right text-xs text-textMuted">
<div>{item.favorite ? "Favorite" : "Indexed"}</div>
<div className="mt-1">{item.rating ? `${item.rating}/5` : "Unrated"}</div>
</div>
</button>
);
})}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,123 @@
import { open } from "@tauri-apps/plugin-dialog";
import type { LibraryRoot, MidiBackendConfig, MidiBackendInfo } from "../lib/types";
interface SettingsPanelProps {
roots: LibraryRoot[];
midiBackends: MidiBackendInfo[];
midiConfig: MidiBackendConfig | null;
onAddRoot: (path: string) => void;
onRemoveRoot: (rootId: number) => void;
onRescanAll: () => void;
onMidiConfigChange: (config: MidiBackendConfig) => void;
}
export function SettingsPanel({
roots,
midiBackends,
midiConfig,
onAddRoot,
onRemoveRoot,
onRescanAll,
onMidiConfigChange,
}: SettingsPanelProps) {
return (
<div className="glass rounded-[32px] p-6">
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr]">
<section className="rounded-3xl border border-line/35 bg-white/5 p-5">
<div className="flex items-center justify-between">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Watched roots</div>
<h2 className="mt-2 text-2xl font-semibold text-white">Library paths</h2>
</div>
<div className="flex gap-3">
<button
type="button"
className="rounded-2xl border border-line/45 px-4 py-3 text-text"
onClick={onRescanAll}
>
Rescan all
</button>
<button
type="button"
className="rounded-2xl bg-accent px-4 py-3 font-semibold text-black"
onClick={async () => {
const picked = await open({ directory: true, multiple: false });
if (typeof picked === "string") onAddRoot(picked);
}}
>
Add root
</button>
</div>
</div>
<div className="mt-5 space-y-3">
{roots.map((root) => (
<div key={root.id} className="flex items-center justify-between rounded-2xl border border-line/35 bg-panel/70 px-4 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-white">{root.path}</div>
<div className="mt-1 text-xs text-textMuted">{root.item_count} indexed items</div>
</div>
<button
type="button"
className="rounded-xl border border-line/45 px-3 py-2 text-xs text-text"
onClick={() => onRemoveRoot(root.id)}
>
Remove
</button>
</div>
))}
</div>
</section>
<section className="rounded-3xl border border-line/35 bg-white/5 p-5">
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">MIDI backend</div>
<h2 className="mt-2 text-2xl font-semibold text-white">Playback routing</h2>
<div className="mt-5 space-y-3">
<select
value={midiConfig?.backend_id ?? "system"}
onChange={(event) =>
onMidiConfigChange({
backend_id: event.target.value,
soundfont_path: midiConfig?.soundfont_path ?? null,
})
}
className="w-full rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none"
>
{midiBackends.map((backend) => (
<option key={backend.id} value={backend.id}>
{backend.label}
</option>
))}
</select>
<div className="rounded-2xl border border-line/35 bg-panel/70 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">SoundFont path</div>
<div className="mt-2 break-all text-sm text-text">
{midiConfig?.soundfont_path || "No soundfont selected"}
</div>
</div>
<button
type="button"
className="w-full rounded-2xl border border-line/45 px-4 py-3 text-text"
onClick={async () => {
const picked = await open({
directory: false,
filters: [{ name: "SoundFont", extensions: ["sf2", "sf3"] }],
});
if (typeof picked === "string") {
onMidiConfigChange({
backend_id: midiConfig?.backend_id ?? "soundfont",
soundfont_path: picked,
});
}
}}
>
Choose SoundFont
</button>
</div>
</section>
</div>
</div>
);
}

141
src/components/Sidebar.tsx Normal file
View File

@ -0,0 +1,141 @@
import type { ComponentType } from "react";
import { motion } from "framer-motion";
import {
Clock3,
FolderArchive,
Heart,
LibraryBig,
Music2,
Settings2,
Sparkles,
} from "lucide-react";
import clsx from "clsx";
import type { CollectionRecord, ScanStatus, SectionKey } from "../lib/types";
const sections: { key: SectionKey; label: string; icon: ComponentType<{ className?: string }> }[] = [
{ key: "library", label: "Library", icon: LibraryBig },
{ key: "favorites", label: "Favorites", icon: Heart },
{ key: "recent", label: "Recent", icon: Music2 },
{ key: "collections", label: "Collections", icon: Sparkles },
{ key: "archives", label: "Archive Tools", icon: FolderArchive },
{ key: "timer", label: "Timer", icon: Clock3 },
{ key: "settings", label: "Settings", icon: Settings2 },
];
interface SidebarProps {
section: SectionKey;
onSectionChange: (section: SectionKey) => void;
collections: CollectionRecord[];
scanStatus?: ScanStatus;
selectedCollectionId: number | null;
onSelectCollection: (id: number) => void;
onCreateCollection: () => void;
}
export function Sidebar({
section,
onSectionChange,
collections,
scanStatus,
selectedCollectionId,
onSelectCollection,
onCreateCollection,
}: SidebarProps) {
return (
<aside className="glass flex h-full w-[300px] flex-col rounded-[28px] p-5">
<div className="mb-5">
<div className="text-xs uppercase tracking-[0.28em] text-textMuted">Indexed sample browser</div>
<div className="mt-2 text-3xl font-semibold tracking-tight text-white">Fbrowser</div>
<div className="mt-2 text-sm text-textMuted">
Rust core, indexed library workflows, and producer-focused preview tools.
</div>
</div>
<div className="space-y-2">
{sections.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => onSectionChange(key)}
className={clsx(
"relative flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition",
section === key
? "border-accent/50 bg-accentSoft/70 text-white shadow-glow"
: "border-transparent bg-white/0 text-textMuted hover:border-line/60 hover:bg-white/5 hover:text-text",
)}
>
{section === key ? (
<motion.div
layoutId="sidebar-highlight"
className="absolute inset-0 rounded-2xl border border-accent/30"
transition={{ type: "spring", stiffness: 380, damping: 32 }}
/>
) : null}
<Icon className="relative z-10 h-4 w-4" />
<span className="relative z-10 font-medium">{label}</span>
</button>
))}
</div>
<div className="mt-6 flex-1 overflow-hidden rounded-3xl border border-line/40 bg-black/10 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium text-white">Collections</div>
<div className="flex items-center gap-2">
<div className="text-xs text-textMuted">{collections.length}</div>
<button
type="button"
onClick={onCreateCollection}
className="rounded-xl border border-line/45 px-3 py-1.5 text-xs text-text transition hover:border-accent/50 hover:text-white"
>
New
</button>
</div>
</div>
<div className="scrollbar-thin h-full space-y-2 overflow-auto pr-1">
{collections.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line/50 p-4 text-sm text-textMuted">
Create curated sets, favorites, and smart bins from the inspector or library actions.
</div>
) : (
collections.map((collection) => (
<button
key={collection.id}
type="button"
onClick={() => {
onSectionChange("collections");
onSelectCollection(collection.id);
}}
className={clsx(
"w-full rounded-2xl border px-3 py-3 text-left transition",
selectedCollectionId === collection.id && section === "collections"
? "border-accent/50 bg-accentSoft/60 text-white"
: "border-line/35 bg-white/5 text-text hover:border-line/60",
)}
>
<div className="font-medium">{collection.name}</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-textMuted">{collection.kind}</div>
</button>
))
)}
</div>
</div>
<div className="mt-5 rounded-3xl border border-line/35 bg-white/5 p-4">
<div className="flex items-center justify-between text-sm text-white">
<span>Indexer</span>
<span className={scanStatus?.active ? "text-accent" : "text-success"}>
{scanStatus?.active ? "Scanning" : "Ready"}
</span>
</div>
<div className="mt-2 text-xs text-textMuted">
{scanStatus?.active
? `${scanStatus.indexed} indexed / ${scanStatus.discovered} discovered`
: `${scanStatus?.roots.length ?? 0} watched roots`}
</div>
{scanStatus?.current_root ? (
<div className="mt-2 truncate text-xs text-textMuted">{scanStatus.current_root}</div>
) : null}
</div>
</aside>
);
}

View File

@ -0,0 +1,53 @@
import { useState } from "react";
import { TimerReset } from "lucide-react";
import type { TimerState } from "../lib/types";
import { formatCountdown } from "../lib/format";
interface TimerPanelProps {
timer: TimerState;
onStart: (durationMs: number) => void;
onStop: () => void;
}
export function TimerPanel({ timer, onStart, onStop }: TimerPanelProps) {
const [hours, setHours] = useState("0");
const [minutes, setMinutes] = useState("30");
const [seconds, setSeconds] = useState("0");
return (
<div className="glass rounded-[32px] p-6">
<div className="flex items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-accentSoft text-accent">
<TimerReset className="h-6 w-6" />
</div>
<div>
<div className="text-xs uppercase tracking-[0.2em] text-textMuted">Focus timer</div>
<div className="mt-1 text-2xl font-semibold text-white">{formatCountdown(timer.remaining_ms)}</div>
</div>
</div>
<div className="mt-6 grid grid-cols-3 gap-3">
<input value={hours} onChange={(event) => setHours(event.target.value)} className="rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none" placeholder="Hours" />
<input value={minutes} onChange={(event) => setMinutes(event.target.value)} className="rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none" placeholder="Minutes" />
<input value={seconds} onChange={(event) => setSeconds(event.target.value)} className="rounded-2xl border border-line/40 bg-panel/70 px-4 py-3 text-text outline-none" placeholder="Seconds" />
</div>
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={() =>
onStart(
(Number(hours || 0) * 3600 + Number(minutes || 0) * 60 + Number(seconds || 0)) * 1000,
)
}
className="rounded-2xl bg-accent px-5 py-3 font-semibold text-black"
>
Start
</button>
<button type="button" onClick={onStop} className="rounded-2xl border border-line/45 px-5 py-3 text-text">
Stop
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,79 @@
import { Pause, Play, Repeat, Square, Volume2 } from "lucide-react";
import type { PlaybackState } from "../lib/types";
import { formatDuration } from "../lib/format";
interface TransportBarProps {
playback: PlaybackState;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
onSeek: (value: number) => void;
onVolume: (value: number) => void;
onToggleLoop: () => void;
}
export function TransportBar({
playback,
onPlay,
onPause,
onStop,
onSeek,
onVolume,
onToggleLoop,
}: TransportBarProps) {
return (
<footer className="glass mt-4 shrink-0 flex items-center gap-4 rounded-[28px] px-5 py-4">
<div className="min-w-[240px]">
<div className="text-xs uppercase tracking-[0.18em] text-textMuted">Transport</div>
<div className="mt-1 truncate text-base font-semibold text-white">
{playback.loaded_path ?? "No file loaded"}
</div>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={onPlay} className="rounded-2xl bg-accent px-4 py-3 text-black">
<Play className="h-4 w-4" />
</button>
<button type="button" onClick={onPause} className="rounded-2xl border border-line/45 px-4 py-3 text-text">
<Pause className="h-4 w-4" />
</button>
<button type="button" onClick={onStop} className="rounded-2xl border border-line/45 px-4 py-3 text-text">
<Square className="h-4 w-4" />
</button>
<button
type="button"
onClick={onToggleLoop}
className={`rounded-2xl border px-4 py-3 ${playback.loop_region ? "border-accent/60 text-accent" : "border-line/45 text-text"}`}
>
<Repeat className="h-4 w-4" />
</button>
</div>
<div className="min-w-0 flex-1">
<input
type="range"
min={0}
max={playback.duration_ms || 1}
value={Math.min(playback.position_ms, playback.duration_ms || 1)}
onChange={(event) => onSeek(Number(event.target.value))}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-textMuted">
<span>{formatDuration(playback.position_ms)}</span>
<span>{formatDuration(playback.duration_ms)}</span>
</div>
</div>
<div className="flex min-w-[220px] items-center gap-3">
<Volume2 className="h-4 w-4 text-textMuted" />
<input
type="range"
min={0}
max={1}
step={0.01}
value={playback.volume}
onChange={(event) => onVolume(Number(event.target.value))}
className="w-full"
/>
<div className="w-12 text-right text-sm text-textMuted">{Math.round(playback.volume * 100)}%</div>
</div>
</footer>
);
}

63
src/lib/api.ts Normal file
View File

@ -0,0 +1,63 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type {
AnnotationUpdate,
ArchiveJobResult,
ArchiveJobSpec,
CollectionItemsMutation,
CollectionMutation,
CollectionRecord,
MediaItemDetail,
MidiBackendConfig,
MidiBackendInfo,
PlaybackState,
ScanStatus,
SearchRequest,
SearchResponse,
TimerState,
} from "./types";
export const api = {
scanAddRoot: (path: string) => invoke("scan_add_root", { path }),
scanRemoveRoot: (rootId: number) => invoke("scan_remove_root", { rootId }),
scanRescan: (rootId?: number, all = false) => invoke("scan_rescan", { rootId, all }),
scanGetStatus: () => invoke<ScanStatus>("scan_get_status"),
librarySearch: (request: SearchRequest) => invoke<SearchResponse>("library_search", { request }),
libraryGetItem: (itemId: number) => invoke<MediaItemDetail>("library_get_item", { itemId }),
libraryUpdateAnnotations: (payload: AnnotationUpdate) =>
invoke<MediaItemDetail>("library_update_annotations", { payload }),
libraryWriteMetadata: (itemId: number, patch: Record<string, string | null>) =>
invoke<MediaItemDetail>("library_write_metadata", { itemId, patch }),
collectionList: () => invoke<CollectionRecord[]>("collection_list"),
collectionCreate: (payload: CollectionMutation) =>
invoke<CollectionRecord>("collection_create", { payload }),
collectionUpdate: (id: number, payload: CollectionMutation) =>
invoke<CollectionRecord>("collection_update", { id, payload }),
collectionDelete: (id: number) => invoke("collection_delete", { id }),
collectionAddItems: (payload: CollectionItemsMutation) =>
invoke("collection_add_items", { payload }),
collectionRemoveItems: (payload: CollectionItemsMutation) =>
invoke("collection_remove_items", { payload }),
playbackLoad: (path: string, mediaKind: string) =>
invoke<PlaybackState>("playback_load", { path, mediaKind }),
playbackPlay: () => invoke<PlaybackState>("playback_play"),
playbackPause: () => invoke<PlaybackState>("playback_pause"),
playbackStop: () => invoke<PlaybackState>("playback_stop"),
playbackSeek: (positionMs: number) => invoke<PlaybackState>("playback_seek", { positionMs }),
playbackSetVolume: (volume: number) => invoke<PlaybackState>("playback_set_volume", { volume }),
playbackSetLoopRegion: (loopStartMs?: number, loopEndMs?: number) =>
invoke<PlaybackState>("playback_set_loop_region", { loopStartMs, loopEndMs }),
playbackGetState: () => invoke<PlaybackState>("playback_get_state"),
waveformGet: (itemId: number) => invoke<number[]>("waveform_get", { itemId }),
midiGetBackends: () => invoke<MidiBackendInfo[]>("midi_get_backends"),
midiGetConfig: () => invoke<MidiBackendConfig>("midi_get_config"),
midiSetBackend: (config: MidiBackendConfig) => invoke<MidiBackendConfig>("midi_set_backend", { config }),
archiveExtract: (spec: ArchiveJobSpec) => invoke<ArchiveJobResult>("archive_extract", { spec }),
archiveCompress: (spec: ArchiveJobSpec) => invoke<ArchiveJobResult>("archive_compress", { spec }),
timerStart: (durationMs: number) => invoke<TimerState>("timer_start", { durationMs }),
timerStop: () => invoke<TimerState>("timer_stop"),
timerGetState: () => invoke<TimerState>("timer_get_state"),
on<T>(event: string, handler: (payload: T) => void) {
return listen<T>(event, ({ payload }) => handler(payload));
},
};

27
src/lib/format.ts Normal file
View File

@ -0,0 +1,27 @@
export function formatDuration(durationMs?: number | null) {
if (!durationMs) return "--:--";
const totalSeconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function formatFileSize(sizeBytes?: number | null) {
if (!sizeBytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let size = sizeBytes;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function formatCountdown(durationMs: number) {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds].map((value) => value.toString().padStart(2, "0")).join(":");
}

157
src/lib/types.ts Normal file
View File

@ -0,0 +1,157 @@
export type SectionKey =
| "library"
| "favorites"
| "recent"
| "collections"
| "archives"
| "timer"
| "settings";
export interface LibraryRoot {
id: number;
path: string;
enabled: boolean;
platform: string;
created_at: string;
updated_at: string;
item_count: number;
}
export interface MediaItemSummary {
id: number;
root_id: number;
absolute_path: string;
file_name: string;
extension: string;
media_kind: string;
size_bytes: number;
mtime_unix: number;
duration_ms?: number | null;
sample_rate?: number | null;
channels?: number | null;
bpm?: number | null;
musical_key?: string | null;
title?: string | null;
artist?: string | null;
album?: string | null;
genre?: string | null;
year?: string | null;
comment?: string | null;
embedded_bpm?: number | null;
favorite: boolean;
rating?: number | null;
note?: string | null;
custom_tags_json?: string | null;
color?: string | null;
last_played_at?: string | null;
}
export interface MediaItemDetail {
summary: MediaItemSummary;
custom_tags: string[];
}
export interface SearchRequest {
query?: string | null;
section?: string | null;
sort?: string | null;
page: number;
page_size: number;
root_id?: number | null;
collection_id?: number | null;
media_kind?: string | null;
}
export interface SearchResponse {
items: MediaItemSummary[];
total: number;
page: number;
page_size: number;
}
export interface AnnotationUpdate {
item_id: number;
favorite?: boolean | null;
rating?: number | null;
note?: string | null;
custom_tags?: string[] | null;
color?: string | null;
}
export interface MetadataPatch {
title?: string | null;
artist?: string | null;
album?: string | null;
genre?: string | null;
year?: string | null;
comment?: string | null;
}
export interface CollectionRecord {
id: number;
name: string;
kind: string;
rules_json?: string | null;
created_at: string;
}
export interface CollectionMutation {
name: string;
kind: string;
rules_json?: string | null;
}
export interface CollectionItemsMutation {
collection_id: number;
item_ids: number[];
}
export interface ScanStatus {
active: boolean;
current_root?: string | null;
indexed: number;
discovered: number;
last_error?: string | null;
roots: LibraryRoot[];
}
export interface PlaybackState {
loaded_path?: string | null;
is_playing: boolean;
volume: number;
position_ms: number;
duration_ms: number;
loop_region?: {
start_ms: number;
end_ms: number;
} | null;
output_device?: string | null;
media_kind: string;
}
export interface MidiBackendInfo {
id: string;
label: string;
supports_soundfont: boolean;
}
export interface MidiBackendConfig {
backend_id: string;
soundfont_path?: string | null;
}
export interface ArchiveJobSpec {
source: string;
destination: string;
}
export interface ArchiveJobResult {
output_path: string;
processed_entries: number;
}
export interface TimerState {
running: boolean;
remaining_ms: number;
started_at?: string | null;
}

15
src/main.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./styles/index.css";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
);

20
src/store/ui.ts Normal file
View File

@ -0,0 +1,20 @@
import { create } from "zustand";
import type { MediaItemSummary, SectionKey } from "../lib/types";
interface UIState {
section: SectionKey;
query: string;
selectedItem: MediaItemSummary | null;
setSection: (section: SectionKey) => void;
setQuery: (query: string) => void;
setSelectedItem: (item: MediaItemSummary | null) => void;
}
export const useUIStore = create<UIState>((set) => ({
section: "library",
query: "",
selectedItem: null,
setSection: (section) => set({ section }),
setQuery: (query) => set({ query }),
setSelectedItem: (selectedItem) => set({ selectedItem }),
}));

67
src/styles/index.css Normal file
View File

@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-canvas: 7 10 16;
--color-panel: 15 20 29;
--color-panel-alt: 23 29 40;
--color-line: 61 74 96;
--color-accent: 36 196 255;
--color-accent-soft: 23 59 79;
--color-text: 237 240 248;
--color-text-muted: 145 157 181;
--color-danger: 255 95 109;
--color-success: 47 211 151;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
height: 100%;
width: 100%;
}
body {
background:
radial-gradient(circle at top left, rgba(36, 196, 255, 0.12), transparent 24%),
radial-gradient(circle at right, rgba(24, 195, 125, 0.12), transparent 22%),
linear-gradient(180deg, rgba(16, 22, 31, 0.98), rgba(4, 6, 10, 1));
font-family: "Outfit", "Inter", ui-sans-serif, system-ui;
overflow: hidden;
}
button,
input,
select,
textarea {
font: inherit;
}
.glass {
background: linear-gradient(180deg, rgba(24, 31, 44, 0.88), rgba(12, 16, 22, 0.72));
backdrop-filter: blur(18px);
border: 1px solid rgba(93, 108, 133, 0.28);
box-shadow: 0 16px 45px rgba(0, 0, 0, 0.28);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(88, 104, 130, 0.65) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(88, 104, 130, 0.65);
}

32
tailwind.config.js Normal file
View File

@ -0,0 +1,32 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
canvas: "rgb(var(--color-canvas) / <alpha-value>)",
panel: "rgb(var(--color-panel) / <alpha-value>)",
panelAlt: "rgb(var(--color-panel-alt) / <alpha-value>)",
line: "rgb(var(--color-line) / <alpha-value>)",
accent: "rgb(var(--color-accent) / <alpha-value>)",
accentSoft: "rgb(var(--color-accent-soft) / <alpha-value>)",
text: "rgb(var(--color-text) / <alpha-value>)",
textMuted: "rgb(var(--color-text-muted) / <alpha-value>)",
danger: "rgb(var(--color-danger) / <alpha-value>)",
success: "rgb(var(--color-success) / <alpha-value>)"
},
boxShadow: {
glow: "0 18px 70px rgba(36, 196, 255, 0.12)",
panel: "0 16px 45px rgba(0, 0, 0, 0.28)"
},
backgroundImage: {
grain:
"radial-gradient(circle at top left, rgba(255,255,255,0.08), transparent 35%), radial-gradient(circle at bottom right, rgba(45,212,191,0.10), transparent 20%)"
},
fontFamily: {
sans: ["Outfit", "Inter", "ui-sans-serif", "system-ui"]
}
}
},
plugins: []
};

BIN
test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -1,204 +0,0 @@
# 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()

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"allowJs": false,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vite/client"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
tsconfig.node.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
});