Compare commits
33 Commits
master
...
fix_tracks
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f22671e38 | ||
|
|
a160473d9e | ||
|
|
f9c060b091 | ||
|
|
b0385789be | ||
|
|
e03e64542f | ||
|
|
83f5ad5303 | ||
|
|
f9c8541b17 | ||
|
|
5e492f4569 | ||
|
|
f3b1e05e83 | ||
|
|
e71f1d072f | ||
|
|
c182a69a5d | ||
|
|
e39518e5ee | ||
|
|
4eaab98745 | ||
|
|
2fce0b34be | ||
|
|
ed7ac0758c | ||
|
|
098ce7198e | ||
|
|
38b166737b | ||
|
|
0ea12eb9d9 | ||
|
|
75cc7a3f19 | ||
|
|
f64671d126 | ||
|
|
2bf1bc64e7 | ||
|
|
3c7fc20e5a | ||
|
|
52d2269ece | ||
|
|
3cd764c893 | ||
|
|
65878b0b75 | ||
|
|
4e89d72a8f | ||
|
|
92ecb632b5 | ||
|
|
6296566c2c | ||
|
|
7e5b170f5e | ||
|
|
3db71a08ae | ||
|
|
7b0e2b2c6c | ||
|
|
4265472d73 | ||
|
|
c94cadf24f |
133
app/classes.py
133
app/classes.py
@ -1,7 +1,7 @@
|
||||
# Standard library imports
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
from enum import auto, Enum
|
||||
import functools
|
||||
import threading
|
||||
@ -46,6 +46,55 @@ def singleton(cls):
|
||||
return wrapper_singleton
|
||||
|
||||
|
||||
# DTOs
|
||||
@dataclass
|
||||
class PlaylistDTO:
|
||||
playlist_id: int
|
||||
name: str
|
||||
open: bool = False
|
||||
favourite: bool = False
|
||||
is_template: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryDTO:
|
||||
query_id: int
|
||||
name: str
|
||||
favourite: bool
|
||||
filter: Filter
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackDTO:
|
||||
track_id: int
|
||||
artist: str
|
||||
bitrate: int
|
||||
duration: int
|
||||
fade_at: int
|
||||
intro: int | None
|
||||
path: str
|
||||
silence_at: int
|
||||
start_gap: int
|
||||
title: str
|
||||
lastplayed: dt.datetime | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistRowDTO:
|
||||
note: str
|
||||
played: bool
|
||||
playlist_id: int
|
||||
playlistrow_id: int
|
||||
row_number: int
|
||||
track: TrackDTO | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaydatesDTO(TrackDTO):
|
||||
playdate_id: int
|
||||
lastplayed: dt.datetime
|
||||
|
||||
|
||||
class ApplicationError(Exception):
|
||||
"""
|
||||
Custom exception
|
||||
@ -91,31 +140,6 @@ class Filter:
|
||||
duration_unit: str = "minutes"
|
||||
|
||||
|
||||
@singleton
|
||||
@dataclass
|
||||
class MusicMusterSignals(QObject):
|
||||
"""
|
||||
Class for all MusicMuster signals. See:
|
||||
- https://zetcode.com/gui/pyqt5/eventssignals/
|
||||
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
|
||||
"""
|
||||
|
||||
begin_reset_model_signal = pyqtSignal(int)
|
||||
enable_escape_signal = pyqtSignal(bool)
|
||||
end_reset_model_signal = pyqtSignal(int)
|
||||
next_track_changed_signal = pyqtSignal()
|
||||
resize_rows_signal = pyqtSignal(int)
|
||||
search_songfacts_signal = pyqtSignal(str)
|
||||
search_wikipedia_signal = pyqtSignal(str)
|
||||
show_warning_signal = pyqtSignal(str, str)
|
||||
span_cells_signal = pyqtSignal(int, int, int, int, int)
|
||||
status_message_signal = pyqtSignal(str, int)
|
||||
track_ended_signal = pyqtSignal()
|
||||
|
||||
def __post_init__(self):
|
||||
super().__init__()
|
||||
|
||||
|
||||
class PlaylistStyle(QProxyStyle):
|
||||
def drawPrimitive(self, element, option, painter, widget=None):
|
||||
"""
|
||||
@ -152,3 +176,60 @@ class Tags(NamedTuple):
|
||||
class TrackInfo(NamedTuple):
|
||||
track_id: int
|
||||
row_number: int
|
||||
|
||||
|
||||
# Classes for signals
|
||||
@dataclass
|
||||
class InsertRows:
|
||||
playlist_id: int
|
||||
from_row: int
|
||||
to_row: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InsertTrack:
|
||||
playlist_id: int
|
||||
track_id: int | None
|
||||
note: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayTrack:
|
||||
playlist_id: int
|
||||
track_id: int
|
||||
|
||||
|
||||
@singleton
|
||||
@dataclass
|
||||
class MusicMusterSignals(QObject):
|
||||
"""
|
||||
Class for all MusicMuster signals. See:
|
||||
- https://zetcode.com/gui/pyqt5/eventssignals/
|
||||
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
|
||||
"""
|
||||
|
||||
begin_reset_model_signal = pyqtSignal(int)
|
||||
enable_escape_signal = pyqtSignal(bool)
|
||||
end_reset_model_signal = pyqtSignal(int)
|
||||
next_track_changed_signal = pyqtSignal()
|
||||
resize_rows_signal = pyqtSignal(int)
|
||||
search_songfacts_signal = pyqtSignal(str)
|
||||
search_wikipedia_signal = pyqtSignal(str)
|
||||
show_warning_signal = pyqtSignal(str, str)
|
||||
signal_add_track_to_header = pyqtSignal(InsertTrack)
|
||||
signal_begin_insert_rows = pyqtSignal(InsertRows)
|
||||
signal_end_insert_rows = pyqtSignal(int)
|
||||
signal_insert_track = pyqtSignal(InsertTrack)
|
||||
signal_playlist_selected_rows = pyqtSignal(int, list)
|
||||
signal_set_next_row = pyqtSignal(int)
|
||||
# signal_set_next_track takes a PlaylistRow as an argument. We can't
|
||||
# specify that here as it requires us to import PlaylistRow from
|
||||
# playlistrow.py, which itself imports MusicMusterSignals
|
||||
signal_set_next_track = pyqtSignal(object)
|
||||
signal_track_started = pyqtSignal(PlayTrack)
|
||||
span_cells_signal = pyqtSignal(int, int, int, int, int)
|
||||
status_message_signal = pyqtSignal(str, int)
|
||||
track_ended_signal = pyqtSignal()
|
||||
|
||||
def __post_init__(self):
|
||||
super().__init__()
|
||||
|
||||
@ -34,6 +34,7 @@ class Config(object):
|
||||
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
|
||||
COLOUR_UNREADABLE = "#dc3545"
|
||||
COLOUR_WARNING_TIMER = "#ffc107"
|
||||
DB_NOT_FOUND = "Database not found"
|
||||
DBFS_SILENCE = -50
|
||||
DEFAULT_COLUMN_WIDTH = 200
|
||||
DISPLAY_SQL = False
|
||||
@ -112,6 +113,8 @@ class Config(object):
|
||||
PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png"
|
||||
PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png"
|
||||
PLAYLIST_ICON_TEMPLATE = ":/icons/redstar.png"
|
||||
PLAYLIST_PENDING_MOVE = -1
|
||||
PLAYLIST_FAILED_MOVE = -2
|
||||
PREVIEW_ADVANCE_MS = 5000
|
||||
PREVIEW_BACK_MS = 5000
|
||||
PREVIEW_END_BUFFER_MS = 1000
|
||||
|
||||
296
app/dialogs.py
296
app/dialogs.py
@ -2,230 +2,168 @@
|
||||
from typing import Optional
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import QEvent, Qt
|
||||
from PyQt6.QtGui import QKeyEvent
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
# Third party imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
from classes import MusicMusterSignals
|
||||
from classes import ApplicationError, InsertTrack, MusicMusterSignals
|
||||
from helpers import (
|
||||
ask_yes_no,
|
||||
get_relative_date,
|
||||
ms_to_mmss,
|
||||
)
|
||||
from log import log
|
||||
from models import Settings, Tracks
|
||||
from playlistmodel import PlaylistModel
|
||||
from ui import dlg_TrackSelect_ui
|
||||
import repository
|
||||
|
||||
|
||||
class TrackSelectDialog(QDialog):
|
||||
"""Select track from database"""
|
||||
|
||||
class TrackInsertDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QMainWindow,
|
||||
session: Session,
|
||||
new_row_number: int,
|
||||
base_model: PlaylistModel,
|
||||
playlist_id: int,
|
||||
add_to_header: Optional[bool] = False,
|
||||
*args: Qt.WindowType,
|
||||
**kwargs: Qt.WindowType,
|
||||
) -> None:
|
||||
"""
|
||||
Subclassed QDialog to manage track selection
|
||||
"""
|
||||
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
self.session = session
|
||||
self.new_row_number = new_row_number
|
||||
self.base_model = base_model
|
||||
super().__init__(parent)
|
||||
self.playlist_id = playlist_id
|
||||
self.add_to_header = add_to_header
|
||||
self.ui = dlg_TrackSelect_ui.Ui_Dialog()
|
||||
self.ui.setupUi(self)
|
||||
self.ui.btnAdd.clicked.connect(self.add_selected)
|
||||
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
|
||||
self.ui.btnClose.clicked.connect(self.close)
|
||||
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
|
||||
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
|
||||
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
|
||||
self.ui.searchString.textEdited.connect(self.chars_typed)
|
||||
self.track: Optional[Tracks] = None
|
||||
self.signals = MusicMusterSignals()
|
||||
self.setWindowTitle("Insert Track")
|
||||
|
||||
record = Settings.get_setting(self.session, "dbdialog_width")
|
||||
width = record.f_int or 800
|
||||
record = Settings.get_setting(self.session, "dbdialog_height")
|
||||
height = record.f_int or 600
|
||||
# Title input on one line
|
||||
self.title_label = QLabel("Title:")
|
||||
self.title_edit = QLineEdit()
|
||||
self.title_edit.textChanged.connect(self.update_list)
|
||||
|
||||
title_layout = QHBoxLayout()
|
||||
title_layout.addWidget(self.title_label)
|
||||
title_layout.addWidget(self.title_edit)
|
||||
|
||||
# Track list
|
||||
self.track_list = QListWidget()
|
||||
self.track_list.itemDoubleClicked.connect(self.add_clicked)
|
||||
self.track_list.itemSelectionChanged.connect(self.selection_changed)
|
||||
|
||||
# Note input on one line
|
||||
self.note_label = QLabel("Note:")
|
||||
self.note_edit = QLineEdit()
|
||||
|
||||
note_layout = QHBoxLayout()
|
||||
note_layout.addWidget(self.note_label)
|
||||
note_layout.addWidget(self.note_edit)
|
||||
|
||||
# Track path
|
||||
self.path = QLabel()
|
||||
path_layout = QHBoxLayout()
|
||||
path_layout.addWidget(self.path)
|
||||
|
||||
# Buttons
|
||||
self.add_btn = QPushButton("Add")
|
||||
self.add_close_btn = QPushButton("Add and close")
|
||||
self.close_btn = QPushButton("Close")
|
||||
|
||||
self.add_btn.clicked.connect(self.add_clicked)
|
||||
self.add_close_btn.clicked.connect(self.add_and_close_clicked)
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addWidget(self.add_btn)
|
||||
btn_layout.addWidget(self.add_close_btn)
|
||||
btn_layout.addWidget(self.close_btn)
|
||||
|
||||
# Main layout
|
||||
layout = QVBoxLayout()
|
||||
layout.addLayout(title_layout)
|
||||
layout.addWidget(self.track_list)
|
||||
layout.addLayout(note_layout)
|
||||
layout.addLayout(path_layout)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
self.resize(800, 600)
|
||||
|
||||
width = repository.get_setting("dbdialog_width") or 800
|
||||
height = repository.get_setting("dbdialog_height") or 800
|
||||
self.resize(width, height)
|
||||
|
||||
if add_to_header:
|
||||
self.ui.lblNote.setVisible(False)
|
||||
self.ui.txtNote.setVisible(False)
|
||||
self.signals = MusicMusterSignals()
|
||||
|
||||
def add_selected(self) -> None:
|
||||
"""Handle Add button"""
|
||||
|
||||
track = None
|
||||
|
||||
if self.ui.matchList.selectedItems():
|
||||
item = self.ui.matchList.currentItem()
|
||||
if item:
|
||||
track = item.data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
note = self.ui.txtNote.text()
|
||||
|
||||
if not (track or note):
|
||||
def update_list(self, text: str) -> None:
|
||||
self.track_list.clear()
|
||||
if text.strip() == "":
|
||||
# Do not search or populate list if input is empty
|
||||
return
|
||||
|
||||
track_id = None
|
||||
if track:
|
||||
track_id = track.id
|
||||
if text.startswith("a/") and len(text) > 2:
|
||||
self.tracks = repository.tracks_by_artist(text[2:])
|
||||
else:
|
||||
self.tracks = repository.tracks_by_title(text)
|
||||
|
||||
if note and not track_id:
|
||||
self.base_model.insert_row(self.new_row_number, track_id, note)
|
||||
self.ui.txtNote.clear()
|
||||
self.new_row_number += 1
|
||||
for track in self.tracks:
|
||||
duration_str = ms_to_mmss(track.duration)
|
||||
last_played_str = get_relative_date(track.lastplayed)
|
||||
item_str = (
|
||||
f"{track.title} - {track.artist} [{duration_str}] {last_played_str}"
|
||||
)
|
||||
item = QListWidgetItem(item_str)
|
||||
item.setData(Qt.ItemDataRole.UserRole, track.track_id)
|
||||
self.track_list.addItem(item)
|
||||
|
||||
def get_selected_track_id(self) -> int | None:
|
||||
selected_items = self.track_list.selectedItems()
|
||||
if selected_items:
|
||||
return selected_items[0].data(Qt.ItemDataRole.UserRole)
|
||||
return None
|
||||
|
||||
def add_clicked(self):
|
||||
track_id = self.get_selected_track_id()
|
||||
note_text = self.note_edit.text()
|
||||
if track_id is None and not note_text:
|
||||
return
|
||||
|
||||
self.ui.txtNote.clear()
|
||||
self.select_searchtext()
|
||||
insert_track_data = InsertTrack(self.playlist_id, track_id, note_text)
|
||||
|
||||
if track_id is None:
|
||||
log.error("track_id is None and should not be")
|
||||
return
|
||||
|
||||
# Check whether track is already in playlist
|
||||
move_existing = False
|
||||
existing_prd = self.base_model.is_track_in_playlist(track_id)
|
||||
if existing_prd is not None:
|
||||
if ask_yes_no(
|
||||
"Duplicate row",
|
||||
"Track already in playlist. " "Move to new location?",
|
||||
default_yes=True,
|
||||
):
|
||||
move_existing = True
|
||||
self.title_edit.clear()
|
||||
self.note_edit.clear()
|
||||
self.track_list.clear()
|
||||
self.title_edit.setFocus()
|
||||
|
||||
if self.add_to_header:
|
||||
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
|
||||
self.base_model.move_track_to_header(
|
||||
self.new_row_number, existing_prd, note
|
||||
)
|
||||
else:
|
||||
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.signals.signal_add_track_to_header.emit(insert_track_data)
|
||||
self.accept()
|
||||
else:
|
||||
# Adding a new track row
|
||||
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
|
||||
self.base_model.move_track_add_note(
|
||||
self.new_row_number, existing_prd, note
|
||||
)
|
||||
else:
|
||||
self.base_model.insert_row(self.new_row_number, track_id, note)
|
||||
self.signals.signal_insert_track.emit(insert_track_data)
|
||||
|
||||
self.new_row_number += 1
|
||||
|
||||
def add_selected_and_close(self) -> None:
|
||||
"""Handle Add and Close button"""
|
||||
|
||||
self.add_selected()
|
||||
def add_and_close_clicked(self):
|
||||
self.add_clicked()
|
||||
self.accept()
|
||||
|
||||
def chars_typed(self, s: str) -> None:
|
||||
"""Handle text typed in search box"""
|
||||
|
||||
self.ui.matchList.clear()
|
||||
if len(s) > 0:
|
||||
if s.startswith("a/") and len(s) > 2:
|
||||
matches = Tracks.search_artists(self.session, "%" + s[2:])
|
||||
elif self.ui.radioTitle.isChecked():
|
||||
matches = Tracks.search_titles(self.session, "%" + s)
|
||||
else:
|
||||
matches = Tracks.search_artists(self.session, "%" + s)
|
||||
if matches:
|
||||
for track in matches:
|
||||
last_played = None
|
||||
last_playdate = max(
|
||||
track.playdates, key=lambda p: p.lastplayed, default=None
|
||||
)
|
||||
if last_playdate:
|
||||
last_played = last_playdate.lastplayed
|
||||
t = QListWidgetItem()
|
||||
track_text = (
|
||||
f"{track.title} - {track.artist} "
|
||||
f"[{ms_to_mmss(track.duration)}] "
|
||||
f"({get_relative_date(last_played)})"
|
||||
)
|
||||
t.setText(track_text)
|
||||
t.setData(Qt.ItemDataRole.UserRole, track)
|
||||
self.ui.matchList.addItem(t)
|
||||
|
||||
def closeEvent(self, event: Optional[QEvent]) -> None:
|
||||
"""
|
||||
Override close and save dialog coordinates
|
||||
"""
|
||||
|
||||
if not event:
|
||||
return
|
||||
|
||||
record = Settings.get_setting(self.session, "dbdialog_height")
|
||||
record.f_int = self.height()
|
||||
|
||||
record = Settings.get_setting(self.session, "dbdialog_width")
|
||||
record.f_int = self.width()
|
||||
|
||||
self.session.commit()
|
||||
|
||||
event.accept()
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent | None) -> None:
|
||||
"""
|
||||
Clear selection on ESC if there is one
|
||||
"""
|
||||
|
||||
if event and event.key() == Qt.Key.Key_Escape:
|
||||
if self.ui.matchList.selectedItems():
|
||||
self.ui.matchList.clearSelection()
|
||||
return
|
||||
|
||||
super(TrackSelectDialog, self).keyPressEvent(event)
|
||||
|
||||
def select_searchtext(self) -> None:
|
||||
"""Select the searchbox"""
|
||||
|
||||
self.ui.searchString.selectAll()
|
||||
self.ui.searchString.setFocus()
|
||||
|
||||
def selection_changed(self) -> None:
|
||||
"""Display selected track path in dialog box"""
|
||||
|
||||
if not self.ui.matchList.selectedItems():
|
||||
self.path.setText("")
|
||||
|
||||
track_id = self.get_selected_track_id()
|
||||
if track_id is None:
|
||||
return
|
||||
|
||||
item = self.ui.matchList.currentItem()
|
||||
track = item.data(Qt.ItemDataRole.UserRole)
|
||||
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
|
||||
if last_playdate:
|
||||
last_played = last_playdate.lastplayed
|
||||
else:
|
||||
last_played = None
|
||||
path_text = f"{track.path} ({get_relative_date(last_played)})"
|
||||
tracklist = [t for t in self.tracks if t.track_id == track_id]
|
||||
if not tracklist:
|
||||
return
|
||||
if len(tracklist) > 1:
|
||||
raise ApplicationError("More than one track returned")
|
||||
track = tracklist[0]
|
||||
|
||||
self.ui.dbPath.setText(path_text)
|
||||
|
||||
def title_artist_toggle(self) -> None:
|
||||
"""
|
||||
Handle switching between searching for artists and searching for
|
||||
titles
|
||||
"""
|
||||
|
||||
# Logic is handled already in chars_typed(), so just call that.
|
||||
self.chars_typed(self.ui.searchString.text())
|
||||
self.path.setText(track.path)
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass, field
|
||||
from fuzzywuzzy import fuzz # type: ignore
|
||||
import os.path
|
||||
import threading
|
||||
from typing import Optional, Sequence
|
||||
import os
|
||||
import shutil
|
||||
|
||||
@ -32,18 +31,21 @@ from classes import (
|
||||
MusicMusterSignals,
|
||||
singleton,
|
||||
Tags,
|
||||
TrackDTO,
|
||||
)
|
||||
from config import Config
|
||||
from helpers import (
|
||||
file_is_unreadable,
|
||||
get_all_track_metadata,
|
||||
get_audio_metadata,
|
||||
get_tags,
|
||||
normalise_track,
|
||||
show_OK,
|
||||
)
|
||||
from log import log
|
||||
from models import db, Tracks
|
||||
from music_manager import track_sequence
|
||||
from playlistrow import TrackSequence
|
||||
from playlistmodel import PlaylistModel
|
||||
import helpers
|
||||
import repository
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -67,7 +69,7 @@ class TrackFileData:
|
||||
destination_path: str = ""
|
||||
import_this_file: bool = False
|
||||
error: str = ""
|
||||
file_path_to_remove: Optional[str] = None
|
||||
file_path_to_remove: str | None = None
|
||||
track_id: int = 0
|
||||
track_match_data: list[TrackMatchData] = field(default_factory=list)
|
||||
|
||||
@ -104,9 +106,7 @@ class FileImporter:
|
||||
# variable or an instance variable are effectively the same thing.
|
||||
workers: dict[str, DoTrackImport] = {}
|
||||
|
||||
def __init__(
|
||||
self, base_model: PlaylistModel, row_number: int = None
|
||||
) -> None:
|
||||
def __init__(self, base_model: PlaylistModel, row_number: int) -> None:
|
||||
"""
|
||||
Initialise the FileImporter singleton instance.
|
||||
"""
|
||||
@ -122,13 +122,7 @@ class FileImporter:
|
||||
# Get signals
|
||||
self.signals = MusicMusterSignals()
|
||||
|
||||
def _get_existing_tracks(self) -> Sequence[Tracks]:
|
||||
"""
|
||||
Return a list of all existing Tracks
|
||||
"""
|
||||
|
||||
with db.Session() as session:
|
||||
return Tracks.get_all(session)
|
||||
self.existing_tracks: list[TrackDTO] = []
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
@ -148,7 +142,7 @@ class FileImporter:
|
||||
|
||||
# Refresh list of existing tracks as they may have been updated
|
||||
# by previous imports
|
||||
self.existing_tracks = self._get_existing_tracks()
|
||||
self.existing_tracks = repository.get_all_tracks()
|
||||
|
||||
for infile in [
|
||||
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
|
||||
@ -258,7 +252,7 @@ class FileImporter:
|
||||
artist_match=artist_score,
|
||||
title=existing_track.title,
|
||||
title_match=title_score,
|
||||
track_id=existing_track.id,
|
||||
track_id=existing_track.track_id,
|
||||
)
|
||||
)
|
||||
|
||||
@ -391,12 +385,14 @@ class FileImporter:
|
||||
else:
|
||||
tfd.destination_path = existing_track_path
|
||||
|
||||
def _get_existing_track(self, track_id: int) -> Tracks:
|
||||
def _get_existing_track(self, track_id: int) -> TrackDTO:
|
||||
"""
|
||||
Lookup in existing track in the local cache and return it
|
||||
"""
|
||||
|
||||
existing_track_records = [a for a in self.existing_tracks if a.id == track_id]
|
||||
existing_track_records = [
|
||||
a for a in self.existing_tracks if a.track_id == track_id
|
||||
]
|
||||
if len(existing_track_records) != 1:
|
||||
raise ApplicationError(
|
||||
f"Internal error in _get_existing_track: {existing_track_records=}"
|
||||
@ -469,13 +465,12 @@ class FileImporter:
|
||||
# file). Check that because the path field in the database is
|
||||
# unique and so adding a duplicate will give a db integrity
|
||||
# error.
|
||||
with db.Session() as session:
|
||||
if Tracks.get_by_path(session, tfd.destination_path):
|
||||
tfd.error = (
|
||||
"Importing a new track but destination path already exists "
|
||||
f"in database ({tfd.destination_path})"
|
||||
)
|
||||
return False
|
||||
if repository.track_by_path(tfd.destination_path):
|
||||
tfd.error = (
|
||||
"Importing a new track but destination path already exists "
|
||||
f"in database ({tfd.destination_path})"
|
||||
)
|
||||
return False
|
||||
|
||||
# Check track_id
|
||||
if tfd.track_id < 0:
|
||||
@ -597,7 +592,7 @@ class DoTrackImport(QThread):
|
||||
tags: Tags,
|
||||
destination_path: str,
|
||||
track_id: int,
|
||||
file_path_to_remove: Optional[str] = None,
|
||||
file_path_to_remove: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Save parameters
|
||||
@ -628,7 +623,7 @@ class DoTrackImport(QThread):
|
||||
)
|
||||
|
||||
# Get audio metadata in this thread rather than calling function to save interactive time
|
||||
self.audio_metadata = helpers.get_audio_metadata(self.import_file_path)
|
||||
self.audio_metadata = get_audio_metadata(self.import_file_path)
|
||||
|
||||
# Remove old file if so requested
|
||||
if self.file_path_to_remove and os.path.exists(self.file_path_to_remove):
|
||||
@ -637,42 +632,22 @@ class DoTrackImport(QThread):
|
||||
# Move new file to destination
|
||||
shutil.move(self.import_file_path, self.destination_track_path)
|
||||
|
||||
with db.Session() as session:
|
||||
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
|
||||
else:
|
||||
log.error(f"Unable to retrieve {self.track_id=}")
|
||||
return
|
||||
session.commit()
|
||||
# Normalise
|
||||
normalise_track(self.destination_track_path)
|
||||
|
||||
helpers.normalise_track(self.destination_track_path)
|
||||
|
||||
self.signals.status_message_signal.emit(
|
||||
f"{os.path.basename(self.import_file_path)} imported", 10000
|
||||
# Update databse
|
||||
metadata = get_all_track_metadata(self.destination_track_path)
|
||||
if self.track_id == 0:
|
||||
track_dto = repository.create_track(self.destination_track_path, metadata)
|
||||
else:
|
||||
track_dto = repository.update_track(
|
||||
self.destination_track_path, self.track_id, metadata
|
||||
)
|
||||
self.import_finished.emit(self.import_file_path, track.id)
|
||||
|
||||
self.signals.status_message_signal.emit(
|
||||
f"{os.path.basename(self.import_file_path)} imported", 10000
|
||||
)
|
||||
self.import_finished.emit(self.import_file_path, track_dto.track_id)
|
||||
|
||||
|
||||
class PickMatch(QDialog):
|
||||
@ -701,6 +676,7 @@ class PickMatch(QDialog):
|
||||
self.setWindowTitle("New or replace")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
track_sequence = TrackSequence()
|
||||
|
||||
# Add instructions
|
||||
instructions = (
|
||||
|
||||
108
app/helpers.py
108
app/helpers.py
@ -20,7 +20,7 @@ from pydub.utils import mediainfo
|
||||
from tinytag import TinyTag, TinyTagException # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import AudioMetadata, ApplicationError, Tags
|
||||
from classes import AudioMetadata, ApplicationError, Tags, TrackDTO
|
||||
from config import Config
|
||||
from log import log
|
||||
from models import Tracks
|
||||
@ -168,7 +168,7 @@ def get_name(prompt: str, default: str = "") -> str | None:
|
||||
|
||||
|
||||
def get_relative_date(
|
||||
past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None
|
||||
past_date: Optional[dt.datetime], now: Optional[dt.datetime] = None
|
||||
) -> str:
|
||||
"""
|
||||
Return how long before reference_date past_date is as string.
|
||||
@ -182,31 +182,33 @@ def get_relative_date(
|
||||
|
||||
if not past_date or past_date == Config.EPOCH:
|
||||
return "Never"
|
||||
if not reference_date:
|
||||
reference_date = dt.datetime.now()
|
||||
if not now:
|
||||
now = dt.datetime.now()
|
||||
|
||||
# Check parameters
|
||||
if past_date > reference_date:
|
||||
return "get_relative_date() past_date is after relative_date"
|
||||
if past_date > now:
|
||||
raise ApplicationError("get_relative_date() past_date is after relative_date")
|
||||
|
||||
days: int
|
||||
days_str: str
|
||||
weeks: int
|
||||
weeks_str: str
|
||||
delta = now - past_date
|
||||
days = delta.days
|
||||
|
||||
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
|
||||
if weeks == days == 0:
|
||||
# Same day so return time instead
|
||||
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M")
|
||||
if weeks == 1:
|
||||
weeks_str = "week"
|
||||
else:
|
||||
weeks_str = "weeks"
|
||||
if days == 1:
|
||||
days_str = "day"
|
||||
else:
|
||||
days_str = "days"
|
||||
return f"{weeks} {weeks_str}, {days} {days_str}"
|
||||
if days == 0:
|
||||
return "(Today)"
|
||||
elif days == 1:
|
||||
return "(Yesterday)"
|
||||
|
||||
years, days_remain = divmod(days, 365)
|
||||
months, days_final = divmod(days_remain, 30)
|
||||
|
||||
parts = []
|
||||
if years:
|
||||
parts.append(f"{years}y")
|
||||
if months:
|
||||
parts.append(f"{months}m")
|
||||
if days_final:
|
||||
parts.append(f"{days_final}d")
|
||||
formatted = " ".join(parts)
|
||||
return f"({formatted} ago)"
|
||||
|
||||
|
||||
def get_tags(path: str) -> Tags:
|
||||
@ -264,39 +266,15 @@ def leading_silence(
|
||||
return min(trim_ms, len(audio_segment))
|
||||
|
||||
|
||||
def ms_to_mmss(
|
||||
ms: Optional[int],
|
||||
decimals: int = 0,
|
||||
negative: bool = False,
|
||||
none: Optional[str] = None,
|
||||
) -> str:
|
||||
def ms_to_mmss(ms: int | None, none: str = "-") -> str:
|
||||
"""Convert milliseconds to mm:ss"""
|
||||
|
||||
minutes: int
|
||||
remainder: int
|
||||
seconds: float
|
||||
if ms is None:
|
||||
return none
|
||||
|
||||
if not ms:
|
||||
if none:
|
||||
return none
|
||||
else:
|
||||
return "-"
|
||||
sign = ""
|
||||
if ms < 0:
|
||||
if negative:
|
||||
sign = "-"
|
||||
else:
|
||||
ms = 0
|
||||
minutes, seconds = divmod(ms // 1000, 60)
|
||||
|
||||
minutes, remainder = divmod(ms, 60 * 1000)
|
||||
seconds = remainder / 1000
|
||||
|
||||
# if seconds >= 59.5, it will be represented as 60, which looks odd.
|
||||
# So, fake it under those circumstances
|
||||
if seconds >= 59.5:
|
||||
seconds = 59.0
|
||||
|
||||
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
|
||||
return f"{minutes}:{seconds:02d}"
|
||||
|
||||
|
||||
def normalise_track(path: str) -> None:
|
||||
@ -353,32 +331,6 @@ def normalise_track(path: str) -> None:
|
||||
os.remove(temp_path)
|
||||
|
||||
|
||||
def remove_substring_case_insensitive(parent_string: str, substring: str) -> str:
|
||||
"""
|
||||
Remove all instances of substring from parent string, case insensitively
|
||||
"""
|
||||
|
||||
# Convert both strings to lowercase for case-insensitive comparison
|
||||
lower_parent = parent_string.lower()
|
||||
lower_substring = substring.lower()
|
||||
|
||||
# Initialize the result string
|
||||
result = parent_string
|
||||
|
||||
# Continue removing the substring until it's no longer found
|
||||
while lower_substring in lower_parent:
|
||||
# Find the index of the substring
|
||||
index = lower_parent.find(lower_substring)
|
||||
|
||||
# Remove the substring
|
||||
result = result[:index] + result[index + len(substring) :]
|
||||
|
||||
# Update the lowercase versions
|
||||
lower_parent = result.lower()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
|
||||
# From https://docs.python.org/3/library/email.examples.html
|
||||
|
||||
|
||||
30
app/log.py
30
app/log.py
@ -80,17 +80,30 @@ log = logging.getLogger(Config.LOG_NAME)
|
||||
|
||||
|
||||
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
error = str(exc_value)
|
||||
"""
|
||||
Inform user of exception
|
||||
"""
|
||||
|
||||
# Navigate to the inner stack frame
|
||||
tb = exc_traceback
|
||||
while tb.tb_next:
|
||||
tb = tb.tb_next
|
||||
|
||||
fname = os.path.basename(tb.tb_frame.f_code.co_filename)
|
||||
lineno = tb.tb_lineno
|
||||
msg = f"ApplicationError: {exc_value}\nat {fname}:{lineno}"
|
||||
logmsg = f"ApplicationError: {exc_value} at {fname}:{lineno}"
|
||||
|
||||
if issubclass(exc_type, ApplicationError):
|
||||
log.error(error)
|
||||
log.error(logmsg)
|
||||
else:
|
||||
# Handle unexpected errors (log and display)
|
||||
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
||||
|
||||
print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg'))
|
||||
|
||||
msg = stackprinter.format(exc_value)
|
||||
log.error(msg)
|
||||
stack = stackprinter.format(exc_value)
|
||||
log.error(stack)
|
||||
log.error(error_msg)
|
||||
print("Critical error:", error_msg) # Consider logging instead of print
|
||||
|
||||
@ -101,11 +114,10 @@ def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
Config.ERRORS_TO,
|
||||
Config.ERRORS_FROM,
|
||||
"Exception (log_uncaught_exceptions) from musicmuster",
|
||||
msg,
|
||||
stack,
|
||||
)
|
||||
if QApplication.instance() is not None:
|
||||
fname = os.path.split(exc_traceback.tb_frame.f_code.co_filename)[1]
|
||||
msg = f"ApplicationError: {error}\nat {fname}:{exc_traceback.tb_lineno}"
|
||||
QMessageBox.critical(None, "Application Error", msg)
|
||||
|
||||
|
||||
@ -124,13 +136,13 @@ def log_call(func):
|
||||
args_repr = [truncate_large(a) for a in args]
|
||||
kwargs_repr = [f"{k}={truncate_large(v)}" for k, v in kwargs.items()]
|
||||
params_repr = ", ".join(args_repr + kwargs_repr)
|
||||
log.debug(f"call {func.__name__}({params_repr})")
|
||||
log.debug(f"call {func.__name__}({params_repr})", stacklevel=2)
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
log.debug(f"return {func.__name__}: {truncate_large(result)}")
|
||||
log.debug(f"return {func.__name__}: {truncate_large(result)}", stacklevel=2)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.debug(f"exception in {func.__name__}: {e}")
|
||||
log.debug(f"exception in {func.__name__}: {e}", stacklevel=2)
|
||||
raise
|
||||
return wrapper
|
||||
|
||||
|
||||
@ -241,7 +241,9 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
"""
|
||||
|
||||
session.execute(
|
||||
update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
|
||||
update(Playlists)
|
||||
.where(Playlists.id.in_(playlist_ids))
|
||||
.values(tab=None)
|
||||
)
|
||||
|
||||
def close(self, session: Session) -> None:
|
||||
@ -395,6 +397,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
given playlist_id and row
|
||||
"""
|
||||
|
||||
# TODO: use selectinload?
|
||||
stmt = (
|
||||
select(PlaylistRows)
|
||||
.options(joinedload(cls.track))
|
||||
@ -435,24 +438,6 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def fixup_rownumbers(session: Session, playlist_id: int) -> None:
|
||||
"""
|
||||
Ensure the row numbers for passed playlist have no gaps
|
||||
"""
|
||||
|
||||
plrs = session.scalars(
|
||||
select(PlaylistRows)
|
||||
.where(PlaylistRows.playlist_id == playlist_id)
|
||||
.order_by(PlaylistRows.row_number)
|
||||
).all()
|
||||
|
||||
for i, plr in enumerate(plrs):
|
||||
plr.row_number = i
|
||||
|
||||
# Ensure new row numbers are available to the caller
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
def plrids_to_plrs(
|
||||
cls, session: Session, playlist_id: int, plr_ids: list[int]
|
||||
@ -753,6 +738,9 @@ class Tracks(dbtables.TracksTable):
|
||||
Return tracks matching filter
|
||||
"""
|
||||
|
||||
# Now implemented in repostory.py
|
||||
return []
|
||||
|
||||
query = select(cls)
|
||||
|
||||
# Path specification
|
||||
|
||||
@ -3,32 +3,23 @@ from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from time import sleep
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Third party imports
|
||||
# import line_profiler
|
||||
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 classes import singleton
|
||||
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
|
||||
@ -63,106 +54,6 @@ from vlcmanager import VLCManager
|
||||
# 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
|
||||
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
|
||||
self.GraphWidget.addItem(self.region)
|
||||
|
||||
# Update region position
|
||||
if self.region:
|
||||
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
|
||||
|
||||
|
||||
class _FadeTrack(QThread):
|
||||
finished = pyqtSignal()
|
||||
|
||||
@ -196,21 +87,32 @@ class _FadeTrack(QThread):
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
# TODO can we move this into the _Music class?
|
||||
vlc_instance = VLCManager().vlc_instance
|
||||
@singleton
|
||||
class VLCManager:
|
||||
"""
|
||||
Singleton class to ensure we only ever have one vlc Instance
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.vlc_instance = vlc.Instance()
|
||||
|
||||
def get_instance(self) -> vlc.Instance:
|
||||
return self.vlc_instance
|
||||
|
||||
|
||||
class _Music:
|
||||
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
|
||||
vlc_manager = VLCManager()
|
||||
self.vlc_instance = vlc_manager.get_instance()
|
||||
self.vlc_instance.set_user_agent(name, name)
|
||||
self.player: vlc.MediaPlayer | None = None
|
||||
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
|
||||
self.start_dt: Optional[dt.datetime] = None
|
||||
self.start_dt: dt.datetime | None = None
|
||||
|
||||
# Set up logging
|
||||
# self._set_vlc_log()
|
||||
@ -238,27 +140,6 @@ class _Music:
|
||||
# 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.
|
||||
@ -292,11 +173,11 @@ class _Music:
|
||||
elapsed_seconds = (now - self.start_dt).total_seconds()
|
||||
return int(elapsed_seconds * 1000)
|
||||
|
||||
def get_position(self) -> Optional[float]:
|
||||
def get_position(self) -> float:
|
||||
"""Return current position"""
|
||||
|
||||
if not self.player:
|
||||
return None
|
||||
return 0.0
|
||||
return self.player.get_position()
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
@ -321,7 +202,7 @@ class _Music:
|
||||
self,
|
||||
path: str,
|
||||
start_time: dt.datetime,
|
||||
position: Optional[float] = None,
|
||||
position: float | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Start playing the track at path.
|
||||
@ -338,7 +219,7 @@ class _Music:
|
||||
log.error(f"play({path}): path not readable")
|
||||
return None
|
||||
|
||||
self.player = vlc.MediaPlayer(vlc_instance, path)
|
||||
self.player = vlc.MediaPlayer(self.vlc_instance, path)
|
||||
if self.player is None:
|
||||
log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
|
||||
helpers.show_warning(
|
||||
@ -353,21 +234,6 @@ class _Music:
|
||||
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
|
||||
@ -377,7 +243,7 @@ class _Music:
|
||||
self.player.set_position(position)
|
||||
|
||||
def set_volume(
|
||||
self, volume: Optional[int] = None, set_default: bool = True
|
||||
self, volume: int | None = None, set_default: bool = True
|
||||
) -> None:
|
||||
"""Set maximum volume used for player"""
|
||||
|
||||
@ -417,333 +283,3 @@ class _Music:
|
||||
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
|
||||
|
||||
# Playlist display data
|
||||
self.row_fg: Optional[str] = None
|
||||
self.row_bg: Optional[str] = None
|
||||
self.note_fg: Optional[str] = None
|
||||
self.note_bg: Optional[str] = None
|
||||
|
||||
# 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 = 0
|
||||
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"""
|
||||
|
||||
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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1146
app/playlistmodel.py
1146
app/playlistmodel.py
File diff suppressed because it is too large
Load Diff
589
app/playlistrow.py
Normal file
589
app/playlistrow.py
Normal file
@ -0,0 +1,589 @@
|
||||
# Standard library imports
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QObject,
|
||||
QThread,
|
||||
)
|
||||
|
||||
# Third party imports
|
||||
from pyqtgraph import PlotWidget # type: ignore
|
||||
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
|
||||
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
|
||||
import numpy as np
|
||||
import pyqtgraph as pg # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import ApplicationError, MusicMusterSignals, PlaylistRowDTO, singleton
|
||||
from config import Config
|
||||
import helpers
|
||||
from log import log
|
||||
from music_manager import Music
|
||||
import repository
|
||||
|
||||
|
||||
class PlaylistRow:
|
||||
"""
|
||||
Object to manage playlist row and track.
|
||||
"""
|
||||
|
||||
def __init__(self, dto: PlaylistRowDTO) -> None:
|
||||
"""
|
||||
The dto object will include a Tracks object if this row has a track.
|
||||
"""
|
||||
|
||||
self.dto = dto
|
||||
self.music = Music(name=Config.VLC_MAIN_PLAYER_NAME)
|
||||
self.signals = MusicMusterSignals()
|
||||
self.end_of_track_signalled: bool = False
|
||||
self.end_time: dt.datetime | None = None
|
||||
self.fade_graph: Any | None = None
|
||||
self.fade_graph_start_updates: dt.datetime | None = None
|
||||
self.forecast_end_time: dt.datetime | None = None
|
||||
self.forecast_start_time: dt.datetime | None = None
|
||||
self.note_bg: str | None = None
|
||||
self.note_fg: str | None = None
|
||||
self.resume_marker: float = 0.0
|
||||
self.row_bg: str | None = None
|
||||
self.row_fg: str | None = None
|
||||
self.start_time: dt.datetime | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
track_id = None
|
||||
if self.dto.track:
|
||||
track_id = self.dto.track.track_id
|
||||
return (
|
||||
f"<PlaylistRow(playlist_id={self.dto.playlist_id}, "
|
||||
f"row_number={self.dto.row_number}, "
|
||||
f"playlistrow_id={self.dto.playlistrow_id}, "
|
||||
f"note={self.dto.note}, track_id={track_id}>"
|
||||
)
|
||||
|
||||
# Expose TrackDTO fields as properties
|
||||
@property
|
||||
def artist(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.artist
|
||||
else:
|
||||
return ""
|
||||
|
||||
@artist.setter
|
||||
def artist(self, value: str) -> None:
|
||||
print(f"set artist attribute for {self=}, {value=}")
|
||||
|
||||
@property
|
||||
def bitrate(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.bitrate
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.duration
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def fade_at(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.fade_at
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def intro(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.intro
|
||||
else:
|
||||
return 0
|
||||
|
||||
@intro.setter
|
||||
def intro(self, value: int) -> None:
|
||||
print(f"set intro attribute for {self=}, {value=}")
|
||||
|
||||
@property
|
||||
def lastplayed(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.lastplayed
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.path
|
||||
else:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def silence_at(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.silence_at
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def start_gap(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.start_gap
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.title
|
||||
else:
|
||||
return ""
|
||||
|
||||
@title.setter
|
||||
def title(self, value: str) -> None:
|
||||
print(f"set title attribute for {self=}, {value=}")
|
||||
|
||||
@property
|
||||
def track_id(self):
|
||||
if self.dto.track:
|
||||
return self.dto.track.track_id
|
||||
else:
|
||||
return 0
|
||||
|
||||
@track_id.setter
|
||||
def track_id(self, track_id: int) -> None:
|
||||
"""
|
||||
Adding a track_id should only happen to a header row.
|
||||
"""
|
||||
|
||||
if self.track_id > 0:
|
||||
raise ApplicationError("Attempting to add track to row with existing track ({self=}")
|
||||
|
||||
repository.add_track_to_header(track_id)
|
||||
|
||||
# Need to update with track information
|
||||
track = repository.track_by_id(track_id)
|
||||
if track:
|
||||
for attr, value in track.__dataclass_fields__.items():
|
||||
setattr(self, attr, value)
|
||||
|
||||
# TODO: set up write access to track_id. Should only update if
|
||||
# track_id == 0. Need to update all other track fields at the
|
||||
# same time.
|
||||
print(f"set track_id attribute for {self=}, {value=}")
|
||||
pass
|
||||
|
||||
# Expose PlaylistRowDTO fields as properties
|
||||
@property
|
||||
def note(self):
|
||||
return self.dto.note
|
||||
|
||||
@note.setter
|
||||
def note(self, value: str) -> None:
|
||||
# TODO set up write access to db
|
||||
print(f"set note attribute for {self=}, {value=}")
|
||||
# self.dto.note = value
|
||||
|
||||
@property
|
||||
def played(self):
|
||||
return self.dto.played
|
||||
|
||||
@played.setter
|
||||
def played(self, value: bool = True) -> None:
|
||||
# TODO set up write access to db
|
||||
print(f"set played attribute for {self=}")
|
||||
# self.dto.played = value
|
||||
|
||||
@property
|
||||
def playlist_id(self):
|
||||
return self.dto.playlist_id
|
||||
|
||||
@property
|
||||
def playlistrow_id(self):
|
||||
return self.dto.playlistrow_id
|
||||
|
||||
@property
|
||||
def row_number(self):
|
||||
return self.dto.row_number
|
||||
|
||||
@row_number.setter
|
||||
def row_number(self, value: int) -> None:
|
||||
# TODO do we need to set up write access to db?
|
||||
self.dto.row_number = value
|
||||
|
||||
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 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 play(self, position: float | None = None) -> None:
|
||||
"""Play track"""
|
||||
|
||||
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 set_forecast_start_time(
|
||||
self, modified_rows: list[int], start: dt.datetime | None
|
||||
) -> dt.datetime | None:
|
||||
"""
|
||||
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())
|
||||
|
||||
|
||||
class _AddFadeCurve(QObject):
|
||||
"""
|
||||
Initialising a fade curve introduces a noticeable delay so carry out in
|
||||
a thread.
|
||||
"""
|
||||
|
||||
finished = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plr: PlaylistRow,
|
||||
track_path: str,
|
||||
track_fade_at: int,
|
||||
track_silence_at: int,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.plr = plr
|
||||
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.plr.fade_graph = fc
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class FadeCurve:
|
||||
GraphWidget: PlotWidget | None = 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: PlotDataItem | None = None
|
||||
self.region: LinearRegionItem | None = 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
|
||||
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
|
||||
self.GraphWidget.addItem(self.region)
|
||||
|
||||
# Update region position
|
||||
if self.region:
|
||||
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
|
||||
|
||||
|
||||
@singleton
|
||||
class TrackSequence:
|
||||
"""
|
||||
Maintain a list of which track (if any) is next, current and
|
||||
previous. A track can only be previous after being current, and can
|
||||
only be current after being next. If one of the tracks listed here
|
||||
moves, the row_number and/or playlist_id will change.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Set up storage for the three monitored tracks
|
||||
"""
|
||||
|
||||
self.next: PlaylistRow | None = None
|
||||
self.current: PlaylistRow | None = None
|
||||
self.previous: PlaylistRow | None = None
|
||||
|
||||
def set_next(self, plr: PlaylistRow | None) -> None:
|
||||
"""
|
||||
Set the 'next' track to be passed PlaylistRow. Clear any previous
|
||||
next track. If passed PlaylistRow 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 plr is None:
|
||||
self.next = None
|
||||
else:
|
||||
self.next = plr
|
||||
self.create_fade_graph()
|
||||
|
||||
def move_next_to_current(self) -> None:
|
||||
"""
|
||||
Make the next track the current track
|
||||
"""
|
||||
|
||||
self.current = self.next
|
||||
self.next = None
|
||||
|
||||
def move_current_to_previous(self) -> None:
|
||||
"""
|
||||
Make the current track the previous track
|
||||
"""
|
||||
|
||||
if self.current is None:
|
||||
raise ApplicationError("Tried to move non-existent track from current to previous")
|
||||
|
||||
# Dereference the fade curve so it can be garbage collected
|
||||
self.current.fade_graph = None
|
||||
self.previous = self.current
|
||||
self.current = None
|
||||
|
||||
def move_previous_to_next(self) -> None:
|
||||
"""
|
||||
Make the previous track the next track
|
||||
"""
|
||||
|
||||
self.next = self.previous
|
||||
self.previous = None
|
||||
|
||||
def create_fade_graph(self) -> None:
|
||||
"""
|
||||
Initialise and add FadeCurve in a thread as it's slow
|
||||
"""
|
||||
|
||||
self.fadecurve_thread = QThread()
|
||||
if self.next is None:
|
||||
raise ApplicationError("hell in a handcart")
|
||||
self.worker = _AddFadeCurve(
|
||||
self.next,
|
||||
track_path=self.next.path,
|
||||
track_fade_at=self.next.fade_at,
|
||||
track_silence_at=self.next.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 update(self) -> None:
|
||||
"""
|
||||
If a PlaylistRow is edited (moved, title changed, etc), the
|
||||
playlistrow_id won't change. We can retrieve the PlaylistRow
|
||||
using the playlistrow_id and update the stored PlaylistRow.
|
||||
"""
|
||||
|
||||
for ts in [self.next, self.current, self.previous]:
|
||||
if not ts:
|
||||
continue
|
||||
playlist_row_dto = repository.get_playlist_row(ts.playlistrow_id)
|
||||
if not playlist_row_dto:
|
||||
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
|
||||
ts = PlaylistRow(playlist_row_dto)
|
||||
119
app/playlists.py
119
app/playlists.py
@ -35,9 +35,16 @@ from PyQt6.QtWidgets import (
|
||||
|
||||
# App imports
|
||||
from audacity_controller import AudacityController
|
||||
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
|
||||
from classes import (
|
||||
ApplicationError,
|
||||
Col,
|
||||
MusicMusterSignals,
|
||||
PlaylistStyle,
|
||||
PlayTrack,
|
||||
TrackInfo
|
||||
)
|
||||
from config import Config
|
||||
from dialogs import TrackSelectDialog
|
||||
from dialogs import TrackInsertDialog
|
||||
from helpers import (
|
||||
ask_yes_no,
|
||||
ms_to_mmss,
|
||||
@ -45,9 +52,9 @@ from helpers import (
|
||||
show_warning,
|
||||
)
|
||||
from log import log, log_call
|
||||
from models import db, Settings
|
||||
from music_manager import track_sequence
|
||||
from playlistrow import TrackSequence
|
||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||
import repository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from musicmuster import Window
|
||||
@ -278,6 +285,7 @@ class PlaylistTab(QTableView):
|
||||
self.musicmuster = musicmuster
|
||||
|
||||
self.playlist_id = model.sourceModel().playlist_id
|
||||
self.track_sequence = TrackSequence()
|
||||
|
||||
# Set up widget
|
||||
self.setItemDelegate(PlaylistDelegate(self, model.sourceModel()))
|
||||
@ -303,6 +311,7 @@ class PlaylistTab(QTableView):
|
||||
self.signals = MusicMusterSignals()
|
||||
self.signals.resize_rows_signal.connect(self.resize_rows)
|
||||
self.signals.span_cells_signal.connect(self._span_cells)
|
||||
self.signals.signal_track_started.connect(self.track_started)
|
||||
|
||||
# Selection model
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
@ -358,7 +367,8 @@ class PlaylistTab(QTableView):
|
||||
# Deselect edited line
|
||||
self.clear_selection()
|
||||
|
||||
def dropEvent(self, event: Optional[QDropEvent], dummy: int | None = None) -> None:
|
||||
@log_call
|
||||
def dropEvent(self, event: Optional[QDropEvent]) -> None:
|
||||
"""
|
||||
Move dropped rows
|
||||
"""
|
||||
@ -394,9 +404,6 @@ class PlaylistTab(QTableView):
|
||||
destination_index = to_index
|
||||
|
||||
to_model_row = self.model().mapToSource(destination_index).row()
|
||||
log.debug(
|
||||
f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_row=}"
|
||||
)
|
||||
|
||||
# Sanity check
|
||||
base_model_row_count = self.get_base_model().rowCount()
|
||||
@ -408,8 +415,8 @@ class PlaylistTab(QTableView):
|
||||
# that moved row the next track
|
||||
set_next_row: Optional[int] = None
|
||||
if (
|
||||
track_sequence.current
|
||||
and to_model_row == track_sequence.current.row_number + 1
|
||||
self.track_sequence.current
|
||||
and to_model_row == self.track_sequence.current.row_number + 1
|
||||
):
|
||||
set_next_row = to_model_row
|
||||
|
||||
@ -456,14 +463,19 @@ class PlaylistTab(QTableView):
|
||||
self, selected: QItemSelection, deselected: QItemSelection
|
||||
) -> None:
|
||||
"""
|
||||
Tell model which rows are selected.
|
||||
|
||||
Toggle drag behaviour according to whether rows are selected
|
||||
"""
|
||||
|
||||
selected_rows = self.get_selected_rows()
|
||||
self.musicmuster.current.selected_rows = selected_rows
|
||||
selected_row_numbers = self.get_selected_rows()
|
||||
|
||||
# Signal selected rows to model
|
||||
self.signals.signal_playlist_selected_rows.emit(self.playlist_id, selected_row_numbers)
|
||||
|
||||
# Put sum of selected tracks' duration in status bar
|
||||
# If no rows are selected, we have nothing to do
|
||||
if len(selected_rows) == 0:
|
||||
if len(selected_row_numbers) == 0:
|
||||
self.musicmuster.lblSumPlaytime.setText("")
|
||||
else:
|
||||
if not self.musicmuster.disable_selection_timing:
|
||||
@ -509,20 +521,12 @@ class PlaylistTab(QTableView):
|
||||
def _add_track(self) -> None:
|
||||
"""Add a track to a section header making it a normal track row"""
|
||||
|
||||
model_row_number = self.source_model_selected_row_number()
|
||||
if model_row_number is None:
|
||||
return
|
||||
|
||||
with db.Session() as session:
|
||||
dlg = TrackSelectDialog(
|
||||
parent=self.musicmuster,
|
||||
session=session,
|
||||
new_row_number=model_row_number,
|
||||
base_model=self.get_base_model(),
|
||||
add_to_header=True,
|
||||
)
|
||||
dlg.exec()
|
||||
session.commit()
|
||||
dlg = TrackInsertDialog(
|
||||
parent=self.musicmuster,
|
||||
playlist_id=self.playlist_id,
|
||||
add_to_header=True,
|
||||
)
|
||||
dlg.exec()
|
||||
|
||||
def _build_context_menu(self, item: QTableWidgetItem) -> None:
|
||||
"""Used to process context (right-click) menu, which is defined here"""
|
||||
@ -535,12 +539,12 @@ class PlaylistTab(QTableView):
|
||||
|
||||
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
|
||||
if self.track_sequence.current:
|
||||
this_is_current_row = model_row_number == self.track_sequence.current.row_number
|
||||
else:
|
||||
this_is_current_row = False
|
||||
if track_sequence.next:
|
||||
this_is_next_row = model_row_number == track_sequence.next.row_number
|
||||
if self.track_sequence.next:
|
||||
this_is_next_row = model_row_number == self.track_sequence.next.row_number
|
||||
else:
|
||||
this_is_next_row = False
|
||||
track_path = base_model.get_row_info(model_row_number).path
|
||||
@ -676,8 +680,6 @@ class PlaylistTab(QTableView):
|
||||
Called when column width changes. Save new width to database.
|
||||
"""
|
||||
|
||||
log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}")
|
||||
|
||||
header = self.horizontalHeader()
|
||||
if not header:
|
||||
return
|
||||
@ -685,11 +687,10 @@ class PlaylistTab(QTableView):
|
||||
# Resize rows if necessary
|
||||
self.resizeRowsToContents()
|
||||
|
||||
with db.Session() as session:
|
||||
attr_name = f"playlist_col_{column_number}_width"
|
||||
record = Settings.get_setting(session, attr_name)
|
||||
record.f_int = self.columnWidth(column_number)
|
||||
session.commit()
|
||||
# Save settings
|
||||
repository.set_setting(
|
||||
f"playlist_col_{column_number}_width", self.columnWidth(column_number)
|
||||
)
|
||||
|
||||
def _context_menu(self, pos):
|
||||
"""Display right-click menu"""
|
||||
@ -722,12 +723,18 @@ class PlaylistTab(QTableView):
|
||||
cb.clear(mode=cb.Mode.Clipboard)
|
||||
cb.setText(track_path, mode=cb.Mode.Clipboard)
|
||||
|
||||
def current_track_started(self) -> None:
|
||||
@log_call
|
||||
def track_started(self, play_track: PlayTrack) -> None:
|
||||
"""
|
||||
Called when track starts playing
|
||||
"""
|
||||
|
||||
self.get_base_model().current_track_started()
|
||||
if play_track.playlist_id != self.playlist_id:
|
||||
# Not for us
|
||||
return
|
||||
|
||||
# TODO - via signal
|
||||
# self.get_base_model().current_track_started()
|
||||
# Scroll to current section if hide mode is by section
|
||||
if (
|
||||
self.musicmuster.hide_played_tracks
|
||||
@ -757,8 +764,8 @@ class PlaylistTab(QTableView):
|
||||
# Don't delete current or next tracks
|
||||
selected_row_numbers = self.selected_model_row_numbers()
|
||||
for ts in [
|
||||
track_sequence.next,
|
||||
track_sequence.current,
|
||||
self.track_sequence.next,
|
||||
self.track_sequence.current,
|
||||
]:
|
||||
if ts:
|
||||
if (
|
||||
@ -809,6 +816,7 @@ class PlaylistTab(QTableView):
|
||||
else:
|
||||
return TrackInfo(track_id, selected_row)
|
||||
|
||||
@log_call
|
||||
def get_selected_row(self) -> Optional[int]:
|
||||
"""
|
||||
Return selected row number. If no rows or multiple rows selected, return None
|
||||
@ -820,6 +828,7 @@ class PlaylistTab(QTableView):
|
||||
else:
|
||||
return None
|
||||
|
||||
@log_call
|
||||
def get_selected_rows(self) -> list[int]:
|
||||
"""Return a list of model-selected row numbers sorted by row"""
|
||||
|
||||
@ -832,6 +841,7 @@ class PlaylistTab(QTableView):
|
||||
|
||||
return sorted(list(set([self.model().mapToSource(a).row() for a in selected_indexes])))
|
||||
|
||||
@log_call
|
||||
def get_top_visible_row(self) -> int:
|
||||
"""
|
||||
Get the viewport of the table view
|
||||
@ -954,8 +964,6 @@ class PlaylistTab(QTableView):
|
||||
If playlist_id is us, resize rows
|
||||
"""
|
||||
|
||||
log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}")
|
||||
|
||||
if playlist_id and playlist_id != self.playlist_id:
|
||||
return
|
||||
|
||||
@ -1002,6 +1010,7 @@ class PlaylistTab(QTableView):
|
||||
# Reset selection mode
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
|
||||
@log_call
|
||||
def source_model_selected_row_number(self) -> Optional[int]:
|
||||
"""
|
||||
Return the model row number corresponding to the selected row or None
|
||||
@ -1012,6 +1021,7 @@ class PlaylistTab(QTableView):
|
||||
return None
|
||||
return self.model().mapToSource(selected_index).row()
|
||||
|
||||
@log_call
|
||||
def selected_model_row_numbers(self) -> list[int]:
|
||||
"""
|
||||
Return a list of model row numbers corresponding to the selected rows or
|
||||
@ -1054,21 +1064,18 @@ class PlaylistTab(QTableView):
|
||||
def _set_column_widths(self) -> None:
|
||||
"""Column widths from settings"""
|
||||
|
||||
log.debug("_set_column_widths()")
|
||||
|
||||
header = self.horizontalHeader()
|
||||
if not header:
|
||||
return
|
||||
|
||||
# Last column is set to stretch so ignore it here
|
||||
with db.Session() as session:
|
||||
for column_number in range(header.count() - 1):
|
||||
attr_name = f"playlist_col_{column_number}_width"
|
||||
record = Settings.get_setting(session, attr_name)
|
||||
if record.f_int is not None:
|
||||
self.setColumnWidth(column_number, record.f_int)
|
||||
else:
|
||||
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
|
||||
for column_number in range(header.count() - 1):
|
||||
attr_name = f"playlist_col_{column_number}_width"
|
||||
value = repository.get_setting(attr_name)
|
||||
if value is not None:
|
||||
self.setColumnWidth(column_number, value)
|
||||
else:
|
||||
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
|
||||
|
||||
def set_row_as_next_track(self) -> None:
|
||||
"""
|
||||
@ -1119,7 +1126,7 @@ class PlaylistTab(QTableView):
|
||||
|
||||
# Update musicmuster
|
||||
self.musicmuster.current.playlist_id = self.playlist_id
|
||||
self.musicmuster.current.selected_rows = self.get_selected_rows()
|
||||
self.musicmuster.current.selected_row_numbers = self.get_selected_rows()
|
||||
self.musicmuster.current.base_model = self.get_base_model()
|
||||
self.musicmuster.current.proxy_model = self.model()
|
||||
|
||||
@ -1128,6 +1135,6 @@ class PlaylistTab(QTableView):
|
||||
def _unmark_as_next(self) -> None:
|
||||
"""Rescan track"""
|
||||
|
||||
track_sequence.set_next(None)
|
||||
self.track_sequence.set_next(None)
|
||||
self.clear_selection()
|
||||
self.signals.next_track_changed_signal.emit()
|
||||
|
||||
@ -21,7 +21,6 @@ from PyQt6.QtGui import (
|
||||
)
|
||||
|
||||
# Third party imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# import snoop # type: ignore
|
||||
|
||||
@ -39,8 +38,9 @@ from helpers import (
|
||||
show_warning,
|
||||
)
|
||||
from log import log
|
||||
from models import db, Playdates, Tracks
|
||||
from music_manager import RowAndTrack
|
||||
from models import db, Playdates
|
||||
from playlistrow import PlaylistRow
|
||||
import repository
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -64,7 +64,7 @@ class QuerylistModel(QAbstractTableModel):
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, session: Session, filter: Filter) -> None:
|
||||
def __init__(self, filter: Filter) -> None:
|
||||
"""
|
||||
Load query
|
||||
"""
|
||||
@ -72,7 +72,6 @@ class QuerylistModel(QAbstractTableModel):
|
||||
log.debug(f"QuerylistModel.__init__({filter=})")
|
||||
|
||||
super().__init__()
|
||||
self.session = session
|
||||
self.filter = filter
|
||||
|
||||
self.querylist_rows: dict[int, QueryRow] = {}
|
||||
@ -136,7 +135,7 @@ class QuerylistModel(QAbstractTableModel):
|
||||
|
||||
row = index.row()
|
||||
column = index.column()
|
||||
# rat for playlist row data as it's used a lot
|
||||
# plr for playlist row data as it's used a lot
|
||||
qrow = self.querylist_rows[row]
|
||||
|
||||
# Dispatch to role-specific functions
|
||||
@ -230,21 +229,16 @@ class QuerylistModel(QAbstractTableModel):
|
||||
row = 0
|
||||
|
||||
try:
|
||||
results = Tracks.get_filtered_tracks(self.session, self.filter)
|
||||
results = repository.get_filtered_tracks(self.filter)
|
||||
for result in results:
|
||||
lastplayed = None
|
||||
if hasattr(result, "playdates"):
|
||||
pds = result.playdates
|
||||
if pds:
|
||||
lastplayed = max([a.lastplayed for a in pds])
|
||||
queryrow = QueryRow(
|
||||
artist=result.artist,
|
||||
bitrate=result.bitrate or 0,
|
||||
duration=result.duration,
|
||||
lastplayed=lastplayed,
|
||||
lastplayed=result.lastplayed,
|
||||
path=result.path,
|
||||
title=result.title,
|
||||
track_id=result.id,
|
||||
track_id=result.track_id,
|
||||
)
|
||||
|
||||
self.querylist_rows[row] = queryrow
|
||||
@ -268,23 +262,14 @@ class QuerylistModel(QAbstractTableModel):
|
||||
bottom_right = self.index(row, self.columnCount() - 1)
|
||||
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
|
||||
|
||||
def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str | QVariant:
|
||||
def _tooltip_role(self, row: int, column: int, plr: PlaylistRow) -> str | QVariant:
|
||||
"""
|
||||
Return tooltip. Currently only used for last_played column.
|
||||
"""
|
||||
|
||||
if column != QueryCol.LAST_PLAYED.value:
|
||||
return QVariant()
|
||||
with db.Session() as session:
|
||||
track_id = self.querylist_rows[row].track_id
|
||||
if not track_id:
|
||||
return QVariant()
|
||||
playdates = Playdates.last_playdates(session, track_id)
|
||||
return (
|
||||
"<br>".join(
|
||||
[
|
||||
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
|
||||
for a in reversed(playdates)
|
||||
]
|
||||
)
|
||||
)
|
||||
track_id = self.querylist_rows[row].track_id
|
||||
if not track_id:
|
||||
return QVariant()
|
||||
return repository.get_last_played_dates(track_id)
|
||||
|
||||
1194
app/repository.py
Normal file
1194
app/repository.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@ import os
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
from config import Config
|
||||
@ -13,10 +12,10 @@ from helpers import (
|
||||
get_tags,
|
||||
)
|
||||
from log import log
|
||||
from models import Tracks
|
||||
import repository
|
||||
|
||||
|
||||
def check_db(session: Session) -> None:
|
||||
def check_db() -> None:
|
||||
"""
|
||||
Database consistency check.
|
||||
|
||||
@ -27,7 +26,7 @@ def check_db(session: Session) -> None:
|
||||
Check all paths in database exist
|
||||
"""
|
||||
|
||||
db_paths = set([a.path for a in Tracks.get_all(session)])
|
||||
db_paths = set([a.path for a in repository.get_all_tracks()])
|
||||
|
||||
os_paths_list = []
|
||||
for root, _dirs, files in os.walk(Config.ROOT):
|
||||
@ -52,7 +51,7 @@ def check_db(session: Session) -> None:
|
||||
|
||||
missing_file_count += 1
|
||||
|
||||
track = Tracks.get_by_path(session, path)
|
||||
track = repository.track_by_path(path)
|
||||
if not track:
|
||||
# This shouldn't happen as we're looking for paths in
|
||||
# database that aren't in filesystem, but just in case...
|
||||
@ -74,7 +73,7 @@ def check_db(session: Session) -> None:
|
||||
for t in paths_not_found:
|
||||
print(
|
||||
f"""
|
||||
Track ID: {t.id}
|
||||
Track ID: {t.track_id}
|
||||
Path: {t.path}
|
||||
Title: {t.title}
|
||||
Artist: {t.artist}
|
||||
@ -84,14 +83,15 @@ def check_db(session: Session) -> None:
|
||||
print("There were more paths than listed that were not found")
|
||||
|
||||
|
||||
def update_bitrates(session: Session) -> None:
|
||||
def update_bitrates() -> None:
|
||||
"""
|
||||
Update bitrates on all tracks in database
|
||||
"""
|
||||
|
||||
for track in Tracks.get_all(session):
|
||||
for track in repository.get_all_tracks():
|
||||
try:
|
||||
t = get_tags(track.path)
|
||||
# TODO this won't persist as we're updating DTO
|
||||
track.bitrate = t.bitrate
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
# Standard library imports
|
||||
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
import vlc # type: ignore
|
||||
|
||||
# App imports
|
||||
|
||||
|
||||
class VLCManager:
|
||||
"""
|
||||
Singleton class to ensure we only ever have one vlc Instance
|
||||
"""
|
||||
|
||||
__instance = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
if VLCManager.__instance is None:
|
||||
self.vlc_instance = vlc.Instance()
|
||||
VLCManager.__instance = self
|
||||
else:
|
||||
raise Exception("Attempted to create a second VLCManager instance")
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> vlc.Instance:
|
||||
if VLCManager.__instance is None:
|
||||
VLCManager()
|
||||
return VLCManager.__instance
|
||||
40
tests/template_test_harness.py
Normal file
40
tests/template_test_harness.py
Normal file
@ -0,0 +1,40 @@
|
||||
# Standard library imports
|
||||
import unittest
|
||||
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
|
||||
# App imports
|
||||
from app.models import (
|
||||
db,
|
||||
)
|
||||
|
||||
|
||||
class MyTestCase(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Runs once before any test in this class"""
|
||||
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Runs once after all tests"""
|
||||
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
"""Runs before each test"""
|
||||
|
||||
db.create_all()
|
||||
|
||||
def tearDown(self):
|
||||
"""Runs after each test"""
|
||||
|
||||
db.drop_all()
|
||||
|
||||
def test_xxx(self):
|
||||
"""Comment"""
|
||||
|
||||
pass
|
||||
@ -134,122 +134,6 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
db.drop_all()
|
||||
|
||||
def test_move_rows_test2(self):
|
||||
# move row 3 to row 5
|
||||
self.model.move_rows([3], 5)
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [3, 4, 5]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 3:
|
||||
assert self.model.playlist_rows[row].note == str(4)
|
||||
elif row == 4:
|
||||
assert self.model.playlist_rows[row].note == str(3)
|
||||
elif row == 5:
|
||||
assert self.model.playlist_rows[row].note == str(5)
|
||||
|
||||
def test_move_rows_test3(self):
|
||||
# move row 4 to row 3
|
||||
|
||||
self.model.move_rows([4], 3)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [3, 4]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 3:
|
||||
assert self.model.playlist_rows[row].note == str(4)
|
||||
elif row == 4:
|
||||
assert self.model.playlist_rows[row].note == str(3)
|
||||
|
||||
def test_move_rows_test4(self):
|
||||
# move row 4 to row 2
|
||||
|
||||
self.model.move_rows([4], 2)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [2, 3, 4]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 2:
|
||||
assert self.model.playlist_rows[row].note == str(4)
|
||||
elif row == 3:
|
||||
assert self.model.playlist_rows[row].note == str(2)
|
||||
elif row == 4:
|
||||
assert self.model.playlist_rows[row].note == str(3)
|
||||
|
||||
def test_move_rows_test5(self):
|
||||
# move rows [1, 4, 5, 10] → 8
|
||||
|
||||
self.model.move_rows([1, 4, 5, 10], 8)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9]
|
||||
|
||||
def test_move_rows_test6(self):
|
||||
# move rows [3, 6] → 5
|
||||
|
||||
self.model.move_rows([3, 6], 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10]
|
||||
|
||||
def test_move_rows_test7(self):
|
||||
# move rows [3, 5, 6] → 8
|
||||
|
||||
self.model.move_rows([3, 5, 6], 8)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10]
|
||||
|
||||
def test_move_rows_test8(self):
|
||||
# move rows [7, 8, 10] → 5
|
||||
|
||||
self.model.move_rows([7, 8, 10], 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
|
||||
|
||||
def test_move_rows_test9(self):
|
||||
# move rows [1, 2, 3] → 0
|
||||
# Replicate issue 244
|
||||
|
||||
self.model.move_rows([0, 1, 2, 3], 0)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
def test_insert_header_row_end(self):
|
||||
# insert header row at end of playlist
|
||||
|
||||
|
||||
298
tests/test_repository.py
Normal file
298
tests/test_repository.py
Normal file
@ -0,0 +1,298 @@
|
||||
# Standard library imports
|
||||
import unittest
|
||||
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
|
||||
# App imports
|
||||
from app import playlistmodel
|
||||
from app import repository
|
||||
from app.models import db
|
||||
from classes import PlaylistDTO
|
||||
from helpers import get_all_track_metadata
|
||||
from playlistmodel import PlaylistModel
|
||||
|
||||
|
||||
class MyTestCase(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Runs once before any test in this class"""
|
||||
|
||||
cls.isa_path = "testdata/isa.mp3"
|
||||
cls.isa_title = "I'm So Afraid"
|
||||
cls.isa_artist = "Fleetwood Mac"
|
||||
cls.mom_path = "testdata/mom.mp3"
|
||||
cls.mom_title = "Man of Mystery"
|
||||
cls.mom_artist = "The Shadows"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Runs once after all tests"""
|
||||
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
"""Runs before each test"""
|
||||
|
||||
db.create_all()
|
||||
|
||||
def create_playlist_and_model(
|
||||
self, playlist_name: str
|
||||
) -> (PlaylistDTO, PlaylistModel):
|
||||
# Create a playlist and model
|
||||
playlist = repository.create_playlist(name=playlist_name, template_id=0)
|
||||
assert playlist
|
||||
model = playlistmodel.PlaylistModel(playlist.playlist_id, is_template=False)
|
||||
assert model
|
||||
|
||||
return (playlist, model)
|
||||
|
||||
def create_playlist_model_tracks(self, playlist_name: str):
|
||||
(playlist, model) = self.create_playlist_and_model(playlist_name)
|
||||
# Create tracks
|
||||
metadata1 = get_all_track_metadata(self.isa_path)
|
||||
self.track1 = repository.create_track(self.isa_path, metadata1)
|
||||
|
||||
metadata2 = get_all_track_metadata(self.mom_path)
|
||||
self.track2 = repository.create_track(self.mom_path, metadata2)
|
||||
|
||||
# Add tracks and header to playlist
|
||||
self.row0 = repository.insert_row(
|
||||
playlist.playlist_id,
|
||||
row_number=0,
|
||||
track_id=self.track1.track_id,
|
||||
note="track 1",
|
||||
)
|
||||
self.row1 = repository.insert_row(
|
||||
playlist.playlist_id,
|
||||
row_number=1,
|
||||
track_id=0,
|
||||
note="Header row",
|
||||
)
|
||||
self.row2 = repository.insert_row(
|
||||
playlist.playlist_id,
|
||||
row_number=2,
|
||||
track_id=self.track2.track_id,
|
||||
note="track 2",
|
||||
)
|
||||
|
||||
def create_rows(
|
||||
self, playlist_name: str, number_of_rows: int
|
||||
) -> (PlaylistDTO, PlaylistModel):
|
||||
(playlist, model) = self.create_playlist_and_model(playlist_name)
|
||||
for row_number in range(number_of_rows):
|
||||
repository.insert_row(
|
||||
playlist.playlist_id, row_number, None, str(row_number)
|
||||
)
|
||||
|
||||
return (playlist, model)
|
||||
|
||||
def tearDown(self):
|
||||
"""Runs after each test"""
|
||||
|
||||
db.drop_all()
|
||||
|
||||
def test_add_track_to_header(self):
|
||||
"""Add a track to a header row"""
|
||||
|
||||
self.create_playlist_model_tracks("my playlist")
|
||||
repository.add_track_to_header(self.row1.playlistrow_id, self.track2.track_id)
|
||||
result = repository.get_playlist_row(self.row1.playlistrow_id)
|
||||
assert result.track.track_id == self.track2.track_id
|
||||
|
||||
def test_create_track(self):
|
||||
metadata = get_all_track_metadata(self.isa_path)
|
||||
repository.create_track(self.isa_path, metadata)
|
||||
results = repository.get_all_tracks()
|
||||
assert len(results) == 1
|
||||
assert results[0].path == self.isa_path
|
||||
|
||||
def test_get_track_by_id(self):
|
||||
metadata = get_all_track_metadata(self.isa_path)
|
||||
dto = repository.create_track(self.isa_path, metadata)
|
||||
result = repository.track_by_id(dto.track_id)
|
||||
assert result.path == self.isa_path
|
||||
|
||||
def test_get_track_by_artist(self):
|
||||
metadata = get_all_track_metadata(self.isa_path)
|
||||
_ = repository.create_track(self.isa_path, metadata)
|
||||
metadata = get_all_track_metadata(self.mom_path)
|
||||
_ = repository.create_track(self.mom_path, metadata)
|
||||
result_isa = repository.tracks_by_artist(self.isa_artist)
|
||||
assert len(result_isa) == 1
|
||||
assert result_isa[0].artist == self.isa_artist
|
||||
result_mom = repository.tracks_by_artist(self.mom_artist)
|
||||
assert len(result_mom) == 1
|
||||
assert result_mom[0].artist == self.mom_artist
|
||||
|
||||
def test_get_track_by_title(self):
|
||||
metadata_isa = get_all_track_metadata(self.isa_path)
|
||||
_ = repository.create_track(self.isa_path, metadata_isa)
|
||||
metadata_mom = get_all_track_metadata(self.mom_path)
|
||||
_ = repository.create_track(self.mom_path, metadata_mom)
|
||||
result_isa = repository.tracks_by_title(self.isa_title)
|
||||
assert len(result_isa) == 1
|
||||
assert result_isa[0].title == self.isa_title
|
||||
result_mom = repository.tracks_by_title(self.mom_title)
|
||||
assert len(result_mom) == 1
|
||||
assert result_mom[0].title == self.mom_title
|
||||
|
||||
def test_tracks_get_all_tracks(self):
|
||||
self.create_playlist_model_tracks(playlist_name="test_track_get_all_tracks")
|
||||
all_tracks = repository.get_all_tracks()
|
||||
assert len(all_tracks) == 2
|
||||
|
||||
def test_tracks_by_path(self):
|
||||
metadata_isa = get_all_track_metadata(self.isa_path)
|
||||
_ = repository.create_track(self.isa_path, metadata_isa)
|
||||
metadata_mom = get_all_track_metadata(self.mom_path)
|
||||
_ = repository.create_track(self.mom_path, metadata_mom)
|
||||
result_isa = repository.track_by_path(self.isa_path)
|
||||
assert result_isa.title == self.isa_title
|
||||
result_mom = repository.track_by_path(self.mom_path)
|
||||
assert result_mom.title == self.mom_title
|
||||
|
||||
def test_move_rows_test1(self):
|
||||
# move row 3 to row 5
|
||||
|
||||
number_of_rows = 10
|
||||
(playlist, model) = self.create_rows("test_move_rows_test1", number_of_rows)
|
||||
|
||||
repository.move_rows([3], playlist.playlist_id, 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in repository.get_playlist_rows(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 4, 5, 3, 6, 7, 8, 9]
|
||||
|
||||
def test_move_rows_test2(self):
|
||||
# move row 4 to row 3
|
||||
|
||||
number_of_rows = 10
|
||||
(playlist, model) = self.create_rows("test_move_rows_test2", number_of_rows)
|
||||
|
||||
repository.move_rows([4], playlist.playlist_id, 3)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in repository.get_playlist_rows(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 4, 3, 5, 6, 7, 8, 9]
|
||||
|
||||
def test_move_rows_test3(self):
|
||||
# move row 4 to row 2
|
||||
|
||||
number_of_rows = 10
|
||||
(playlist, model) = self.create_rows("test_move_rows_test3", number_of_rows)
|
||||
|
||||
repository.move_rows([4], playlist.playlist_id, 2)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in repository.get_playlist_rows(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 4, 2, 3, 5, 6, 7, 8, 9]
|
||||
|
||||
def test_move_rows_test4(self):
|
||||
# move rows [1, 4, 5, 10] → 8
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test4", number_of_rows)
|
||||
|
||||
repository.move_rows([1, 4, 5, 10], playlist.playlist_id, 8)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in repository.get_playlist_rows(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 2, 3, 6, 7, 8, 1, 4, 5, 10, 9]
|
||||
|
||||
def test_move_rows_test5(self):
|
||||
# move rows [3, 6] → 5
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test5", number_of_rows)
|
||||
|
||||
repository.move_rows([3, 6], playlist.playlist_id, 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in repository.get_playlist_rows(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 4, 5, 3, 6, 7, 8, 9, 10]
|
||||
|
||||
def test_move_rows_test6(self):
|
||||
# move rows [3, 5, 6] → 8
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test6", number_of_rows)
|
||||
|
||||
repository.move_rows([3, 5, 6], playlist.playlist_id, 8)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in repository.get_playlist_rows(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 4, 7, 8, 9, 10, 3, 5, 6]
|
||||
|
||||
def test_move_rows_test7(self):
|
||||
# move rows [7, 8, 10] → 5
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test7", number_of_rows)
|
||||
|
||||
repository.move_rows([7, 8, 10], playlist.playlist_id, 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in repository.get_playlist_rows(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
|
||||
|
||||
def test_move_rows_test8(self):
|
||||
# move rows [1, 2, 3] → 0
|
||||
# Replicate issue 244
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test8", number_of_rows)
|
||||
|
||||
repository.move_rows([0, 1, 2, 3], playlist.playlist_id, 0)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in repository.get_playlist_rows(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
def test_move_rows_to_playlist(self):
|
||||
number_of_rows = 11
|
||||
rows_to_move = [2, 4, 6]
|
||||
to_row = 5
|
||||
|
||||
(playlist_src, model_src) = self.create_rows("src playlist", number_of_rows)
|
||||
(playlist_dst, model_dst) = self.create_rows("dst playlist", number_of_rows)
|
||||
|
||||
repository.move_rows(
|
||||
rows_to_move, playlist_src.playlist_id, to_row, playlist_dst.playlist_id
|
||||
)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order_src = []
|
||||
for row in repository.get_playlist_rows(playlist_src.playlist_id):
|
||||
new_order_src.append(int(row.note))
|
||||
assert new_order_src == [0, 1, 3, 5, 7, 8, 9, 10]
|
||||
new_order_dst = []
|
||||
for row in repository.get_playlist_rows(playlist_dst.playlist_id):
|
||||
new_order_dst.append(int(row.note))
|
||||
assert new_order_dst == [0, 1, 2, 3, 4, 2, 4, 6, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
def test_remove_rows(self):
|
||||
pass
|
||||
|
||||
def test_get_playlist_by_id(self):
|
||||
pass
|
||||
|
||||
def test_settings(self):
|
||||
pass
|
||||
@ -132,9 +132,8 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
Config.ROOT = os.path.join(os.path.dirname(__file__), "testdata")
|
||||
|
||||
with db.Session() as session:
|
||||
utilities.check_db(session)
|
||||
utilities.update_bitrates(session)
|
||||
utilities.check_db()
|
||||
utilities.update_bitrates()
|
||||
|
||||
|
||||
# def test_meta_all_clear(qtbot, session):
|
||||
|
||||
230
uv.lock
230
uv.lock
@ -16,16 +16,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.15.1"
|
||||
version = "1.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/ed/901044acb892caa5604bf818d2da9ab0df94ef606c6059fdf367894ebf60/alembic-1.15.1.tar.gz", hash = "sha256:e1a1c738577bca1f27e68728c910cd389b9a92152ff91d902da649c192e30c49", size = 1924789 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/57/e314c31b261d1e8a5a5f1908065b4ff98270a778ce7579bd4254477209a7/alembic-1.15.2.tar.gz", hash = "sha256:1c72391bbdeffccfe317eefba686cb9a3c078005478885413b95c3b26c57a8a7", size = 1925573 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/f7/d398fae160568472ddce0b3fde9c4581afc593019a6adc91006a66406991/alembic-1.15.1-py3-none-any.whl", hash = "sha256:197de710da4b3e91cf66a826a5b31b5d59a127ab41bd0fc42863e2902ce2bbbe", size = 231753 },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/18/d89a443ed1ab9bcda16264716f809c663866d4ca8de218aa78fd50b38ead/alembic-1.15.2-py3-none-any.whl", hash = "sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53", size = 231911 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -39,11 +39,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.1.0"
|
||||
version = "25.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -141,31 +141,31 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.6.12"
|
||||
version = "7.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116 },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -223,16 +223,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "flake8"
|
||||
version = "7.1.2"
|
||||
version = "7.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mccabe" },
|
||||
{ name = "pycodestyle" },
|
||||
{ name = "pyflakes" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/16/3f2a0bb700ad65ac9663262905a025917c020a3f92f014d2ba8964b4602c/flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd", size = 48119 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/c4/5842fc9fc94584c455543540af62fd9900faade32511fab650e9891ec225/flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426", size = 48177 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/35/f8/08d37b2cd89da306e3520bd27f8a85692122b42b56c0c2c3784ff09c022f/flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", size = 57745 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/5c/0627be4c9976d56b1217cb5187b7504e7fd7d3503f8bfd312a04077bd4f7/flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343", size = 57786 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -287,11 +287,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -309,7 +309,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ipython"
|
||||
version = "9.0.1"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@ -323,9 +323,9 @@ dependencies = [
|
||||
{ name = "stack-data" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/33/1901c9a842b301d8674f367dee597e654e402548a903faf7280aae8fc2d4/ipython-9.0.1.tar.gz", hash = "sha256:377ea91c8226b48dc9021ac9846a64761abc7ddf74c5efe38e6eb06f6e052f3a", size = 4365847 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/ce/012a0f40ca58a966f87a6e894d6828e2817657cbdf522b02a5d3a87d92ce/ipython-9.0.2.tar.gz", hash = "sha256:ec7b479e3e5656bf4f58c652c120494df1820f4f28f522fb7ca09e213c2aab52", size = 4366102 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/39/fda74f8215ef94a812dd780073c61a826a88a01e51f627a3454f7ae6951d/ipython-9.0.1-py3-none-any.whl", hash = "sha256:3e878273824b52e0a2280ed84f8193aba8c4ba9a6f45a438348a3d5ef1a34bd0", size = 600186 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/3a/917cb9e72f4e1a4ea13c862533205ae1319bd664119189ee5cc9e4e95ebf/ipython-9.0.2-py3-none-any.whl", hash = "sha256:143ef3ea6fb1e1bffb4c74b114051de653ffb7737a3f7ab1670e657ca6ae8c44", size = 600524 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -616,30 +616,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.2.3"
|
||||
version = "2.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/90/8956572f5c4ae52201fdec7ba2044b2c882832dcec7d5d0922c9e9acf2de/numpy-2.2.3.tar.gz", hash = "sha256:dbdc15f0c81611925f382dfa97b3bd0bc2c1ce19d4fe50482cb0ddc12ba30020", size = 20262700 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/8b/88b98ed534d6a03ba8cddb316950fe80842885709b58501233c29dfa24a9/numpy-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bfdb06b395385ea9b91bf55c1adf1b297c9fdb531552845ff1d3ea6e40d5aba", size = 20916001 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/b4/def6ec32c725cc5fbd8bdf8af80f616acf075fe752d8a23e895da8c67b70/numpy-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23c9f4edbf4c065fddb10a4f6e8b6a244342d95966a48820c614891e5059bb50", size = 14130721 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/60/70af0acc86495b25b672d403e12cb25448d79a2b9658f4fc45e845c397a8/numpy-2.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:a0c03b6be48aaf92525cccf393265e02773be8fd9551a2f9adbe7db1fa2b60f1", size = 5130999 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/69/d96c006fb73c9a47bcb3611417cf178049aae159afae47c48bd66df9c536/numpy-2.2.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:2376e317111daa0a6739e50f7ee2a6353f768489102308b0d98fcf4a04f7f3b5", size = 6665299 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/3f/d8a877b6e48103733ac224ffa26b30887dc9944ff95dffdfa6c4ce3d7df3/numpy-2.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fb62fe3d206d72fe1cfe31c4a1106ad2b136fcc1606093aeab314f02930fdf2", size = 14064096 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/43/619c2c7a0665aafc80efca465ddb1f260287266bdbdce517396f2f145d49/numpy-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52659ad2534427dffcc36aac76bebdd02b67e3b7a619ac67543bc9bfe6b7cdb1", size = 16114758 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/ee4fe4f60967ccd3897aa71ae14cdee9e3c097e3256975cc9575d393cb42/numpy-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b416af7d0ed3271cad0f0a0d0bee0911ed7eba23e66f8424d9f3dfcdcae1304", size = 15259880 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/8b55cf05db6d85b7a7d414b3d1bd5a740706df00bfa0824a08bf041e52ee/numpy-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1402da8e0f435991983d0a9708b779f95a8c98c6b18a171b9f1be09005e64d9d", size = 17876721 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/d6/b4c2f0564b7dcc413117b0ffbb818d837e4b29996b9234e38b2025ed24e7/numpy-2.2.3-cp313-cp313-win32.whl", hash = "sha256:136553f123ee2951bfcfbc264acd34a2fc2f29d7cdf610ce7daf672b6fbaa693", size = 6290195 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/e7/7d55a86719d0de7a6a597949f3febefb1009435b79ba510ff32f05a8c1d7/numpy-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5b732c8beef1d7bc2d9e476dbba20aaff6167bf205ad9aa8d30913859e82884b", size = 12619013 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/1f/0b863d5528b9048fd486a56e0b97c18bf705e88736c8cea7239012119a54/numpy-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:435e7a933b9fda8126130b046975a968cc2d833b505475e588339e09f7672890", size = 20944621 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/99/b478c384f7a0a2e0736177aafc97dc9152fc036a3fdb13f5a3ab225f1494/numpy-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7678556eeb0152cbd1522b684dcd215250885993dd00adb93679ec3c0e6e091c", size = 14142502 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/61/2d9a694a0f9cd0a839501d362de2a18de75e3004576a3008e56bdd60fcdb/numpy-2.2.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2e8da03bd561504d9b20e7a12340870dfc206c64ea59b4cfee9fceb95070ee94", size = 5176293 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/35/51e94011b23e753fa33f891f601e5c1c9a3d515448659b06df9d40c0aa6e/numpy-2.2.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:c9aa4496fd0e17e3843399f533d62857cef5900facf93e735ef65aa4bbc90ef0", size = 6691874 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/cf/06e37619aad98a9d03bd8d65b8e3041c3a639be0f5f6b0a0e2da544538d4/numpy-2.2.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ca91d61a4bf61b0f2228f24bbfa6a9facd5f8af03759fe2a655c50ae2c6610", size = 14036826 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/93/5d7d19955abd4d6099ef4a8ee006f9ce258166c38af259f9e5558a172e3e/numpy-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deaa09cd492e24fd9b15296844c0ad1b3c976da7907e1c1ed3a0ad21dded6f76", size = 16096567 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/53/d1c599acf7732d81f46a93621dab6aa8daad914b502a7a115b3f17288ab2/numpy-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:246535e2f7496b7ac85deffe932896a3577be7af8fb7eebe7146444680297e9a", size = 15242514 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/43/c0f5411c7b3ea90adf341d05ace762dad8cb9819ef26093e27b15dd121ac/numpy-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:daf43a3d1ea699402c5a850e5313680ac355b4adc9770cd5cfc2940e7861f1bf", size = 17872920 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/57/6dbdd45ab277aff62021cafa1e15f9644a52f5b5fc840bc7591b4079fb58/numpy-2.2.3-cp313-cp313t-win32.whl", hash = "sha256:cf802eef1f0134afb81fef94020351be4fe1d6681aadf9c5e862af6602af64ef", size = 6346584 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/9b/484f7d04b537d0a1202a5ba81c6f53f1846ae6c63c2127f8df869ed31342/numpy-2.2.3-cp313-cp313t-win_amd64.whl", hash = "sha256:aee2512827ceb6d7f517c8b85aa5d3923afe8fc7a57d028cffcd522f1c6fd082", size = 12706784 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -720,11 +720,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.6"
|
||||
version = "4.3.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -799,11 +799,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pycodestyle"
|
||||
version = "2.12.1"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/6e/1f4a62078e4d95d82367f24e685aef3a672abfd27d1a868068fed4ed2254/pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae", size = 39312 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/be/b00116df1bfb3e0bb5b45e29d604799f7b91dd861637e4d448b4e09e6a3e/pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9", size = 31424 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -841,11 +841,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyflakes"
|
||||
version = "3.2.0"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/cc/1df338bd7ed1fa7c317081dcf29bf2f01266603b301e6858856d346a12b3/pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b", size = 64175 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/40/b293a4fa769f3b02ab9e387c707c4cbdc34f073f945de0386107d4e669e6/pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a", size = 63164 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -995,15 +995,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.0.0"
|
||||
version = "6.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1071,68 +1071,68 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "rapidfuzz"
|
||||
version = "3.12.2"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/be/8dff25a6157dfbde9867720b1282157fe7b809e085130bb89d7655c62186/rapidfuzz-3.12.2.tar.gz", hash = "sha256:b0ba1ccc22fff782e7152a3d3d0caca44ec4e32dc48ba01c560b8593965b5aa3", size = 57907839 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/6895abc3a3d056b9698da3199b04c0e56226d530ae44a470edabf8b664f0/rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8", size = 57904226 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/59/2ea3b5bb82798eae73d6ee892264ebfe42727626c1f0e96c77120f0d5cf6/rapidfuzz-3.12.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:941f31038dba5d3dedcfcceba81d61570ad457c873a24ceb13f4f44fcb574260", size = 1936870 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/85/4e486bf9ea05e771ad231731305ed701db1339157f630b76b246ce29cf71/rapidfuzz-3.12.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fe2dfc454ee51ba168a67b1e92b72aad251e45a074972cef13340bbad2fd9438", size = 1424231 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/60/aeea3eed402c40a8cf055d554678769fbee0dd95c22f04546070a22bb90e/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78fafaf7f5a48ee35ccd7928339080a0136e27cf97396de45259eca1d331b714", size = 1398055 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/757106f4c21fe3f20ce13ba3df560da60e52fe0dc390fd22bf613761669c/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0c7989ff32c077bb8fd53253fd6ca569d1bfebc80b17557e60750e6909ba4fe", size = 5526188 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/a2/7c680cdc5532746dba67ecf302eed975252657094e50ae334fa9268352e8/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96fa00bc105caa34b6cd93dca14a29243a3a7f0c336e4dcd36348d38511e15ac", size = 1648483 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b0/ce942a1448b1a75d64af230dd746dede502224dd29ca9001665bbfd4bee6/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bccfb30c668620c5bc3490f2dc7d7da1cca0ead5a9da8b755e2e02e2ef0dff14", size = 1676076 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/71/81f77b08333200be6984b6cdf2bdfd7cfca4943f16b478a2f7838cba8d66/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f9b0adc3d894beb51f5022f64717b6114a6fabaca83d77e93ac7675911c8cc5", size = 3114169 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/16/f3f34b207fdc8c61a33f9d2d61fc96b62c7dadca88bda1df1be4b94afb0b/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:32691aa59577f42864d5535cb6225d0f47e2c7bff59cf4556e5171e96af68cc1", size = 2485317 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a6/b954f0766f644eb8dd8df44703e024ab4f5f15a8f8f5ea969963dd036f50/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:758b10380ad34c1f51753a070d7bb278001b5e6fcf544121c6df93170952d705", size = 7844495 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/8f/1dc604d05e07150a02b56a8ffc47df75ce316c65467259622c9edf098451/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:50a9c54c0147b468363119132d514c5024fbad1ed8af12bd8bd411b0119f9208", size = 2873242 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/a9/9c649ace4b7f885e0a5fdcd1f33b057ebd83ecc2837693e6659bd944a2bb/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e3ceb87c11d2d0fbe8559bb795b0c0604b84cfc8bb7b8720b5c16e9e31e00f41", size = 3519124 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/81/ce0b774e540a2e22ec802e383131d7ead18347197304d584c4ccf7b8861a/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7c9a003002434889255ff5676ca0f8934a478065ab5e702f75dc42639505bba", size = 4557831 },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/28/7bf0ee8d35efa7ab14e83d1795cdfd54833aa0428b6f87e987893136c372/rapidfuzz-3.12.2-cp313-cp313-win32.whl", hash = "sha256:cf165a76870cd875567941cf861dfd361a0a6e6a56b936c5d30042ddc9def090", size = 1842802 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/7e/792d609484776c8a40e1695ebd28b62196be9f8347b785b9104604dc7268/rapidfuzz-3.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:55bcc003541f5f16ec0a73bf6de758161973f9e8d75161954380738dd147f9f2", size = 1615808 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/43/ca3d1018b392f49131843648e10b08ace23afe8dad3bee5f136e4346b7cd/rapidfuzz-3.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:69f6ecdf1452139f2b947d0c169a605de578efdb72cbb2373cb0a94edca1fd34", size = 863535 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/76/606e71e4227790750f1646f3c5c873e18d6cfeb6f9a77b2b8c4dec8f0f66/rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23", size = 1982282 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/f5/d0b48c6b902607a59fd5932a54e3518dae8223814db8349b0176e6e9444b/rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae", size = 1439274 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/cf/c3ac8c80d8ced6c1f99b5d9674d397ce5d0e9d0939d788d67c010e19c65f/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa", size = 1399854 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/5d/ca8698e452b349c8313faf07bfa84e7d1c2d2edf7ccc67bcfc49bee1259a/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611", size = 5308962 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/0a/bebada332854e78e68f3d6c05226b23faca79d71362509dbcf7b002e33b7/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b", size = 1625016 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/0c/9e58d4887b86d7121d1c519f7050d1be5eb189d8a8075f5417df6492b4f5/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527", size = 1600414 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/df/6096bc669c1311568840bdcbb5a893edc972d1c8d2b4b4325c21d54da5b1/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939", size = 3053179 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/5179c583b75fce3e65a5cd79a3561bd19abd54518cb7c483a89b284bf2b9/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df", size = 2456856 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/64/e9804212e3286d027ac35bbb66603c9456c2bce23f823b67d2f5cabc05c1/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798", size = 7567107 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f2/7d69e7bf4daec62769b11757ffc31f69afb3ce248947aadbb109fefd9f65/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d", size = 2854192 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/21/ab4ad7d7d0f653e6fe2e4ccf11d0245092bef94cdff587a21e534e57bda8/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566", size = 3398876 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a8/45bba94c2489cb1ee0130dcb46e1df4fa2c2b25269e21ffd15240a80322b/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72", size = 4377077 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f3/5e0c6ae452cbb74e5436d3445467447e8c32f3021f48f93f15934b8cffc2/rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8", size = 1822066 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e3/a98c25c4f74051df4dcf2f393176b8663bfd93c7afc6692c84e96de147a2/rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264", size = 1615100 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/b1/05cd5e697c00cd46d7791915f571b38c8531f714832eff2c5e34537c49ee/rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53", size = 858976 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "13.9.4"
|
||||
version = "14.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "75.8.2"
|
||||
version = "78.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/53/43d99d7687e8cdef5ab5f9ec5eaf2c0423c2b35133a2b7e7bc276fc32b21/setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2", size = 1344083 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/0db4da3bc908df06e5efae42b44e75c81dd52716e10192ff36d0c1c8e379/setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54", size = 1367827 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/21/f43f0a1fa8b06b32812e0975981f4677d28e0f3271601dc88ac5a5b83220/setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8", size = 1256108 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.38"
|
||||
version = "2.0.40"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/08/9a90962ea72acd532bda71249a626344d855c4032603924b1b547694b837/sqlalchemy-2.0.38.tar.gz", hash = "sha256:e5a4d82bdb4bf1ac1285a68eab02d253ab73355d9f0fe725a97e1e0fa689decb", size = 9634782 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/77/caa875a1f5a8a8980b564cc0e6fee1bc992d62d29101252561d0a5e9719c/SQLAlchemy-2.0.38-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ecef029b69843b82048c5b347d8e6049356aa24ed644006c9a9d7098c3bd3bfd", size = 2100201 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ec/94bb036ec78bf9a20f8010c807105da9152dd84f72e8c51681ad2f30b3fd/SQLAlchemy-2.0.38-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c8bcad7fc12f0cc5896d8e10fdf703c45bd487294a986903fe032c72201596b", size = 2090678 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/63ff1893f146e34d3934c0860209fdd3925c25ee064330e6c2152bacc335/SQLAlchemy-2.0.38-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a0ef3f98175d77180ffdc623d38e9f1736e8d86b6ba70bff182a7e68bed7727", size = 3177107 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/4f/b933bea41a602b5f274065cc824fae25780ed38664d735575192490a021b/SQLAlchemy-2.0.38-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b0ac78898c50e2574e9f938d2e5caa8fe187d7a5b69b65faa1ea4648925b096", size = 3190435 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/23/9e654b4059e385988de08c5d3b38a369ea042f4c4d7c8902376fd737096a/SQLAlchemy-2.0.38-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9eb4fa13c8c7a2404b6a8e3772c17a55b1ba18bc711e25e4d6c0c9f5f541b02a", size = 3123648 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/59/94c6d804e76ebc6412a08d2b086a8cb3e5a056cd61508e18ddaf3ec70100/SQLAlchemy-2.0.38-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5dba1cdb8f319084f5b00d41207b2079822aa8d6a4667c0f369fce85e34b0c86", size = 3151789 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/27/17f143013aabbe1256dce19061eafdce0b0142465ce32168cdb9a18c04b1/SQLAlchemy-2.0.38-cp313-cp313-win32.whl", hash = "sha256:eae27ad7580529a427cfdd52c87abb2dfb15ce2b7a3e0fc29fbb63e2ed6f8120", size = 2073023 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/3e/259404b03c3ed2e7eee4c179e001a07d9b61070334be91124cf4ad32eec7/SQLAlchemy-2.0.38-cp313-cp313-win_amd64.whl", hash = "sha256:b335a7c958bc945e10c522c069cd6e5804f4ff20f9a744dd38e748eb602cbbda", size = 2096908 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e4/592120713a314621c692211eba034d09becaf6bc8848fabc1dc2a54d8c16/SQLAlchemy-2.0.38-py3-none-any.whl", hash = "sha256:63178c675d4c80def39f1febd625a6333f44c0ba269edd8a468b156394b27753", size = 1896347 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/18/4e3a86cc0232377bc48c373a9ba6a1b3fb79ba32dbb4eda0b357f5a2c59d/sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01", size = 2107887 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/60/9fa692b1d2ffc4cbd5f47753731fd332afed30137115d862d6e9a1e962c7/sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705", size = 2098367 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/9f/84b78357ca641714a439eb3fbbddb17297dacfa05d951dbf24f28d7b5c08/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364", size = 3184806 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/7d/e06164161b6bfce04c01bfa01518a20cccbd4100d5c951e5a7422189191a/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0", size = 3198131 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/51/354af20da42d7ec7b5c9de99edafbb7663a1d75686d1999ceb2c15811302/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db", size = 3131364 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/2f/48a41ff4e6e10549d83fcc551ab85c268bde7c03cf77afb36303c6594d11/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26", size = 3159482 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/ac/e5e0a807163652a35be878c0ad5cfd8b1d29605edcadfb5df3c512cdf9f3/sqlalchemy-2.0.40-cp313-cp313-win32.whl", hash = "sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500", size = 2080704 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/cb/f38c61f7f2fd4d10494c1c135ff6a6ddb63508d0b47bccccd93670637309/sqlalchemy-2.0.40-cp313-cp313-win_amd64.whl", hash = "sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad", size = 2104564 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1208,29 +1208,29 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-psutil"
|
||||
version = "7.0.0.20250218"
|
||||
version = "7.0.0.20250401"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/7c/145600d30456e7ccbb499abcf718aab2bd830e604a0ae8eb32b67cd346a6/types_psutil-7.0.0.20250218.tar.gz", hash = "sha256:1e642cdafe837b240295b23b1cbd4691d80b08a07d29932143cbbae30eb0db9c", size = 19828 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/fc/3829cb113aa05c268b18369f1f003a4589216931658ebfa69e3d4931ba60/types_psutil-7.0.0.20250401.tar.gz", hash = "sha256:2a7d663c0888a079fc1643ebc109ad12e57a21c9552a9e2035da504191336dbf", size = 20273 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/c8/f4365293408da4a9bcb1849d3efd8c60427cffff68cbb98ab1b81851d8bb/types_psutil-7.0.0.20250218-py3-none-any.whl", hash = "sha256:1447a30c282aafefcf8941ece854e1100eee7b0296a9d9be9977292f0269b121", size = 22763 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/42/45e01f3bce242c0caad36b968114a00f454169df6c771c092c96727239d8/types_psutil-7.0.0.20250401-py3-none-any.whl", hash = "sha256:ed23f7140368104afe4e05a6085a5fa56fbe8c880a0f4dfe8d63e041106071ed", size = 23173 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyyaml"
|
||||
version = "6.0.12.20241230"
|
||||
version = "6.0.12.20250402"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/68/609eed7402f87c9874af39d35942744e39646d1ea9011765ec87b01b2a3c/types_pyyaml-6.0.12.20250402.tar.gz", hash = "sha256:d7c13c3e6d335b6af4b0122a01ff1d270aba84ab96d1a1a1063ecba3e13ec075", size = 17282 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/56/1fe61db05685fbb512c07ea9323f06ea727125951f1eb4dff110b3311da3/types_pyyaml-6.0.12.20250402-py3-none-any.whl", hash = "sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681", size = 20329 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
version = "4.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user