Compare commits

..

14 Commits

Author SHA1 Message Date
Keith Edmunds
e5dc3dbf03 Fix adding duplicate track and merging comments
Fixes #271
2024-12-26 15:05:07 +00:00
Keith Edmunds
3fde474a5b Save proxy model example in archive 2024-12-26 14:10:26 +00:00
Keith Edmunds
b14b90396f Major update: correct use of proxy model
Fixes #273
2024-12-26 14:09:21 +00:00
Keith Edmunds
937f3cd074 Fix search
Fixed #272
2024-12-23 21:20:59 +00:00
Keith Edmunds
cb16a07451 Menu reorganised. Other minor cleanups. 2024-12-23 19:19:01 +00:00
Keith Edmunds
6da6f7044b Add tooltip to radio buttons on import file choices 2024-12-22 17:26:33 +00:00
Keith Edmunds
a1709e92ae Misc tidying 2024-12-22 15:23:22 +00:00
Keith Edmunds
b389a348c1 Remove mtime from Track 2024-12-22 15:23:04 +00:00
Keith Edmunds
4c53791f4d Rewrite file importer 2024-12-22 15:22:21 +00:00
Keith Edmunds
d400ba3957 Make AudioMetadata a NamedTuple 2024-12-22 15:16:02 +00:00
Keith Edmunds
6e258a0ee2 Split music_manager from classes 2024-12-22 15:14:00 +00:00
Keith Edmunds
205667faa1 Tighten up AudacityController type hints 2024-12-22 15:11:30 +00:00
Keith Edmunds
d9abf72f6a Fix section hiding
We were suppressing hiding when section contained previous track.

Now, when all are played, we hide.
2024-12-21 16:40:51 +00:00
Keith Edmunds
96807a945c Resize rows in config-defined chunks 2024-12-17 20:55:25 +00:00
23 changed files with 2142 additions and 1663 deletions

View File

@ -3,6 +3,7 @@ import os
import psutil import psutil
import socket import socket
import select import select
from typing import Optional
# PyQt imports # PyQt imports
@ -31,7 +32,7 @@ class AudacityController:
""" """
self.method = method self.method = method
self.path: str = "" self.path: Optional[str] = None
self.timeout = timeout self.timeout = timeout
if method == "pipe": if method == "pipe":
user_uid = os.getuid() # Get the user's UID user_uid = os.getuid() # Get the user's UID

View File

