Compare commits
No commits in common. "master" and "565be4e1e7facbfe0588a4622ca478b39326599d" have entirely different histories.
master
...
565be4e1e7
141
.gitignore
vendored
141
.gitignore
vendored
@ -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
6739
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal 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"] }
|
||||
@ -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.
|
||||
|
||||
907
Fbrowser.py
907
Fbrowser.py
@ -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()
|
||||
524
MidPlay.py
524
MidPlay.py
@ -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
|
||||
@ -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.
|
||||
@ -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, add‑ons, and accompanying
|
||||
documentation (collectively, the “Software”).
|
||||
|
||||
1. GRANT OF LICENSE
|
||||
Licensor hereby grants Licensee a non‑exclusive, non‑transferable, revocable
|
||||
license to install and use one copy of the Software on up to [N] machines
|
||||
owned or controlled by Licensee, solely for Licensee’s 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 one‑time license fee of USD $[Amount].
|
||||
b. All payments are non‑refundable. Licensor may suspend license rights for
|
||||
non‑payment or late payment beyond [30] days after invoice date.
|
||||
|
||||
3. RESTRICTIONS
|
||||
Licensee shall not, and shall not permit others to:
|
||||
- Reverse‑engineer, 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 bug‑fix 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 NON‑INFRINGEMENT.
|
||||
|
||||
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
28
README.md
Normal 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.
|
||||
624
ScanOrg101.py
624
ScanOrg101.py
@ -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
|
||||
153
archiver.py
153
archiver.py
@ -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()
|
||||
13
crates/fbrowser-archive/Cargo.toml
Normal file
13
crates/fbrowser-archive/Cargo.toml
Normal 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
|
||||
345
crates/fbrowser-archive/src/lib.rs
Normal file
345
crates/fbrowser-archive/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
12
crates/fbrowser-audio/Cargo.toml
Normal file
12
crates/fbrowser-audio/Cargo.toml
Normal 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
|
||||
287
crates/fbrowser-audio/src/lib.rs
Normal file
287
crates/fbrowser-audio/src/lib.rs
Normal 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
|
||||
}
|
||||
17
crates/fbrowser-core/Cargo.toml
Normal file
17
crates/fbrowser-core/Cargo.toml
Normal 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
|
||||
558
crates/fbrowser-core/src/db.rs
Normal file
558
crates/fbrowser-core/src/db.rs
Normal 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 "),
|
||||
};
|
||||
}
|
||||
}
|
||||
5
crates/fbrowser-core/src/lib.rs
Normal file
5
crates/fbrowser-core/src/lib.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod db;
|
||||
pub mod models;
|
||||
pub mod scanner;
|
||||
|
||||
pub use db::AppDatabase;
|
||||
151
crates/fbrowser-core/src/models.rs
Normal file
151
crates/fbrowser-core/src/models.rs
Normal 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>,
|
||||
}
|
||||
173
crates/fbrowser-core/src/scanner.rs
Normal file
173
crates/fbrowser-core/src/scanner.rs
Normal 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))
|
||||
}
|
||||
12
crates/fbrowser-midi/Cargo.toml
Normal file
12
crates/fbrowser-midi/Cargo.toml
Normal 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
|
||||
366
crates/fbrowser-midi/src/lib.rs
Normal file
366
crates/fbrowser-midi/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
8
crates/fbrowser-plugin-core/Cargo.toml
Normal file
8
crates/fbrowser-plugin-core/Cargo.toml
Normal file
@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "fbrowser-plugin-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
12
crates/fbrowser-plugin-core/src/lib.rs
Normal file
12
crates/fbrowser-plugin-core/src/lib.rs
Normal 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
288
dbman.py
@ -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
|
||||
@ -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
18
index.html
Normal 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>
|
||||
@ -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
|
||||
186
metaextract.py
186
metaextract.py
@ -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
3596
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
pypaqtest.paq
Normal file
0
pypaqtest.paq
Normal file
1
python-src/.python-version
Normal file
1
python-src/.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.13
|
||||
216
python-src/Fbrowser.py
Normal file
216
python-src/Fbrowser.py
Normal 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
261
python-src/MidPlay.py
Normal 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
187
python-src/ScanOrg.py
Normal 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
69
python-src/ScanOrg100.py
Normal 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()
|
||||
94
python-src/archive_compression.py
Normal file
94
python-src/archive_compression.py
Normal 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
172
python-src/bsshpy.py
Normal 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
74
python-src/extraction.py
Normal 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
147
python-src/maintest.py
Normal 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
178
python-src/paqtest.py
Normal 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
9
python-src/readme.txt
Normal 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
|
||||
@ -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
152
python-src/stanzip.py
Normal 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
168
python-src/test.py
Normal 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()
|
||||
37
python-src/test_Fbrowser.py
Normal file
37
python-src/test_Fbrowser.py
Normal 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
136
python-src/testmid.py
Normal 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
159
python-src/testmidi.py
Normal 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
141
python-src/timer_m.py
Normal 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
129
readme.md
@ -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
22
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||
2310
src-tauri/gen/schemas/desktop-schema.json
Normal file
2310
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2310
src-tauri/gen/schemas/windows-schema.json
Normal file
2310
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.ico
Normal file
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
496
src-tauri/src/main.rs
Normal 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) = ¤t.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
32
src-tauri/tauri.conf.json
Normal 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
298
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/components/ArchivePanel.tsx
Normal file
156
src/components/ArchivePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
src/components/InspectorPanel.tsx
Normal file
229
src/components/InspectorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/components/LibraryPanel.tsx
Normal file
124
src/components/LibraryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/SettingsPanel.tsx
Normal file
123
src/components/SettingsPanel.tsx
Normal 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
141
src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/TimerPanel.tsx
Normal file
53
src/components/TimerPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/TransportBar.tsx
Normal file
79
src/components/TransportBar.tsx
Normal 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
63
src/lib/api.ts
Normal 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
27
src/lib/format.ts
Normal 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
157
src/lib/types.ts
Normal 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
15
src/main.tsx
Normal 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
20
src/store/ui.ts
Normal 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
67
src/styles/index.css
Normal 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
32
tailwind.config.js
Normal 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: []
|
||||
};
|
||||
204
timer_m.py
204
timer_m.py
@ -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
20
tsconfig.json
Normal 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
9
tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user