Compare commits
14 Commits
b9cb7cc326
...
e5dc3dbf03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5dc3dbf03 | ||
|
|
3fde474a5b | ||
|
|
b14b90396f | ||
|
|
937f3cd074 | ||
|
|
cb16a07451 | ||
|
|
6da6f7044b | ||
|
|
a1709e92ae | ||
|
|
b389a348c1 | ||
|
|
4c53791f4d | ||
|
|
d400ba3957 | ||
|
|
6e258a0ee2 | ||
|
|
205667faa1 | ||
|
|
d9abf72f6a | ||
|
|
96807a945c |
@ -3,6 +3,7 @@ import os
|
||||
import psutil
|
||||
import socket
|
||||
import select
|
||||
from typing import Optional
|
||||
|
||||
# PyQt imports
|
||||
|
||||
@ -31,7 +32,7 @@ class AudacityController:
|
||||
"""
|
||||
|
||||
self.method = method
|
||||
self.path: str = ""
|
||||
self.path: Optional[str] = None
|
||||
self.timeout = timeout
|
||||
if method == "pipe":
|
||||
user_uid = os.getuid() # Get the user's UID
|
||||
|
||||
769
app/classes.py
769
app/classes.py
@ -1,71 +1,20 @@
|
||||
# Standard library imports
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from dataclasses import dataclass, field
|
||||
import datetime as dt
|
||||
from enum import auto, Enum
|
||||
import platform
|
||||
from time import sleep
|
||||
from typing import Any, Optional, NamedTuple
|
||||
import functools
|
||||
from typing import NamedTuple
|
||||
|
||||
# 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 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):
|
||||
@ -81,362 +30,25 @@ class Col(Enum):
|
||||
NOTE = auto()
|
||||
|
||||
|
||||
class _AddFadeCurve(QObject):
|
||||
def singleton(cls):
|
||||
"""
|
||||
Initialising a fade curve introduces a noticeable delay so carry out in
|
||||
a thread.
|
||||
Make a class a Singleton class (see
|
||||
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__(
|
||||
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
|
||||
wrapper_singleton.instance = None
|
||||
return wrapper_singleton
|
||||
|
||||
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 = 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 FileErrors(NamedTuple):
|
||||
path: str
|
||||
error: str
|
||||
|
||||
|
||||
class ApplicationError(Exception):
|
||||
@ -447,6 +59,12 @@ class ApplicationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AudioMetadata(NamedTuple):
|
||||
start_gap: int = 0
|
||||
silence_at: int = 0
|
||||
fade_at: int = 0
|
||||
|
||||
|
||||
@singleton
|
||||
@dataclass
|
||||
class MusicMusterSignals(QObject):
|
||||
@ -475,345 +93,20 @@ class MusicMusterSignals(QObject):
|
||||
super().__init__()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@singleton
|
||||
@dataclass
|
||||
class TrackFileData:
|
||||
"""
|
||||
Simple class to track details changes to a track file
|
||||
"""
|
||||
class Selection:
|
||||
playlist_id: int = 0
|
||||
rows: list[int] = field(default_factory=list)
|
||||
|
||||
new_file_path: str
|
||||
track_id: int = 0
|
||||
track_path: Optional[str] = None
|
||||
obsolete_path: Optional[str] = None
|
||||
tags: dict[str, Any] = field(default_factory=dict)
|
||||
audio_metadata: dict[str, str | int | float] = field(default_factory=dict)
|
||||
|
||||
class Tags(NamedTuple):
|
||||
artist: str
|
||||
title: str
|
||||
bitrate: int
|
||||
duration: int
|
||||
|
||||
|
||||
class TrackInfo(NamedTuple):
|
||||
track_id: 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()
|
||||
|
||||
@ -39,6 +39,7 @@ class Config(object):
|
||||
DEBUG_MODULES: List[Optional[str]] = []
|
||||
DEFAULT_COLUMN_WIDTH = 200
|
||||
DISPLAY_SQL = False
|
||||
DO_NOT_IMPORT = "Do not import"
|
||||
ENGINE_OPTIONS = dict(pool_pre_ping=True)
|
||||
EPOCH = dt.datetime(1970, 1, 1)
|
||||
ERRORS_FROM = ["noreply@midnighthax.com"]
|
||||
@ -63,6 +64,7 @@ class Config(object):
|
||||
HIDE_AFTER_PLAYING_OFFSET = 5000
|
||||
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
|
||||
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
|
||||
IMPORT_AS_NEW = "Import as new track"
|
||||
INFO_TAB_TITLE_LENGTH = 15
|
||||
INTRO_SECONDS_FORMAT = ".1f"
|
||||
INTRO_SECONDS_WARNING_MS = 3000
|
||||
@ -80,6 +82,7 @@ class Config(object):
|
||||
MAX_INFO_TABS = 5
|
||||
MAX_MISSING_FILES_TO_REPORT = 10
|
||||
MILLISECOND_SIGFIGS = 0
|
||||
MINIMUM_FUZZYMATCH = 60.0
|
||||
MINIMUM_ROW_HEIGHT = 30
|
||||
NOTE_TIME_FORMAT = "%H:%M"
|
||||
OBS_HOST = "localhost"
|
||||
@ -93,6 +96,7 @@ class Config(object):
|
||||
PREVIEW_BACK_MS = 5000
|
||||
PREVIEW_END_BUFFER_MS = 1000
|
||||
REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp"
|
||||
RESIZE_ROW_CHUNK_SIZE = 40
|
||||
RETURN_KEY_DEBOUNCE_MS = 1000
|
||||
ROOT = os.environ.get("ROOT") or "/home/kae/music"
|
||||
ROW_PADDING = 4
|
||||
@ -102,6 +106,7 @@ class Config(object):
|
||||
SECTION_STARTS = ("+", "+-", "-+")
|
||||
SONGFACTS_ON_NEXT = False
|
||||
START_GAP_WARNING_THRESHOLD = 300
|
||||
SUBTOTAL_ON_ROW_ZERO = "[No subtotal on first row]"
|
||||
TEXT_NO_TRACK_NO_NOTE = "[Section header]"
|
||||
TOD_TIME_FORMAT = "%H:%M:%S"
|
||||
TRACK_TIME_FORMAT = "%H:%M:%S"
|
||||
|
||||
@ -142,7 +142,6 @@ class TracksTable(Model):
|
||||
duration: Mapped[int] = mapped_column(index=True)
|
||||
fade_at: Mapped[int] = mapped_column(index=False)
|
||||
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)
|
||||
silence_at: Mapped[int] = mapped_column(index=False)
|
||||
start_gap: Mapped[int] = mapped_column(index=False)
|
||||
|
||||
215
app/dialogs.py
215
app/dialogs.py
@ -17,7 +17,7 @@ import pydymenu # type: ignore
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
from classes import MusicMusterSignals, TrackFileData
|
||||
from classes import MusicMusterSignals
|
||||
from config import Config
|
||||
from helpers import (
|
||||
ask_yes_no,
|
||||
@ -32,203 +32,6 @@ from playlistmodel import PlaylistModel
|
||||
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):
|
||||
"""Select track from database"""
|
||||
|
||||
@ -237,7 +40,7 @@ class TrackSelectDialog(QDialog):
|
||||
parent: QMainWindow,
|
||||
session: Session,
|
||||
new_row_number: int,
|
||||
source_model: PlaylistModel,
|
||||
base_model: PlaylistModel,
|
||||
add_to_header: Optional[bool] = False,
|
||||
*args: Qt.WindowType,
|
||||
**kwargs: Qt.WindowType,
|
||||
@ -249,7 +52,7 @@ class TrackSelectDialog(QDialog):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
self.session = session
|
||||
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.ui = dlg_TrackSelect_ui.Ui_Dialog()
|
||||
self.ui.setupUi(self)
|
||||
@ -293,7 +96,7 @@ class TrackSelectDialog(QDialog):
|
||||
track_id = 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.new_row_number += 1
|
||||
return
|
||||
@ -307,7 +110,7 @@ class TrackSelectDialog(QDialog):
|
||||
|
||||
# Check whether track is already in playlist
|
||||
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 ask_yes_no(
|
||||
"Duplicate row",
|
||||
@ -318,21 +121,21 @@ class TrackSelectDialog(QDialog):
|
||||
|
||||
if self.add_to_header:
|
||||
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
|
||||
)
|
||||
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
|
||||
self.accept()
|
||||
else:
|
||||
# Adding a new track row
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
536
app/file_importer.py
Normal file
536
app/file_importer.py
Normal 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
|
||||
@ -1,8 +1,7 @@
|
||||
# Standard library imports
|
||||
import datetime as dt
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, Optional
|
||||
import functools
|
||||
from typing import Optional
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@ -18,9 +17,10 @@ from mutagen.flac import FLAC # type: ignore
|
||||
from mutagen.mp3 import MP3 # type: ignore
|
||||
from pydub import AudioSegment, effects
|
||||
from pydub.utils import mediainfo
|
||||
from tinytag import TinyTag # type: ignore
|
||||
from tinytag import TinyTag, TinyTagException # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import AudioMetadata, ApplicationError, Tags
|
||||
from config import Config
|
||||
from log import log
|
||||
from models import Tracks
|
||||
@ -121,29 +121,25 @@ def get_embedded_time(text: str) -> Optional[dt.datetime]:
|
||||
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 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"""
|
||||
|
||||
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
|
||||
audio = get_audio_segment(filepath)
|
||||
if not audio:
|
||||
audio_values = dict(start_gap=0, fade_at=0, silence_at=0)
|
||||
return AudioMetadata()
|
||||
else:
|
||||
audio_values = dict(
|
||||
return AudioMetadata(
|
||||
start_gap=leading_silence(audio),
|
||||
fade_at=int(
|
||||
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
|
||||
),
|
||||
)
|
||||
metadata |= audio_values
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def get_relative_date(
|
||||
@ -199,17 +192,19 @@ def get_relative_date(
|
||||
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:
|
||||
tag = TinyTag.get(path)
|
||||
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,
|
||||
artist=tag.artist,
|
||||
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)
|
||||
|
||||
# Remove the substring
|
||||
result = result[:index] + result[index + len(substring):]
|
||||
result = result[:index] + result[index + len(substring) :]
|
||||
|
||||
# Update the lowercase versions
|
||||
lower_parent = result.lower()
|
||||
@ -391,10 +386,10 @@ def set_track_metadata(track: Tracks) -> None:
|
||||
audio_metadata = get_audio_metadata(track.path)
|
||||
tags = get_tags(track.path)
|
||||
|
||||
for audio_key in audio_metadata:
|
||||
setattr(track, audio_key, audio_metadata[audio_key])
|
||||
for tag_key in tags:
|
||||
setattr(track, tag_key, tags[tag_key])
|
||||
for audio_key in AudioMetadata._fields:
|
||||
setattr(track, audio_key, getattr(audio_metadata, audio_key))
|
||||
for tag_key in Tags._fields:
|
||||
setattr(track, tag_key, getattr(tags, tag_key))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
audio_segment: AudioSegment,
|
||||
silence_threshold: int = -50,
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
# Standard library imports
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Sequence
|
||||
import datetime as dt
|
||||
import os
|
||||
@ -70,7 +72,9 @@ class NoteColours(dbtables.NoteColoursTable):
|
||||
return result
|
||||
|
||||
@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
|
||||
string if matched, else None
|
||||
@ -243,10 +247,7 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
|
||||
return session.scalars(
|
||||
select(cls)
|
||||
.where(
|
||||
cls.is_template.is_(True),
|
||||
cls.deleted.is_not(True)
|
||||
)
|
||||
.where(cls.is_template.is_(True), cls.deleted.is_not(True))
|
||||
.order_by(cls.name)
|
||||
).all()
|
||||
|
||||
@ -629,7 +630,6 @@ class Tracks(dbtables.TracksTable):
|
||||
start_gap: int,
|
||||
fade_at: int,
|
||||
silence_at: int,
|
||||
mtime: int,
|
||||
bitrate: int,
|
||||
):
|
||||
self.path = path
|
||||
@ -640,7 +640,6 @@ class Tracks(dbtables.TracksTable):
|
||||
self.start_gap = start_gap
|
||||
self.fade_at = fade_at
|
||||
self.silence_at = silence_at
|
||||
self.mtime = mtime
|
||||
|
||||
try:
|
||||
session.add(self)
|
||||
@ -657,19 +656,35 @@ class Tracks(dbtables.TracksTable):
|
||||
return session.scalars(select(cls)).unique().all()
|
||||
|
||||
@classmethod
|
||||
def get_by_basename(
|
||||
cls, session: Session, basename: str
|
||||
) -> Optional[Sequence["Tracks"]]:
|
||||
def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
|
||||
"""
|
||||
Return track(s) with passed basename, or None.
|
||||
Return a dictionary of all tracks, keyed by title
|
||||
"""
|
||||
|
||||
try:
|
||||
return session.scalars(
|
||||
Tracks.select().where(Tracks.path.like("%/" + basename))
|
||||
).all()
|
||||
except NoResultFound:
|
||||
return None
|
||||
result: dict[int, Tracks] = {}
|
||||
|
||||
for track in cls.get_all(session):
|
||||
result[track.id] = track
|
||||
|
||||
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
|
||||
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
|
||||
|
||||
746
app/music_manager.py
Normal file
746
app/music_manager.py
Normal 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()
|
||||
@ -1,13 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Standard library imports
|
||||
from os.path import basename
|
||||
from dataclasses import dataclass, field
|
||||
from slugify import slugify # type: ignore
|
||||
from typing import List, Optional
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import os
|
||||
import shutil
|
||||
from slugify import slugify # type: ignore
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
@ -15,11 +14,8 @@ import webbrowser
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QDate,
|
||||
QObject,
|
||||
Qt,
|
||||
QThread,
|
||||
QTime,
|
||||
QTimer,
|
||||
)
|
||||
@ -47,23 +43,22 @@ from PyQt6.QtWidgets import (
|
||||
# Third party imports
|
||||
import line_profiler
|
||||
from pygame import mixer
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm.session import Session
|
||||
import stackprinter # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import (
|
||||
MusicMusterSignals,
|
||||
RowAndTrack,
|
||||
TrackFileData,
|
||||
Selection,
|
||||
TrackInfo,
|
||||
track_sequence,
|
||||
)
|
||||
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 log import log
|
||||
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
|
||||
from music_manager import RowAndTrack, track_sequence
|
||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||
from playlists import PlaylistTab
|
||||
from ui import icons_rc # noqa F401
|
||||
@ -74,80 +69,25 @@ from utilities import check_db, update_bitrates
|
||||
import helpers
|
||||
|
||||
|
||||
class ImportTrack(QObject):
|
||||
import_finished = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
track_files: List[TrackFileData],
|
||||
source_model: PlaylistModel,
|
||||
row_number: Optional[int],
|
||||
) -> None:
|
||||
class DownloadCSV(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
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
|
||||
for tf in track_files:
|
||||
if not tf.tags:
|
||||
raise Exception(f"ImportTrack: no tags for {tf.new_file_path}")
|
||||
if not tf.audio_metadata:
|
||||
raise Exception(
|
||||
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}")
|
||||
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)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Create track objects from passed files and add to visible playlist
|
||||
"""
|
||||
|
||||
with db.Session() as session:
|
||||
for tf in self.track_files:
|
||||
self.signals.status_message_signal.emit(
|
||||
f"Importing {basename(tf.new_file_path)}", 5000
|
||||
)
|
||||
@dataclass
|
||||
class PlaylistData:
|
||||
base_model: PlaylistModel
|
||||
proxy_model: PlaylistProxyModel
|
||||
|
||||
# Sanity check
|
||||
if not os.path.exists(tf.new_file_path):
|
||||
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()
|
||||
def __post_init__(self):
|
||||
self.proxy_model.setSourceModel(self.base_model)
|
||||
|
||||
|
||||
class PreviewManager:
|
||||
@ -261,6 +201,52 @@ class PreviewManager:
|
||||
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):
|
||||
def __init__(
|
||||
self, parent: Optional[QWidget] = None, *args: list, **kwargs: dict
|
||||
@ -287,10 +273,8 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.widgetFadeVolume.setDefaultPadding(0)
|
||||
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_model: Optional[PlaylistProxyModel] = None
|
||||
self.move_source_model: Optional[PlaylistModel] = None
|
||||
|
||||
self.disable_selection_timing = False
|
||||
self.clock_counter = 0
|
||||
@ -301,6 +285,9 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.signals = MusicMusterSignals()
|
||||
self.connect_signals_slots()
|
||||
self.catch_return_key = False
|
||||
self.importer: Optional[FileImporter] = None
|
||||
self.selection = Selection()
|
||||
self.playlists: dict[int, PlaylistData] = {}
|
||||
|
||||
if not Config.USE_INTERNAL_BROWSER:
|
||||
webbrowser.register(
|
||||
@ -337,6 +324,12 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
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:
|
||||
"""
|
||||
Clear next track
|
||||
@ -351,6 +344,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
# Unselect any selected rows
|
||||
if self.active_tab():
|
||||
self.active_tab().clear_selection()
|
||||
|
||||
# Clear the search bar
|
||||
self.search_playlist_clear()
|
||||
|
||||
@ -450,26 +444,26 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
def connect_signals_slots(self) -> None:
|
||||
self.action_About.triggered.connect(self.about)
|
||||
self.action_Clear_selection.triggered.connect(self.clear_selection)
|
||||
self.actionDebug.triggered.connect(self.debug)
|
||||
self.actionClosePlaylist.triggered.connect(self.close_playlist_tab)
|
||||
self.actionDebug.triggered.connect(self.debug)
|
||||
self.actionDeletePlaylist.triggered.connect(self.delete_playlist)
|
||||
self.actionDownload_CSV_of_played_tracks.triggered.connect(
|
||||
self.download_played_tracks
|
||||
)
|
||||
self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
|
||||
self.actionFade.triggered.connect(self.fade)
|
||||
self.actionImport.triggered.connect(self.import_track)
|
||||
self.actionInsertSectionHeader.triggered.connect(self.insert_header)
|
||||
self.actionInsertTrack.triggered.connect(self.insert_track)
|
||||
self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving)
|
||||
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.actionNewPlaylist.triggered.connect(self.create_and_show_playlist)
|
||||
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
|
||||
self.actionPaste.triggered.connect(self.paste_rows)
|
||||
self.actionPlay_next.triggered.connect(self.play_next)
|
||||
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.actionSave_as_template.triggered.connect(self.save_as_template)
|
||||
self.actionSearch_title_in_Songfacts.triggered.connect(
|
||||
@ -482,10 +476,10 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.actionSelect_duplicate_rows.triggered.connect(
|
||||
lambda: self.active_tab().select_duplicate_rows()
|
||||
)
|
||||
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
|
||||
self.actionSetNext.triggered.connect(self.set_selected_track_next)
|
||||
self.actionSkipToNext.triggered.connect(self.play_next)
|
||||
self.actionStop.triggered.connect(self.stop)
|
||||
|
||||
self.btnDrop3db.clicked.connect(self.drop3db)
|
||||
self.btnFade.clicked.connect(self.fade)
|
||||
self.btnHidePlayed.clicked.connect(self.hide_played)
|
||||
@ -550,15 +544,20 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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(
|
||||
musicmuster=self,
|
||||
playlist_id=playlist.id,
|
||||
musicmuster=self, model=self.playlists[playlist.id].proxy_model
|
||||
)
|
||||
idx = self.tabPlaylist.addTab(playlist_tab, playlist.name)
|
||||
|
||||
@ -725,6 +724,13 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
if track_sequence.current:
|
||||
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):
|
||||
"""Toggle hide played tracks"""
|
||||
|
||||
@ -742,112 +748,22 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
# Reset row heights
|
||||
self.active_tab().resize_rows()
|
||||
|
||||
def import_track(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:
|
||||
def import_files_wrapper(self) -> None:
|
||||
"""
|
||||
Import the list of filenames as new tracks
|
||||
Pass import files call to file_importer module
|
||||
"""
|
||||
|
||||
# Import in separate thread
|
||||
self.import_thread = QThread()
|
||||
self.worker = ImportTrack(
|
||||
track_files,
|
||||
self.active_proxy_model(),
|
||||
# We need to keep a referent to the FileImporter else it will be
|
||||
# garbage collected while import threads are still running
|
||||
self.importer = FileImporter(
|
||||
self.get_active_base_model(),
|
||||
self.active_tab().source_model_selected_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 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
|
||||
self.importer.do_import()
|
||||
|
||||
def insert_header(self) -> None:
|
||||
"""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
|
||||
dlg: QInputDialog = QInputDialog(self)
|
||||
dlg.setInputMode(QInputDialog.InputMode.TextInput)
|
||||
@ -855,7 +771,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
dlg.resize(500, 100)
|
||||
ok = dlg.exec()
|
||||
if ok:
|
||||
proxy_model.insert_row(
|
||||
self.get_active_base_model().insert_row(
|
||||
proposed_row_number=self.active_tab().source_model_selected_row_number(),
|
||||
note=dlg.textValue(),
|
||||
)
|
||||
@ -872,7 +788,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
parent=self,
|
||||
session=session,
|
||||
new_row_number=new_row_number,
|
||||
source_model=self.active_proxy_model(),
|
||||
base_model=self.get_active_base_model(),
|
||||
)
|
||||
dlg.exec()
|
||||
session.commit()
|
||||
@ -884,9 +800,10 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
with db.Session() as session:
|
||||
for playlist in Playlists.get_open(session):
|
||||
if playlist:
|
||||
_ = self.create_playlist_tab(playlist)
|
||||
playlist_ids.append(playlist.id)
|
||||
log.debug(f"load_last_playlists() loaded {playlist=}")
|
||||
# Create tab
|
||||
playlist_ids.append(self.create_playlist_tab(playlist))
|
||||
|
||||
# Set active tab
|
||||
record = Settings.get_setting(session, "active_tab")
|
||||
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
|
||||
# paste
|
||||
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:
|
||||
"""
|
||||
@ -964,7 +883,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
to_row = 0
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
@ -994,7 +913,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
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:
|
||||
return
|
||||
# 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:
|
||||
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()
|
||||
if selected_rows:
|
||||
destination_row = selected_rows[0]
|
||||
@ -1094,10 +1013,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
):
|
||||
set_next_row = destination_row
|
||||
|
||||
if (
|
||||
to_playlist_model.playlist_id
|
||||
== self.move_source_model.source_model.playlist_id
|
||||
):
|
||||
if to_playlist_model.playlist_id == self.move_source_model.playlist_id:
|
||||
self.move_source_model.move_rows(self.move_source_rows, destination_row)
|
||||
else:
|
||||
self.move_source_model.move_rows_between_playlists(
|
||||
@ -1224,6 +1140,8 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
)
|
||||
else:
|
||||
return
|
||||
if not track_info:
|
||||
return
|
||||
self.preview_manager.set_track_info(track_info)
|
||||
self.preview_manager.play()
|
||||
else:
|
||||
@ -1256,6 +1174,8 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
if self.preview_manager.is_playing():
|
||||
track_id = self.preview_manager.track_id
|
||||
row_number = self.preview_manager.row_number
|
||||
if not row_number:
|
||||
return
|
||||
with db.Session() as session:
|
||||
track = session.get(Tracks, track_id)
|
||||
if track:
|
||||
@ -1266,8 +1186,8 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
track.intro = intro
|
||||
session.commit()
|
||||
self.preview_manager.set_intro(intro)
|
||||
self.active_tab().source_model.refresh_row(session, row_number)
|
||||
self.active_tab().source_model.invalidate_row(row_number)
|
||||
self.get_active_base_model().refresh_row(session, row_number)
|
||||
self.get_active_base_model().invalidate_row(row_number)
|
||||
|
||||
def preview_start(self) -> None:
|
||||
"""Restart preview"""
|
||||
@ -1306,79 +1226,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.tabBar.setTabText(idx, new_name)
|
||||
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:
|
||||
"""
|
||||
Check whether Return key has been pressed in error.
|
||||
@ -1527,22 +1374,12 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
if row_number is 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:
|
||||
return None
|
||||
|
||||
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:
|
||||
"""Set size of window from database"""
|
||||
|
||||
@ -1628,9 +1465,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
display_row = (
|
||||
self.active_proxy_model()
|
||||
.mapFromSource(
|
||||
self.active_proxy_model().source_model.index(
|
||||
playlist_track.row_number, 0
|
||||
)
|
||||
self.get_active_base_model().index(playlist_track.row_number, 0)
|
||||
)
|
||||
.row()
|
||||
)
|
||||
@ -1670,10 +1505,10 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
if track_sequence.current:
|
||||
track_sequence.current.stop()
|
||||
|
||||
def tab_change(self):
|
||||
def tab_change(self) -> None:
|
||||
"""Called when active tab changed"""
|
||||
|
||||
self.active_tab().resize_rows()
|
||||
self.active_tab().tab_live()
|
||||
|
||||
def tick_10ms(self) -> None:
|
||||
"""
|
||||
@ -1861,64 +1696,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
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 command line arguments given, carry out requested function and
|
||||
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from operator import attrgetter
|
||||
from random import shuffle
|
||||
from typing import Optional
|
||||
from typing import cast, Optional
|
||||
import datetime as dt
|
||||
import re
|
||||
|
||||
@ -35,8 +35,6 @@ import obswebsocket # type: ignore
|
||||
from classes import (
|
||||
Col,
|
||||
MusicMusterSignals,
|
||||
RowAndTrack,
|
||||
track_sequence,
|
||||
)
|
||||
from config import Config
|
||||
from helpers import (
|
||||
@ -50,6 +48,7 @@ from helpers import (
|
||||
)
|
||||
from log import log
|
||||
from models import db, NoteColours, Playdates, PlaylistRows, Tracks
|
||||
from music_manager import RowAndTrack, track_sequence
|
||||
|
||||
|
||||
HEADER_NOTES_COLUMN = 1
|
||||
@ -124,9 +123,12 @@ class PlaylistModel(QAbstractTableModel):
|
||||
for ts in [
|
||||
track_sequence.next,
|
||||
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
|
||||
else:
|
||||
continue # continue iterating over playlist_rows
|
||||
@ -983,6 +985,7 @@ class PlaylistModel(QAbstractTableModel):
|
||||
playlist_row.note += "\n" + note
|
||||
else:
|
||||
playlist_row.note = note
|
||||
self.refresh_row(session, playlist_row.row_number)
|
||||
session.commit()
|
||||
|
||||
# Carry out the move outside of the session context to ensure
|
||||
@ -1338,8 +1341,9 @@ class PlaylistModel(QAbstractTableModel):
|
||||
unplayed_count += 1
|
||||
duration += row_rat.duration
|
||||
|
||||
# Should never get here
|
||||
return f"Error calculating subtotal ({row_rat.note})"
|
||||
# We should only get here if there were no rows in section (ie,
|
||||
# this was row zero)
|
||||
return Config.SUBTOTAL_ON_ROW_ZERO
|
||||
|
||||
def selection_is_sortable(self, row_numbers: list[int]) -> bool:
|
||||
"""
|
||||
@ -1660,19 +1664,14 @@ class PlaylistProxyModel(QSortFilterProxyModel):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_model: PlaylistModel,
|
||||
*args: QObject,
|
||||
**kwargs: QObject,
|
||||
) -> None:
|
||||
self.source_model = source_model
|
||||
super().__init__(*args, **kwargs)
|
||||
super().__init__()
|
||||
|
||||
self.setSourceModel(source_model)
|
||||
# Search all columns
|
||||
self.setFilterKeyColumn(-1)
|
||||
|
||||
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:
|
||||
"""
|
||||
@ -1680,15 +1679,15 @@ class PlaylistProxyModel(QSortFilterProxyModel):
|
||||
"""
|
||||
|
||||
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.source_model.is_played_row(source_row):
|
||||
if self.sourceModel().played_tracks_hidden:
|
||||
if self.sourceModel().is_played_row(source_row):
|
||||
# Don't hide current track
|
||||
if (
|
||||
track_sequence.current
|
||||
and track_sequence.current.playlist_id
|
||||
== self.source_model.playlist_id
|
||||
== self.sourceModel().playlist_id
|
||||
and track_sequence.current.row_number == source_row
|
||||
):
|
||||
return True
|
||||
@ -1696,7 +1695,8 @@ class PlaylistProxyModel(QSortFilterProxyModel):
|
||||
# Don't hide next track
|
||||
if (
|
||||
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
|
||||
):
|
||||
return True
|
||||
@ -1705,7 +1705,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
|
||||
if track_sequence.previous:
|
||||
if (
|
||||
track_sequence.previous.playlist_id
|
||||
!= self.source_model.playlist_id
|
||||
!= self.sourceModel().playlist_id
|
||||
or track_sequence.previous.row_number != source_row
|
||||
):
|
||||
# This row isn't our previous track: hide it
|
||||
@ -1729,7 +1729,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
|
||||
# true next time through.
|
||||
QTimer.singleShot(
|
||||
Config.HIDE_AFTER_PLAYING_OFFSET + 100,
|
||||
lambda: self.source_model.invalidate_row(source_row),
|
||||
lambda: self.sourceModel().invalidate_row(source_row),
|
||||
)
|
||||
return True
|
||||
# Next track not playing yet so don't hide previous
|
||||
@ -1752,105 +1752,9 @@ class PlaylistProxyModel(QSortFilterProxyModel):
|
||||
)
|
||||
)
|
||||
|
||||
# ######################################
|
||||
# Forward functions not handled in proxy
|
||||
# ######################################
|
||||
def sourceModel(self) -> PlaylistModel:
|
||||
"""
|
||||
Override sourceModel to return correct type
|
||||
"""
|
||||
|
||||
def current_track_started(self):
|
||||
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()
|
||||
return cast(PlaylistModel, super().sourceModel())
|
||||
|
||||
210
app/playlists.py
210
app/playlists.py
@ -37,7 +37,7 @@ import line_profiler
|
||||
|
||||
# App imports
|
||||
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 dialogs import TrackSelectDialog
|
||||
from helpers import (
|
||||
@ -48,6 +48,7 @@ from helpers import (
|
||||
)
|
||||
from log import log
|
||||
from models import db, Settings
|
||||
from music_manager import track_sequence
|
||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -81,9 +82,9 @@ class PlaylistDelegate(QStyledItemDelegate):
|
||||
|
||||
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)
|
||||
self.source_model = source_model
|
||||
self.base_model = base_model
|
||||
self.signals = MusicMusterSignals()
|
||||
self.click_position = None
|
||||
self.current_editor: Optional[Any] = None
|
||||
@ -213,7 +214,13 @@ class PlaylistDelegate(QStyledItemDelegate):
|
||||
doc.setTextWidth(option.rect.width())
|
||||
doc.setDefaultFont(option.font)
|
||||
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 +++
|
||||
# Calculate sizes
|
||||
@ -238,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
||||
proxy_model = index.model()
|
||||
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
|
||||
)
|
||||
if index.column() == Col.INTRO.value:
|
||||
@ -255,7 +262,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
||||
value = editor.toPlainText().strip()
|
||||
elif isinstance(editor, QDoubleSpinBox):
|
||||
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):
|
||||
editor.setGeometry(option.rect)
|
||||
@ -284,22 +291,17 @@ class PlaylistTab(QTableView):
|
||||
The playlist view
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
musicmuster: "Window",
|
||||
playlist_id: int,
|
||||
) -> None:
|
||||
def __init__(self, musicmuster: "Window", model: PlaylistProxyModel) -> None:
|
||||
super().__init__()
|
||||
|
||||
# Save passed settings
|
||||
self.musicmuster = musicmuster
|
||||
self.playlist_id = playlist_id
|
||||
log.debug(f"PlaylistTab.__init__({playlist_id=})")
|
||||
self.musicmuster = (
|
||||
musicmuster # TODO: do we need to keep a reference to musicmuster?
|
||||
)
|
||||
self.playlist_id = model.sourceModel().playlist_id
|
||||
|
||||
# Set up widget
|
||||
self.source_model = PlaylistModel(playlist_id)
|
||||
self.proxy_model = PlaylistProxyModel(self.source_model)
|
||||
self.setItemDelegate(PlaylistDelegate(self, self.source_model))
|
||||
self.setItemDelegate(PlaylistDelegate(self, model.sourceModel()))
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
|
||||
@ -327,9 +329,8 @@ class PlaylistTab(QTableView):
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
|
||||
# Load playlist rows
|
||||
self.setModel(self.proxy_model)
|
||||
self._set_column_widths()
|
||||
# Singleton object to store selection
|
||||
self.selection = Selection()
|
||||
|
||||
# Set up for Audacity
|
||||
try:
|
||||
@ -338,6 +339,10 @@ class PlaylistTab(QTableView):
|
||||
self.ac = None
|
||||
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
|
||||
# *much* faster
|
||||
h_header = self.horizontalHeader()
|
||||
@ -372,7 +377,7 @@ class PlaylistTab(QTableView):
|
||||
|
||||
# Update start times in case a start time in a note has been
|
||||
# edited
|
||||
self.source_model.update_track_times()
|
||||
self.get_base_model().update_track_times()
|
||||
|
||||
# Deselect edited line
|
||||
self.clear_selection()
|
||||
@ -381,36 +386,50 @@ class PlaylistTab(QTableView):
|
||||
def dropEvent(
|
||||
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
|
||||
) -> None:
|
||||
"""
|
||||
Move dropped rows
|
||||
"""
|
||||
|
||||
if not event:
|
||||
return
|
||||
|
||||
if event.source() is not self or (
|
||||
event.dropAction() != Qt.DropAction.MoveAction
|
||||
and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove
|
||||
):
|
||||
super().dropEvent(event)
|
||||
return super().dropEvent(event)
|
||||
|
||||
from_rows = self.selected_model_row_numbers()
|
||||
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 (
|
||||
self.dropIndicatorPosition()
|
||||
== QAbstractItemView.DropIndicatorPosition.BelowItem
|
||||
):
|
||||
proxy_index = self.proxy_model.createIndex(
|
||||
to_index.row() + 1,
|
||||
to_index.column(),
|
||||
to_index.internalId(),
|
||||
)
|
||||
# Drop on the row below
|
||||
next_row = to_index.row() + 1
|
||||
if next_row < self.model().rowCount(): # Ensure the row exists
|
||||
destination_index = to_index.siblingAtRow(next_row)
|
||||
else:
|
||||
# Handle edge case where next_row is beyond the last row
|
||||
destination_index = to_index
|
||||
else:
|
||||
proxy_index = to_index
|
||||
to_model_row = self.proxy_model.mapToSource(proxy_index).row()
|
||||
destination_index = to_index
|
||||
|
||||
to_model_row = self.model().mapToSource(destination_index).row()
|
||||
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 (
|
||||
0 <= min(from_rows) <= self.source_model.rowCount()
|
||||
and 0 <= max(from_rows) <= self.source_model.rowCount()
|
||||
and 0 <= to_model_row <= self.source_model.rowCount()
|
||||
0 <= min(from_rows) <= base_model_row_count
|
||||
and 0 <= to_model_row <= base_model_row_count
|
||||
):
|
||||
# If we move a row to immediately under the current track, make
|
||||
# that moved row the next track
|
||||
@ -421,7 +440,7 @@ class PlaylistTab(QTableView):
|
||||
):
|
||||
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
|
||||
self.setDragEnabled(False)
|
||||
@ -434,7 +453,7 @@ class PlaylistTab(QTableView):
|
||||
|
||||
# Set next row if we are immediately under current 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()
|
||||
|
||||
@ -468,12 +487,14 @@ class PlaylistTab(QTableView):
|
||||
"""
|
||||
|
||||
selected_rows = self.get_selected_rows()
|
||||
self.selection.rows = selected_rows
|
||||
|
||||
# If no rows are selected, we have nothing to do
|
||||
if len(selected_rows) == 0:
|
||||
self.musicmuster.lblSumPlaytime.setText("")
|
||||
else:
|
||||
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()
|
||||
)
|
||||
if selected_duration > 0:
|
||||
@ -524,7 +545,7 @@ class PlaylistTab(QTableView):
|
||||
parent=self.musicmuster,
|
||||
session=session,
|
||||
new_row_number=model_row_number,
|
||||
source_model=self.source_model,
|
||||
base_model=self.get_base_model(),
|
||||
add_to_header=True,
|
||||
)
|
||||
dlg.exec()
|
||||
@ -534,12 +555,12 @@ class PlaylistTab(QTableView):
|
||||
"""Used to process context (right-click) menu, which is defined here"""
|
||||
|
||||
self.menu.clear()
|
||||
proxy_model = self.proxy_model
|
||||
|
||||
index = proxy_model.index(item.row(), item.column())
|
||||
model_row_number = proxy_model.mapToSource(index).row()
|
||||
index = self.model().index(item.row(), item.column())
|
||||
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
|
||||
if track_sequence.current:
|
||||
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
|
||||
else:
|
||||
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
|
||||
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:
|
||||
self._add_context_menu(
|
||||
"Remove track from row",
|
||||
lambda: proxy_model.remove_track(model_row_number),
|
||||
lambda: base_model.remove_track(model_row_number),
|
||||
)
|
||||
|
||||
# Remove comments
|
||||
@ -604,7 +625,7 @@ class PlaylistTab(QTableView):
|
||||
self.menu.addSeparator()
|
||||
|
||||
# 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(
|
||||
"Mark unplayed",
|
||||
lambda: self._mark_as_unplayed(self.get_selected_rows()),
|
||||
@ -623,27 +644,27 @@ class PlaylistTab(QTableView):
|
||||
sort_menu = self.menu.addMenu("Sort")
|
||||
self._add_context_menu(
|
||||
"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,
|
||||
)
|
||||
self._add_context_menu(
|
||||
"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,
|
||||
)
|
||||
self._add_context_menu(
|
||||
"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,
|
||||
)
|
||||
self._add_context_menu(
|
||||
"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,
|
||||
)
|
||||
self._add_context_menu(
|
||||
"randomly",
|
||||
lambda: proxy_model.sort_randomly(self.get_selected_rows()),
|
||||
lambda: base_model.sort_randomly(self.get_selected_rows()),
|
||||
parent_menu=sort_menu,
|
||||
)
|
||||
|
||||
@ -663,7 +684,8 @@ class PlaylistTab(QTableView):
|
||||
that we have an edit open.
|
||||
"""
|
||||
|
||||
self.ac.path = None
|
||||
if self.ac:
|
||||
self.ac.path = None
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
"""Unselect all tracks and reset drag mode"""
|
||||
@ -709,7 +731,7 @@ class PlaylistTab(QTableView):
|
||||
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:
|
||||
return
|
||||
|
||||
@ -732,7 +754,7 @@ class PlaylistTab(QTableView):
|
||||
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
|
||||
if (
|
||||
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}?"):
|
||||
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()
|
||||
|
||||
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]:
|
||||
"""
|
||||
Return the track_id and row number of the selected
|
||||
@ -778,11 +809,13 @@ class PlaylistTab(QTableView):
|
||||
if selected_row is None:
|
||||
return None
|
||||
|
||||
base_model = self.get_base_model()
|
||||
model_row_number = self.source_model_selected_row_number()
|
||||
|
||||
if model_row_number is None:
|
||||
return None
|
||||
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:
|
||||
return None
|
||||
else:
|
||||
@ -806,12 +839,7 @@ class PlaylistTab(QTableView):
|
||||
# items in that row selected)
|
||||
result = sorted(
|
||||
list(
|
||||
set(
|
||||
[
|
||||
self.proxy_model.mapToSource(a).row()
|
||||
for a in self.selectedIndexes()
|
||||
]
|
||||
)
|
||||
set([self.model().mapToSource(a).row() for a in self.selectedIndexes()])
|
||||
)
|
||||
)
|
||||
|
||||
@ -823,7 +851,7 @@ class PlaylistTab(QTableView):
|
||||
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:
|
||||
"""
|
||||
@ -842,7 +870,7 @@ class PlaylistTab(QTableView):
|
||||
def _info_row(self, row_number: int) -> None:
|
||||
"""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:
|
||||
txt = (
|
||||
f"Title: {prd.title}\n"
|
||||
@ -861,7 +889,7 @@ class PlaylistTab(QTableView):
|
||||
def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
|
||||
"""Mark row as unplayed"""
|
||||
|
||||
self.source_model.mark_unplayed(row_numbers)
|
||||
self.get_base_model().mark_unplayed(row_numbers)
|
||||
self.clear_selection()
|
||||
|
||||
def _mark_for_moving(self) -> None:
|
||||
@ -871,6 +899,13 @@ class PlaylistTab(QTableView):
|
||||
|
||||
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:
|
||||
"""
|
||||
Move selected rows here
|
||||
@ -883,7 +918,7 @@ class PlaylistTab(QTableView):
|
||||
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:
|
||||
log.error(f"_open_in_audacity: can't get path for {row_number=}")
|
||||
return
|
||||
@ -901,7 +936,7 @@ class PlaylistTab(QTableView):
|
||||
"""
|
||||
|
||||
# Let the model know
|
||||
self.source_model.previous_track_ended()
|
||||
self.get_base_model().previous_track_ended()
|
||||
|
||||
def _remove_comments(self) -> None:
|
||||
"""
|
||||
@ -912,12 +947,12 @@ class PlaylistTab(QTableView):
|
||||
if not row_numbers:
|
||||
return
|
||||
|
||||
self.source_model.remove_comments(row_numbers)
|
||||
self.get_base_model().remove_comments(row_numbers)
|
||||
|
||||
def _rescan(self, row_number: int) -> None:
|
||||
"""Rescan track"""
|
||||
|
||||
self.source_model.rescan_track(row_number)
|
||||
self.get_base_model().rescan_track(row_number)
|
||||
self.clear_selection()
|
||||
|
||||
def resize_rows(self, playlist_id: Optional[int] = None) -> None:
|
||||
@ -932,7 +967,7 @@ class PlaylistTab(QTableView):
|
||||
|
||||
# Suggestion from phind.com
|
||||
def resize_row(row, count=1):
|
||||
row_count = self.source_model.rowCount()
|
||||
row_count = self.model().rowCount()
|
||||
for todo in range(count):
|
||||
if row < row_count:
|
||||
self.resizeRowToContents(row)
|
||||
@ -941,7 +976,7 @@ class PlaylistTab(QTableView):
|
||||
QTimer.singleShot(0, lambda: resize_row(row, count))
|
||||
|
||||
# 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:
|
||||
"""
|
||||
@ -951,7 +986,7 @@ class PlaylistTab(QTableView):
|
||||
if row_number is None:
|
||||
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)
|
||||
|
||||
def select_duplicate_rows(self) -> None:
|
||||
@ -966,7 +1001,7 @@ class PlaylistTab(QTableView):
|
||||
# We need to be in MultiSelection mode
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
|
||||
# Get the duplicate rows
|
||||
duplicate_rows = self.source_model.get_duplicate_rows()
|
||||
duplicate_rows = self.get_base_model().get_duplicate_rows()
|
||||
# Select the rows
|
||||
for duplicate_row in duplicate_rows:
|
||||
self.selectRow(duplicate_row)
|
||||
@ -981,7 +1016,7 @@ class PlaylistTab(QTableView):
|
||||
selected_index = self._selected_row_index()
|
||||
if selected_index is 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]:
|
||||
"""
|
||||
@ -992,9 +1027,8 @@ class PlaylistTab(QTableView):
|
||||
selected_indexes = self._selected_row_indexes()
|
||||
if selected_indexes is None:
|
||||
return []
|
||||
if hasattr(self.proxy_model, "mapToSource"):
|
||||
return [self.proxy_model.mapToSource(a).row() for a in selected_indexes]
|
||||
return [a.row() for a in selected_indexes]
|
||||
|
||||
return [self.model().mapToSource(a).row() for a in selected_indexes]
|
||||
|
||||
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=}")
|
||||
if model_row_number is None:
|
||||
return
|
||||
self.source_model.set_next_row(model_row_number)
|
||||
self.get_base_model().set_next_row(model_row_number)
|
||||
self.clearSelection()
|
||||
|
||||
def _span_cells(
|
||||
@ -1059,17 +1093,19 @@ class PlaylistTab(QTableView):
|
||||
) -> None:
|
||||
"""
|
||||
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:
|
||||
return
|
||||
|
||||
proxy_model = self.proxy_model
|
||||
edit_index = proxy_model.mapFromSource(
|
||||
self.source_model.createIndex(row, column)
|
||||
)
|
||||
row = edit_index.row()
|
||||
column = edit_index.column()
|
||||
base_model = self.get_base_model()
|
||||
|
||||
cell_index = self.model().mapFromSource(base_model.createIndex(row, column))
|
||||
row = cell_index.row()
|
||||
column = cell_index.column()
|
||||
|
||||
# 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
|
||||
@ -1082,6 +1118,16 @@ class PlaylistTab(QTableView):
|
||||
|
||||
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:
|
||||
"""Rescan track"""
|
||||
|
||||
|
||||
@ -967,70 +967,64 @@ padding-left: 8px;</string>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>&Playlists</string>
|
||||
<string>&Playlist</string>
|
||||
</property>
|
||||
<addaction name="actionNewPlaylist"/>
|
||||
<addaction name="actionNew_from_template"/>
|
||||
<addaction name="separator"/>
|
||||
<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>&File</string>
|
||||
</property>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionOpenPlaylist"/>
|
||||
<addaction name="actionNewPlaylist"/>
|
||||
<addaction name="actionClosePlaylist"/>
|
||||
<addaction name="actionRenamePlaylist"/>
|
||||
<addaction name="actionDeletePlaylist"/>
|
||||
<addaction name="actionExport_playlist"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSelect_duplicate_rows"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionMoveSelected"/>
|
||||
<addaction name="actionMoveUnplayed"/>
|
||||
<addaction name="actionDownload_CSV_of_played_tracks"/>
|
||||
<addaction name="actionNew_from_template"/>
|
||||
<addaction name="actionSave_as_template"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionReplace_files"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionDebug"/>
|
||||
<addaction name="action_About"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionE_xit"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuPlaylist">
|
||||
<widget class="QMenu" name="menuSearc_h">
|
||||
<property name="title">
|
||||
<string>Sho&wtime</string>
|
||||
<string>&Music</string>
|
||||
</property>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSetNext"/>
|
||||
<addaction name="actionPlay_next"/>
|
||||
<addaction name="actionFade"/>
|
||||
<addaction name="actionStop"/>
|
||||
<addaction name="actionResume"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSkipToNext"/>
|
||||
<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>&Search</string>
|
||||
</property>
|
||||
<addaction name="actionSearch"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSearch_title_in_Wikipedia"/>
|
||||
<addaction name="actionSearch_title_in_Songfacts"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>&Help</string>
|
||||
</property>
|
||||
<addaction name="action_About"/>
|
||||
<addaction name="actionDebug"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuPlaylist"/>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuSearc_h"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar">
|
||||
<property name="enabled">
|
||||
|
||||
@ -495,8 +495,6 @@ class Ui_MainWindow(object):
|
||||
self.menuPlaylist.setObjectName("menuPlaylist")
|
||||
self.menuSearc_h = QtWidgets.QMenu(parent=self.menubar)
|
||||
self.menuSearc_h.setObjectName("menuSearc_h")
|
||||
self.menuHelp = QtWidgets.QMenu(parent=self.menubar)
|
||||
self.menuHelp.setObjectName("menuHelp")
|
||||
MainWindow.setMenuBar(self.menubar)
|
||||
self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
|
||||
self.statusbar.setEnabled(True)
|
||||
@ -657,52 +655,51 @@ class Ui_MainWindow(object):
|
||||
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
|
||||
self.actionReplace_files = QtGui.QAction(parent=MainWindow)
|
||||
self.actionReplace_files.setObjectName("actionReplace_files")
|
||||
self.menuFile.addAction(self.actionNewPlaylist)
|
||||
self.menuFile.addAction(self.actionNew_from_template)
|
||||
self.menuFile.addAction(self.actionOpenPlaylist)
|
||||
self.menuFile.addAction(self.actionClosePlaylist)
|
||||
self.menuFile.addAction(self.actionRenamePlaylist)
|
||||
self.menuFile.addAction(self.actionDeletePlaylist)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionInsertTrack)
|
||||
self.menuFile.addAction(self.actionRemove)
|
||||
self.menuFile.addAction(self.actionInsertSectionHeader)
|
||||
self.menuFile.addSeparator()
|
||||
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.actionDownload_CSV_of_played_tracks)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionSelect_duplicate_rows)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionMoveSelected)
|
||||
self.menuFile.addAction(self.actionMoveUnplayed)
|
||||
self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks)
|
||||
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.menuFile.addAction(self.action_Clear_selection)
|
||||
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.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.addAction(self.actionInsertSectionHeader)
|
||||
self.menuPlaylist.addAction(self.actionInsertTrack)
|
||||
self.menuPlaylist.addAction(self.actionRemove)
|
||||
self.menuPlaylist.addAction(self.actionImport)
|
||||
self.menuPlaylist.addAction(self.actionNew_from_template)
|
||||
self.menuPlaylist.addAction(self.actionSave_as_template)
|
||||
self.menuPlaylist.addSeparator()
|
||||
self.menuPlaylist.addAction(self.actionSetNext)
|
||||
self.menuPlaylist.addAction(self.action_Clear_selection)
|
||||
self.menuPlaylist.addAction(self.actionReplace_files)
|
||||
self.menuPlaylist.addSeparator()
|
||||
self.menuPlaylist.addAction(self.actionMark_for_moving)
|
||||
self.menuPlaylist.addAction(self.actionPaste)
|
||||
self.menuSearc_h.addAction(self.actionSearch)
|
||||
self.menuPlaylist.addAction(self.actionDebug)
|
||||
self.menuPlaylist.addAction(self.action_About)
|
||||
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.addAction(self.actionSearch)
|
||||
self.menuSearc_h.addAction(self.actionSearch_title_in_Wikipedia)
|
||||
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.menuFile.menuAction())
|
||||
self.menubar.addAction(self.menuSearc_h.menuAction())
|
||||
self.menubar.addAction(self.menuHelp.menuAction())
|
||||
|
||||
self.retranslateUi(MainWindow)
|
||||
self.tabPlaylist.setCurrentIndex(-1)
|
||||
@ -733,10 +730,9 @@ class Ui_MainWindow(object):
|
||||
self.label_silent_timer.setText(_translate("MainWindow", "00:00"))
|
||||
self.btnFade.setText(_translate("MainWindow", " Fade"))
|
||||
self.btnStop.setText(_translate("MainWindow", " Stop"))
|
||||
self.menuFile.setTitle(_translate("MainWindow", "&Playlists"))
|
||||
self.menuPlaylist.setTitle(_translate("MainWindow", "Sho&wtime"))
|
||||
self.menuSearc_h.setTitle(_translate("MainWindow", "&Search"))
|
||||
self.menuHelp.setTitle(_translate("MainWindow", "&Help"))
|
||||
self.menuFile.setTitle(_translate("MainWindow", "&Playlist"))
|
||||
self.menuPlaylist.setTitle(_translate("MainWindow", "&File"))
|
||||
self.menuSearc_h.setTitle(_translate("MainWindow", "&Music"))
|
||||
self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
|
||||
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
|
||||
self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next"))
|
||||
|
||||
@ -92,6 +92,6 @@ def update_bitrates(session: Session) -> None:
|
||||
for track in Tracks.get_all(session):
|
||||
try:
|
||||
t = get_tags(track.path)
|
||||
track.bitrate = t["bitrate"]
|
||||
track.bitrate = t.bitrate
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
@ -22,8 +22,9 @@ def fade_point(audio_segment, fade_threshold=-12, chunk_size=10):
|
||||
print(f"{max_vol=}")
|
||||
fade_threshold = max_vol
|
||||
while (
|
||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
|
||||
and trim_ms > 0): # noqa W503
|
||||
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
|
||||
and trim_ms > 0
|
||||
): # noqa W503
|
||||
trim_ms -= chunk_size
|
||||
|
||||
# if there is no trailing silence, return lenght of track (it's less
|
||||
|
||||
125
archive/proxymodel.py
Executable file
125
archive/proxymodel.py
Executable 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())
|
||||
|
||||
46
migrations/versions/164bd5ef3074_remove_mtime_from_tracks.py
Normal file
46
migrations/versions/164bd5ef3074_remove_mtime_from_tracks.py
Normal 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
230
poetry.lock
generated
@ -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"]
|
||||
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]]
|
||||
name = "greenlet"
|
||||
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)"]
|
||||
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]]
|
||||
name = "line-profiler"
|
||||
version = "4.1.3"
|
||||
@ -1388,6 +1502,20 @@ pytest = "*"
|
||||
dev = ["pre-commit", "tox"]
|
||||
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]]
|
||||
name = "python-slugify"
|
||||
version = "8.0.4"
|
||||
@ -1416,6 +1544,106 @@ files = [
|
||||
{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]]
|
||||
name = "rich"
|
||||
version = "13.9.4"
|
||||
@ -1745,4 +1973,4 @@ test = ["websockets"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "f1b96a77f00820c0db315ba2db9f40aa918a47770317ce54efaa670eea41e83d"
|
||||
content-hash = "6a887314789a17a0d0875f1c7e6ce169c90142164de98e386131a7836e3db3b5"
|
||||
|
||||
@ -25,6 +25,8 @@ obs-websocket-py = "^1.0"
|
||||
pygame = "^2.6.1"
|
||||
psutil = "^6.1.0"
|
||||
pyqt6-webengine = "^6.7.0"
|
||||
fuzzywuzzy = "^0.18.0"
|
||||
python-levenshtein = "^0.26.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
ipdb = "^0.13.9"
|
||||
|
||||
@ -55,8 +55,8 @@ class TestMMHelpers(unittest.TestCase):
|
||||
with open(test_track_data) as f:
|
||||
testdata = eval(f.read())
|
||||
|
||||
assert tags["artist"] == testdata["artist"]
|
||||
assert tags["title"] == testdata["title"]
|
||||
assert tags.artist == testdata["artist"]
|
||||
assert tags.title == testdata["title"]
|
||||
|
||||
def test_get_relative_date(self):
|
||||
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)
|
||||
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
|
||||
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)
|
||||
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):
|
||||
test_track_path = "testdata/isa.mp3"
|
||||
|
||||
@ -63,7 +63,6 @@ class MyTestCase(unittest.TestCase):
|
||||
"start_gap": 60,
|
||||
"fade_at": 236263,
|
||||
"silence_at": 260343,
|
||||
"mtime": 371900000,
|
||||
},
|
||||
2: {
|
||||
"path": "testdata/mom.mp3",
|
||||
@ -74,7 +73,6 @@ class MyTestCase(unittest.TestCase):
|
||||
"start_gap": 70,
|
||||
"fade_at": 115000,
|
||||
"silence_at": 118000,
|
||||
"mtime": 1642760000,
|
||||
},
|
||||
}
|
||||
|
||||
@ -82,7 +80,7 @@ class MyTestCase(unittest.TestCase):
|
||||
for track in self.tracks.values():
|
||||
db_track = Tracks(session=session, **track)
|
||||
session.add(db_track)
|
||||
track['id'] = db_track.id
|
||||
track["id"] = db_track.id
|
||||
|
||||
session.commit()
|
||||
|
||||
@ -136,12 +134,13 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
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:
|
||||
utilities.check_db(session)
|
||||
utilities.update_bitrates(session)
|
||||
|
||||
|
||||
# def test_meta_all_clear(qtbot, session):
|
||||
# # Create playlist
|
||||
# playlist = models.Playlists(session, "my playlist")
|
||||
|
||||
16
web.py
16
web.py
@ -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())
|
||||
Loading…
Reference in New Issue
Block a user