@ -1,71 +1,20 @@
# Standard library imports # Standard library imports
from __future__ import annotations from __future__ import annotations
import ctypes
from dataclasses import dataclass, field from dataclasses import dataclass, field
import datetime as dt
from enum import auto, Enum from enum import auto, Enum
import platform import functools
from time import sleep from typing import NamedTuple
from typing import Any, Optional, NamedTuple
# Third party imports # Third party imports
import numpy as np
import pyqtgraph as pg # type: ignore
from sqlalchemy.orm.session import Session
import vlc # type: ignore
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QObject, QObject,
QThread,
) )
from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
# App imports # App imports
from config import Config
from log import log
from models import PlaylistRows
from helpers import (
file_is_unreadable,
get_audio_segment,
show_warning,
singleton,
)
from vlcmanager import VLCManager
# Define the VLC callback function type
# VLC logging is very noisy so comment out unless needed
# VLC_LOG_CB = ctypes.CFUNCTYPE(
# None,
# ctypes.c_void_p,
# ctypes.c_int,
# ctypes.c_void_p,
# ctypes.c_char_p,
# ctypes.c_void_p,
# )
# Determine the correct C library for vsnprintf based on the platform
if platform.system() == "Windows":
libc = ctypes.CDLL("msvcrt")
elif platform.system() == "Linux":
libc = ctypes.CDLL("libc.so.6")
elif platform.system() == "Darwin": # macOS
libc = ctypes.CDLL("libc.dylib")
else:
raise OSError("Unsupported operating system")
# Define the vsnprintf function
libc.vsnprintf.argtypes = [
ctypes.c_char_p,
ctypes.c_size_t,
ctypes.c_char_p,
ctypes.c_void_p,
]
libc.vsnprintf.restype = ctypes.c_int
class Col(Enum): class Col(Enum):
@ -81,362 +30,25 @@ class Col(Enum):
NOTE = auto() NOTE = auto()
class _AddFadeCurve(QObject): def singleton(cls):
""" """
Initialising a fade curve introduces a noticeable delay so carry out in Make a class a Singleton class (see
a thread. https://realpython.com/primer-on-python-decorators/#creating-singletons)
""" """
finished = pyqtSignal() @functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if not wrapper_singleton.instance:
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
def __init__( wrapper_singleton.instance = None
self, return wrapper_singleton
rat: RowAndTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.rat = rat
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self) -> None:
"""
Create fade curve and add to PlaylistTrack object
"""
fc = _FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at) class FileErrors(NamedTuple):
if not fc: path: str
log.error(f"Failed to create FadeCurve for {self.track_path=}") error: str
else:
self.rat.fade_graph = fc
self.finished.emit()
class _FadeCurve:
GraphWidget: Optional[PlotWidget] = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.curve: Optional[PlotDataItem] = None
self.region: Optional[LinearRegionItem] = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self) -> None:
if self.GraphWidget:
self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time: int) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
log.debug("issue223: _FadeCurve: create region")
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
# Next line is very noisy
# log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QThread):
finished = pyqtSignal()
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
if total_steps > 0:
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.finished.emit()
vlc_instance = VLCManager().vlc_instance
class _Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name: str) -> None:
vlc_instance.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
self.name = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
# Set up logging
# self._set_vlc_log()
# VLC logging very noisy so comment out unless needed
# @VLC_LOG_CB
# def log_callback(data, level, ctx, fmt, args):
# try:
# # Create a ctypes string buffer to hold the formatted message
# buf = ctypes.create_string_buffer(1024)
# # Use vsnprintf to format the string with the va_list
# libc.vsnprintf(buf, len(buf), fmt, args)
# # Decode the formatted message
# message = buf.value.decode("utf-8", errors="replace")
# log.debug("VLC: " + message)
# except Exception as e:
# log.error(f"Error in VLC log callback: {e}")
# def _set_vlc_log(self):
# try:
# vlc.libvlc_log_set(vlc_instance, self.log_callback, None)
# log.debug("VLC logging set up successfully")
# except Exception as e:
# log.error(f"Failed to set up VLC logging: {e}")
def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds"""
if not self.player:
return
elapsed_ms = self.get_playtime()
position = self.get_position()
if not position:
position = 0.0
new_position = max(0.0, position + ((position * ms) / elapsed_ms))
self.set_position(new_position)
# Adjus start time so elapsed time calculations are correct
if new_position == 0:
self.start_dt = dt.datetime.now()
else:
if self.start_dt:
self.start_dt -= dt.timedelta(milliseconds=ms)
else:
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
def fade(self, fade_seconds: int) -> None:
"""
Fade the currently playing track.
The actual management of fading runs in its own thread so as not
to hold up the UI during the fade.
"""
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds)
self.fader_worker.finished.connect(self.player.release)
self.fader_worker.start()
self.start_dt = None
def get_playtime(self) -> int:
"""
Return number of milliseconds current track has been playing or
zero if not playing. The vlc function get_time() only updates 3-4
times a second; this function has much better resolution.
"""
if self.start_dt is None:
return 0
now = dt.datetime.now()
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def is_playing(self) -> bool:
"""
Return True if we're playing
"""
if not self.player:
return False
# There is a discrete time between starting playing a track and
# player.is_playing() returning True, so assume playing if less
# than Config.PLAY_SETTLE microseconds have passed since
# starting play.
return self.start_dt is not None and (
self.player.is_playing()
or (dt.datetime.now() - self.start_dt)
< dt.timedelta(microseconds=Config.PLAY_SETTLE)
)
def play(
self,
path: str,
start_time: dt.datetime,
position: Optional[float] = None,
) -> None:
"""
Start playing the track at path.
Log and return if path not found.
start_time ensures our version and our caller's version of
the start time is the same
"""
log.debug(f"Music[{self.name}].play({path=}, {position=}")
if file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
self.player = vlc.MediaPlayer(vlc_instance, path)
if self.player is None:
log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
show_warning(
None, "Error creating MediaPlayer", f"Cannot play file ({path})"
)
return
_ = self.player.play()
self.set_volume(self.max_volume)
if position:
self.player.set_position(position)
self.start_dt = start_time
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
# Update August 2024: This no longer seems to be an issue
# for _ in range(3):
# if self.player:
# volume = self.player.audio_get_volume()
# if volume < Config.VLC_VOLUME_DEFAULT:
# self.set_volume(Config.VLC_VOLUME_DEFAULT)
# log.error(f"Reset from {volume=}")
# sleep(0.1)
def set_position(self, position: float) -> None:
"""
Set player position
"""
if self.player:
self.player.set_position(position)
def set_volume(
self, volume: Optional[int] = None, set_default: bool = True
) -> None:
"""Set maximum volume used for player"""
if not self.player:
return
if set_default and volume:
self.max_volume = volume
if volume is None:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
# Ensure volume correct
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
for _ in range(3):
current_volume = self.player.audio_get_volume()
if current_volume < volume:
self.player.audio_set_volume(volume)
log.debug(f"Reset from {volume=}")
sleep(0.1)
def stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
if self.player.is_playing():
self.player.stop()
self.player.release()
self.player = None
class ApplicationError(Exception): class ApplicationError(Exception):
@ -447,6 +59,12 @@ class ApplicationError(Exception):
pass pass
class AudioMetadata(NamedTuple):
start_gap: int = 0
silence_at: int = 0
fade_at: int = 0
@singleton @singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
@ -475,345 +93,20 @@ class MusicMusterSignals(QObject):
super().__init__() super().__init__()
class RowAndTrack: @singleton
"""
Object to manage playlist rows and tracks.
"""
def __init__(self, playlist_row: PlaylistRows) -> None:
"""
Initialises data structure.
The passed PlaylistRows object will include a Tracks object if this
row has a track.
"""
# Collect playlistrow data
self.note = playlist_row.note
self.played = playlist_row.played
self.playlist_id = playlist_row.playlist_id
self.playlistrow_id = playlist_row.id
self.row_number = playlist_row.row_number
self.track_id = playlist_row.track_id
# Collect track data if there's a track
if playlist_row.track_id:
self.artist = playlist_row.track.artist
self.bitrate = playlist_row.track.bitrate
self.duration = playlist_row.track.duration
self.fade_at = playlist_row.track.fade_at
self.intro = playlist_row.track.intro
if playlist_row.track.playdates:
self.lastplayed = max(
[a.lastplayed for a in playlist_row.track.playdates]
)
else:
self.lastplayed = Config.EPOCH
self.path = playlist_row.track.path
self.silence_at = playlist_row.track.silence_at
self.start_gap = playlist_row.track.start_gap
self.title = playlist_row.track.title
else:
self.artist = ""
self.bitrate = None
self.duration = 0
self.fade_at = 0
self.intro = None
self.lastplayed = Config.EPOCH
self.path = ""
self.silence_at = 0
self.start_gap = 0
self.title = ""
# Track playing data
self.end_of_track_signalled: bool = False
self.end_time: Optional[dt.datetime] = None
self.fade_graph: Optional[_FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None
self.resume_marker: Optional[float] = 0.0
self.forecast_end_time: Optional[dt.datetime] = None
self.forecast_start_time: Optional[dt.datetime] = None
self.start_time: Optional[dt.datetime] = None
# Other object initialisation
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
def __repr__(self) -> str:
return (
f"<RowAndTrack(playlist_id={self.playlist_id}, "
f"row_number={self.row_number}, "
f"playlistrow_id={self.playlistrow_id}, "
f"note={self.note}, track_id={self.track_id}>"
)
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.start_time is None:
return
if self.end_of_track_signalled:
return
if self.music.is_playing():
return
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
# Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=self.path,
track_fade_at=self.fade_at,
track_silence_at=self.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def drop3db(self, enable: bool) -> None:
"""
If enable is true, drop output by 3db else restore to full volume
"""
if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music"""
self.resume_marker = self.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.music.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
log.debug(f"issue223: RowAndTrack: play {self.track_id=}")
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def restart(self) -> None:
"""
Restart player
"""
self.music.adjust_by_ms(self.time_playing() * -1)
def set_forecast_start_time(
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
"""
Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
if self.fade_graph:
self.fade_graph.clear()
def time_playing(self) -> int:
"""
Return time track has been playing in milliseconds, zero if not playing
"""
if self.start_time is None:
return 0
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.fade_at - self.time_playing()
def time_to_silence(self) -> int:
"""
Return milliseconds until silent. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.silence_at - self.time_playing()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
def update_playlist_and_row(self, session: Session) -> None:
"""
Update local playlist_id and row_number from playlistrow_id
"""
plr = session.get(PlaylistRows, self.playlistrow_id)
if not plr:
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
self.playlist_id = plr.playlist_id
self.row_number = plr.row_number
@dataclass @dataclass
class TrackFileData: class Selection:
""" playlist_id: int = 0
Simple class to track details changes to a track file rows: list[int] = field(default_factory=list)
"""
new_file_path: str
track_id: int = 0 class Tags(NamedTuple):
track_path: Optional[str] = None artist: str
obsolete_path: Optional[str] = None title: str
tags: dict[str, Any] = field(default_factory=dict) bitrate: int
audio_metadata: dict[str, str | int | float] = field(default_factory=dict) duration: int
class TrackInfo(NamedTuple): class TrackInfo(NamedTuple):
track_id: int track_id: int
row_number: int row_number: int
class TrackSequence:
next: Optional[RowAndTrack] = None
current: Optional[RowAndTrack] = None
previous: Optional[RowAndTrack] = None
def set_next(self, rat: Optional[RowAndTrack]) -> None:
"""
Set the 'next' track to be passed rat. Clear
any previous next track. If passed rat is None
just clear existing next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if rat is None:
self.next = None
else:
self.next = rat
self.next.create_fade_graph()
track_sequence = TrackSequence()

View File

@ -39,6 +39,7 @@ class Config(object):
DEBUG_MODULES: List[Optional[str]] = [] DEBUG_MODULES: List[Optional[str]] = []
DEFAULT_COLUMN_WIDTH = 200 DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False DISPLAY_SQL = False
DO_NOT_IMPORT = "Do not import"
ENGINE_OPTIONS = dict(pool_pre_ping=True) ENGINE_OPTIONS = dict(pool_pre_ping=True)
EPOCH = dt.datetime(1970, 1, 1) EPOCH = dt.datetime(1970, 1, 1)
ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_FROM = ["noreply@midnighthax.com"]
@ -63,6 +64,7 @@ class Config(object):
HIDE_AFTER_PLAYING_OFFSET = 5000 HIDE_AFTER_PLAYING_OFFSET = 5000
HIDE_PLAYED_MODE_TRACKS = "TRACKS" HIDE_PLAYED_MODE_TRACKS = "TRACKS"
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS" HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
IMPORT_AS_NEW = "Import as new track"
INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_TITLE_LENGTH = 15
INTRO_SECONDS_FORMAT = ".1f" INTRO_SECONDS_FORMAT = ".1f"
INTRO_SECONDS_WARNING_MS = 3000 INTRO_SECONDS_WARNING_MS = 3000
@ -80,6 +82,7 @@ class Config(object):
MAX_INFO_TABS = 5 MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10 MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0 MILLISECOND_SIGFIGS = 0
MINIMUM_FUZZYMATCH = 60.0
MINIMUM_ROW_HEIGHT = 30 MINIMUM_ROW_HEIGHT = 30
NOTE_TIME_FORMAT = "%H:%M" NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost" OBS_HOST = "localhost"
@ -93,6 +96,7 @@ class Config(object):
PREVIEW_BACK_MS = 5000 PREVIEW_BACK_MS = 5000
PREVIEW_END_BUFFER_MS = 1000 PREVIEW_END_BUFFER_MS = 1000
REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp" REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp"
RESIZE_ROW_CHUNK_SIZE = 40
RETURN_KEY_DEBOUNCE_MS = 1000 RETURN_KEY_DEBOUNCE_MS = 1000
ROOT = os.environ.get("ROOT") or "/home/kae/music" ROOT = os.environ.get("ROOT") or "/home/kae/music"
ROW_PADDING = 4 ROW_PADDING = 4
@ -102,6 +106,7 @@ class Config(object):
SECTION_STARTS = ("+", "+-", "-+") SECTION_STARTS = ("+", "+-", "-+")
SONGFACTS_ON_NEXT = False SONGFACTS_ON_NEXT = False
START_GAP_WARNING_THRESHOLD = 300 START_GAP_WARNING_THRESHOLD = 300
SUBTOTAL_ON_ROW_ZERO = "[No subtotal on first row]"
TEXT_NO_TRACK_NO_NOTE = "[Section header]" TEXT_NO_TRACK_NO_NOTE = "[Section header]"
TOD_TIME_FORMAT = "%H:%M:%S" TOD_TIME_FORMAT = "%H:%M:%S"
TRACK_TIME_FORMAT = "%H:%M:%S" TRACK_TIME_FORMAT = "%H:%M:%S"

View File

@ -142,7 +142,6 @@ class TracksTable(Model):
duration: Mapped[int] = mapped_column(index=True) duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False) fade_at: Mapped[int] = mapped_column(index=False)
intro: Mapped[Optional[int]] = mapped_column(default=None) intro: Mapped[Optional[int]] = mapped_column(default=None)
mtime: Mapped[float] = mapped_column(index=True)
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True) path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False) silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False) start_gap: Mapped[int] = mapped_column(index=False)

View File

@ -17,7 +17,7 @@ import pydymenu # type: ignore
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
# App imports # App imports
from classes import MusicMusterSignals, TrackFileData from classes import MusicMusterSignals
from config import Config from config import Config
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
@ -32,203 +32,6 @@ from playlistmodel import PlaylistModel
from ui import dlg_TrackSelect_ui, dlg_replace_files_ui from ui import dlg_TrackSelect_ui, dlg_replace_files_ui
class ReplaceFilesDialog(QDialog):
"""Import files as new or replacements"""
def __init__(
self,
session: Session,
main_window: QMainWindow,
*args: Qt.WindowType,
**kwargs: Qt.WindowType,
) -> None:
super().__init__(main_window, *args, **kwargs)
self.session = session
self.main_window = main_window
self.ui = dlg_replace_files_ui.Ui_Dialog()
self.ui.setupUi(self)
self.ui.lblSourceDirectory.setText(Config.REPLACE_FILES_DEFAULT_SOURCE)
self.ui.lblDestinationDirectory.setText(
Config.REPLACE_FILES_DEFAULT_DESTINATION
)
self.replacement_files: list[TrackFileData] = []
# We only want to run this against the production database because
# we will affect files in the common pool of tracks used by all
# databases
dburi = os.environ.get("DATABASE_URL")
if not dburi or "musicmuster_prod" not in dburi:
if not ask_yes_no(
"Not production database",
"Not on production database - continue?",
default_yes=False,
):
return
if self.ui.lblSourceDirectory.text() == self.ui.lblDestinationDirectory.text():
show_warning(
parent=self.main_window,
title="Error",
msg="Cannot import into source directory",
)
return
self.ui.tableWidget.setHorizontalHeaderLabels(["Path", "Title", "Artist"])
# Work through new files
source_dir = self.ui.lblSourceDirectory.text()
with db.Session() as session:
for new_file_basename in os.listdir(source_dir):
new_file_path = os.path.join(source_dir, new_file_basename)
if not os.path.isfile(new_file_path):
continue
rf = TrackFileData(new_file_path=new_file_path)
rf.tags = get_tags(new_file_path)
if not (
"title" in rf.tags
and "artist" in rf.tags
and rf.tags["title"]
and rf.tags["artist"]
):
show_warning(
parent=self.main_window,
title="Error",
msg=(
f"File {new_file_path} missing tags\n\n:"
f"Title={rf.tags['title']}\n"
f"Artist={rf.tags['artist']}\n"
),
)
return
# Check for same filename
match_track = self.check_by_basename(
session, new_file_path, rf.tags["artist"], rf.tags["title"]
)
if not match_track:
match_track = self.check_by_title(
session, new_file_path, rf.tags["artist"], rf.tags["title"]
)
if not match_track:
match_track = self.get_fuzzy_match(session, new_file_basename)
# Build summary
if match_track:
# We will store new file in the same directory as the
# existing file but with the new file name
rf.track_path = os.path.join(
os.path.dirname(match_track.path), new_file_basename
)
# We will remove existing track file
rf.obsolete_path = match_track.path
rf.track_id = match_track.id
match_basename = os.path.basename(match_track.path)
if match_basename == new_file_basename:
path_text = " " + new_file_basename + " (no change)"
else:
path_text = (
f" {match_basename}\n {new_file_basename} (replace)"
)
filename_item = QTableWidgetItem(path_text)
if match_track.title == rf.tags["title"]:
title_text = " " + rf.tags["title"] + " (no change)"
else:
title_text = (
f" {match_track.title}\n {rf.tags['title']} (update)"
)
title_item = QTableWidgetItem(title_text)
if match_track.artist == rf.tags["artist"]:
artist_text = " " + rf.tags["artist"] + " (no change)"
else:
artist_text = (
f" {match_track.artist}\n {rf.tags['artist']} (update)"
)
artist_item = QTableWidgetItem(artist_text)
else:
rf.track_path = os.path.join(
Config.REPLACE_FILES_DEFAULT_DESTINATION, new_file_basename
)
filename_item = QTableWidgetItem(" " + new_file_basename + " (new)")
title_item = QTableWidgetItem(" " + rf.tags["title"])
artist_item = QTableWidgetItem(" " + rf.tags["artist"])
self.replacement_files.append(rf)
row = self.ui.tableWidget.rowCount()
self.ui.tableWidget.insertRow(row)
self.ui.tableWidget.setItem(row, 0, filename_item)
self.ui.tableWidget.setItem(row, 1, title_item)
self.ui.tableWidget.setItem(row, 2, artist_item)
self.ui.tableWidget.resizeColumnsToContents()
self.ui.tableWidget.resizeRowsToContents()
def check_by_basename(
self, session: Session, new_path: str, new_path_artist: str, new_path_title: str
) -> Optional[Tracks]:
"""
Return Track that matches basename and tags
"""
match_track = None
candidates_by_basename = Tracks.get_by_basename(session, new_path)
if candidates_by_basename:
# Check tags are the same
for cbbn in candidates_by_basename:
cbbn_tags = get_tags(cbbn.path)
if (
"title" in cbbn_tags
and cbbn_tags["title"].lower() == new_path_title.lower()
and "artist" in cbbn_tags
and cbbn_tags["artist"].lower() == new_path_artist.lower()
):
match_track = cbbn
break
return match_track
def check_by_title(
self, session: Session, new_path: str, new_path_artist: str, new_path_title: str
) -> Optional[Tracks]:
"""
Return Track that mathces title and artist
"""
match_track = None
candidates_by_title = Tracks.search_titles(session, new_path_title)
if candidates_by_title:
# Check artist tag
for cbt in candidates_by_title:
if not os.path.exists(cbt.path):
return None
try:
cbt_artist = get_tags(cbt.path)["artist"]
if cbt_artist.lower() == new_path_artist.lower():
match_track = cbt
break
except KeyError:
return None
return match_track
def get_fuzzy_match(self, session: Session, fname: str) -> Optional[Tracks]:
"""
Return Track that matches fuzzy filename search
"""
match_track = None
choice = pydymenu.rofi([a.path for a in Tracks.get_all(session)], prompt=fname)
if choice:
match_track = Tracks.get_by_path(session, choice[0])
return match_track
class TrackSelectDialog(QDialog): class TrackSelectDialog(QDialog):
"""Select track from database""" """Select track from database"""
@ -237,7 +40,7 @@ class TrackSelectDialog(QDialog):
parent: QMainWindow, parent: QMainWindow,
session: Session, session: Session,
new_row_number: int, new_row_number: int,
source_model: PlaylistModel, base_model: PlaylistModel,
add_to_header: Optional[bool] = False, add_to_header: Optional[bool] = False,
*args: Qt.WindowType, *args: Qt.WindowType,
**kwargs: Qt.WindowType, **kwargs: Qt.WindowType,
@ -249,7 +52,7 @@ class TrackSelectDialog(QDialog):
super().__init__(parent, *args, **kwargs) super().__init__(parent, *args, **kwargs)
self.session = session self.session = session
self.new_row_number = new_row_number self.new_row_number = new_row_number
self.source_model = source_model self.base_model = base_model
self.add_to_header = add_to_header self.add_to_header = add_to_header
self.ui = dlg_TrackSelect_ui.Ui_Dialog() self.ui = dlg_TrackSelect_ui.Ui_Dialog()
self.ui.setupUi(self) self.ui.setupUi(self)
@ -293,7 +96,7 @@ class TrackSelectDialog(QDialog):
track_id = track.id track_id = track.id
if note and not track_id: if note and not track_id:
self.source_model.insert_row(self.new_row_number, track_id, note) self.base_model.insert_row(self.new_row_number, track_id, note)
self.ui.txtNote.clear() self.ui.txtNote.clear()
self.new_row_number += 1 self.new_row_number += 1
return return
@ -307,7 +110,7 @@ class TrackSelectDialog(QDialog):
# Check whether track is already in playlist # Check whether track is already in playlist
move_existing = False move_existing = False
existing_prd = self.source_model.is_track_in_playlist(track_id) existing_prd = self.base_model.is_track_in_playlist(track_id)
if existing_prd is not None: if existing_prd is not None:
if ask_yes_no( if ask_yes_no(
"Duplicate row", "Duplicate row",
@ -318,21 +121,21 @@ class TrackSelectDialog(QDialog):
if self.add_to_header: if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.source_model.move_track_to_header( self.base_model.move_track_to_header(
self.new_row_number, existing_prd, note self.new_row_number, existing_prd, note
) )
else: else:
self.source_model.add_track_to_header(self.new_row_number, track_id) self.base_model.add_track_to_header(self.new_row_number, track_id)
# Close dialog - we can only add one track to a header # Close dialog - we can only add one track to a header
self.accept() self.accept()
else: else:
# Adding a new track row # Adding a new track row
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.source_model.move_track_add_note( self.base_model.move_track_add_note(
self.new_row_number, existing_prd, note self.new_row_number, existing_prd, note
) )
else: else:
self.source_model.insert_row(self.new_row_number, track_id, note) self.base_model.insert_row(self.new_row_number, track_id, note)
self.new_row_number += 1 self.new_row_number += 1

536
app/file_importer.py Normal file
View File

@ -0,0 +1,536 @@
# Standard library imports
from __future__ import annotations
from dataclasses import dataclass
from fuzzywuzzy import fuzz # type: ignore
import os.path
from typing import Optional
import os
import shutil
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QThread,
)
from PyQt6.QtWidgets import (
QButtonGroup,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QStatusBar,
QVBoxLayout,
)
# Third party imports
# App imports
from classes import (
ApplicationError,
AudioMetadata,
FileErrors,
MusicMusterSignals,
Tags,
)
from config import Config
from helpers import (
file_is_unreadable,
get_tags,
show_warning,
)
from log import log
from models import db, Tracks
from music_manager import track_sequence
from playlistmodel import PlaylistModel
import helpers
class DoTrackImport(QObject):
import_finished = pyqtSignal()
def __init__(
self,
import_file_path: str,
tags: Tags,
destination_track_path: str,
track_id: int,
audio_metadata: AudioMetadata,
base_model: PlaylistModel,
row_number: Optional[int],
) -> None:
"""
Save parameters
"""
super().__init__()
self.import_file_path = import_file_path
self.tags = tags
self.destination_track_path = destination_track_path
self.track_id = track_id
self.audio_metadata = audio_metadata
self.base_model = base_model
if row_number is None:
self.next_row_number = base_model.rowCount()
else:
self.next_row_number = row_number
self.signals = MusicMusterSignals()
def run(self) -> None:
"""
Either create track objects from passed files or update exising track
objects.
And add to visible playlist or update playlist if track already present.
"""
temp_file: Optional[str] = None
# If destination exists, move it out of the way
if os.path.exists(self.destination_track_path):
temp_file = self.destination_track_path + ".TMP"
shutil.move(self.destination_track_path, temp_file)
# Move file to destination
shutil.move(self.import_file_path, self.destination_track_path)
with db.Session() as session:
self.signals.status_message_signal.emit(
f"Importing {os.path.basename(self.import_file_path)}", 5000
)
if self.track_id == 0:
# Import new track
try:
track = Tracks(
session,
path=self.destination_track_path,
**self.tags._asdict(),
**self.audio_metadata._asdict(),
)
except Exception as e:
self.signals.show_warning_signal.emit(
"Error importing track", str(e)
)
return
else:
track = session.get(Tracks, self.track_id)
if track:
for key, value in self.tags._asdict().items():
if hasattr(track, key):
setattr(track, key, value)
for key, value in self.audio_metadata._asdict().items():
if hasattr(track, key):
setattr(track, key, value)
track.path = self.destination_track_path
session.commit()
helpers.normalise_track(self.destination_track_path)
self.base_model.insert_row(self.next_row_number, track.id, "imported")
self.next_row_number += 1
self.signals.status_message_signal.emit(
f"{os.path.basename(self.import_file_path)} imported", 10000
)
self.import_finished.emit()
class FileImporter:
"""
Manage importing of files
"""
def __init__(
self, base_model: PlaylistModel, row_number: Optional[int] = None
) -> None:
"""
Set up class
"""
# Save parameters
self.base_model = base_model
if row_number:
self.row_number = row_number
else:
self.row_number = base_model.rowCount()
# Data structure to track files to import
self.import_files_data: list[TrackFileData] = []
# Dictionary of exsting tracks
self.existing_tracks = self._get_existing_tracks()
# List of track_id, title tuples
self.track_idx_and_title = [
((a.id, a.title)) for a in self.existing_tracks.values()
]
# Files to import
self.import_files_paths = [
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
for f in os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE)
if f.endswith((".mp3", ".flac"))
]
# Files we can't import
self.unimportable_files: list[FileErrors] = []
# Files user doesn't want imported
self.do_not_import: list[str] = []
def do_import(self) -> None:
"""
Scan source directory and:
- check all file are readable
- load readable files and tags into self.import_files
- check all files are tagged
- check for exact match of existing file
- check for duplicates and replacements
- allow deselection of import for any one file
- import files and either replace existing or add to pool
"""
# check all file are readable
self.check_files_are_readable()
# load readable files and tags into self.import_files
for import_file in self.import_files_paths:
try:
tags = get_tags(import_file)
except ApplicationError as e:
self.unimportable_files.append(
FileErrors(path=import_file, error=str(e))
)
self.import_files_paths.remove(import_file)
try:
self.import_files_data.append(
TrackFileData(import_file_path=import_file, tags=tags)
)
except Exception as e:
self.unimportable_files.append(
FileErrors(path=import_file, error=str(e))
)
self.import_files_paths.remove(import_file)
if self.unimportable_files:
msg = "The following files could not be read and won't be imported:\n"
for unimportable_file in self.unimportable_files:
msg += f"\n\t{unimportable_file.path} ({unimportable_file.error})"
show_warning(None, "Unimportable files", msg)
# check for close matches.
for idx in range(len(self.import_files_data)):
self.check_match(idx=idx)
self.import_files_data = [
x
for x in self.import_files_data
if x.import_file_path not in self.do_not_import
]
# Import all that's left.
for idx in range(len(self.import_files_data)):
self._import_file(idx)
def check_match(self, idx: int) -> None:
"""
Work on and update the idx element of self.import_file_data.
Check for similar existing titles. If none found, set up to
import this as a new track. If one is found, check with user
whether this is a new track or replacement. If more than one
is found, as for one but order the tracks in
artist-similarity order.
"""
similar_track_ids = self._find_similar_strings(
self.import_files_data[idx].tags.title, self.track_idx_and_title
)
if len(similar_track_ids) == 0:
matching_track = 0
elif len(similar_track_ids) == 1:
matching_track = self._pick_match(idx, similar_track_ids)
else:
matching_track = self._pick_match(
idx, self.order_by_artist(idx, similar_track_ids)
)
if matching_track < 0: # User cancelled
return
if matching_track == 0:
self.import_files_data[idx].destination_track_path = os.path.join(
Config.IMPORT_DESTINATION,
os.path.basename(self.import_files_data[idx].import_file_path),
)
else:
self.import_files_data[idx].destination_track_path = self.existing_tracks[
matching_track
].path
self.import_files_data[idx].track_id = matching_track
def _import_file(self, idx: int) -> None:
"""
Import the file specified at self.import_files_data[idx]
"""
log.debug(f"_import_file({idx=}), {self.import_files_data[idx]=}")
f = self.import_files_data[idx]
# Import in separate thread
self.import_thread = QThread()
self.worker = DoTrackImport(
import_file_path=f.import_file_path,
tags=f.tags,
destination_track_path=f.destination_track_path,
track_id=f.track_id,
audio_metadata=helpers.get_audio_metadata(f.import_file_path),
base_model=self.base_model,
row_number=self.row_number,
)
self.worker.moveToThread(self.import_thread)
self.import_thread.started.connect(self.worker.run)
self.worker.import_finished.connect(self.import_thread.quit)
self.worker.import_finished.connect(self.worker.deleteLater)
self.import_thread.finished.connect(self.import_thread.deleteLater)
self.import_thread.start()
def order_by_artist(self, idx: int, track_ids_to_check: list[int]) -> list[int]:
"""
Return the list of track_ids sorted by how well the artist at idx matches the
track artist.
"""
track_idx_and_artist = [
((key, a.artist))
for key, a in self.existing_tracks.items()
if key in track_ids_to_check
]
# We want to return all of the passed tracks so set minimum_score
# to zero
return self._find_similar_strings(
self.import_files_data[idx].tags.artist,
track_idx_and_artist,
minimum_score=0.0,
)
def _pick_match(self, idx: int, track_ids: list[int]) -> int:
"""
Return the track_id selected by the user, including "import as new" which will be
track_id 0. Return -1 if user cancels.
If user chooses not to import this track, remove it from the list of tracks to
import and return -1.
"""
log.debug(f"_pick_match({idx=}, {track_ids=})")
new_track_details = (
f"{self.import_files_data[idx].tags.title} "
f"({self.import_files_data[idx].tags.artist})"
)
# Build a list of (track title and artist, track_id, track path)
choices: list[tuple[str, int, str]] = []
# First choice is always to import as a new track
choices.append((Config.DO_NOT_IMPORT, -2, ""))
choices.append((Config.IMPORT_AS_NEW, 0, ""))
for track_id in track_ids:
choices.append(
(
f"{self.existing_tracks[track_id].title} "
f"({self.existing_tracks[track_id].artist})",
track_id,
str(self.existing_tracks[track_id].path),
)
)
dialog = PickMatch(new_track_details, choices)
if dialog.exec() and dialog.selected_id >= 0:
return dialog.selected_id
else:
self.do_not_import.append(self.import_files_data[idx].import_file_path)
return -1
def check_files_are_readable(self) -> None:
"""
Check files to be imported are readable. If not, remove them from the
import list and add them to the file errors list.
"""
for path in self.import_files_paths:
if file_is_unreadable(path):
self.unimportable_files.append(
FileErrors(path=os.path.basename(path), error="File is unreadable")
)
self.import_files_paths.remove(path)
def import_files_are_tagged(self) -> list:
"""
Return a (possibly empty) list of all untagged files in the
import directory. Add tags to file_data
"""
untagged_files: list[str] = []
for fullpath in self.import_files_paths:
tags = get_tags(fullpath)
if not tags:
untagged_files.append(os.path.basename(fullpath))
# Remove from import list
del self.import_files_data[fullpath]
log.warning(f"Import: no tags found, {fullpath=}")
else:
self.import_files_data.append(
TrackFileData(import_file_path=fullpath, tags=tags)
)
return untagged_files
def _get_existing_tracks(self):
"""
Return a dictionary {title: Track} for all existing tracks
"""
with db.Session() as session:
return Tracks.all_tracks_indexed_by_id(session)
def _find_similar_strings(
self,
needle: str,
haystack: list[tuple[int, str]],
minimum_score: float = Config.MINIMUM_FUZZYMATCH,
) -> list[int]:
"""
Search for the needle in the string element of the haystack.
Discard similarities less that minimum_score. Return a list of
the int element of the haystack in order of decreasing score (ie,
best match first).
"""
# Create a dictionary to store similarities
similarities: dict[int, float] = {}
for hayblade in haystack:
# Calculate similarity using multiple metrics
ratio = fuzz.ratio(needle, hayblade[1])
partial_ratio = fuzz.partial_ratio(needle, hayblade[1])
token_sort_ratio = fuzz.token_sort_ratio(needle, hayblade[1])
token_set_ratio = fuzz.token_set_ratio(needle, hayblade[1])
# Combine scores
combined_score = (
ratio * 0.25
+ partial_ratio * 0.25
+ token_sort_ratio * 0.25
+ token_set_ratio * 0.25
)
if combined_score >= minimum_score:
similarities[hayblade[0]] = combined_score
log.debug(
f"_find_similar_strings({needle=}), {len(haystack)=}, "
f"{minimum_score=}, {hayblade=}, {combined_score=}"
)
# Sort matches by score
sorted_matches = sorted(similarities.items(), key=lambda x: x[1], reverse=True)
# Return list of indexes, highest score first
return [a[0] for a in sorted_matches]
class PickMatch(QDialog):
"""
Dialog for user to select which existing track to replace or to
import to a new track
"""
def __init__(
self, new_track_details: str, items_with_ids: list[tuple[str, int, str]]
) -> None:
super().__init__()
self.new_track_details = new_track_details
self.init_ui(items_with_ids)
self.selected_id = -1
def init_ui(self, items_with_ids: list[tuple[str, int, str]]) -> None:
"""
Set up dialog
"""
self.setWindowTitle("New or replace")
layout = QVBoxLayout()
# Add instructions
instructions = (
f"Importing {self.new_track_details}.\n"
"Import as a new track or replace existing track?"
)
instructions_label = QLabel(instructions)
layout.addWidget(instructions_label)
# Create a button group for radio buttons
self.button_group = QButtonGroup()
# Add radio buttons for each item
for idx, (text, track_id, track_path) in enumerate(items_with_ids):
if (
track_sequence.current
and track_id
and track_sequence.current.track_id == track_id
):
# Don't allow current track to be replaced
text = "(Currently playing) " + text
radio_button = QRadioButton(text)
radio_button.setDisabled(True)
self.button_group.addButton(radio_button, -1)
else:
radio_button = QRadioButton(text)
radio_button.setToolTip(track_path)
self.button_group.addButton(radio_button, track_id)
layout.addWidget(radio_button)
# Select the second item by default (import as new)
if idx == 1:
radio_button.setChecked(True)
# Add OK and Cancel buttons
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
cancel_button = QPushButton("Cancel")
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
# Connect buttons to actions
ok_button.clicked.connect(self.on_ok)
cancel_button.clicked.connect(self.reject)
def on_ok(self):
# Get the ID of the selected button
self.selected_id = self.button_group.checkedId()
self.accept()
@dataclass
class TrackFileData:
"""
Simple class to track details changes to a track file
"""
import_file_path: str
tags: Tags
destination_track_path: str = ""
file_path_to_removed: Optional[str] = None
track_id: int = 0
audio_metadata: Optional[AudioMetadata] = None
def set_destination_track_path(self, path: str) -> None:
"""
Assigned the passed path
"""
self.destination_track_path = path

View File

@ -1,8 +1,7 @@
# Standard library imports # Standard library imports
import datetime as dt import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import Any, Dict, Optional from typing import Optional
import functools
import os import os
import re import re
import shutil import shutil
@ -18,9 +17,10 @@ from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore from mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects from pydub import AudioSegment, effects
from pydub.utils import mediainfo from pydub.utils import mediainfo
from tinytag import TinyTag # type: ignore from tinytag import TinyTag, TinyTagException # type: ignore
# App imports # App imports
from classes import AudioMetadata, ApplicationError, Tags
from config import Config from config import Config
from log import log from log import log
from models import Tracks from models import Tracks
@ -121,29 +121,25 @@ def get_embedded_time(text: str) -> Optional[dt.datetime]:
return None return None
def get_all_track_metadata(filepath: str) -> Dict[str, str | int | float]: def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]:
"""Return all track metadata""" """Return all track metadata"""
return get_audio_metadata(filepath) | get_tags(filepath) | dict(path=filepath) return (
get_audio_metadata(filepath)._asdict()
| get_tags(filepath)._asdict()
| dict(path=filepath)
)
def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]: def get_audio_metadata(filepath: str) -> AudioMetadata:
"""Return audio metadata""" """Return audio metadata"""
metadata: Dict[str, str | int | float] = {}
try:
metadata["mtime"] = os.path.getmtime(filepath)
except FileNotFoundError:
show_warning(None, "File not found", f"Filepath {filepath} not found")
return {}
# Set start_gap, fade_at and silence_at # Set start_gap, fade_at and silence_at
audio = get_audio_segment(filepath) audio = get_audio_segment(filepath)
if not audio: if not audio:
audio_values = dict(start_gap=0, fade_at=0, silence_at=0) return AudioMetadata()
else: else:
audio_values = dict( return AudioMetadata(
start_gap=leading_silence(audio), start_gap=leading_silence(audio),
fade_at=int( fade_at=int(
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
@ -152,9 +148,6 @@ def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]:
round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
), ),
) )
metadata |= audio_values
return metadata
def get_relative_date( def get_relative_date(
@ -199,17 +192,19 @@ def get_relative_date(
return f"{weeks} {weeks_str}, {days} {days_str}" return f"{weeks} {weeks_str}, {days} {days_str}"
def get_tags(path: str) -> Dict[str, Any]: def get_tags(path: str) -> Tags:
""" """
Return a dictionary of title, artist, duration-in-milliseconds and path. Return a dictionary of title, artist, bitrate and duration-in-milliseconds.
""" """
try: try:
tag = TinyTag.get(path) tag = TinyTag.get(path)
except FileNotFoundError: except FileNotFoundError:
return {} raise ApplicationError(f"File not found: get_tags({path=})")
except TinyTagException:
raise ApplicationError(f"Can't read tags: get_tags({path=})")
return dict( return Tags(
title=tag.title, title=tag.title,
artist=tag.artist, artist=tag.artist,
bitrate=round(tag.bitrate), bitrate=round(tag.bitrate),
@ -351,7 +346,7 @@ def remove_substring_case_insensitive(parent_string: str, substring: str) -> str
index = lower_parent.find(lower_substring) index = lower_parent.find(lower_substring)
# Remove the substring # Remove the substring
result = result[:index] + result[index + len(substring):] result = result[:index] + result[index + len(substring) :]
# Update the lowercase versions # Update the lowercase versions
lower_parent = result.lower() lower_parent = result.lower()
@ -391,10 +386,10 @@ def set_track_metadata(track: Tracks) -> None:
audio_metadata = get_audio_metadata(track.path) audio_metadata = get_audio_metadata(track.path)
tags = get_tags(track.path) tags = get_tags(track.path)
for audio_key in audio_metadata: for audio_key in AudioMetadata._fields:
setattr(track, audio_key, audio_metadata[audio_key]) setattr(track, audio_key, getattr(audio_metadata, audio_key))
for tag_key in tags: for tag_key in Tags._fields:
setattr(track, tag_key, tags[tag_key]) setattr(track, tag_key, getattr(tags, tag_key))
def show_OK(parent: QMainWindow, title: str, msg: str) -> None: def show_OK(parent: QMainWindow, title: str, msg: str) -> None:
@ -409,22 +404,6 @@ def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel) QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel)
def singleton(cls):
"""
Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons)
"""
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if not wrapper_singleton.instance:
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None
return wrapper_singleton
def trailing_silence( def trailing_silence(
audio_segment: AudioSegment, audio_segment: AudioSegment,
silence_threshold: int = -50, silence_threshold: int = -50,

View File

@ -1,4 +1,6 @@
# Standard library imports # Standard library imports
from __future__ import annotations
from typing import List, Optional, Sequence from typing import List, Optional, Sequence
import datetime as dt import datetime as dt
import os import os
@ -70,7 +72,9 @@ class NoteColours(dbtables.NoteColoursTable):
return result return result
@staticmethod @staticmethod
def get_colour(session: Session, text: str, foreground: bool = False) -> Optional[str]: def get_colour(
session: Session, text: str, foreground: bool = False
) -> Optional[str]:
""" """
Parse text and return background (foreground if foreground==True) colour Parse text and return background (foreground if foreground==True) colour
string if matched, else None string if matched, else None
@ -243,10 +247,7 @@ class Playlists(dbtables.PlaylistsTable):
return session.scalars( return session.scalars(
select(cls) select(cls)
.where( .where(cls.is_template.is_(True), cls.deleted.is_not(True))
cls.is_template.is_(True),
cls.deleted.is_not(True)
)
.order_by(cls.name) .order_by(cls.name)
).all() ).all()
@ -629,7 +630,6 @@ class Tracks(dbtables.TracksTable):
start_gap: int, start_gap: int,
fade_at: int, fade_at: int,
silence_at: int, silence_at: int,
mtime: int,
bitrate: int, bitrate: int,
): ):
self.path = path self.path = path
@ -640,7 +640,6 @@ class Tracks(dbtables.TracksTable):
self.start_gap = start_gap self.start_gap = start_gap
self.fade_at = fade_at self.fade_at = fade_at
self.silence_at = silence_at self.silence_at = silence_at
self.mtime = mtime
try: try:
session.add(self) session.add(self)
@ -657,19 +656,35 @@ class Tracks(dbtables.TracksTable):
return session.scalars(select(cls)).unique().all() return session.scalars(select(cls)).unique().all()
@classmethod @classmethod
def get_by_basename( def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
cls, session: Session, basename: str
) -> Optional[Sequence["Tracks"]]:
""" """
Return track(s) with passed basename, or None. Return a dictionary of all tracks, keyed by title
""" """
try: result: dict[int, Tracks] = {}
return session.scalars(
Tracks.select().where(Tracks.path.like("%/" + basename)) for track in cls.get_all(session):
).all() result[track.id] = track
except NoResultFound:
return None return result
@classmethod
def exact_title_and_artist(
cls, session: Session, title: str, artist: str
) -> Sequence["Tracks"]:
"""
Search for exact but case-insensitive match of title and artist
"""
return (
session.scalars(
select(cls)
.where(cls.title.ilike(title), cls.artist.ilike(artist))
.order_by(cls.title)
)
.unique()
.all()
)
@classmethod @classmethod
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]: def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:

746
app/music_manager.py Normal file
View File

@ -0,0 +1,746 @@
# Standard library imports
from __future__ import annotations
import datetime as dt
from time import sleep
from typing import Optional
# Third party imports
import numpy as np
import pyqtgraph as pg # type: ignore
from sqlalchemy.orm.session import Session
import vlc # type: ignore
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QThread,
)
from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
# App imports
from classes import ApplicationError, MusicMusterSignals
from config import Config
import helpers
from log import log
from models import PlaylistRows
from vlcmanager import VLCManager
# Define the VLC callback function type
# import ctypes
# import platform
# VLC logging is very noisy so comment out unless needed
# VLC_LOG_CB = ctypes.CFUNCTYPE(
# None,
# ctypes.c_void_p,
# ctypes.c_int,
# ctypes.c_void_p,
# ctypes.c_char_p,
# ctypes.c_void_p,
# )
# # Determine the correct C library for vsnprintf based on the platform
# if platform.system() == "Windows":
# libc = ctypes.CDLL("msvcrt")
# elif platform.system() == "Linux":
# libc = ctypes.CDLL("libc.so.6")
# elif platform.system() == "Darwin": # macOS
# libc = ctypes.CDLL("libc.dylib")
# else:
# raise OSError("Unsupported operating system")
# # Define the vsnprintf function
# libc.vsnprintf.argtypes = [
# ctypes.c_char_p,
# ctypes.c_size_t,
# ctypes.c_char_p,
# ctypes.c_void_p,
# ]
# libc.vsnprintf.restype = ctypes.c_int
class _AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
rat: RowAndTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.rat = rat
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self) -> None:
"""
Create fade curve and add to PlaylistTrack object
"""
fc = _FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.rat.fade_graph = fc
self.finished.emit()
class _FadeCurve:
GraphWidget: Optional[PlotWidget] = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.curve: Optional[PlotDataItem] = None
self.region: Optional[LinearRegionItem] = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self) -> None:
if self.GraphWidget:
self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time: int) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
log.debug("issue223: _FadeCurve: create region")
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
# Next line is very noisy
# log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QThread):
finished = pyqtSignal()
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
if total_steps > 0:
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.finished.emit()
# TODO can we move this into the _Music class?
vlc_instance = VLCManager().vlc_instance
class _Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name: str) -> None:
vlc_instance.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
self.name = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
# Set up logging
# self._set_vlc_log()
# VLC logging very noisy so comment out unless needed
# @VLC_LOG_CB
# def log_callback(data, level, ctx, fmt, args):
# try:
# # Create a ctypes string buffer to hold the formatted message
# buf = ctypes.create_string_buffer(1024)
# # Use vsnprintf to format the string with the va_list
# libc.vsnprintf(buf, len(buf), fmt, args)
# # Decode the formatted message
# message = buf.value.decode("utf-8", errors="replace")
# log.debug("VLC: " + message)
# except Exception as e:
# log.error(f"Error in VLC log callback: {e}")
# def _set_vlc_log(self):
# try:
# vlc.libvlc_log_set(vlc_instance, self.log_callback, None)
# log.debug("VLC logging set up successfully")
# except Exception as e:
# log.error(f"Failed to set up VLC logging: {e}")
def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds"""
if not self.player:
return
elapsed_ms = self.get_playtime()
position = self.get_position()
if not position:
position = 0.0
new_position = max(0.0, position + ((position * ms) / elapsed_ms))
self.set_position(new_position)
# Adjus start time so elapsed time calculations are correct
if new_position == 0:
self.start_dt = dt.datetime.now()
else:
if self.start_dt:
self.start_dt -= dt.timedelta(milliseconds=ms)
else:
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
def fade(self, fade_seconds: int) -> None:
"""
Fade the currently playing track.
The actual management of fading runs in its own thread so as not
to hold up the UI during the fade.
"""
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds)
self.fader_worker.finished.connect(self.player.release)
self.fader_worker.start()
self.start_dt = None
def get_playtime(self) -> int:
"""
Return number of milliseconds current track has been playing or
zero if not playing. The vlc function get_time() only updates 3-4
times a second; this function has much better resolution.
"""
if self.start_dt is None:
return 0
now = dt.datetime.now()
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def is_playing(self) -> bool:
"""
Return True if we're playing
"""
if not self.player:
return False
# There is a discrete time between starting playing a track and
# player.is_playing() returning True, so assume playing if less
# than Config.PLAY_SETTLE microseconds have passed since
# starting play.
return self.start_dt is not None and (
self.player.is_playing()
or (dt.datetime.now() - self.start_dt)
< dt.timedelta(microseconds=Config.PLAY_SETTLE)
)
def play(
self,
path: str,
start_time: dt.datetime,
position: Optional[float] = None,
) -> None:
"""
Start playing the track at path.
Log and return if path not found.
start_time ensures our version and our caller's version of
the start time is the same
"""
log.debug(f"Music[{self.name}].play({path=}, {position=}")
if helpers.file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
self.player = vlc.MediaPlayer(vlc_instance, path)
if self.player is None:
log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
helpers.show_warning(
None, "Error creating MediaPlayer", f"Cannot play file ({path})"
)
return
_ = self.player.play()
self.set_volume(self.max_volume)
if position:
self.player.set_position(position)
self.start_dt = start_time
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
# Update August 2024: This no longer seems to be an issue
# for _ in range(3):
# if self.player:
# volume = self.player.audio_get_volume()
# if volume < Config.VLC_VOLUME_DEFAULT:
# self.set_volume(Config.VLC_VOLUME_DEFAULT)
# log.error(f"Reset from {volume=}")
# sleep(0.1)
def set_position(self, position: float) -> None:
"""
Set player position
"""
if self.player:
self.player.set_position(position)
def set_volume(
self, volume: Optional[int] = None, set_default: bool = True
) -> None:
"""Set maximum volume used for player"""
if not self.player:
return
if set_default and volume:
self.max_volume = volume
if volume is None:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
# Ensure volume correct
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
for _ in range(3):
current_volume = self.player.audio_get_volume()
if current_volume < volume:
self.player.audio_set_volume(volume)
log.debug(f"Reset from {volume=}")
sleep(0.1)
def stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
if self.player.is_playing():
self.player.stop()
self.player.release()
self.player = None
class RowAndTrack:
"""
Object to manage playlist rows and tracks.
"""
def __init__(self, playlist_row: PlaylistRows) -> None:
"""
Initialises data structure.
The passed PlaylistRows object will include a Tracks object if this
row has a track.
"""
# Collect playlistrow data
self.note = playlist_row.note
self.played = playlist_row.played
self.playlist_id = playlist_row.playlist_id
self.playlistrow_id = playlist_row.id
self.row_number = playlist_row.row_number
self.track_id = playlist_row.track_id
# Collect track data if there's a track
if playlist_row.track_id:
self.artist = playlist_row.track.artist
self.bitrate = playlist_row.track.bitrate
self.duration = playlist_row.track.duration
self.fade_at = playlist_row.track.fade_at
self.intro = playlist_row.track.intro
if playlist_row.track.playdates:
self.lastplayed = max(
[a.lastplayed for a in playlist_row.track.playdates]
)
else:
self.lastplayed = Config.EPOCH
self.path = playlist_row.track.path
self.silence_at = playlist_row.track.silence_at
self.start_gap = playlist_row.track.start_gap
self.title = playlist_row.track.title
else:
self.artist = ""
self.bitrate = None
self.duration = 0
self.fade_at = 0
self.intro = None
self.lastplayed = Config.EPOCH
self.path = ""
self.silence_at = 0
self.start_gap = 0
self.title = ""
# Track playing data
self.end_of_track_signalled: bool = False
self.end_time: Optional[dt.datetime] = None
self.fade_graph: Optional[_FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None
self.resume_marker: Optional[float] = 0.0
self.forecast_end_time: Optional[dt.datetime] = None
self.forecast_start_time: Optional[dt.datetime] = None
self.start_time: Optional[dt.datetime] = None
# Other object initialisation
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
def __repr__(self) -> str:
return (
f"<RowAndTrack(playlist_id={self.playlist_id}, "
f"row_number={self.row_number}, "
f"playlistrow_id={self.playlistrow_id}, "
f"note={self.note}, track_id={self.track_id}>"
)
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.start_time is None:
return
if self.end_of_track_signalled:
return
if self.music.is_playing():
return
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
# Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=self.path,
track_fade_at=self.fade_at,
track_silence_at=self.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def drop3db(self, enable: bool) -> None:
"""
If enable is true, drop output by 3db else restore to full volume
"""
if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music"""
self.resume_marker = self.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.music.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
log.debug(f"issue223: RowAndTrack: play {self.track_id=}")
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def restart(self) -> None:
"""
Restart player
"""
self.music.adjust_by_ms(self.time_playing() * -1)
def set_forecast_start_time(
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
"""
Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
if self.fade_graph:
self.fade_graph.clear()
def time_playing(self) -> int:
"""
Return time track has been playing in milliseconds, zero if not playing
"""
if self.start_time is None:
return 0
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.fade_at - self.time_playing()
def time_to_silence(self) -> int:
"""
Return milliseconds until silent. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.silence_at - self.time_playing()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
def update_playlist_and_row(self, session: Session) -> None:
"""
Update local playlist_id and row_number from playlistrow_id
"""
plr = session.get(PlaylistRows, self.playlistrow_id)
if not plr:
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
self.playlist_id = plr.playlist_id
self.row_number = plr.row_number
class TrackSequence:
next: Optional[RowAndTrack] = None
current: Optional[RowAndTrack] = None
previous: Optional[RowAndTrack] = None
def set_next(self, rat: Optional[RowAndTrack]) -> None:
"""
Set the 'next' track to be passed rat. Clear
any previous next track. If passed rat is None
just clear existing next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if rat is None:
self.next = None
else:
self.next = rat
self.next.create_fade_graph()
track_sequence = TrackSequence()

View File

@ -1,13 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Standard library imports # Standard library imports
from os.path import basename from dataclasses import dataclass, field
from slugify import slugify # type: ignore
from typing import List, Optional from typing import List, Optional
import argparse import argparse
import datetime as dt import datetime as dt
import os import os
import shutil
from slugify import slugify # type: ignore
import subprocess import subprocess
import sys import sys
import urllib.parse import urllib.parse
@ -15,11 +14,8 @@ import webbrowser
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal,
QDate, QDate,
QObject,
Qt, Qt,
QThread,
QTime, QTime,
QTimer, QTimer,
) )
@ -47,23 +43,22 @@ from PyQt6.QtWidgets import (
# Third party imports # Third party imports
import line_profiler import line_profiler
from pygame import mixer from pygame import mixer
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
import stackprinter # type: ignore import stackprinter # type: ignore
# App imports # App imports
from classes import ( from classes import (
MusicMusterSignals, MusicMusterSignals,
RowAndTrack, Selection,
TrackFileData,
TrackInfo, TrackInfo,
track_sequence,
) )
from config import Config from config import Config
from dialogs import TrackSelectDialog, ReplaceFilesDialog from dialogs import TrackSelectDialog
from file_importer import FileImporter
from helpers import file_is_unreadable from helpers import file_is_unreadable
from log import log from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
from music_manager import RowAndTrack, track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab from playlists import PlaylistTab
from ui import icons_rc # noqa F401 from ui import icons_rc # noqa F401
@ -74,80 +69,25 @@ from utilities import check_db, update_bitrates
import helpers import helpers
class ImportTrack(QObject): class DownloadCSV(QDialog):
import_finished = pyqtSignal() def __init__(self, parent=None):
def __init__(
self,
track_files: List[TrackFileData],
source_model: PlaylistModel,
row_number: Optional[int],
) -> None:
super().__init__() super().__init__()
self.track_files = track_files
self.source_model = source_model
if row_number is None:
self.next_row_number = source_model.rowCount()
else:
self.next_row_number = row_number
self.signals = MusicMusterSignals()
# Sanity check self.ui = Ui_DateSelect()
for tf in track_files: self.ui.setupUi(self)
if not tf.tags: self.ui.dateTimeEdit.setDate(QDate.currentDate())
raise Exception(f"ImportTrack: no tags for {tf.new_file_path}") self.ui.dateTimeEdit.setTime(QTime(19, 59, 0))
if not tf.audio_metadata: self.ui.buttonBox.accepted.connect(self.accept)
raise Exception( self.ui.buttonBox.rejected.connect(self.reject)
f"ImportTrack: no audio_metadata for {tf.new_file_path}"
)
if tf.track_path is None:
raise Exception(f"ImportTrack: no track_path for {tf.new_file_path}")
def run(self):
"""
Create track objects from passed files and add to visible playlist
"""
with db.Session() as session: @dataclass
for tf in self.track_files: class PlaylistData:
self.signals.status_message_signal.emit( base_model: PlaylistModel
f"Importing {basename(tf.new_file_path)}", 5000 proxy_model: PlaylistProxyModel
)
# Sanity check def __post_init__(self):
if not os.path.exists(tf.new_file_path): self.proxy_model.setSourceModel(self.base_model)
log.error(f"ImportTrack: file not found: {tf.new_file_path=}")
continue
# Move the track file. Check that we're not importing a
# file that's already in its final destination.
if os.path.exists(tf.track_path) and tf.track_path != tf.new_file_path:
os.unlink(tf.track_path)
shutil.move(tf.new_file_path, tf.track_path)
# Import track
try:
track = Tracks(
session, path=tf.track_path, **tf.audio_metadata | tf.tags
)
except Exception as e:
self.signals.show_warning_signal.emit(
"Error importing track", str(e)
)
return
helpers.normalise_track(tf.track_path)
# We're importing potentially multiple tracks in a loop.
# If there's an error adding the track to the Tracks
# table, the session will rollback, thus losing any
# previous additions in this loop. So, commit now to
# lock in what we've just done.
session.commit()
self.source_model.insert_row(self.next_row_number, track.id, "")
self.next_row_number += 1
self.signals.status_message_signal.emit(
f"{len(self.track_files)} tracks imported", 10000
)
self.import_finished.emit()
class PreviewManager: class PreviewManager:
@ -261,6 +201,52 @@ class PreviewManager:
self.start_time = None self.start_time = None
class SelectPlaylistDialog(QDialog):
def __init__(self, parent=None, playlists=None, session=None):
super().__init__()
if playlists is None:
return
self.ui = Ui_dlgSelectPlaylist()
self.ui.setupUi(self)
self.ui.lstPlaylists.itemDoubleClicked.connect(self.list_doubleclick)
self.ui.buttonBox.accepted.connect(self.open)
self.ui.buttonBox.rejected.connect(self.close)
self.session = session
self.playlist = None
record = Settings.get_setting(self.session, "select_playlist_dialog_width")
width = record.f_int or 800
record = Settings.get_setting(self.session, "select_playlist_dialog_height")
height = record.f_int or 600
self.resize(width, height)
for playlist in playlists:
p = QListWidgetItem()
p.setText(playlist.name)
p.setData(Qt.ItemDataRole.UserRole, playlist)
self.ui.lstPlaylists.addItem(p)
def __del__(self): # review
record = Settings.get_setting(self.session, "select_playlist_dialog_height")
record.f_int = self.height()
record = Settings.get_setting(self.session, "select_playlist_dialog_width")
record.f_int = self.width()
self.session.commit()
def list_doubleclick(self, entry): # review
self.playlist = entry.data(Qt.ItemDataRole.UserRole)
self.accept()
def open(self): # review
if self.ui.lstPlaylists.selectedItems():
item = self.ui.lstPlaylists.currentItem()
self.playlist = item.data(Qt.ItemDataRole.UserRole)
self.accept()
class Window(QMainWindow, Ui_MainWindow): class Window(QMainWindow, Ui_MainWindow):
def __init__( def __init__(
self, parent: Optional[QWidget] = None, *args: list, **kwargs: dict self, parent: Optional[QWidget] = None, *args: list, **kwargs: dict
@ -287,10 +273,8 @@ class Window(QMainWindow, Ui_MainWindow):
self.widgetFadeVolume.setDefaultPadding(0) self.widgetFadeVolume.setDefaultPadding(0)
self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND) self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND)
self.active_tab = lambda: self.tabPlaylist.currentWidget()
self.active_proxy_model = lambda: self.tabPlaylist.currentWidget().model()
self.move_source_rows: Optional[List[int]] = None self.move_source_rows: Optional[List[int]] = None
self.move_source_model: Optional[PlaylistProxyModel] = None self.move_source_model: Optional[PlaylistModel] = None
self.disable_selection_timing = False self.disable_selection_timing = False
self.clock_counter = 0 self.clock_counter = 0
@ -301,6 +285,9 @@ class Window(QMainWindow, Ui_MainWindow):
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.connect_signals_slots() self.connect_signals_slots()
self.catch_return_key = False self.catch_return_key = False
self.importer: Optional[FileImporter] = None
self.selection = Selection()
self.playlists: dict[int, PlaylistData] = {}
if not Config.USE_INTERNAL_BROWSER: if not Config.USE_INTERNAL_BROWSER:
webbrowser.register( webbrowser.register(
@ -337,6 +324,12 @@ class Window(QMainWindow, Ui_MainWindow):
QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.Ok,
) )
def active_tab(self) -> PlaylistTab:
return self.tabPlaylist.currentWidget()
def active_proxy_model(self) -> PlaylistProxyModel:
return self.tabPlaylist.currentWidget().model()
def clear_next(self) -> None: def clear_next(self) -> None:
""" """
Clear next track Clear next track
@ -351,6 +344,7 @@ class Window(QMainWindow, Ui_MainWindow):
# Unselect any selected rows # Unselect any selected rows
if self.active_tab(): if self.active_tab():
self.active_tab().clear_selection() self.active_tab().clear_selection()
# Clear the search bar # Clear the search bar
self.search_playlist_clear() self.search_playlist_clear()
@ -450,26 +444,26 @@ class Window(QMainWindow, Ui_MainWindow):
def connect_signals_slots(self) -> None: def connect_signals_slots(self) -> None:
self.action_About.triggered.connect(self.about) self.action_About.triggered.connect(self.about)
self.action_Clear_selection.triggered.connect(self.clear_selection) self.action_Clear_selection.triggered.connect(self.clear_selection)
self.actionDebug.triggered.connect(self.debug)
self.actionClosePlaylist.triggered.connect(self.close_playlist_tab) self.actionClosePlaylist.triggered.connect(self.close_playlist_tab)
self.actionDebug.triggered.connect(self.debug)
self.actionDeletePlaylist.triggered.connect(self.delete_playlist) self.actionDeletePlaylist.triggered.connect(self.delete_playlist)
self.actionDownload_CSV_of_played_tracks.triggered.connect( self.actionDownload_CSV_of_played_tracks.triggered.connect(
self.download_played_tracks self.download_played_tracks
) )
self.actionExport_playlist.triggered.connect(self.export_playlist_tab) self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
self.actionFade.triggered.connect(self.fade) self.actionFade.triggered.connect(self.fade)
self.actionImport.triggered.connect(self.import_track)
self.actionInsertSectionHeader.triggered.connect(self.insert_header) self.actionInsertSectionHeader.triggered.connect(self.insert_header)
self.actionInsertTrack.triggered.connect(self.insert_track) self.actionInsertTrack.triggered.connect(self.insert_track)
self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving) self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving)
self.actionMoveSelected.triggered.connect(self.move_selected) self.actionMoveSelected.triggered.connect(self.move_selected)
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
self.actionNew_from_template.triggered.connect(self.new_from_template) self.actionNew_from_template.triggered.connect(self.new_from_template)
self.actionNewPlaylist.triggered.connect(self.create_and_show_playlist) self.actionNewPlaylist.triggered.connect(self.create_and_show_playlist)
self.actionOpenPlaylist.triggered.connect(self.open_playlist) self.actionOpenPlaylist.triggered.connect(self.open_playlist)
self.actionPaste.triggered.connect(self.paste_rows) self.actionPaste.triggered.connect(self.paste_rows)
self.actionPlay_next.triggered.connect(self.play_next) self.actionPlay_next.triggered.connect(self.play_next)
self.actionRenamePlaylist.triggered.connect(self.rename_playlist) self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
self.actionReplace_files.triggered.connect(self.import_files) self.actionReplace_files.triggered.connect(self.import_files_wrapper)
self.actionResume.triggered.connect(self.resume) self.actionResume.triggered.connect(self.resume)
self.actionSave_as_template.triggered.connect(self.save_as_template) self.actionSave_as_template.triggered.connect(self.save_as_template)
self.actionSearch_title_in_Songfacts.triggered.connect( self.actionSearch_title_in_Songfacts.triggered.connect(
@ -482,10 +476,10 @@ class Window(QMainWindow, Ui_MainWindow):
self.actionSelect_duplicate_rows.triggered.connect( self.actionSelect_duplicate_rows.triggered.connect(
lambda: self.active_tab().select_duplicate_rows() lambda: self.active_tab().select_duplicate_rows()
) )
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
self.actionSetNext.triggered.connect(self.set_selected_track_next) self.actionSetNext.triggered.connect(self.set_selected_track_next)
self.actionSkipToNext.triggered.connect(self.play_next) self.actionSkipToNext.triggered.connect(self.play_next)
self.actionStop.triggered.connect(self.stop) self.actionStop.triggered.connect(self.stop)
self.btnDrop3db.clicked.connect(self.drop3db) self.btnDrop3db.clicked.connect(self.drop3db)
self.btnFade.clicked.connect(self.fade) self.btnFade.clicked.connect(self.fade)
self.btnHidePlayed.clicked.connect(self.hide_played) self.btnHidePlayed.clicked.connect(self.hide_played)
@ -550,15 +544,20 @@ class Window(QMainWindow, Ui_MainWindow):
def create_playlist_tab(self, playlist: Playlists) -> int: def create_playlist_tab(self, playlist: Playlists) -> int:
""" """
Take the passed playlist database object, create a playlist tab and Take the passed proxy model, create a playlist tab and
add tab to display. Return index number of tab. add tab to display. Return index number of tab.
""" """
log.debug(f"create_playlist_tab({playlist=})") log.debug(f"create_playlist_tab({playlist=})")
# Create model and proxy model
self.playlists[playlist.id] = PlaylistData(
base_model=PlaylistModel(playlist.id), proxy_model=PlaylistProxyModel()
)
# Create tab
playlist_tab = PlaylistTab( playlist_tab = PlaylistTab(
musicmuster=self, musicmuster=self, model=self.playlists[playlist.id].proxy_model
playlist_id=playlist.id,
) )
idx = self.tabPlaylist.addTab(playlist_tab, playlist.name) idx = self.tabPlaylist.addTab(playlist_tab, playlist.name)
@ -725,6 +724,13 @@ class Window(QMainWindow, Ui_MainWindow):
if track_sequence.current: if track_sequence.current:
track_sequence.current.fade() track_sequence.current.fade()
def get_active_base_model(self) -> PlaylistModel:
"""
Return the model for the current tab
"""
return self.playlists[self.selection.playlist_id].base_model
def hide_played(self): def hide_played(self):
"""Toggle hide played tracks""" """Toggle hide played tracks"""
@ -742,112 +748,22 @@ class Window(QMainWindow, Ui_MainWindow):
# Reset row heights # Reset row heights
self.active_tab().resize_rows() self.active_tab().resize_rows()
def import_track(self) -> None: def import_files_wrapper(self) -> None:
"""Import track file"""
dlg = QFileDialog()
dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
dlg.setViewMode(QFileDialog.ViewMode.Detail)
dlg.setDirectory(Config.IMPORT_DESTINATION)
dlg.setNameFilter("Music files (*.flac *.mp3)")
if not dlg.exec():
return
with db.Session() as session:
track_files: list[TrackFileData] = []
for fpath in dlg.selectedFiles():
tf = TrackFileData(fpath)
tf.tags = helpers.get_tags(fpath)
do_import = self.ok_to_import(session, fpath, tf.tags)
if do_import:
tf.track_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(fpath)
)
tf.audio_metadata = helpers.get_audio_metadata(fpath)
track_files.append(tf)
self.import_filenames(track_files)
def import_filenames(self, track_files: list[TrackFileData]) -> None:
""" """
Import the list of filenames as new tracks Pass import files call to file_importer module
""" """
# Import in separate thread # We need to keep a referent to the FileImporter else it will be
self.import_thread = QThread() # garbage collected while import threads are still running
self.worker = ImportTrack( self.importer = FileImporter(
track_files, self.get_active_base_model(),
self.active_proxy_model(),
self.active_tab().source_model_selected_row_number(), self.active_tab().source_model_selected_row_number(),
) )
self.worker.moveToThread(self.import_thread) self.importer.do_import()
self.import_thread.started.connect(self.worker.run)
self.worker.import_finished.connect(self.import_thread.quit)
self.worker.import_finished.connect(self.worker.deleteLater)
self.import_thread.finished.connect(self.import_thread.deleteLater)
self.import_thread.start()
def ok_to_import(self, session: Session, fname: str, tags: dict[str, str]) -> bool:
"""
Check file has tags, check it's not a duplicate. Return True if this filenam
is OK to import, False if not.
"""
title = tags["title"]
if not title:
helpers.show_warning(
self,
"Problem with track file",
f"{fname} does not have a title tag",
)
return False
artist = tags["artist"]
if not artist:
helpers.show_warning(
self,
"Problem with track file",
f"{fname} does not have an artist tag",
)
return False
txt = ""
count = 0
possible_matches = Tracks.search_titles(session, title)
if possible_matches:
txt += "Similar to new track "
txt += f'"{title}" by "{artist} ({fname})":\n\n'
for track in possible_matches:
txt += f' "{track.title}" by {track.artist}'
txt += f" ({track.path})\n\n"
count += 1
if count >= Config.MAX_IMPORT_MATCHES:
txt += "\nThere are more similar-looking tracks"
break
txt += "\n"
# Check whether to proceed if there were potential matches
txt += "Proceed with import?"
result = QMessageBox.question(
self,
"Possible duplicates",
txt,
QMessageBox.StandardButton.Ok,
QMessageBox.StandardButton.Cancel,
)
if result == QMessageBox.StandardButton.Cancel:
return False
return True
def insert_header(self) -> None: def insert_header(self) -> None:
"""Show dialog box to enter header text and add to playlist""" """Show dialog box to enter header text and add to playlist"""
proxy_model = self.active_proxy_model()
if proxy_model is None:
log.error("No proxy model")
return
# Get header text # Get header text
dlg: QInputDialog = QInputDialog(self) dlg: QInputDialog = QInputDialog(self)
dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setInputMode(QInputDialog.InputMode.TextInput)
@ -855,7 +771,7 @@ class Window(QMainWindow, Ui_MainWindow):
dlg.resize(500, 100) dlg.resize(500, 100)
ok = dlg.exec() ok = dlg.exec()
if ok: if ok:
proxy_model.insert_row( self.get_active_base_model().insert_row(
proposed_row_number=self.active_tab().source_model_selected_row_number(), proposed_row_number=self.active_tab().source_model_selected_row_number(),
note=dlg.textValue(), note=dlg.textValue(),
) )
@ -872,7 +788,7 @@ class Window(QMainWindow, Ui_MainWindow):
parent=self, parent=self,
session=session, session=session,
new_row_number=new_row_number, new_row_number=new_row_number,
source_model=self.active_proxy_model(), base_model=self.get_active_base_model(),
) )
dlg.exec() dlg.exec()
session.commit() session.commit()
@ -884,9 +800,10 @@ class Window(QMainWindow, Ui_MainWindow):
with db.Session() as session: with db.Session() as session:
for playlist in Playlists.get_open(session): for playlist in Playlists.get_open(session):
if playlist: if playlist:
_ = self.create_playlist_tab(playlist)
playlist_ids.append(playlist.id)
log.debug(f"load_last_playlists() loaded {playlist=}") log.debug(f"load_last_playlists() loaded {playlist=}")
# Create tab
playlist_ids.append(self.create_playlist_tab(playlist))
# Set active tab # Set active tab
record = Settings.get_setting(session, "active_tab") record = Settings.get_setting(session, "active_tab")
if record.f_int is not None and record.f_int >= 0: if record.f_int is not None and record.f_int >= 0:
@ -929,9 +846,11 @@ class Window(QMainWindow, Ui_MainWindow):
# Save the selected PlaylistRows items ready for a later # Save the selected PlaylistRows items ready for a later
# paste # paste
self.move_source_rows = self.active_tab().get_selected_rows() self.move_source_rows = self.active_tab().get_selected_rows()
self.move_source_model = self.active_proxy_model() self.move_source_model = self.get_active_base_model()
log.debug(f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}") log.debug(
f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}"
)
def move_playlist_rows(self, row_numbers: List[int]) -> None: def move_playlist_rows(self, row_numbers: List[int]) -> None:
""" """
@ -964,7 +883,7 @@ class Window(QMainWindow, Ui_MainWindow):
to_row = 0 to_row = 0
# Move rows # Move rows
self.active_proxy_model().move_rows_between_playlists( self.get_active_base_model().move_rows_between_playlists(
row_numbers, to_row, to_playlist_id row_numbers, to_row, to_playlist_id
) )
@ -994,7 +913,7 @@ class Window(QMainWindow, Ui_MainWindow):
Move unplayed rows to another playlist Move unplayed rows to another playlist
""" """
unplayed_rows = self.active_proxy_model().get_unplayed_rows() unplayed_rows = self.get_active_base_model().get_unplayed_rows()
if not unplayed_rows: if not unplayed_rows:
return return
# We can get a race condition as selected rows change while # We can get a race condition as selected rows change while
@ -1077,7 +996,7 @@ class Window(QMainWindow, Ui_MainWindow):
if not self.move_source_rows or not self.move_source_model: if not self.move_source_rows or not self.move_source_model:
return return
to_playlist_model: PlaylistModel = self.active_tab().source_model to_playlist_model = self.get_active_base_model()
selected_rows = self.active_tab().get_selected_rows() selected_rows = self.active_tab().get_selected_rows()
if selected_rows: if selected_rows:
destination_row = selected_rows[0] destination_row = selected_rows[0]
@ -1094,10 +1013,7 @@ class Window(QMainWindow, Ui_MainWindow):
): ):
set_next_row = destination_row set_next_row = destination_row
if ( if to_playlist_model.playlist_id == self.move_source_model.playlist_id:
to_playlist_model.playlist_id
== self.move_source_model.source_model.playlist_id
):
self.move_source_model.move_rows(self.move_source_rows, destination_row) self.move_source_model.move_rows(self.move_source_rows, destination_row)
else: else:
self.move_source_model.move_rows_between_playlists( self.move_source_model.move_rows_between_playlists(
@ -1224,6 +1140,8 @@ class Window(QMainWindow, Ui_MainWindow):
) )
else: else:
return return
if not track_info:
return
self.preview_manager.set_track_info(track_info) self.preview_manager.set_track_info(track_info)
self.preview_manager.play() self.preview_manager.play()
else: else:
@ -1256,6 +1174,8 @@ class Window(QMainWindow, Ui_MainWindow):
if self.preview_manager.is_playing(): if self.preview_manager.is_playing():
track_id = self.preview_manager.track_id track_id = self.preview_manager.track_id
row_number = self.preview_manager.row_number row_number = self.preview_manager.row_number
if not row_number:
return
with db.Session() as session: with db.Session() as session:
track = session.get(Tracks, track_id) track = session.get(Tracks, track_id)
if track: if track:
@ -1266,8 +1186,8 @@ class Window(QMainWindow, Ui_MainWindow):
track.intro = intro track.intro = intro
session.commit() session.commit()
self.preview_manager.set_intro(intro) self.preview_manager.set_intro(intro)
self.active_tab().source_model.refresh_row(session, row_number) self.get_active_base_model().refresh_row(session, row_number)
self.active_tab().source_model.invalidate_row(row_number) self.get_active_base_model().invalidate_row(row_number)
def preview_start(self) -> None: def preview_start(self) -> None:
"""Restart preview""" """Restart preview"""
@ -1306,79 +1226,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.tabBar.setTabText(idx, new_name) self.tabBar.setTabText(idx, new_name)
session.commit() session.commit()
def import_files(self) -> None:
"""
Scan source directory and offer to replace existing files with "similar"
files, or import the source file as a new track.
"""
import_files: list[TrackFileData] = []
with db.Session() as session:
dlg = ReplaceFilesDialog(
session=session,
main_window=self,
)
status = dlg.exec()
if status:
for rf in dlg.replacement_files:
if rf.track_id:
# We're updating an existing track
# If the filename has changed, remove the
# existing file
if rf.obsolete_path is not None:
if os.path.exists(rf.obsolete_path):
os.unlink(rf.obsolete_path)
else:
log.error(
f"replace_files: could not unlink {rf.obsolete_path=}"
)
continue
if rf.track_path:
if os.path.exists(rf.track_path):
os.unlink(rf.track_path)
shutil.move(rf.new_file_path, rf.track_path)
track = session.get(Tracks, rf.track_id)
if not track:
raise Exception(
f"replace_files: could not retrieve track {rf.track_id}"
)
track.artist = rf.tags["artist"]
track.title = rf.tags["title"]
if track.path != rf.track_path:
track.path = rf.track_path
try:
session.commit()
except IntegrityError:
# https://jira.mariadb.org/browse/MDEV-29345 workaround
log.debug(
"Working around https://jira.mariadb.org/browse/MDEV-29345"
)
session.rollback()
track.path = "DUMMY"
session.commit()
track.path = rf.track_path
session.commit()
else:
session.commit()
else:
# We're importing a new track
do_import = self.ok_to_import(
session, os.path.basename(rf.new_file_path), rf.tags
)
if do_import:
rf.audio_metadata = helpers.get_audio_metadata(
rf.new_file_path
)
import_files.append(rf)
# self.import_filenames(dlg.replacement_files)
self.import_filenames(import_files)
else:
session.rollback()
session.close()
def return_pressed_in_error(self) -> bool: def return_pressed_in_error(self) -> bool:
""" """
Check whether Return key has been pressed in error. Check whether Return key has been pressed in error.
@ -1527,22 +1374,12 @@ class Window(QMainWindow, Ui_MainWindow):
if row_number is None: if row_number is None:
return None return None
track_info = self.active_proxy_model().get_row_info(row_number) track_info = self.get_active_base_model().get_row_info(row_number)
if track_info is None: if track_info is None:
return None return None
return track_info return track_info
def select_next_row(self) -> None:
"""Select next or first row in playlist"""
self.active_tab().select_next_row()
def select_previous_row(self) -> None:
"""Select previous or first row in playlist"""
self.active_tab().select_previous_row()
def set_main_window_size(self) -> None: def set_main_window_size(self) -> None:
"""Set size of window from database""" """Set size of window from database"""
@ -1628,9 +1465,7 @@ class Window(QMainWindow, Ui_MainWindow):
display_row = ( display_row = (
self.active_proxy_model() self.active_proxy_model()
.mapFromSource( .mapFromSource(
self.active_proxy_model().source_model.index( self.get_active_base_model().index(playlist_track.row_number, 0)
playlist_track.row_number, 0
)
) )
.row() .row()
) )
@ -1670,10 +1505,10 @@ class Window(QMainWindow, Ui_MainWindow):
if track_sequence.current: if track_sequence.current:
track_sequence.current.stop() track_sequence.current.stop()
def tab_change(self): def tab_change(self) -> None:
"""Called when active tab changed""" """Called when active tab changed"""
self.active_tab().resize_rows() self.active_tab().tab_live()
def tick_10ms(self) -> None: def tick_10ms(self) -> None:
""" """
@ -1861,64 +1696,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.tabPlaylist.setTabIcon(idx, QIcon()) self.tabPlaylist.setTabIcon(idx, QIcon())
class DownloadCSV(QDialog):
def __init__(self, parent=None):
super().__init__()
self.ui = Ui_DateSelect()
self.ui.setupUi(self)
self.ui.dateTimeEdit.setDate(QDate.currentDate())
self.ui.dateTimeEdit.setTime(QTime(19, 59, 0))
self.ui.buttonBox.accepted.connect(self.accept)
self.ui.buttonBox.rejected.connect(self.reject)
class SelectPlaylistDialog(QDialog):
def __init__(self, parent=None, playlists=None, session=None):
super().__init__()
if playlists is None:
return
self.ui = Ui_dlgSelectPlaylist()
self.ui.setupUi(self)
self.ui.lstPlaylists.itemDoubleClicked.connect(self.list_doubleclick)
self.ui.buttonBox.accepted.connect(self.open)
self.ui.buttonBox.rejected.connect(self.close)
self.session = session
self.playlist = None
record = Settings.get_setting(self.session, "select_playlist_dialog_width")
width = record.f_int or 800
record = Settings.get_setting(self.session, "select_playlist_dialog_height")
height = record.f_int or 600
self.resize(width, height)
for playlist in playlists:
p = QListWidgetItem()
p.setText(playlist.name)
p.setData(Qt.ItemDataRole.UserRole, playlist)
self.ui.lstPlaylists.addItem(p)
def __del__(self): # review
record = Settings.get_setting(self.session, "select_playlist_dialog_height")
record.f_int = self.height()
record = Settings.get_setting(self.session, "select_playlist_dialog_width")
record.f_int = self.width()
self.session.commit()
def list_doubleclick(self, entry): # review
self.playlist = entry.data(Qt.ItemDataRole.UserRole)
self.accept()
def open(self): # review
if self.ui.lstPlaylists.selectedItems():
item = self.ui.lstPlaylists.currentItem()
self.playlist = item.data(Qt.ItemDataRole.UserRole)
self.accept()
if __name__ == "__main__": if __name__ == "__main__":
""" """
If command line arguments given, carry out requested function and If command line arguments given, carry out requested function and

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from operator import attrgetter from operator import attrgetter
from random import shuffle from random import shuffle
from typing import Optional from typing import cast, Optional
import datetime as dt import datetime as dt
import re import re
@ -35,8 +35,6 @@ import obswebsocket # type: ignore
from classes import ( from classes import (
Col, Col,
MusicMusterSignals, MusicMusterSignals,
RowAndTrack,
track_sequence,
) )
from config import Config from config import Config
from helpers import ( from helpers import (
@ -50,6 +48,7 @@ from helpers import (
) )
from log import log from log import log
from models import db, NoteColours, Playdates, PlaylistRows, Tracks from models import db, NoteColours, Playdates, PlaylistRows, Tracks
from music_manager import RowAndTrack, track_sequence
HEADER_NOTES_COLUMN = 1 HEADER_NOTES_COLUMN = 1
@ -124,9 +123,12 @@ class PlaylistModel(QAbstractTableModel):
for ts in [ for ts in [
track_sequence.next, track_sequence.next,
track_sequence.current, track_sequence.current,
track_sequence.previous,
]: ]:
if ts and ts.row_number == row_number and ts.playlist_id == self.playlist_id: if (
ts
and ts.row_number == row_number
and ts.playlist_id == self.playlist_id
):
break break
else: else:
continue # continue iterating over playlist_rows continue # continue iterating over playlist_rows
@ -983,6 +985,7 @@ class PlaylistModel(QAbstractTableModel):
playlist_row.note += "\n" + note playlist_row.note += "\n" + note
else: else:
playlist_row.note = note playlist_row.note = note
self.refresh_row(session, playlist_row.row_number)
session.commit() session.commit()
# Carry out the move outside of the session context to ensure # Carry out the move outside of the session context to ensure
@ -1338,8 +1341,9 @@ class PlaylistModel(QAbstractTableModel):
unplayed_count += 1 unplayed_count += 1
duration += row_rat.duration duration += row_rat.duration
# Should never get here # We should only get here if there were no rows in section (ie,
return f"Error calculating subtotal ({row_rat.note})" # this was row zero)
return Config.SUBTOTAL_ON_ROW_ZERO
def selection_is_sortable(self, row_numbers: list[int]) -> bool: def selection_is_sortable(self, row_numbers: list[int]) -> bool:
""" """
@ -1660,19 +1664,14 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def __init__( def __init__(
self, self,
source_model: PlaylistModel,
*args: QObject,
**kwargs: QObject,
) -> None: ) -> None:
self.source_model = source_model super().__init__()
super().__init__(*args, **kwargs)
self.setSourceModel(source_model)
# Search all columns # Search all columns
self.setFilterKeyColumn(-1) self.setFilterKeyColumn(-1)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<PlaylistProxyModel: source_model={self.source_model}>" return f"<PlaylistProxyModel: sourceModel={self.sourceModel}>"
def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool:
""" """
@ -1680,15 +1679,15 @@ class PlaylistProxyModel(QSortFilterProxyModel):
""" """
if Config.HIDE_PLAYED_MODE != Config.HIDE_PLAYED_MODE_TRACKS: if Config.HIDE_PLAYED_MODE != Config.HIDE_PLAYED_MODE_TRACKS:
return True return super().filterAcceptsRow(source_row, source_parent)
if self.source_model.played_tracks_hidden: if self.sourceModel().played_tracks_hidden:
if self.source_model.is_played_row(source_row): if self.sourceModel().is_played_row(source_row):
# Don't hide current track # Don't hide current track
if ( if (
track_sequence.current track_sequence.current
and track_sequence.current.playlist_id and track_sequence.current.playlist_id
== self.source_model.playlist_id == self.sourceModel().playlist_id
and track_sequence.current.row_number == source_row and track_sequence.current.row_number == source_row
): ):
return True return True
@ -1696,7 +1695,8 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# Don't hide next track # Don't hide next track
if ( if (
track_sequence.next track_sequence.next
and track_sequence.next.playlist_id == self.source_model.playlist_id and track_sequence.next.playlist_id
== self.sourceModel().playlist_id
and track_sequence.next.row_number == source_row and track_sequence.next.row_number == source_row
): ):
return True return True
@ -1705,7 +1705,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if track_sequence.previous: if track_sequence.previous:
if ( if (
track_sequence.previous.playlist_id track_sequence.previous.playlist_id
!= self.source_model.playlist_id != self.sourceModel().playlist_id
or track_sequence.previous.row_number != source_row or track_sequence.previous.row_number != source_row
): ):
# This row isn't our previous track: hide it # This row isn't our previous track: hide it
@ -1729,7 +1729,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# true next time through. # true next time through.
QTimer.singleShot( QTimer.singleShot(
Config.HIDE_AFTER_PLAYING_OFFSET + 100, Config.HIDE_AFTER_PLAYING_OFFSET + 100,
lambda: self.source_model.invalidate_row(source_row), lambda: self.sourceModel().invalidate_row(source_row),
) )
return True return True
# Next track not playing yet so don't hide previous # Next track not playing yet so don't hide previous
@ -1752,105 +1752,9 @@ class PlaylistProxyModel(QSortFilterProxyModel):
) )
) )
# ###################################### def sourceModel(self) -> PlaylistModel:
# Forward functions not handled in proxy """
# ###################################### Override sourceModel to return correct type
"""
def current_track_started(self): return cast(PlaylistModel, super().sourceModel())
return self.source_model.current_track_started()
def delete_rows(self, row_numbers: list[int]) -> None:
return self.source_model.delete_rows(row_numbers)
def get_duplicate_rows(self) -> list[int]:
return self.source_model.get_duplicate_rows()
def get_rows_duration(self, row_numbers: list[int]) -> int:
return self.source_model.get_rows_duration(row_numbers)
def get_row_info(self, row_number: int) -> RowAndTrack:
return self.source_model.get_row_info(row_number)
def get_row_track_path(self, row_number: int) -> str:
return self.source_model.get_row_track_path(row_number)
def get_unplayed_rows(self) -> list[int]:
return self.source_model.get_unplayed_rows()
def hide_played_tracks(self, hide: bool) -> None:
return self.source_model.hide_played_tracks(hide)
def insert_row(
self,
proposed_row_number: Optional[int],
track_id: Optional[int] = None,
note: str = "",
) -> None:
return self.source_model.insert_row(proposed_row_number, track_id, note)
def is_header_row(self, row_number: int) -> bool:
return self.source_model.is_header_row(row_number)
def is_played_row(self, row_number: int) -> bool:
return self.source_model.is_played_row(row_number)
def is_track_in_playlist(self, track_id: int) -> Optional[RowAndTrack]:
return self.source_model.is_track_in_playlist(track_id)
def mark_unplayed(self, row_numbers: list[int]) -> None:
return self.source_model.mark_unplayed(row_numbers)
def move_rows(self, from_rows: list[int], to_row_number: int) -> None:
return self.source_model.move_rows(from_rows, to_row_number)
def move_rows_between_playlists(
self, from_rows: list[int], to_row_number: int, to_playlist_id: int
) -> None:
return self.source_model.move_rows_between_playlists(
from_rows, to_row_number, to_playlist_id
)
def move_track_add_note(
self, new_row_number: int, existing_rat: RowAndTrack, note: str
) -> None:
return self.source_model.move_track_add_note(new_row_number, existing_rat, note)
def move_track_to_header(
self,
header_row_number: int,
existing_rat: RowAndTrack,
note: Optional[str],
) -> None:
return self.source_model.move_track_to_header(
header_row_number, existing_rat, note
)
def previous_track_ended(self) -> None:
return self.source_model.previous_track_ended()
def remove_track(self, row_number: int) -> None:
return self.source_model.remove_track(row_number)
def rescan_track(self, row_number: int) -> None:
return self.source_model.rescan_track(row_number)
def set_next_row(self, row_number: Optional[int]) -> None:
self.source_model.set_next_row(row_number)
def sort_by_artist(self, row_numbers: list[int]) -> None:
return self.source_model.sort_by_artist(row_numbers)
def sort_by_duration(self, row_numbers: list[int]) -> None:
return self.source_model.sort_by_duration(row_numbers)
def sort_by_lastplayed(self, row_numbers: list[int]) -> None:
return self.source_model.sort_by_lastplayed(row_numbers)
def sort_randomly(self, row_numbers: list[int]) -> None:
return self.source_model.sort_randomly(row_numbers)
def sort_by_title(self, row_numbers: list[int]) -> None:
return self.source_model.sort_by_title(row_numbers)
def update_track_times(self) -> None:
return self.source_model.update_track_times()

View File

@ -37,7 +37,7 @@ import line_profiler
# App imports # App imports
from audacity_controller import AudacityController from audacity_controller import AudacityController
from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo, track_sequence from classes import ApplicationError, Col, MusicMusterSignals, Selection, TrackInfo
from config import Config from config import Config
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from helpers import ( from helpers import (
@ -48,6 +48,7 @@ from helpers import (
) )
from log import log from log import log
from models import db, Settings from models import db, Settings
from music_manager import track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
if TYPE_CHECKING: if TYPE_CHECKING:
@ -81,9 +82,9 @@ class PlaylistDelegate(QStyledItemDelegate):
QTimer.singleShot(0, resize_func) QTimer.singleShot(0, resize_func)
def __init__(self, parent: QWidget, source_model: PlaylistModel) -> None: def __init__(self, parent: QWidget, base_model: PlaylistModel) -> None:
super().__init__(parent) super().__init__(parent)
self.source_model = source_model self.base_model = base_model
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.click_position = None self.click_position = None
self.current_editor: Optional[Any] = None self.current_editor: Optional[Any] = None
@ -213,7 +214,13 @@ class PlaylistDelegate(QStyledItemDelegate):
doc.setTextWidth(option.rect.width()) doc.setTextWidth(option.rect.width())
doc.setDefaultFont(option.font) doc.setDefaultFont(option.font)
doc.setDocumentMargin(Config.ROW_PADDING) doc.setDocumentMargin(Config.ROW_PADDING)
doc.setHtml(option.text) if '\n' in option.text:
txt = option.text.replace('\n', '<br>')
elif '\u2028' in option.text:
txt = option.text.replace('\u2028', '<br>')
else:
txt = option.text
doc.setHtml(txt)
# For debugging +++ # For debugging +++
# Calculate sizes # Calculate sizes
@ -238,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate):
proxy_model = index.model() proxy_model = index.model()
edit_index = proxy_model.mapToSource(index) edit_index = proxy_model.mapToSource(index)
self.original_model_data = self.source_model.data( self.original_model_data = self.base_model.data(
edit_index, Qt.ItemDataRole.EditRole edit_index, Qt.ItemDataRole.EditRole
) )
if index.column() == Col.INTRO.value: if index.column() == Col.INTRO.value:
@ -255,7 +262,7 @@ class PlaylistDelegate(QStyledItemDelegate):
value = editor.toPlainText().strip() value = editor.toPlainText().strip()
elif isinstance(editor, QDoubleSpinBox): elif isinstance(editor, QDoubleSpinBox):
value = editor.value() value = editor.value()
self.source_model.setData(edit_index, value, Qt.ItemDataRole.EditRole) self.base_model.setData(edit_index, value, Qt.ItemDataRole.EditRole)
def updateEditorGeometry(self, editor, option, index): def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect) editor.setGeometry(option.rect)
@ -284,22 +291,17 @@ class PlaylistTab(QTableView):
The playlist view The playlist view
""" """
def __init__( def __init__(self, musicmuster: "Window", model: PlaylistProxyModel) -> None:
self,
musicmuster: "Window",
playlist_id: int,
) -> None:
super().__init__() super().__init__()
# Save passed settings # Save passed settings
self.musicmuster = musicmuster self.musicmuster = (
self.playlist_id = playlist_id musicmuster # TODO: do we need to keep a reference to musicmuster?
log.debug(f"PlaylistTab.__init__({playlist_id=})") )
self.playlist_id = model.sourceModel().playlist_id
# Set up widget # Set up widget
self.source_model = PlaylistModel(playlist_id) self.setItemDelegate(PlaylistDelegate(self, model.sourceModel()))
self.proxy_model = PlaylistProxyModel(self.source_model)
self.setItemDelegate(PlaylistDelegate(self, self.source_model))
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
@ -327,9 +329,8 @@ class PlaylistTab(QTableView):
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Load playlist rows # Singleton object to store selection
self.setModel(self.proxy_model) self.selection = Selection()
self._set_column_widths()
# Set up for Audacity # Set up for Audacity
try: try:
@ -338,6 +339,10 @@ class PlaylistTab(QTableView):
self.ac = None self.ac = None
show_warning(self.musicmuster, "Audacity error", str(e)) show_warning(self.musicmuster, "Audacity error", str(e))
# Load model, set column widths
self.setModel(model)
self._set_column_widths()
# Stretch last column *after* setting column widths which is # Stretch last column *after* setting column widths which is
# *much* faster # *much* faster
h_header = self.horizontalHeader() h_header = self.horizontalHeader()
@ -372,7 +377,7 @@ class PlaylistTab(QTableView):
# Update start times in case a start time in a note has been # Update start times in case a start time in a note has been
# edited # edited
self.source_model.update_track_times() self.get_base_model().update_track_times()
# Deselect edited line # Deselect edited line
self.clear_selection() self.clear_selection()
@ -381,36 +386,50 @@ class PlaylistTab(QTableView):
def dropEvent( def dropEvent(
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
) -> None: ) -> None:
"""
Move dropped rows
"""
if not event: if not event:
return return
if event.source() is not self or ( if event.source() is not self or (
event.dropAction() != Qt.DropAction.MoveAction event.dropAction() != Qt.DropAction.MoveAction
and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove
): ):
super().dropEvent(event) return super().dropEvent(event)
from_rows = self.selected_model_row_numbers() from_rows = self.selected_model_row_numbers()
to_index = self.indexAt(event.position().toPoint()) to_index = self.indexAt(event.position().toPoint())
# The drop indicator can either be immediately below a row or
# immediately above a row. There's about a 1 pixel difference,
# but we always want to drop between rows regardless of where
# drop indicator is.
if ( if (
self.dropIndicatorPosition() self.dropIndicatorPosition()
== QAbstractItemView.DropIndicatorPosition.BelowItem == QAbstractItemView.DropIndicatorPosition.BelowItem
): ):
proxy_index = self.proxy_model.createIndex( # Drop on the row below
to_index.row() + 1, next_row = to_index.row() + 1
to_index.column(), if next_row < self.model().rowCount(): # Ensure the row exists
to_index.internalId(), destination_index = to_index.siblingAtRow(next_row)
)
else: else:
proxy_index = to_index # Handle edge case where next_row is beyond the last row
to_model_row = self.proxy_model.mapToSource(proxy_index).row() destination_index = to_index
else:
destination_index = to_index
to_model_row = self.model().mapToSource(destination_index).row()
log.debug( log.debug(
f"PlaylistTab.dropEvent(): {from_rows=}, {proxy_index=}, {to_model_row=}" f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_row=}"
) )
# Sanity check
base_model_row_count = self.get_base_model().rowCount()
if ( if (
0 <= min(from_rows) <= self.source_model.rowCount() 0 <= min(from_rows) <= base_model_row_count
and 0 <= max(from_rows) <= self.source_model.rowCount() and 0 <= to_model_row <= base_model_row_count
and 0 <= to_model_row <= self.source_model.rowCount()
): ):
# If we move a row to immediately under the current track, make # If we move a row to immediately under the current track, make
# that moved row the next track # that moved row the next track
@ -421,7 +440,7 @@ class PlaylistTab(QTableView):
): ):
set_next_row = to_model_row set_next_row = to_model_row
self.source_model.move_rows(from_rows, to_model_row) self.get_base_model().move_rows(from_rows, to_model_row)
# Reset drag mode to allow row selection by dragging # Reset drag mode to allow row selection by dragging
self.setDragEnabled(False) self.setDragEnabled(False)
@ -434,7 +453,7 @@ class PlaylistTab(QTableView):
# Set next row if we are immediately under current row # Set next row if we are immediately under current row
if set_next_row: if set_next_row:
self.source_model.set_next_row(set_next_row) self.get_base_model().set_next_row(set_next_row)
event.accept() event.accept()
@ -468,12 +487,14 @@ class PlaylistTab(QTableView):
""" """
selected_rows = self.get_selected_rows() selected_rows = self.get_selected_rows()
self.selection.rows = selected_rows
# If no rows are selected, we have nothing to do # If no rows are selected, we have nothing to do
if len(selected_rows) == 0: if len(selected_rows) == 0:
self.musicmuster.lblSumPlaytime.setText("") self.musicmuster.lblSumPlaytime.setText("")
else: else:
if not self.musicmuster.disable_selection_timing: if not self.musicmuster.disable_selection_timing:
selected_duration = self.source_model.get_rows_duration( selected_duration = self.get_base_model().get_rows_duration(
self.get_selected_rows() self.get_selected_rows()
) )
if selected_duration > 0: if selected_duration > 0:
@ -524,7 +545,7 @@ class PlaylistTab(QTableView):
parent=self.musicmuster, parent=self.musicmuster,
session=session, session=session,
new_row_number=model_row_number, new_row_number=model_row_number,
source_model=self.source_model, base_model=self.get_base_model(),
add_to_header=True, add_to_header=True,
) )
dlg.exec() dlg.exec()
@ -534,12 +555,12 @@ class PlaylistTab(QTableView):
"""Used to process context (right-click) menu, which is defined here""" """Used to process context (right-click) menu, which is defined here"""
self.menu.clear() self.menu.clear()
proxy_model = self.proxy_model
index = proxy_model.index(item.row(), item.column()) index = self.model().index(item.row(), item.column())
model_row_number = proxy_model.mapToSource(index).row() model_row_number = self.model().mapToSource(index).row()
base_model = self.get_base_model()
header_row = proxy_model.is_header_row(model_row_number) header_row = self.get_base_model().is_header_row(model_row_number)
track_row = not header_row track_row = not header_row
if track_sequence.current: if track_sequence.current:
this_is_current_row = model_row_number == track_sequence.current.row_number this_is_current_row = model_row_number == track_sequence.current.row_number
@ -549,7 +570,7 @@ class PlaylistTab(QTableView):
this_is_next_row = model_row_number == track_sequence.next.row_number this_is_next_row = model_row_number == track_sequence.next.row_number
else: else:
this_is_next_row = False this_is_next_row = False
track_path = self.source_model.get_row_info(model_row_number).path track_path = base_model.get_row_info(model_row_number).path
# Open/import in/from Audacity # Open/import in/from Audacity
if track_row and not this_is_current_row: if track_row and not this_is_current_row:
@ -590,7 +611,7 @@ class PlaylistTab(QTableView):
if track_row and not this_is_current_row and not this_is_next_row: if track_row and not this_is_current_row and not this_is_next_row:
self._add_context_menu( self._add_context_menu(
"Remove track from row", "Remove track from row",
lambda: proxy_model.remove_track(model_row_number), lambda: base_model.remove_track(model_row_number),
) )
# Remove comments # Remove comments
@ -604,7 +625,7 @@ class PlaylistTab(QTableView):
self.menu.addSeparator() self.menu.addSeparator()
# Mark unplayed # Mark unplayed
if track_row and proxy_model.is_played_row(model_row_number): if track_row and base_model.is_played_row(model_row_number):
self._add_context_menu( self._add_context_menu(
"Mark unplayed", "Mark unplayed",
lambda: self._mark_as_unplayed(self.get_selected_rows()), lambda: self._mark_as_unplayed(self.get_selected_rows()),
@ -623,27 +644,27 @@ class PlaylistTab(QTableView):
sort_menu = self.menu.addMenu("Sort") sort_menu = self.menu.addMenu("Sort")
self._add_context_menu( self._add_context_menu(
"by title", "by title",
lambda: proxy_model.sort_by_title(self.get_selected_rows()), lambda: base_model.sort_by_title(self.get_selected_rows()),
parent_menu=sort_menu, parent_menu=sort_menu,
) )
self._add_context_menu( self._add_context_menu(
"by artist", "by artist",
lambda: proxy_model.sort_by_artist(self.get_selected_rows()), lambda: base_model.sort_by_artist(self.get_selected_rows()),
parent_menu=sort_menu, parent_menu=sort_menu,
) )
self._add_context_menu( self._add_context_menu(
"by duration", "by duration",
lambda: proxy_model.sort_by_duration(self.get_selected_rows()), lambda: base_model.sort_by_duration(self.get_selected_rows()),
parent_menu=sort_menu, parent_menu=sort_menu,
) )
self._add_context_menu( self._add_context_menu(
"by last played", "by last played",
lambda: proxy_model.sort_by_lastplayed(self.get_selected_rows()), lambda: base_model.sort_by_lastplayed(self.get_selected_rows()),
parent_menu=sort_menu, parent_menu=sort_menu,
) )
self._add_context_menu( self._add_context_menu(
"randomly", "randomly",
lambda: proxy_model.sort_randomly(self.get_selected_rows()), lambda: base_model.sort_randomly(self.get_selected_rows()),
parent_menu=sort_menu, parent_menu=sort_menu,
) )
@ -663,6 +684,7 @@ class PlaylistTab(QTableView):
that we have an edit open. that we have an edit open.
""" """
if self.ac:
self.ac.path = None self.ac.path = None
def clear_selection(self) -> None: def clear_selection(self) -> None:
@ -709,7 +731,7 @@ class PlaylistTab(QTableView):
to the clipboard. Otherwise, return None. to the clipboard. Otherwise, return None.
""" """
track_path = self.source_model.get_row_info(row_number).path track_path = self.get_base_model().get_row_info(row_number).path
if not track_path: if not track_path:
return return
@ -732,7 +754,7 @@ class PlaylistTab(QTableView):
Called when track starts playing Called when track starts playing
""" """
self.source_model.current_track_started() self.get_base_model().current_track_started()
# Scroll to current section if hide mode is by section # Scroll to current section if hide mode is by section
if ( if (
self.musicmuster.hide_played_tracks self.musicmuster.hide_played_tracks
@ -764,9 +786,18 @@ class PlaylistTab(QTableView):
if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
return return
self.source_model.delete_rows(self.selected_model_row_numbers()) base_model = self.get_base_model()
base_model.delete_rows(self.selected_model_row_numbers())
self.clear_selection() self.clear_selection()
def get_base_model(self) -> PlaylistModel:
"""
Return the base model for this proxy model
"""
return cast(PlaylistModel, self.model().sourceModel())
def get_selected_row_track_info(self) -> Optional[TrackInfo]: def get_selected_row_track_info(self) -> Optional[TrackInfo]:
""" """
Return the track_id and row number of the selected Return the track_id and row number of the selected
@ -778,11 +809,13 @@ class PlaylistTab(QTableView):
if selected_row is None: if selected_row is None:
return None return None
base_model = self.get_base_model()
model_row_number = self.source_model_selected_row_number() model_row_number = self.source_model_selected_row_number()
if model_row_number is None: if model_row_number is None:
return None return None
else: else:
track_id = self.source_model.get_row_track_id(model_row_number) track_id = base_model.get_row_track_id(model_row_number)
if not track_id: if not track_id:
return None return None
else: else:
@ -806,12 +839,7 @@ class PlaylistTab(QTableView):
# items in that row selected) # items in that row selected)
result = sorted( result = sorted(
list( list(
set( set([self.model().mapToSource(a).row() for a in self.selectedIndexes()])
[
self.proxy_model.mapToSource(a).row()
for a in self.selectedIndexes()
]
)
) )
) )
@ -823,7 +851,7 @@ class PlaylistTab(QTableView):
Scroll played sections off screen Scroll played sections off screen
""" """
self.scroll_to_top(self.source_model.active_section_header()) self.scroll_to_top(self.get_base_model().active_section_header())
def _import_from_audacity(self, row_number: int) -> None: def _import_from_audacity(self, row_number: int) -> None:
""" """
@ -842,7 +870,7 @@ class PlaylistTab(QTableView):
def _info_row(self, row_number: int) -> None: def _info_row(self, row_number: int) -> None:
"""Display popup with info re row""" """Display popup with info re row"""
prd = self.source_model.get_row_info(row_number) prd = self.get_base_model().get_row_info(row_number)
if prd: if prd:
txt = ( txt = (
f"Title: {prd.title}\n" f"Title: {prd.title}\n"
@ -861,7 +889,7 @@ class PlaylistTab(QTableView):
def _mark_as_unplayed(self, row_numbers: List[int]) -> None: def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
"""Mark row as unplayed""" """Mark row as unplayed"""
self.source_model.mark_unplayed(row_numbers) self.get_base_model().mark_unplayed(row_numbers)
self.clear_selection() self.clear_selection()
def _mark_for_moving(self) -> None: def _mark_for_moving(self) -> None:
@ -871,6 +899,13 @@ class PlaylistTab(QTableView):
self.musicmuster.mark_rows_for_moving() self.musicmuster.mark_rows_for_moving()
def model(self) -> PlaylistProxyModel:
"""
Override return type to keep mypy happy in this module
"""
return cast(PlaylistProxyModel, super().model())
def _move_selected_rows(self) -> None: def _move_selected_rows(self) -> None:
""" """
Move selected rows here Move selected rows here
@ -883,7 +918,7 @@ class PlaylistTab(QTableView):
Open track in passed row in Audacity Open track in passed row in Audacity
""" """
path = self.source_model.get_row_track_path(row_number) path = self.get_base_model().get_row_track_path(row_number)
if not path: if not path:
log.error(f"_open_in_audacity: can't get path for {row_number=}") log.error(f"_open_in_audacity: can't get path for {row_number=}")
return return
@ -901,7 +936,7 @@ class PlaylistTab(QTableView):
""" """
# Let the model know # Let the model know
self.source_model.previous_track_ended() self.get_base_model().previous_track_ended()
def _remove_comments(self) -> None: def _remove_comments(self) -> None:
""" """
@ -912,12 +947,12 @@ class PlaylistTab(QTableView):
if not row_numbers: if not row_numbers:
return return
self.source_model.remove_comments(row_numbers) self.get_base_model().remove_comments(row_numbers)
def _rescan(self, row_number: int) -> None: def _rescan(self, row_number: int) -> None:
"""Rescan track""" """Rescan track"""
self.source_model.rescan_track(row_number) self.get_base_model().rescan_track(row_number)
self.clear_selection() self.clear_selection()
def resize_rows(self, playlist_id: Optional[int] = None) -> None: def resize_rows(self, playlist_id: Optional[int] = None) -> None:
@ -932,7 +967,7 @@ class PlaylistTab(QTableView):
# Suggestion from phind.com # Suggestion from phind.com
def resize_row(row, count=1): def resize_row(row, count=1):
row_count = self.source_model.rowCount() row_count = self.model().rowCount()
for todo in range(count): for todo in range(count):
if row < row_count: if row < row_count:
self.resizeRowToContents(row) self.resizeRowToContents(row)
@ -941,7 +976,7 @@ class PlaylistTab(QTableView):
QTimer.singleShot(0, lambda: resize_row(row, count)) QTimer.singleShot(0, lambda: resize_row(row, count))
# Start resizing from row 0, 10 rows at a time # Start resizing from row 0, 10 rows at a time
QTimer.singleShot(0, lambda: resize_row(0, 10)) QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE))
def scroll_to_top(self, row_number: int) -> None: def scroll_to_top(self, row_number: int) -> None:
""" """
@ -951,7 +986,7 @@ class PlaylistTab(QTableView):
if row_number is None: if row_number is None:
return return
row_index = self.proxy_model.index(row_number, 0) row_index = self.model().index(row_number, 0)
self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop) self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop)
def select_duplicate_rows(self) -> None: def select_duplicate_rows(self) -> None:
@ -966,7 +1001,7 @@ class PlaylistTab(QTableView):
# We need to be in MultiSelection mode # We need to be in MultiSelection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
# Get the duplicate rows # Get the duplicate rows
duplicate_rows = self.source_model.get_duplicate_rows() duplicate_rows = self.get_base_model().get_duplicate_rows()
# Select the rows # Select the rows
for duplicate_row in duplicate_rows: for duplicate_row in duplicate_rows:
self.selectRow(duplicate_row) self.selectRow(duplicate_row)
@ -981,7 +1016,7 @@ class PlaylistTab(QTableView):
selected_index = self._selected_row_index() selected_index = self._selected_row_index()
if selected_index is None: if selected_index is None:
return None return None
return self.proxy_model.mapToSource(selected_index).row() return self.model().mapToSource(selected_index).row()
def selected_model_row_numbers(self) -> List[int]: def selected_model_row_numbers(self) -> List[int]:
""" """
@ -992,9 +1027,8 @@ class PlaylistTab(QTableView):
selected_indexes = self._selected_row_indexes() selected_indexes = self._selected_row_indexes()
if selected_indexes is None: if selected_indexes is None:
return [] return []
if hasattr(self.proxy_model, "mapToSource"):
return [self.proxy_model.mapToSource(a).row() for a in selected_indexes] return [self.model().mapToSource(a).row() for a in selected_indexes]
return [a.row() for a in selected_indexes]
def _selected_row_index(self) -> Optional[QModelIndex]: def _selected_row_index(self) -> Optional[QModelIndex]:
""" """
@ -1051,7 +1085,7 @@ class PlaylistTab(QTableView):
log.debug(f"set_row_as_next_track() {model_row_number=}") log.debug(f"set_row_as_next_track() {model_row_number=}")
if model_row_number is None: if model_row_number is None:
return return
self.source_model.set_next_row(model_row_number) self.get_base_model().set_next_row(model_row_number)
self.clearSelection() self.clearSelection()
def _span_cells( def _span_cells(
@ -1059,17 +1093,19 @@ class PlaylistTab(QTableView):
) -> None: ) -> None:
""" """
Implement spanning of cells, initiated by signal Implement spanning of cells, initiated by signal
row and column are from the base model so we need to translate
the row into this display row
""" """
if playlist_id != self.playlist_id: if playlist_id != self.playlist_id:
return return
proxy_model = self.proxy_model base_model = self.get_base_model()
edit_index = proxy_model.mapFromSource(
self.source_model.createIndex(row, column) cell_index = self.model().mapFromSource(base_model.createIndex(row, column))
) row = cell_index.row()
row = edit_index.row() column = cell_index.column()
column = edit_index.column()
# Don't set spanning if already in place because that is seen as # Don't set spanning if already in place because that is seen as
# a change to the view and thus it refreshes the data which # a change to the view and thus it refreshes the data which
@ -1082,6 +1118,16 @@ class PlaylistTab(QTableView):
self.setSpan(row, column, rowSpan, columnSpan) self.setSpan(row, column, rowSpan, columnSpan)
def tab_live(self) -> None:
"""
Called when tab gets focus
"""
self.selection.playlist_id = self.playlist_id
self.selection.rows = self.get_selected_rows()
self.resize_rows()
def _unmark_as_next(self) -> None: def _unmark_as_next(self) -> None:
"""Rescan track""" """Rescan track"""

View File

@ -967,70 +967,64 @@ padding-left: 8px;</string>
</property> </property>
<widget class="QMenu" name="menuFile"> <widget class="QMenu" name="menuFile">
<property name="title"> <property name="title">
<string>&amp;Playlists</string> <string>&amp;Playlist</string>
</property> </property>
<addaction name="actionNewPlaylist"/> <addaction name="separator"/>
<addaction name="actionNew_from_template"/> <addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/>
<addaction name="actionInsertSectionHeader"/>
<addaction name="separator"/>
<addaction name="actionMark_for_moving"/>
<addaction name="actionPaste"/>
<addaction name="separator"/>
<addaction name="actionExport_playlist"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="separator"/>
<addaction name="actionSelect_duplicate_rows"/>
<addaction name="actionMoveSelected"/>
<addaction name="actionMoveUnplayed"/>
<addaction name="action_Clear_selection"/>
</widget>
<widget class="QMenu" name="menuPlaylist">
<property name="title">
<string>&amp;File</string>
</property>
<addaction name="separator"/>
<addaction name="separator"/>
<addaction name="actionOpenPlaylist"/> <addaction name="actionOpenPlaylist"/>
<addaction name="actionNewPlaylist"/>
<addaction name="actionClosePlaylist"/> <addaction name="actionClosePlaylist"/>
<addaction name="actionRenamePlaylist"/> <addaction name="actionRenamePlaylist"/>
<addaction name="actionDeletePlaylist"/> <addaction name="actionDeletePlaylist"/>
<addaction name="actionExport_playlist"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSelect_duplicate_rows"/> <addaction name="actionNew_from_template"/>
<addaction name="separator"/>
<addaction name="actionMoveSelected"/>
<addaction name="actionMoveUnplayed"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="actionSave_as_template"/> <addaction name="actionSave_as_template"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionReplace_files"/> <addaction name="actionReplace_files"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionDebug"/>
<addaction name="action_About"/>
<addaction name="separator"/>
<addaction name="actionE_xit"/> <addaction name="actionE_xit"/>
</widget> </widget>
<widget class="QMenu" name="menuPlaylist"> <widget class="QMenu" name="menuSearc_h">
<property name="title"> <property name="title">
<string>Sho&amp;wtime</string> <string>&amp;Music</string>
</property> </property>
<addaction name="separator"/> <addaction name="actionSetNext"/>
<addaction name="actionPlay_next"/> <addaction name="actionPlay_next"/>
<addaction name="actionFade"/> <addaction name="actionFade"/>
<addaction name="actionStop"/> <addaction name="actionStop"/>
<addaction name="actionResume"/> <addaction name="actionResume"/>
<addaction name="separator"/>
<addaction name="actionSkipToNext"/> <addaction name="actionSkipToNext"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionInsertSectionHeader"/>
<addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/>
<addaction name="actionImport"/>
<addaction name="separator"/>
<addaction name="actionSetNext"/>
<addaction name="action_Clear_selection"/>
<addaction name="separator"/>
<addaction name="actionMark_for_moving"/>
<addaction name="actionPaste"/>
</widget>
<widget class="QMenu" name="menuSearc_h">
<property name="title">
<string>&amp;Search</string>
</property>
<addaction name="actionSearch"/> <addaction name="actionSearch"/>
<addaction name="separator"/>
<addaction name="actionSearch_title_in_Wikipedia"/> <addaction name="actionSearch_title_in_Wikipedia"/>
<addaction name="actionSearch_title_in_Songfacts"/> <addaction name="actionSearch_title_in_Songfacts"/>
</widget> </widget>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>&amp;Help</string>
</property>
<addaction name="action_About"/>
<addaction name="actionDebug"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuPlaylist"/> <addaction name="menuPlaylist"/>
<addaction name="menuFile"/>
<addaction name="menuSearc_h"/> <addaction name="menuSearc_h"/>
<addaction name="menuHelp"/>
</widget> </widget>
<widget class="QStatusBar" name="statusbar"> <widget class="QStatusBar" name="statusbar">
<property name="enabled"> <property name="enabled">

View File

@ -495,8 +495,6 @@ class Ui_MainWindow(object):
self.menuPlaylist.setObjectName("menuPlaylist") self.menuPlaylist.setObjectName("menuPlaylist")
self.menuSearc_h = QtWidgets.QMenu(parent=self.menubar) self.menuSearc_h = QtWidgets.QMenu(parent=self.menubar)
self.menuSearc_h.setObjectName("menuSearc_h") self.menuSearc_h.setObjectName("menuSearc_h")
self.menuHelp = QtWidgets.QMenu(parent=self.menubar)
self.menuHelp.setObjectName("menuHelp")
MainWindow.setMenuBar(self.menubar) MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(parent=MainWindow) self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
self.statusbar.setEnabled(True) self.statusbar.setEnabled(True)
@ -657,52 +655,51 @@ class Ui_MainWindow(object):
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows") self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.actionReplace_files = QtGui.QAction(parent=MainWindow) self.actionReplace_files = QtGui.QAction(parent=MainWindow)
self.actionReplace_files.setObjectName("actionReplace_files") self.actionReplace_files.setObjectName("actionReplace_files")
self.menuFile.addAction(self.actionNewPlaylist) self.menuFile.addSeparator()
self.menuFile.addAction(self.actionNew_from_template) self.menuFile.addAction(self.actionInsertTrack)
self.menuFile.addAction(self.actionOpenPlaylist) self.menuFile.addAction(self.actionRemove)
self.menuFile.addAction(self.actionClosePlaylist) self.menuFile.addAction(self.actionInsertSectionHeader)
self.menuFile.addAction(self.actionRenamePlaylist) self.menuFile.addSeparator()
self.menuFile.addAction(self.actionDeletePlaylist) self.menuFile.addAction(self.actionMark_for_moving)
self.menuFile.addAction(self.actionPaste)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionExport_playlist) self.menuFile.addAction(self.actionExport_playlist)
self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks)
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.actionSelect_duplicate_rows) self.menuFile.addAction(self.actionSelect_duplicate_rows)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionMoveSelected) self.menuFile.addAction(self.actionMoveSelected)
self.menuFile.addAction(self.actionMoveUnplayed) self.menuFile.addAction(self.actionMoveUnplayed)
self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks) self.menuFile.addAction(self.action_Clear_selection)
self.menuFile.addAction(self.actionSave_as_template)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionReplace_files)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionE_xit)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionPlay_next)
self.menuPlaylist.addAction(self.actionFade)
self.menuPlaylist.addAction(self.actionStop)
self.menuPlaylist.addAction(self.actionResume)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSkipToNext) self.menuPlaylist.addAction(self.actionOpenPlaylist)
self.menuPlaylist.addAction(self.actionNewPlaylist)
self.menuPlaylist.addAction(self.actionClosePlaylist)
self.menuPlaylist.addAction(self.actionRenamePlaylist)
self.menuPlaylist.addAction(self.actionDeletePlaylist)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionInsertSectionHeader) self.menuPlaylist.addAction(self.actionNew_from_template)
self.menuPlaylist.addAction(self.actionInsertTrack) self.menuPlaylist.addAction(self.actionSave_as_template)
self.menuPlaylist.addAction(self.actionRemove)
self.menuPlaylist.addAction(self.actionImport)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSetNext) self.menuPlaylist.addAction(self.actionReplace_files)
self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionMark_for_moving) self.menuPlaylist.addAction(self.actionDebug)
self.menuPlaylist.addAction(self.actionPaste) self.menuPlaylist.addAction(self.action_About)
self.menuSearc_h.addAction(self.actionSearch) self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionE_xit)
self.menuSearc_h.addAction(self.actionSetNext)
self.menuSearc_h.addAction(self.actionPlay_next)
self.menuSearc_h.addAction(self.actionFade)
self.menuSearc_h.addAction(self.actionStop)
self.menuSearc_h.addAction(self.actionResume)
self.menuSearc_h.addAction(self.actionSkipToNext)
self.menuSearc_h.addSeparator() self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSearch)
self.menuSearc_h.addAction(self.actionSearch_title_in_Wikipedia) self.menuSearc_h.addAction(self.actionSearch_title_in_Wikipedia)
self.menuSearc_h.addAction(self.actionSearch_title_in_Songfacts) self.menuSearc_h.addAction(self.actionSearch_title_in_Songfacts)
self.menuHelp.addAction(self.action_About)
self.menuHelp.addAction(self.actionDebug)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuPlaylist.menuAction()) self.menubar.addAction(self.menuPlaylist.menuAction())
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuSearc_h.menuAction()) self.menubar.addAction(self.menuSearc_h.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.retranslateUi(MainWindow) self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1) self.tabPlaylist.setCurrentIndex(-1)
@ -733,10 +730,9 @@ class Ui_MainWindow(object):
self.label_silent_timer.setText(_translate("MainWindow", "00:00")) self.label_silent_timer.setText(_translate("MainWindow", "00:00"))
self.btnFade.setText(_translate("MainWindow", " Fade")) self.btnFade.setText(_translate("MainWindow", " Fade"))
self.btnStop.setText(_translate("MainWindow", " Stop")) self.btnStop.setText(_translate("MainWindow", " Stop"))
self.menuFile.setTitle(_translate("MainWindow", "&Playlists")) self.menuFile.setTitle(_translate("MainWindow", "&Playlist"))
self.menuPlaylist.setTitle(_translate("MainWindow", "Sho&wtime")) self.menuPlaylist.setTitle(_translate("MainWindow", "&File"))
self.menuSearc_h.setTitle(_translate("MainWindow", "&Search")) self.menuSearc_h.setTitle(_translate("MainWindow", "&Music"))
self.menuHelp.setTitle(_translate("MainWindow", "&Help"))
self.actionPlay_next.setText(_translate("MainWindow", "&Play next")) self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return")) self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next")) self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next"))

View File

@ -92,6 +92,6 @@ def update_bitrates(session: Session) -> None:
for track in Tracks.get_all(session): for track in Tracks.get_all(session):
try: try:
t = get_tags(track.path) t = get_tags(track.path)
track.bitrate = t["bitrate"] track.bitrate = t.bitrate
except FileNotFoundError: except FileNotFoundError:
continue continue

View File

@ -22,8 +22,9 @@ def fade_point(audio_segment, fade_threshold=-12, chunk_size=10):
print(f"{max_vol=}") print(f"{max_vol=}")
fade_threshold = max_vol fade_threshold = max_vol
while ( while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0): # noqa W503 and trim_ms > 0
): # noqa W503
trim_ms -= chunk_size trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less # if there is no trailing silence, return lenght of track (it's less

125
archive/proxymodel.py Executable file
View File

@ -0,0 +1,125 @@
#!/usr/bin/env python
import sys
from PyQt6.QtCore import (Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel)
from PyQt6.QtWidgets import (QApplication, QMainWindow, QTableView, QLineEdit, QVBoxLayout, QWidget)
class CustomTableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
def rowCount(self, parent=QModelIndex()):
return len(self._data)
def columnCount(self, parent=QModelIndex()):
return 2 # Row number and data
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole:
row, col = index.row(), index.column()
if col == 0:
return row + 1 # Row number (1-based index)
elif col == 1:
return self._data[row]
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
if role == Qt.ItemDataRole.EditRole and index.isValid():
self._data[index.row()] = value
self.dataChanged.emit(index, index, [Qt.ItemDataRole.EditRole])
return True
return False
def flags(self, index):
default_flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
if index.isValid():
return default_flags | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsDropEnabled
return default_flags | Qt.ItemFlag.ItemIsDropEnabled
def removeRow(self, row):
self.beginRemoveRows(QModelIndex(), row, row)
self._data.pop(row)
self.endRemoveRows()
def insertRow(self, row, value):
self.beginInsertRows(QModelIndex(), row, row)
self._data.insert(row, value)
self.endInsertRows()
def moveRows(self, sourceParent, sourceRow, count, destinationParent, destinationRow):
if sourceRow < destinationRow:
destinationRow -= 1
self.beginMoveRows(sourceParent, sourceRow, sourceRow, destinationParent, destinationRow)
row_data = self._data.pop(sourceRow)
self._data.insert(destinationRow, row_data)
self.endMoveRows()
return True
class ProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.filterString = ""
def setFilterString(self, text):
self.filterString = text
self.invalidateFilter()
def filterAcceptsRow(self, source_row, source_parent):
if self.filterString:
data = self.sourceModel().data(self.sourceModel().index(source_row, 1), Qt.ItemDataRole.DisplayRole)
return self.filterString in str(data)
return True
class TableView(QTableView):
def __init__(self, model):
super().__init__()
self.setModel(model)
self.setDragDropMode(QTableView.DragDropMode.InternalMove)
self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.setSortingEnabled(False)
self.setDragDropOverwriteMode(False)
def dropEvent(self, event):
source_index = self.indexAt(event.pos())
if not source_index.isValid():
return
destination_row = source_index.row()
dragged_row = self.currentIndex().row()
if dragged_row != destination_row:
self.model().sourceModel().moveRows(QModelIndex(), dragged_row, 1, QModelIndex(), destination_row)
super().dropEvent(event)
self.model().layoutChanged.emit() # Refresh model to update row numbers
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.data = ["dog", "hog", "don", "cat", "bat"]
self.baseModel = CustomTableModel(self.data)
self.proxyModel = ProxyModel()
self.proxyModel.setSourceModel(self.baseModel)
self.view = TableView(self.proxyModel)
self.filterLineEdit = QLineEdit()
self.filterLineEdit.setPlaceholderText("Filter by substring")
self.filterLineEdit.textChanged.connect(self.proxyModel.setFilterString)
layout = QVBoxLayout()
layout.addWidget(self.filterLineEdit)
layout.addWidget(self.view)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

View File

@ -0,0 +1,46 @@
"""Remove mtime from Tracks
Revision ID: 164bd5ef3074
Revises: a524796269fa
Create Date: 2024-12-22 14:11:48.045995
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '164bd5ef3074'
down_revision = 'a524796269fa'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.drop_index('ix_tracks_mtime')
batch_op.drop_column('mtime')
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.add_column(sa.Column('mtime', mysql.FLOAT(), nullable=False))
batch_op.create_index('ix_tracks_mtime', ['mtime'], unique=False)
# ### end Alembic commands ###

230
poetry.lock generated
View File

@ -289,6 +289,20 @@ urllib3 = "*"
dev = ["dlint", "flake8-2020", "flake8-aaa", "flake8-absolute-import", "flake8-alfred", "flake8-annotations-complexity", "flake8-bandit", "flake8-black", "flake8-broken-line", "flake8-bugbear", "flake8-builtins", "flake8-coding", "flake8-cognitive-complexity", "flake8-commas", "flake8-comprehensions", "flake8-debugger", "flake8-django", "flake8-docstrings", "flake8-eradicate", "flake8-executable", "flake8-expression-complexity", "flake8-fixme", "flake8-functions", "flake8-future-import", "flake8-import-order", "flake8-isort", "flake8-logging-format", "flake8-mock", "flake8-mutable", "flake8-mypy", "flake8-pep3101", "flake8-pie", "flake8-print", "flake8-printf-formatting", "flake8-pyi", "flake8-pytest", "flake8-pytest-style", "flake8-quotes", "flake8-requirements", "flake8-rst-docstrings", "flake8-scrapy", "flake8-spellcheck", "flake8-sql", "flake8-strict", "flake8-string-format", "flake8-tidy-imports", "flake8-todo", "flake8-use-fstring", "flake8-variables-names", "isort[pyproject]", "mccabe", "pandas-vet", "pep8-naming", "pylint", "pytest", "typing-extensions", "wemake-python-styleguide"] dev = ["dlint", "flake8-2020", "flake8-aaa", "flake8-absolute-import", "flake8-alfred", "flake8-annotations-complexity", "flake8-bandit", "flake8-black", "flake8-broken-line", "flake8-bugbear", "flake8-builtins", "flake8-coding", "flake8-cognitive-complexity", "flake8-commas", "flake8-comprehensions", "flake8-debugger", "flake8-django", "flake8-docstrings", "flake8-eradicate", "flake8-executable", "flake8-expression-complexity", "flake8-fixme", "flake8-functions", "flake8-future-import", "flake8-import-order", "flake8-isort", "flake8-logging-format", "flake8-mock", "flake8-mutable", "flake8-mypy", "flake8-pep3101", "flake8-pie", "flake8-print", "flake8-printf-formatting", "flake8-pyi", "flake8-pytest", "flake8-pytest-style", "flake8-quotes", "flake8-requirements", "flake8-rst-docstrings", "flake8-scrapy", "flake8-spellcheck", "flake8-sql", "flake8-strict", "flake8-string-format", "flake8-tidy-imports", "flake8-todo", "flake8-use-fstring", "flake8-variables-names", "isort[pyproject]", "mccabe", "pandas-vet", "pep8-naming", "pylint", "pytest", "typing-extensions", "wemake-python-styleguide"]
docs = ["alabaster", "pygments-github-lexers", "recommonmark", "sphinx"] docs = ["alabaster", "pygments-github-lexers", "recommonmark", "sphinx"]
[[package]]
name = "fuzzywuzzy"
version = "0.18.0"
description = "Fuzzy string matching in python"
optional = false
python-versions = "*"
files = [
{file = "fuzzywuzzy-0.18.0-py2.py3-none-any.whl", hash = "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993"},
{file = "fuzzywuzzy-0.18.0.tar.gz", hash = "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8"},
]
[package.extras]
speedup = ["python-levenshtein (>=0.12)"]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.1.1" version = "3.1.1"
@ -457,6 +471,106 @@ docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alab
qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
[[package]]
name = "levenshtein"
version = "0.26.1"
description = "Python extension for computing string edit distances and similarities."
optional = false
python-versions = ">=3.9"
files = [
{file = "levenshtein-0.26.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8dc4a4aecad538d944a1264c12769c99e3c0bf8e741fc5e454cc954913befb2e"},
{file = "levenshtein-0.26.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec108f368c12b25787c8b1a4537a1452bc53861c3ee4abc810cc74098278edcd"},
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69229d651c97ed5b55b7ce92481ed00635cdbb80fbfb282a22636e6945dc52d5"},
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79dcd157046d62482a7719b08ba9e3ce9ed3fc5b015af8ea989c734c702aedd4"},
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f53f9173ae21b650b4ed8aef1d0ad0c37821f367c221a982f4d2922b3044e0d"},
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3956f3c5c229257dbeabe0b6aacd2c083ebcc1e335842a6ff2217fe6cc03b6b"},
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1e83af732726987d2c4cd736f415dae8b966ba17b7a2239c8b7ffe70bfb5543"},
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4f052c55046c2a9c9b5f742f39e02fa6e8db8039048b8c1c9e9fdd27c8a240a1"},
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9895b3a98f6709e293615fde0dcd1bb0982364278fa2072361a1a31b3e388b7a"},
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a3777de1d8bfca054465229beed23994f926311ce666f5a392c8859bb2722f16"},
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:81c57e1135c38c5e6e3675b5e2077d8a8d3be32bf0a46c57276c092b1dffc697"},
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:91d5e7d984891df3eff7ea9fec8cf06fdfacc03cd074fd1a410435706f73b079"},
{file = "levenshtein-0.26.1-cp310-cp310-win32.whl", hash = "sha256:f48abff54054b4142ad03b323e80aa89b1d15cabc48ff49eb7a6ff7621829a56"},
{file = "levenshtein-0.26.1-cp310-cp310-win_amd64.whl", hash = "sha256:79dd6ad799784ea7b23edd56e3bf94b3ca866c4c6dee845658ee75bb4aefdabf"},
{file = "levenshtein-0.26.1-cp310-cp310-win_arm64.whl", hash = "sha256:3351ddb105ef010cc2ce474894c5d213c83dddb7abb96400beaa4926b0b745bd"},
{file = "levenshtein-0.26.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44c51f5d33b3cfb9db518b36f1288437a509edd82da94c4400f6a681758e0cb6"},
{file = "levenshtein-0.26.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56b93203e725f9df660e2afe3d26ba07d71871b6d6e05b8b767e688e23dfb076"},
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:270d36c5da04a0d89990660aea8542227cbd8f5bc34e9fdfadd34916ff904520"},
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:480674c05077eeb0b0f748546d4fcbb386d7c737f9fff0010400da3e8b552942"},
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13946e37323728695ba7a22f3345c2e907d23f4600bc700bf9b4352fb0c72a48"},
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceb673f572d1d0dc9b1cd75792bb8bad2ae8eb78a7c6721e23a3867d318cb6f2"},
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42d6fa242e3b310ce6bfd5af0c83e65ef10b608b885b3bb69863c01fb2fcff98"},
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8b68295808893a81e0a1dbc2274c30dd90880f14d23078e8eb4325ee615fc68"},
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b01061d377d1944eb67bc40bef5d4d2f762c6ab01598efd9297ce5d0047eb1b5"},
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9d12c8390f156745e533d01b30773b9753e41d8bbf8bf9dac4b97628cdf16314"},
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:48825c9f967f922061329d1481b70e9fee937fc68322d6979bc623f69f75bc91"},
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d8ec137170b95736842f99c0e7a9fd8f5641d0c1b63b08ce027198545d983e2b"},
{file = "levenshtein-0.26.1-cp311-cp311-win32.whl", hash = "sha256:798f2b525a2e90562f1ba9da21010dde0d73730e277acaa5c52d2a6364fd3e2a"},
{file = "levenshtein-0.26.1-cp311-cp311-win_amd64.whl", hash = "sha256:55b1024516c59df55f1cf1a8651659a568f2c5929d863d3da1ce8893753153bd"},
{file = "levenshtein-0.26.1-cp311-cp311-win_arm64.whl", hash = "sha256:e52575cbc6b9764ea138a6f82d73d3b1bc685fe62e207ff46a963d4c773799f6"},
{file = "levenshtein-0.26.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc741ca406d3704dc331a69c04b061fc952509a069b79cab8287413f434684bd"},
{file = "levenshtein-0.26.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:821ace3b4e1c2e02b43cf5dc61aac2ea43bdb39837ac890919c225a2c3f2fea4"},
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92694c9396f55d4c91087efacf81297bef152893806fc54c289fc0254b45384"},
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51ba374de7a1797d04a14a4f0ad3602d2d71fef4206bb20a6baaa6b6a502da58"},
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7aa5c3327dda4ef952769bacec09c09ff5bf426e07fdc94478c37955681885b"},
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e2517e8d3c221de2d1183f400aed64211fcfc77077b291ed9f3bb64f141cdc"},
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9092b622765c7649dd1d8af0f43354723dd6f4e570ac079ffd90b41033957438"},
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fc16796c85d7d8b259881d59cc8b5e22e940901928c2ff6924b2c967924e8a0b"},
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4370733967f5994ceeed8dc211089bedd45832ee688cecea17bfd35a9eb22b9"},
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3535ecfd88c9b283976b5bc61265855f59bba361881e92ed2b5367b6990c93fe"},
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:90236e93d98bdfd708883a6767826fafd976dac8af8fc4a0fb423d4fa08e1bf0"},
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:04b7cabb82edf566b1579b3ed60aac0eec116655af75a3c551fee8754ffce2ea"},
{file = "levenshtein-0.26.1-cp312-cp312-win32.whl", hash = "sha256:ae382af8c76f6d2a040c0d9ca978baf461702ceb3f79a0a3f6da8d596a484c5b"},
{file = "levenshtein-0.26.1-cp312-cp312-win_amd64.whl", hash = "sha256:fd091209798cfdce53746f5769987b4108fe941c54fb2e058c016ffc47872918"},
{file = "levenshtein-0.26.1-cp312-cp312-win_arm64.whl", hash = "sha256:7e82f2ea44a81ad6b30d92a110e04cd3c8c7c6034b629aca30a3067fa174ae89"},
{file = "levenshtein-0.26.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:790374a9f5d2cbdb30ee780403a62e59bef51453ac020668c1564d1e43438f0e"},
{file = "levenshtein-0.26.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7b05c0415c386d00efda83d48db9db68edd02878d6dbc6df01194f12062be1bb"},
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3114586032361722ddededf28401ce5baf1cf617f9f49fb86b8766a45a423ff"},
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2532f8a13b68bf09f152d906f118a88da2063da22f44c90e904b142b0a53d534"},
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:219c30be6aa734bf927188d1208b7d78d202a3eb017b1c5f01ab2034d2d4ccca"},
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397e245e77f87836308bd56305bba630010cd8298c34c4c44bd94990cdb3b7b1"},
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeff6ea3576f72e26901544c6c55c72a7b79b9983b6f913cba0e9edbf2f87a97"},
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a19862e3539a697df722a08793994e334cd12791e8144851e8a1dee95a17ff63"},
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:dc3b5a64f57c3c078d58b1e447f7d68cad7ae1b23abe689215d03fc434f8f176"},
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bb6c7347424a91317c5e1b68041677e4c8ed3e7823b5bbaedb95bffb3c3497ea"},
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b817376de4195a207cc0e4ca37754c0e1e1078c2a2d35a6ae502afde87212f9e"},
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b50c3620ff47c9887debbb4c154aaaac3e46be7fc2e5789ee8dbe128bce6a17"},
{file = "levenshtein-0.26.1-cp313-cp313-win32.whl", hash = "sha256:9fb859da90262eb474c190b3ca1e61dee83add022c676520f5c05fdd60df902a"},
{file = "levenshtein-0.26.1-cp313-cp313-win_amd64.whl", hash = "sha256:8adcc90e3a5bfb0a463581d85e599d950fe3c2938ac6247b29388b64997f6e2d"},
{file = "levenshtein-0.26.1-cp313-cp313-win_arm64.whl", hash = "sha256:c2599407e029865dc66d210b8804c7768cbdbf60f061d993bb488d5242b0b73e"},
{file = "levenshtein-0.26.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc54ced948fc3feafce8ad4ba4239d8ffc733a0d70e40c0363ac2a7ab2b7251e"},
{file = "levenshtein-0.26.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6516f69213ae393a220e904332f1a6bfc299ba22cf27a6520a1663a08eba0fb"},
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4cfea4eada1746d0c75a864bc7e9e63d4a6e987c852d6cec8d9cb0c83afe25b"},
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a323161dfeeac6800eb13cfe76a8194aec589cd948bcf1cdc03f66cc3ec26b72"},
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c23e749b68ebc9a20b9047317b5cd2053b5856315bc8636037a8adcbb98bed1"},
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f80dd7432d4b6cf493d012d22148db7af769017deb31273e43406b1fb7f091c"},
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ae7cd6e4312c6ef34b2e273836d18f9fff518d84d823feff5ad7c49668256e0"},
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dcdad740e841d791b805421c2b20e859b4ed556396d3063b3aa64cd055be648c"},
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e07afb1613d6f5fd99abd4e53ad3b446b4efaa0f0d8e9dfb1d6d1b9f3f884d32"},
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f1add8f1d83099a98ae4ac472d896b7e36db48c39d3db25adf12b373823cdeff"},
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1010814b1d7a60833a951f2756dfc5c10b61d09976ce96a0edae8fecdfb0ea7c"},
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:33fa329d1bb65ce85e83ceda281aea31cee9f2f6e167092cea54f922080bcc66"},
{file = "levenshtein-0.26.1-cp39-cp39-win32.whl", hash = "sha256:488a945312f2f16460ab61df5b4beb1ea2254c521668fd142ce6298006296c98"},
{file = "levenshtein-0.26.1-cp39-cp39-win_amd64.whl", hash = "sha256:9f942104adfddd4b336c3997050121328c39479f69de702d7d144abb69ea7ab9"},
{file = "levenshtein-0.26.1-cp39-cp39-win_arm64.whl", hash = "sha256:c1d8f85b2672939f85086ed75effcf768f6077516a3e299c2ba1f91bc4644c22"},
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6cf8f1efaf90ca585640c5d418c30b7d66d9ac215cee114593957161f63acde0"},
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d5b2953978b8c158dd5cd93af8216a5cfddbf9de66cf5481c2955f44bb20767a"},
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b952b3732c4631c49917d4b15d78cb4a2aa006c1d5c12e2a23ba8e18a307a055"},
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07227281e12071168e6ae59238918a56d2a0682e529f747b5431664f302c0b42"},
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8191241cd8934feaf4d05d0cc0e5e72877cbb17c53bbf8c92af9f1aedaa247e9"},
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9e70d7ee157a9b698c73014f6e2b160830e7d2d64d2e342fefc3079af3c356fc"},
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0eb3059f826f6cb0a5bca4a85928070f01e8202e7ccafcba94453470f83e49d4"},
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:6c389e44da12d6fb1d7ba0a709a32a96c9391e9be4160ccb9269f37e040599ee"},
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e9de292f2c51a7d34a0ae23bec05391b8f61f35781cd3e4c6d0533e06250c55"},
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d87215113259efdca8716e53b6d59ab6d6009e119d95d45eccc083148855f33"},
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f00a3eebf68a82fb651d8d0e810c10bfaa60c555d21dde3ff81350c74fb4c2"},
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b3554c1b59de63d05075577380340c185ff41b028e541c0888fddab3c259a2b4"},
{file = "levenshtein-0.26.1.tar.gz", hash = "sha256:0d19ba22330d50609b2349021ec3cf7d905c6fe21195a2d0d876a146e7ed2575"},
]
[package.dependencies]
rapidfuzz = ">=3.9.0,<4.0.0"
[[package]] [[package]]
name = "line-profiler" name = "line-profiler"
version = "4.1.3" version = "4.1.3"
@ -1388,6 +1502,20 @@ pytest = "*"
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx-rtd-theme"] doc = ["sphinx", "sphinx-rtd-theme"]
[[package]]
name = "python-levenshtein"
version = "0.26.1"
description = "Python extension for computing string edit distances and similarities."
optional = false
python-versions = ">=3.9"
files = [
{file = "python_Levenshtein-0.26.1-py3-none-any.whl", hash = "sha256:8ef5e529dd640fb00f05ee62d998d2ee862f19566b641ace775d5ae16167b2ef"},
{file = "python_levenshtein-0.26.1.tar.gz", hash = "sha256:24ba578e28058ebb4afa2700057e1678d7adf27e43cd1f17700c09a9009d5d3a"},
]
[package.dependencies]
Levenshtein = "0.26.1"
[[package]] [[package]]
name = "python-slugify" name = "python-slugify"
version = "8.0.4" version = "8.0.4"
@ -1416,6 +1544,106 @@ files = [
{file = "python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec"}, {file = "python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec"},
] ]
[[package]]
name = "rapidfuzz"
version = "3.11.0"
description = "rapid fuzzy string matching"
optional = false
python-versions = ">=3.9"
files = [
{file = "rapidfuzz-3.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb8a54543d16ab1b69e2c5ed96cabbff16db044a50eddfc028000138ca9ddf33"},
{file = "rapidfuzz-3.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:231c8b2efbd7f8d2ecd1ae900363ba168b8870644bb8f2b5aa96e4a7573bde19"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54e7f442fb9cca81e9df32333fb075ef729052bcabe05b0afc0441f462299114"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:906f1f2a1b91c06599b3dd1be207449c5d4fc7bd1e1fa2f6aef161ea6223f165"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed59044aea9eb6c663112170f2399b040d5d7b162828b141f2673e822093fa8"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cb1965a28b0fa64abdee130c788a0bc0bb3cf9ef7e3a70bf055c086c14a3d7e"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b488b244931d0291412917e6e46ee9f6a14376625e150056fe7c4426ef28225"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f0ba13557fec9d5ffc0a22826754a7457cc77f1b25145be10b7bb1d143ce84c6"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3871fa7dfcef00bad3c7e8ae8d8fd58089bad6fb21f608d2bf42832267ca9663"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b2669eafee38c5884a6e7cc9769d25c19428549dcdf57de8541cf9e82822e7db"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ffa1bb0e26297b0f22881b219ffc82a33a3c84ce6174a9d69406239b14575bd5"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:45b15b8a118856ac9caac6877f70f38b8a0d310475d50bc814698659eabc1cdb"},
{file = "rapidfuzz-3.11.0-cp310-cp310-win32.whl", hash = "sha256:22033677982b9c4c49676f215b794b0404073f8974f98739cb7234e4a9ade9ad"},
{file = "rapidfuzz-3.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:be15496e7244361ff0efcd86e52559bacda9cd975eccf19426a0025f9547c792"},
{file = "rapidfuzz-3.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:714a7ba31ba46b64d30fccfe95f8013ea41a2e6237ba11a805a27cdd3bce2573"},
{file = "rapidfuzz-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8724a978f8af7059c5323d523870bf272a097478e1471295511cf58b2642ff83"},
{file = "rapidfuzz-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b63cb1f2eb371ef20fb155e95efd96e060147bdd4ab9fc400c97325dfee9fe1"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82497f244aac10b20710448645f347d862364cc4f7d8b9ba14bd66b5ce4dec18"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:339607394941801e6e3f6c1ecd413a36e18454e7136ed1161388de674f47f9d9"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84819390a36d6166cec706b9d8f0941f115f700b7faecab5a7e22fc367408bc3"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eea8d9e20632d68f653455265b18c35f90965e26f30d4d92f831899d6682149b"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b659e1e2ea2784a9a397075a7fc395bfa4fe66424042161c4bcaf6e4f637b38"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1315cd2a351144572e31fe3df68340d4b83ddec0af8b2e207cd32930c6acd037"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a7743cca45b4684c54407e8638f6d07b910d8d811347b9d42ff21262c7c23245"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5bb636b0150daa6d3331b738f7c0f8b25eadc47f04a40e5c23c4bfb4c4e20ae3"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:42f4dd264ada7a9aa0805ea0da776dc063533917773cf2df5217f14eb4429eae"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51f24cb39e64256221e6952f22545b8ce21cacd59c0d3e367225da8fc4b868d8"},
{file = "rapidfuzz-3.11.0-cp311-cp311-win32.whl", hash = "sha256:aaf391fb6715866bc14681c76dc0308f46877f7c06f61d62cc993b79fc3c4a2a"},
{file = "rapidfuzz-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:ebadd5b8624d8ad503e505a99b8eb26fe3ea9f8e9c2234e805a27b269e585842"},
{file = "rapidfuzz-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:d895998fec712544c13cfe833890e0226585cf0391dd3948412441d5d68a2b8c"},
{file = "rapidfuzz-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f382fec4a7891d66fb7163c90754454030bb9200a13f82ee7860b6359f3f2fa8"},
{file = "rapidfuzz-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dfaefe08af2a928e72344c800dcbaf6508e86a4ed481e28355e8d4b6a6a5230e"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92ebb7c12f682b5906ed98429f48a3dd80dd0f9721de30c97a01473d1a346576"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a1b3ebc62d4bcdfdeba110944a25ab40916d5383c5e57e7c4a8dc0b6c17211a"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c6d7fea39cb33e71de86397d38bf7ff1a6273e40367f31d05761662ffda49e4"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99aebef8268f2bc0b445b5640fd3312e080bd17efd3fbae4486b20ac00466308"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4469307f464ae3089acf3210b8fc279110d26d10f79e576f385a98f4429f7d97"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:eb97c53112b593f89a90b4f6218635a9d1eea1d7f9521a3b7d24864228bbc0aa"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef8937dae823b889c0273dfa0f0f6c46a3658ac0d851349c464d1b00e7ff4252"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d95f9e9f3777b96241d8a00d6377cc9c716981d828b5091082d0fe3a2924b43e"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b1d67d67f89e4e013a5295e7523bc34a7a96f2dba5dd812c7c8cb65d113cbf28"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d994cf27e2f874069884d9bddf0864f9b90ad201fcc9cb2f5b82bacc17c8d5f2"},
{file = "rapidfuzz-3.11.0-cp312-cp312-win32.whl", hash = "sha256:ba26d87fe7fcb56c4a53b549a9e0e9143f6b0df56d35fe6ad800c902447acd5b"},
{file = "rapidfuzz-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1f7efdd7b7adb32102c2fa481ad6f11923e2deb191f651274be559d56fc913b"},
{file = "rapidfuzz-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:ed78c8e94f57b44292c1a0350f580e18d3a3c5c0800e253f1583580c1b417ad2"},
{file = "rapidfuzz-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e60814edd0c9b511b5f377d48b9782b88cfe8be07a98f99973669299c8bb318a"},
{file = "rapidfuzz-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f28952da055dbfe75828891cd3c9abf0984edc8640573c18b48c14c68ca5e06"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e8f93bc736020351a6f8e71666e1f486bb8bd5ce8112c443a30c77bfde0eb68"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76a4a11ba8f678c9e5876a7d465ab86def047a4fcc043617578368755d63a1bc"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc0e0d41ad8a056a9886bac91ff9d9978e54a244deb61c2972cc76b66752de9c"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e8ea35f2419c7d56b3e75fbde2698766daedb374f20eea28ac9b1f668ef4f74"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd340bbd025302276b5aa221dccfe43040c7babfc32f107c36ad783f2ffd8775"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:494eef2c68305ab75139034ea25328a04a548d297712d9cf887bf27c158c388b"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a167344c1d6db06915fb0225592afdc24d8bafaaf02de07d4788ddd37f4bc2f"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c7af25bda96ac799378ac8aba54a8ece732835c7b74cfc201b688a87ed11152"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d2a0f7e17f33e7890257367a1662b05fecaf56625f7dbb6446227aaa2b86448b"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d0d26c7172bdb64f86ee0765c5b26ea1dc45c52389175888ec073b9b28f4305"},
{file = "rapidfuzz-3.11.0-cp313-cp313-win32.whl", hash = "sha256:6ad02bab756751c90fa27f3069d7b12146613061341459abf55f8190d899649f"},
{file = "rapidfuzz-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:b1472986fd9c5d318399a01a0881f4a0bf4950264131bb8e2deba9df6d8c362b"},
{file = "rapidfuzz-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c408f09649cbff8da76f8d3ad878b64ba7f7abdad1471efb293d2c075e80c822"},
{file = "rapidfuzz-3.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1bac4873f6186f5233b0084b266bfb459e997f4c21fc9f029918f44a9eccd304"},
{file = "rapidfuzz-3.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f9f12c2d0aa52b86206d2059916153876a9b1cf9dfb3cf2f344913167f1c3d4"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dd501de6f7a8f83557d20613b58734d1cb5f0be78d794cde64fe43cfc63f5f2"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4416ca69af933d4a8ad30910149d3db6d084781d5c5fdedb713205389f535385"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0821b9bdf18c5b7d51722b906b233a39b17f602501a966cfbd9b285f8ab83cd"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0edecc3f90c2653298d380f6ea73b536944b767520c2179ec5d40b9145e47aa"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4513dd01cee11e354c31b75f652d4d466c9440b6859f84e600bdebfccb17735a"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9727b85511b912571a76ce53c7640ba2c44c364e71cef6d7359b5412739c570"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ab9eab33ee3213f7751dc07a1a61b8d9a3d748ca4458fffddd9defa6f0493c16"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6b01c1ddbb054283797967ddc5433d5c108d680e8fa2684cf368be05407b07e4"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3857e335f97058c4b46fa39ca831290b70de554a5c5af0323d2f163b19c5f2a6"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d98a46cf07c0c875d27e8a7ed50f304d83063e49b9ab63f21c19c154b4c0d08d"},
{file = "rapidfuzz-3.11.0-cp39-cp39-win32.whl", hash = "sha256:c36539ed2c0173b053dafb221458812e178cfa3224ade0960599bec194637048"},
{file = "rapidfuzz-3.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:ec8d7d8567e14af34a7911c98f5ac74a3d4a743cd848643341fc92b12b3784ff"},
{file = "rapidfuzz-3.11.0-cp39-cp39-win_arm64.whl", hash = "sha256:62171b270ecc4071be1c1f99960317db261d4c8c83c169e7f8ad119211fe7397"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f06e3c4c0a8badfc4910b9fd15beb1ad8f3b8fafa8ea82c023e5e607b66a78e4"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fe7aaf5a54821d340d21412f7f6e6272a9b17a0cbafc1d68f77f2fc11009dcd5"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25398d9ac7294e99876a3027ffc52c6bebeb2d702b1895af6ae9c541ee676702"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a52eea839e4bdc72c5e60a444d26004da00bb5bc6301e99b3dde18212e41465"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c87319b0ab9d269ab84f6453601fd49b35d9e4a601bbaef43743f26fabf496c"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3048c6ed29d693fba7d2a7caf165f5e0bb2b9743a0989012a98a47b975355cca"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b04f29735bad9f06bb731c214f27253bd8bedb248ef9b8a1b4c5bde65b838454"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7864e80a0d4e23eb6194254a81ee1216abdc53f9dc85b7f4d56668eced022eb8"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3794df87313dfb56fafd679b962e0613c88a293fd9bd5dd5c2793d66bf06a101"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d71da0012face6f45432a11bc59af19e62fac5a41f8ce489e80c0add8153c3d1"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff38378346b7018f42cbc1f6d1d3778e36e16d8595f79a312b31e7c25c50bd08"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:6668321f90aa02a5a789d4e16058f2e4f2692c5230252425c3532a8a62bc3424"},
{file = "rapidfuzz-3.11.0.tar.gz", hash = "sha256:a53ca4d3f52f00b393fab9b5913c5bafb9afc27d030c8a1db1283da6917a860f"},
]
[package.extras]
all = ["numpy"]
[[package]] [[package]]
name = "rich" name = "rich"
version = "13.9.4" version = "13.9.4"
@ -1745,4 +1973,4 @@ test = ["websockets"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "f1b96a77f00820c0db315ba2db9f40aa918a47770317ce54efaa670eea41e83d" content-hash = "6a887314789a17a0d0875f1c7e6ce169c90142164de98e386131a7836e3db3b5"

View File

@ -25,6 +25,8 @@ obs-websocket-py = "^1.0"
pygame = "^2.6.1" pygame = "^2.6.1"
psutil = "^6.1.0" psutil = "^6.1.0"
pyqt6-webengine = "^6.7.0" pyqt6-webengine = "^6.7.0"
fuzzywuzzy = "^0.18.0"
python-levenshtein = "^0.26.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
ipdb = "^0.13.9" ipdb = "^0.13.9"

View File

@ -55,8 +55,8 @@ class TestMMHelpers(unittest.TestCase):
with open(test_track_data) as f: with open(test_track_data) as f:
testdata = eval(f.read()) testdata = eval(f.read())
assert tags["artist"] == testdata["artist"] assert tags.artist == testdata["artist"]
assert tags["title"] == testdata["title"] assert tags.title == testdata["title"]
def test_get_relative_date(self): def test_get_relative_date(self):
assert get_relative_date(None) == "Never" assert get_relative_date(None) == "Never"
@ -64,9 +64,9 @@ class TestMMHelpers(unittest.TestCase):
today_at_11 = dt.datetime.now().replace(hour=11, minute=0) today_at_11 = dt.datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00" assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
eight_days_ago = today_at_10 - dt.timedelta(days=8) eight_days_ago = today_at_10 - dt.timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago" assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day"
sixteen_days_ago = today_at_10 - dt.timedelta(days=16) sixteen_days_ago = today_at_10 - dt.timedelta(days=16)
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago" assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days"
def test_leading_silence(self): def test_leading_silence(self):
test_track_path = "testdata/isa.mp3" test_track_path = "testdata/isa.mp3"

View File

@ -63,7 +63,6 @@ class MyTestCase(unittest.TestCase):
"start_gap": 60, "start_gap": 60,
"fade_at": 236263, "fade_at": 236263,
"silence_at": 260343, "silence_at": 260343,
"mtime": 371900000,
}, },
2: { 2: {
"path": "testdata/mom.mp3", "path": "testdata/mom.mp3",
@ -74,7 +73,6 @@ class MyTestCase(unittest.TestCase):
"start_gap": 70, "start_gap": 70,
"fade_at": 115000, "fade_at": 115000,
"silence_at": 118000, "silence_at": 118000,
"mtime": 1642760000,
}, },
} }
@ -82,7 +80,7 @@ class MyTestCase(unittest.TestCase):
for track in self.tracks.values(): for track in self.tracks.values():
db_track = Tracks(session=session, **track) db_track = Tracks(session=session, **track)
session.add(db_track) session.add(db_track)
track['id'] = db_track.id track["id"] = db_track.id
session.commit() session.commit()
@ -136,12 +134,13 @@ class MyTestCase(unittest.TestCase):
from config import Config from config import Config
Config.ROOT = os.path.join(os.path.dirname(__file__), 'testdata') Config.ROOT = os.path.join(os.path.dirname(__file__), "testdata")
with db.Session() as session: with db.Session() as session:
utilities.check_db(session) utilities.check_db(session)
utilities.update_bitrates(session) utilities.update_bitrates(session)
# def test_meta_all_clear(qtbot, session): # def test_meta_all_clear(qtbot, session):
# # Create playlist # # Create playlist
# playlist = models.Playlists(session, "my playlist") # playlist = models.Playlists(session, "my playlist")

16
web.py
View File

@ -1,16 +0,0 @@
#!/usr/bin/env python3
import sys
from PyQt6.QtWidgets import QApplication, QLabel
from PyQt6.QtGui import QColor, QPalette
app = QApplication(sys.argv)
pal = app.palette()
pal.setColor(QPalette.ColorRole.WindowText, QColor("#000000"))
app.setPalette(pal)
label = QLabel("my label")
label.resize(300, 200)
label.show()
sys.exit(app.exec())