Rewrite file importer
This commit is contained in:
parent
d400ba3957
commit
4c53791f4d
@ -39,6 +39,7 @@ class Config(object):
|
||||
DEBUG_MODULES: List[Optional[str]] = []
|
||||
DEFAULT_COLUMN_WIDTH = 200
|
||||
DISPLAY_SQL = False
|
||||
DO_NOT_IMPORT = "Do not import"
|
||||
ENGINE_OPTIONS = dict(pool_pre_ping=True)
|
||||
EPOCH = dt.datetime(1970, 1, 1)
|
||||
ERRORS_FROM = ["noreply@midnighthax.com"]
|
||||
@ -63,6 +64,7 @@ class Config(object):
|
||||
HIDE_AFTER_PLAYING_OFFSET = 5000
|
||||
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
|
||||
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
|
||||
IMPORT_AS_NEW = "Import as new track"
|
||||
INFO_TAB_TITLE_LENGTH = 15
|
||||
INTRO_SECONDS_FORMAT = ".1f"
|
||||
INTRO_SECONDS_WARNING_MS = 3000
|
||||
@ -80,6 +82,7 @@ class Config(object):
|
||||
MAX_INFO_TABS = 5
|
||||
MAX_MISSING_FILES_TO_REPORT = 10
|
||||
MILLISECOND_SIGFIGS = 0
|
||||
MINIMUM_FUZZYMATCH = 60.0
|
||||
MINIMUM_ROW_HEIGHT = 30
|
||||
NOTE_TIME_FORMAT = "%H:%M"
|
||||
OBS_HOST = "localhost"
|
||||
|
||||
199
app/dialogs.py
199
app/dialogs.py
@ -17,7 +17,7 @@ import pydymenu # type: ignore
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
from classes import MusicMusterSignals, TrackFileData
|
||||
from classes import MusicMusterSignals
|
||||
from config import Config
|
||||
from helpers import (
|
||||
ask_yes_no,
|
||||
@ -32,203 +32,6 @@ from playlistmodel import PlaylistModel
|
||||
from ui import dlg_TrackSelect_ui, dlg_replace_files_ui
|
||||
|
||||
|
||||
class ReplaceFilesDialog(QDialog):
|
||||
"""Import files as new or replacements"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
main_window: QMainWindow,
|
||||
*args: Qt.WindowType,
|
||||
**kwargs: Qt.WindowType,
|
||||
) -> None:
|
||||
super().__init__(main_window, *args, **kwargs)
|
||||
self.session = session
|
||||
self.main_window = main_window
|
||||
self.ui = dlg_replace_files_ui.Ui_Dialog()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.ui.lblSourceDirectory.setText(Config.REPLACE_FILES_DEFAULT_SOURCE)
|
||||
self.ui.lblDestinationDirectory.setText(
|
||||
Config.REPLACE_FILES_DEFAULT_DESTINATION
|
||||
)
|
||||
self.replacement_files: list[TrackFileData] = []
|
||||
|
||||
# We only want to run this against the production database because
|
||||
# we will affect files in the common pool of tracks used by all
|
||||
# databases
|
||||
dburi = os.environ.get("DATABASE_URL")
|
||||
if not dburi or "musicmuster_prod" not in dburi:
|
||||
if not ask_yes_no(
|
||||
"Not production database",
|
||||
"Not on production database - continue?",
|
||||
default_yes=False,
|
||||
):
|
||||
return
|
||||
if self.ui.lblSourceDirectory.text() == self.ui.lblDestinationDirectory.text():
|
||||
show_warning(
|
||||
parent=self.main_window,
|
||||
title="Error",
|
||||
msg="Cannot import into source directory",
|
||||
)
|
||||
return
|
||||
|
||||
self.ui.tableWidget.setHorizontalHeaderLabels(["Path", "Title", "Artist"])
|
||||
|
||||
# Work through new files
|
||||
source_dir = self.ui.lblSourceDirectory.text()
|
||||
with db.Session() as session:
|
||||
for new_file_basename in os.listdir(source_dir):
|
||||
new_file_path = os.path.join(source_dir, new_file_basename)
|
||||
if not os.path.isfile(new_file_path):
|
||||
continue
|
||||
rf = TrackFileData(new_file_path=new_file_path)
|
||||
rf.tags = get_tags(new_file_path)
|
||||
if not (
|
||||
"title" in rf.tags
|
||||
and "artist" in rf.tags
|
||||
and rf.tags["title"]
|
||||
and rf.tags["artist"]
|
||||
):
|
||||
show_warning(
|
||||
parent=self.main_window,
|
||||
title="Error",
|
||||
msg=(
|
||||
f"File {new_file_path} missing tags\n\n:"
|
||||
f"Title={rf.tags['title']}\n"
|
||||
f"Artist={rf.tags['artist']}\n"
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Check for same filename
|
||||
match_track = self.check_by_basename(
|
||||
session, new_file_path, rf.tags["artist"], rf.tags["title"]
|
||||
)
|
||||
if not match_track:
|
||||
match_track = self.check_by_title(
|
||||
session, new_file_path, rf.tags["artist"], rf.tags["title"]
|
||||
)
|
||||
|
||||
if not match_track:
|
||||
match_track = self.get_fuzzy_match(session, new_file_basename)
|
||||
|
||||
# Build summary
|
||||
if match_track:
|
||||
# We will store new file in the same directory as the
|
||||
# existing file but with the new file name
|
||||
rf.track_path = os.path.join(
|
||||
os.path.dirname(match_track.path), new_file_basename
|
||||
)
|
||||
|
||||
# We will remove existing track file
|
||||
rf.obsolete_path = match_track.path
|
||||
|
||||
rf.track_id = match_track.id
|
||||
match_basename = os.path.basename(match_track.path)
|
||||
if match_basename == new_file_basename:
|
||||
path_text = " " + new_file_basename + " (no change)"
|
||||
else:
|
||||
path_text = (
|
||||
f" {match_basename} →\n {new_file_basename} (replace)"
|
||||
)
|
||||
filename_item = QTableWidgetItem(path_text)
|
||||
|
||||
if match_track.title == rf.tags["title"]:
|
||||
title_text = " " + rf.tags["title"] + " (no change)"
|
||||
else:
|
||||
title_text = (
|
||||
f" {match_track.title} →\n {rf.tags['title']} (update)"
|
||||
)
|
||||
title_item = QTableWidgetItem(title_text)
|
||||
|
||||
if match_track.artist == rf.tags["artist"]:
|
||||
artist_text = " " + rf.tags["artist"] + " (no change)"
|
||||
else:
|
||||
artist_text = (
|
||||
f" {match_track.artist} →\n {rf.tags['artist']} (update)"
|
||||
)
|
||||
artist_item = QTableWidgetItem(artist_text)
|
||||
|
||||
else:
|
||||
rf.track_path = os.path.join(
|
||||
Config.REPLACE_FILES_DEFAULT_DESTINATION, new_file_basename
|
||||
)
|
||||
filename_item = QTableWidgetItem(" " + new_file_basename + " (new)")
|
||||
title_item = QTableWidgetItem(" " + rf.tags["title"])
|
||||
artist_item = QTableWidgetItem(" " + rf.tags["artist"])
|
||||
|
||||
self.replacement_files.append(rf)
|
||||
row = self.ui.tableWidget.rowCount()
|
||||
self.ui.tableWidget.insertRow(row)
|
||||
self.ui.tableWidget.setItem(row, 0, filename_item)
|
||||
self.ui.tableWidget.setItem(row, 1, title_item)
|
||||
self.ui.tableWidget.setItem(row, 2, artist_item)
|
||||
|
||||
self.ui.tableWidget.resizeColumnsToContents()
|
||||
self.ui.tableWidget.resizeRowsToContents()
|
||||
|
||||
def check_by_basename(
|
||||
self, session: Session, new_path: str, new_path_artist: str, new_path_title: str
|
||||
) -> Optional[Tracks]:
|
||||
"""
|
||||
Return Track that matches basename and tags
|
||||
"""
|
||||
|
||||
match_track = None
|
||||
candidates_by_basename = Tracks.get_by_basename(session, new_path)
|
||||
if candidates_by_basename:
|
||||
# Check tags are the same
|
||||
for cbbn in candidates_by_basename:
|
||||
cbbn_tags = get_tags(cbbn.path)
|
||||
if (
|
||||
"title" in cbbn_tags
|
||||
and cbbn_tags["title"].lower() == new_path_title.lower()
|
||||
and "artist" in cbbn_tags
|
||||
and cbbn_tags["artist"].lower() == new_path_artist.lower()
|
||||
):
|
||||
match_track = cbbn
|
||||
break
|
||||
|
||||
return match_track
|
||||
|
||||
def check_by_title(
|
||||
self, session: Session, new_path: str, new_path_artist: str, new_path_title: str
|
||||
) -> Optional[Tracks]:
|
||||
"""
|
||||
Return Track that mathces title and artist
|
||||
"""
|
||||
|
||||
match_track = None
|
||||
candidates_by_title = Tracks.search_titles(session, new_path_title)
|
||||
if candidates_by_title:
|
||||
# Check artist tag
|
||||
for cbt in candidates_by_title:
|
||||
if not os.path.exists(cbt.path):
|
||||
return None
|
||||
try:
|
||||
cbt_artist = get_tags(cbt.path)["artist"]
|
||||
if cbt_artist.lower() == new_path_artist.lower():
|
||||
match_track = cbt
|
||||
break
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
return match_track
|
||||
|
||||
def get_fuzzy_match(self, session: Session, fname: str) -> Optional[Tracks]:
|
||||
"""
|
||||
Return Track that matches fuzzy filename search
|
||||
"""
|
||||
|
||||
match_track = None
|
||||
choice = pydymenu.rofi([a.path for a in Tracks.get_all(session)], prompt=fname)
|
||||
if choice:
|
||||
match_track = Tracks.get_by_path(session, choice[0])
|
||||
|
||||
return match_track
|
||||
|
||||
|
||||
class TrackSelectDialog(QDialog):
|
||||
"""Select track from database"""
|
||||
|
||||
|
||||
533
app/file_importer.py
Normal file
533
app/file_importer.py
Normal file
@ -0,0 +1,533 @@
|
||||
# Standard library imports
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fuzzywuzzy import fuzz # type: ignore
|
||||
import os.path
|
||||
from typing import Optional
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QObject,
|
||||
Qt,
|
||||
QThread,
|
||||
)
|
||||
from PyQt6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
# Third party imports
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
from classes import (
|
||||
ApplicationError,
|
||||
AudioMetadata,
|
||||
FileErrors,
|
||||
MusicMusterSignals,
|
||||
Tags,
|
||||
)
|
||||
from config import Config
|
||||
from helpers import (
|
||||
file_is_unreadable,
|
||||
get_tags,
|
||||
show_warning,
|
||||
)
|
||||
from log import log
|
||||
from models import db, Tracks
|
||||
from music_manager import track_sequence
|
||||
from playlistmodel import PlaylistModel
|
||||
import helpers
|
||||
|
||||
|
||||
class DoTrackImport(QObject):
|
||||
import_finished = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
import_file_path: str,
|
||||
tags: Tags,
|
||||
destination_track_path: str,
|
||||
track_id: int,
|
||||
audio_metadata: AudioMetadata,
|
||||
source_model: PlaylistModel,
|
||||
row_number: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
Save parameters
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.import_file_path = import_file_path
|
||||
self.tags = tags
|
||||
self.destination_track_path = destination_track_path
|
||||
self.track_id = track_id
|
||||
self.audio_metadata = audio_metadata
|
||||
self.source_model = source_model
|
||||
|
||||
if row_number is None:
|
||||
self.next_row_number = source_model.rowCount()
|
||||
else:
|
||||
self.next_row_number = row_number
|
||||
|
||||
self.signals = MusicMusterSignals()
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Either create track objects from passed files or update exising track
|
||||
objects.
|
||||
|
||||
And add to visible playlist or update playlist if track already present.
|
||||
"""
|
||||
|
||||
temp_file: Optional[str] = None
|
||||
|
||||
# If destination exists, move it out of the way
|
||||
if os.path.exists(self.destination_track_path):
|
||||
temp_file = self.destination_track_path + ".TMP"
|
||||
shutil.move(self.destination_track_path, temp_file)
|
||||
# Move file to destination
|
||||
shutil.move(self.import_file_path, self.destination_track_path)
|
||||
|
||||
with db.Session() as session:
|
||||
self.signals.status_message_signal.emit(
|
||||
f"Importing {os.path.basename(self.import_file_path)}", 5000
|
||||
)
|
||||
|
||||
if self.track_id == 0:
|
||||
# Import new track
|
||||
try:
|
||||
track = Tracks(
|
||||
session,
|
||||
path=self.destination_track_path,
|
||||
**self.tags._asdict(),
|
||||
**self.audio_metadata._asdict(),
|
||||
)
|
||||
except Exception as e:
|
||||
self.signals.show_warning_signal.emit(
|
||||
"Error importing track", str(e)
|
||||
)
|
||||
return
|
||||
else:
|
||||
track = session.get(Tracks, self.track_id)
|
||||
if track:
|
||||
for key, value in self.tags._asdict().items():
|
||||
if hasattr(track, key):
|
||||
setattr(track, key, value)
|
||||
for key, value in self.audio_metadata._asdict().items():
|
||||
if hasattr(track, key):
|
||||
setattr(track, key, value)
|
||||
track.path = self.destination_track_path
|
||||
session.commit()
|
||||
|
||||
helpers.normalise_track(self.destination_track_path)
|
||||
self.source_model.insert_row(self.next_row_number, track.id, "imported")
|
||||
self.next_row_number += 1
|
||||
|
||||
self.signals.status_message_signal.emit(
|
||||
f"{os.path.basename(self.import_file_path)} imported", 10000
|
||||
)
|
||||
self.import_finished.emit()
|
||||
|
||||
|
||||
class FileImporter:
|
||||
"""
|
||||
Manage importing of files
|
||||
"""
|
||||
|
||||
def __init__(self, active_proxy_model: PlaylistModel, row_number: int) -> None:
|
||||
"""
|
||||
Set up class
|
||||
"""
|
||||
|
||||
# Save parameters
|
||||
self.active_proxy_model = active_proxy_model
|
||||
self.row_number = row_number
|
||||
# Data structure to track files to import
|
||||
self.import_files_data: list[TrackFileData] = []
|
||||
# Dictionary of exsting tracks
|
||||
self.existing_tracks = self._get_existing_tracks()
|
||||
# List of track_id, title tuples
|
||||
self.track_idx_and_title = [
|
||||
((a.id, a.title)) for a in self.existing_tracks.values()
|
||||
]
|
||||
# Files to import
|
||||
self.import_files_paths = [
|
||||
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
|
||||
for f in os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE)
|
||||
if f.endswith((".mp3", ".flac"))
|
||||
]
|
||||
# Files we can't import
|
||||
self.unimportable_files: list[FileErrors] = []
|
||||
# Files user doesn't want imported
|
||||
self.do_not_import: list[str] = []
|
||||
|
||||
def do_import(self) -> None:
|
||||
"""
|
||||
Scan source directory and:
|
||||
- check all file are readable
|
||||
- load readable files and tags into self.import_files
|
||||
- check all files are tagged
|
||||
- check for exact match of existing file
|
||||
- check for duplicates and replacements
|
||||
- allow deselection of import for any one file
|
||||
- import files and either replace existing or add to pool
|
||||
"""
|
||||
|
||||
# check all file are readable
|
||||
self.check_files_are_readable()
|
||||
|
||||
# load readable files and tags into self.import_files
|
||||
for import_file in self.import_files_paths:
|
||||
try:
|
||||
tags = get_tags(import_file)
|
||||
except ApplicationError as e:
|
||||
self.unimportable_files.append(
|
||||
FileErrors(path=import_file, error=str(e))
|
||||
)
|
||||
self.import_files_paths.remove(import_file)
|
||||
try:
|
||||
self.import_files_data.append(
|
||||
TrackFileData(import_file_path=import_file, tags=tags)
|
||||
)
|
||||
except Exception as e:
|
||||
self.unimportable_files.append(
|
||||
FileErrors(path=import_file, error=str(e))
|
||||
)
|
||||
self.import_files_paths.remove(import_file)
|
||||
|
||||
if self.unimportable_files:
|
||||
msg = "The following files could not be read and won't be imported:\n"
|
||||
for unimportable_file in self.unimportable_files:
|
||||
msg += f"\n\t• {unimportable_file.path} ({unimportable_file.error})"
|
||||
show_warning(None, "Unimportable files", msg)
|
||||
|
||||
# check for close matches.
|
||||
for idx in range(len(self.import_files_data)):
|
||||
self.check_match(idx=idx)
|
||||
|
||||
self.import_files_data = [
|
||||
x
|
||||
for x in self.import_files_data
|
||||
if x.import_file_path not in self.do_not_import
|
||||
]
|
||||
|
||||
# Import all that's left.
|
||||
for idx in range(len(self.import_files_data)):
|
||||
self._import_file(idx)
|
||||
|
||||
def check_match(self, idx: int) -> None:
|
||||
"""
|
||||
Work on and update the idx element of self.import_file_data.
|
||||
Check for similar existing titles. If none found, set up to
|
||||
import this as a new track. If one is found, check with user
|
||||
whether this is a new track or replacement. If more than one
|
||||
is found, as for one but order the tracks in
|
||||
artist-similarity order.
|
||||
"""
|
||||
|
||||
similar_track_ids = self._find_similar_strings(
|
||||
self.import_files_data[idx].tags.title, self.track_idx_and_title
|
||||
)
|
||||
if len(similar_track_ids) == 0:
|
||||
matching_track = 0
|
||||
elif len(similar_track_ids) == 1:
|
||||
matching_track = self._pick_match(idx, similar_track_ids)
|
||||
else:
|
||||
matching_track = self._pick_match(
|
||||
idx, self.order_by_artist(idx, similar_track_ids)
|
||||
)
|
||||
|
||||
if matching_track < 0: # User cancelled
|
||||
return
|
||||
|
||||
if matching_track == 0:
|
||||
self.import_files_data[idx].destination_track_path = os.path.join(
|
||||
Config.IMPORT_DESTINATION,
|
||||
os.path.basename(self.import_files_data[idx].import_file_path),
|
||||
)
|
||||
else:
|
||||
self.import_files_data[idx].destination_track_path = self.existing_tracks[
|
||||
matching_track
|
||||
].path
|
||||
|
||||
self.import_files_data[idx].track_id = matching_track
|
||||
|
||||
def _import_file(self, idx: int) -> None:
|
||||
"""
|
||||
Import the file specified at self.import_files_data[idx]
|
||||
"""
|
||||
|
||||
log.debug(f"_import_file({idx=}), {self.import_files_data[idx]=}")
|
||||
|
||||
f = self.import_files_data[idx]
|
||||
|
||||
# Import in separate thread
|
||||
self.import_thread = QThread()
|
||||
self.worker = DoTrackImport(
|
||||
import_file_path=f.import_file_path,
|
||||
tags=f.tags,
|
||||
destination_track_path=f.destination_track_path,
|
||||
track_id=f.track_id,
|
||||
audio_metadata=helpers.get_audio_metadata(f.import_file_path),
|
||||
source_model=self.active_proxy_model,
|
||||
row_number=self.row_number,
|
||||
)
|
||||
|
||||
self.worker.moveToThread(self.import_thread)
|
||||
self.import_thread.started.connect(self.worker.run)
|
||||
self.worker.import_finished.connect(self.import_thread.quit)
|
||||
self.worker.import_finished.connect(self.worker.deleteLater)
|
||||
self.import_thread.finished.connect(self.import_thread.deleteLater)
|
||||
self.import_thread.start()
|
||||
|
||||
def order_by_artist(self, idx: int, track_ids_to_check: list[int]) -> list[int]:
|
||||
"""
|
||||
Return the list of track_ids sorted by how well the artist at idx matches the
|
||||
track artist.
|
||||
"""
|
||||
|
||||
track_idx_and_artist = [
|
||||
((key, a.artist))
|
||||
for key, a in self.existing_tracks.items()
|
||||
if key in track_ids_to_check
|
||||
]
|
||||
# We want to return all of the passed tracks so set minimum_score
|
||||
# to zero
|
||||
return self._find_similar_strings(
|
||||
self.import_files_data[idx].tags.artist,
|
||||
track_idx_and_artist,
|
||||
minimum_score=0.0,
|
||||
)
|
||||
|
||||
def _pick_match(self, idx: int, track_ids: list[int]) -> int:
|
||||
"""
|
||||
Return the track_id selected by the user, including "import as new" which will be
|
||||
track_id 0. Return -1 if user cancels.
|
||||
|
||||
If user chooses not to import this track, remove it from the list of tracks to
|
||||
import and return -1.
|
||||
"""
|
||||
|
||||
log.debug(f"_pick_match({idx=}, {track_ids=})")
|
||||
|
||||
new_track_details = (
|
||||
f"{self.import_files_data[idx].tags.title} "
|
||||
f"({self.import_files_data[idx].tags.artist})"
|
||||
)
|
||||
|
||||
# Build a list of (track title and artise, track_id)
|
||||
choices: list[tuple[str, int]] = []
|
||||
# First choice is always to import as a new track
|
||||
choices.append((Config.DO_NOT_IMPORT, -2))
|
||||
choices.append((Config.IMPORT_AS_NEW, 0))
|
||||
for track_id in track_ids:
|
||||
choices.append(
|
||||
(
|
||||
f"{self.existing_tracks[track_id].title} "
|
||||
f"({self.existing_tracks[track_id].artist})",
|
||||
track_id,
|
||||
)
|
||||
)
|
||||
|
||||
dialog = PickMatch(new_track_details, choices)
|
||||
if dialog.exec() and dialog.selected_id >= 0:
|
||||
return dialog.selected_id
|
||||
else:
|
||||
self.do_not_import.append(self.import_files_data[idx].import_file_path)
|
||||
return -1
|
||||
|
||||
def check_files_are_readable(self) -> None:
|
||||
"""
|
||||
Check files to be imported are readable. If not, remove them from the
|
||||
import list and add them to the file errors list.
|
||||
"""
|
||||
|
||||
for path in self.import_files_paths:
|
||||
if file_is_unreadable(path):
|
||||
self.unimportable_files.append(
|
||||
FileErrors(path=os.path.basename(path), error="File is unreadable")
|
||||
)
|
||||
self.import_files_paths.remove(path)
|
||||
|
||||
def import_files_are_tagged(self) -> list:
|
||||
"""
|
||||
Return a (possibly empty) list of all untagged files in the
|
||||
import directory. Add tags to file_data
|
||||
"""
|
||||
|
||||
untagged_files: list[str] = []
|
||||
for fullpath in self.import_files_paths:
|
||||
tags = get_tags(fullpath)
|
||||
if not tags:
|
||||
untagged_files.append(os.path.basename(fullpath))
|
||||
# Remove from import list
|
||||
del self.import_files_data[fullpath]
|
||||
log.warning(f"Import: no tags found, {fullpath=}")
|
||||
else:
|
||||
self.import_files_data.append(
|
||||
TrackFileData(import_file_path=fullpath, tags=tags)
|
||||
)
|
||||
|
||||
return untagged_files
|
||||
|
||||
def _get_existing_tracks(self):
|
||||
"""
|
||||
Return a dictionary {title: Track} for all existing tracks
|
||||
"""
|
||||
|
||||
with db.Session() as session:
|
||||
return Tracks.all_tracks_indexed_by_id(session)
|
||||
|
||||
def _find_similar_strings(
|
||||
self,
|
||||
needle: str,
|
||||
haystack: list[tuple[int, str]],
|
||||
minimum_score: float = Config.MINIMUM_FUZZYMATCH,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Search for the needle in the string element of the haystack.
|
||||
Discard similarities less that minimum_score. Return a list of
|
||||
the int element of the haystack in order of decreasing score (ie,
|
||||
best match first).
|
||||
"""
|
||||
|
||||
# Create a dictionary to store similarities
|
||||
similarities: dict[int, float] = {}
|
||||
|
||||
for hayblade in haystack:
|
||||
# Calculate similarity using multiple metrics
|
||||
ratio = fuzz.ratio(needle, hayblade[1])
|
||||
partial_ratio = fuzz.partial_ratio(needle, hayblade[1])
|
||||
token_sort_ratio = fuzz.token_sort_ratio(needle, hayblade[1])
|
||||
token_set_ratio = fuzz.token_set_ratio(needle, hayblade[1])
|
||||
|
||||
# Combine scores
|
||||
combined_score = (
|
||||
ratio * 0.25
|
||||
+ partial_ratio * 0.25
|
||||
+ token_sort_ratio * 0.25
|
||||
+ token_set_ratio * 0.25
|
||||
)
|
||||
|
||||
if combined_score >= minimum_score:
|
||||
similarities[hayblade[0]] = combined_score
|
||||
log.debug(
|
||||
f"_find_similar_strings({needle=}), {len(haystack)=}, "
|
||||
f"{minimum_score=}, {hayblade=}, {combined_score=}"
|
||||
)
|
||||
|
||||
# Sort matches by score
|
||||
sorted_matches = sorted(similarities.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Return list of indexes, highest score first
|
||||
return [a[0] for a in sorted_matches]
|
||||
|
||||
|
||||
class PickMatch(QDialog):
|
||||
"""
|
||||
Dialog for user to select which existing track to replace or to
|
||||
import to a new track
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, new_track_details: str, items_with_ids: list[tuple[str, int]]
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.new_track_details = new_track_details
|
||||
self.init_ui(items_with_ids)
|
||||
self.selected_id = -1
|
||||
|
||||
def init_ui(self, items_with_ids: list[tuple[str, int]]) -> None:
|
||||
"""
|
||||
Set up dialog
|
||||
"""
|
||||
|
||||
self.setWindowTitle("New or replace")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Add instructions
|
||||
instructions = (
|
||||
f"Importing {self.new_track_details}.\n"
|
||||
"Import as a new track or replace existing track?"
|
||||
)
|
||||
instructions_label = QLabel(instructions)
|
||||
layout.addWidget(instructions_label)
|
||||
|
||||
# Create a button group for radio buttons
|
||||
self.button_group = QButtonGroup()
|
||||
|
||||
# Add radio buttons for each item
|
||||
for idx, (text, track_id) in enumerate(items_with_ids):
|
||||
if (
|
||||
track_sequence.current
|
||||
and track_id
|
||||
and track_sequence.current.track_id == track_id
|
||||
):
|
||||
# Don't allow current track to be replaced
|
||||
text = "(Currently playing) " + text
|
||||
radio_button = QRadioButton(text)
|
||||
radio_button.setDisabled(True)
|
||||
self.button_group.addButton(radio_button, -1)
|
||||
else:
|
||||
radio_button = QRadioButton(text)
|
||||
self.button_group.addButton(radio_button, track_id)
|
||||
layout.addWidget(radio_button)
|
||||
|
||||
# Select the second item by default (import as new)
|
||||
if idx == 1:
|
||||
radio_button.setChecked(True)
|
||||
|
||||
# Add OK and Cancel buttons
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("OK")
|
||||
cancel_button = QPushButton("Cancel")
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connect buttons to actions
|
||||
ok_button.clicked.connect(self.on_ok)
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
|
||||
def on_ok(self):
|
||||
# Get the ID of the selected button
|
||||
self.selected_id = self.button_group.checkedId()
|
||||
self.accept()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackFileData:
|
||||
"""
|
||||
Simple class to track details changes to a track file
|
||||
"""
|
||||
|
||||
import_file_path: str
|
||||
tags: Tags
|
||||
destination_track_path: str = ""
|
||||
file_path_to_removed: Optional[str] = None
|
||||
track_id: int = 0
|
||||
audio_metadata: Optional[AudioMetadata] = None
|
||||
|
||||
def set_destination_track_path(self, path: str) -> None:
|
||||
"""
|
||||
Assigned the passed path
|
||||
"""
|
||||
|
||||
self.destination_track_path = path
|
||||
@ -1,8 +1,7 @@
|
||||
# Standard library imports
|
||||
import datetime as dt
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, Optional
|
||||
import functools
|
||||
from typing import Optional
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@ -18,9 +17,10 @@ from mutagen.flac import FLAC # type: ignore
|
||||
from mutagen.mp3 import MP3 # type: ignore
|
||||
from pydub import AudioSegment, effects
|
||||
from pydub.utils import mediainfo
|
||||
from tinytag import TinyTag # type: ignore
|
||||
from tinytag import TinyTag, TinyTagException # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import AudioMetadata, ApplicationError, Tags
|
||||
from config import Config
|
||||
from log import log
|
||||
from models import Tracks
|
||||
@ -121,29 +121,25 @@ def get_embedded_time(text: str) -> Optional[dt.datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def get_all_track_metadata(filepath: str) -> Dict[str, str | int | float]:
|
||||
def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]:
|
||||
"""Return all track metadata"""
|
||||
|
||||
return get_audio_metadata(filepath) | get_tags(filepath) | dict(path=filepath)
|
||||
return (
|
||||
get_audio_metadata(filepath)._asdict()
|
||||
| get_tags(filepath)._asdict()
|
||||
| dict(path=filepath)
|
||||
)
|
||||
|
||||
|
||||
def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]:
|
||||
def get_audio_metadata(filepath: str) -> AudioMetadata:
|
||||
"""Return audio metadata"""
|
||||
|
||||
metadata: Dict[str, str | int | float] = {}
|
||||
|
||||
try:
|
||||
metadata["mtime"] = os.path.getmtime(filepath)
|
||||
except FileNotFoundError:
|
||||
show_warning(None, "File not found", f"Filepath {filepath} not found")
|
||||
return {}
|
||||
|
||||
# Set start_gap, fade_at and silence_at
|
||||
audio = get_audio_segment(filepath)
|
||||
if not audio:
|
||||
audio_values = dict(start_gap=0, fade_at=0, silence_at=0)
|
||||
return AudioMetadata()
|
||||
else:
|
||||
audio_values = dict(
|
||||
return AudioMetadata(
|
||||
start_gap=leading_silence(audio),
|
||||
fade_at=int(
|
||||
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
|
||||
@ -152,9 +148,6 @@ def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]:
|
||||
round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
|
||||
),
|
||||
)
|
||||
metadata |= audio_values
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def get_relative_date(
|
||||
@ -199,17 +192,19 @@ def get_relative_date(
|
||||
return f"{weeks} {weeks_str}, {days} {days_str}"
|
||||
|
||||
|
||||
def get_tags(path: str) -> Dict[str, Any]:
|
||||
def get_tags(path: str) -> Tags:
|
||||
"""
|
||||
Return a dictionary of title, artist, duration-in-milliseconds and path.
|
||||
Return a dictionary of title, artist, bitrate and duration-in-milliseconds.
|
||||
"""
|
||||
|
||||
try:
|
||||
tag = TinyTag.get(path)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
raise ApplicationError(f"File not found: get_tags({path=})")
|
||||
except TinyTagException:
|
||||
raise ApplicationError(f"Can't read tags: get_tags({path=})")
|
||||
|
||||
return dict(
|
||||
return Tags(
|
||||
title=tag.title,
|
||||
artist=tag.artist,
|
||||
bitrate=round(tag.bitrate),
|
||||
@ -391,10 +386,10 @@ def set_track_metadata(track: Tracks) -> None:
|
||||
audio_metadata = get_audio_metadata(track.path)
|
||||
tags = get_tags(track.path)
|
||||
|
||||
for audio_key in audio_metadata:
|
||||
setattr(track, audio_key, audio_metadata[audio_key])
|
||||
for tag_key in tags:
|
||||
setattr(track, tag_key, tags[tag_key])
|
||||
for audio_key in AudioMetadata._fields:
|
||||
setattr(track, audio_key, getattr(audio_metadata, audio_key))
|
||||
for tag_key in Tags._fields:
|
||||
setattr(track, tag_key, getattr(tags, tag_key))
|
||||
|
||||
|
||||
def show_OK(parent: QMainWindow, title: str, msg: str) -> None:
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
# Standard library imports
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Sequence
|
||||
import datetime as dt
|
||||
import os
|
||||
@ -657,19 +659,35 @@ class Tracks(dbtables.TracksTable):
|
||||
return session.scalars(select(cls)).unique().all()
|
||||
|
||||
@classmethod
|
||||
def get_by_basename(
|
||||
cls, session: Session, basename: str
|
||||
) -> Optional[Sequence["Tracks"]]:
|
||||
def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
|
||||
"""
|
||||
Return track(s) with passed basename, or None.
|
||||
Return a dictionary of all tracks, keyed by title
|
||||
"""
|
||||
|
||||
try:
|
||||
return session.scalars(
|
||||
Tracks.select().where(Tracks.path.like("%/" + basename))
|
||||
).all()
|
||||
except NoResultFound:
|
||||
return None
|
||||
result: dict[int, Tracks] = {}
|
||||
|
||||
for track in cls.get_all(session):
|
||||
result[track.id] = track
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def exact_title_and_artist(
|
||||
cls, session: Session, title: str, artist: str
|
||||
) -> Sequence["Tracks"]:
|
||||
"""
|
||||
Search for exact but case-insensitive match of title and artist
|
||||
"""
|
||||
|
||||
return (
|
||||
session.scalars(
|
||||
select(cls)
|
||||
.where(cls.title.ilike(title), cls.artist.ilike(artist))
|
||||
.order_by(cls.title)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Standard library imports
|
||||
from os.path import basename
|
||||
from typing import List, Optional
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import os
|
||||
import shutil
|
||||
from slugify import slugify # type: ignore
|
||||
import subprocess
|
||||
import sys
|
||||
@ -15,11 +13,8 @@ import webbrowser
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QDate,
|
||||
QObject,
|
||||
Qt,
|
||||
QThread,
|
||||
QTime,
|
||||
QTimer,
|
||||
)
|
||||
@ -47,23 +42,21 @@ from PyQt6.QtWidgets import (
|
||||
# Third party imports
|
||||
import line_profiler
|
||||
from pygame import mixer
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm.session import Session
|
||||
import stackprinter # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import (
|
||||
MusicMusterSignals,
|
||||
RowAndTrack,
|
||||
TrackFileData,
|
||||
TrackInfo,
|
||||
track_sequence,
|
||||
)
|
||||
from config import Config
|
||||
from dialogs import TrackSelectDialog, ReplaceFilesDialog
|
||||
from dialogs import TrackSelectDialog
|
||||
from file_importer import FileImporter
|
||||
from helpers import file_is_unreadable
|
||||
from log import log
|
||||
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
|
||||
from music_manager import RowAndTrack, track_sequence
|
||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||
from playlists import PlaylistTab
|
||||
from ui import icons_rc # noqa F401
|
||||
@ -74,82 +67,6 @@ from utilities import check_db, update_bitrates
|
||||
import helpers
|
||||
|
||||
|
||||
class ImportTrack(QObject):
|
||||
import_finished = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
track_files: List[TrackFileData],
|
||||
source_model: PlaylistModel,
|
||||
row_number: Optional[int],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.track_files = track_files
|
||||
self.source_model = source_model
|
||||
if row_number is None:
|
||||
self.next_row_number = source_model.rowCount()
|
||||
else:
|
||||
self.next_row_number = row_number
|
||||
self.signals = MusicMusterSignals()
|
||||
|
||||
# Sanity check
|
||||
for tf in track_files:
|
||||
if not tf.tags:
|
||||
raise Exception(f"ImportTrack: no tags for {tf.new_file_path}")
|
||||
if not tf.audio_metadata:
|
||||
raise Exception(
|
||||
f"ImportTrack: no audio_metadata for {tf.new_file_path}"
|
||||
)
|
||||
if tf.track_path is None:
|
||||
raise Exception(f"ImportTrack: no track_path for {tf.new_file_path}")
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Create track objects from passed files and add to visible playlist
|
||||
"""
|
||||
|
||||
with db.Session() as session:
|
||||
for tf in self.track_files:
|
||||
self.signals.status_message_signal.emit(
|
||||
f"Importing {basename(tf.new_file_path)}", 5000
|
||||
)
|
||||
|
||||
# Sanity check
|
||||
if not os.path.exists(tf.new_file_path):
|
||||
log.error(f"ImportTrack: file not found: {tf.new_file_path=}")
|
||||
continue
|
||||
|
||||
# Move the track file. Check that we're not importing a
|
||||
# file that's already in its final destination.
|
||||
if os.path.exists(tf.track_path) and tf.track_path != tf.new_file_path:
|
||||
os.unlink(tf.track_path)
|
||||
shutil.move(tf.new_file_path, tf.track_path)
|
||||
|
||||
# Import track
|
||||
try:
|
||||
track = Tracks(
|
||||
session, path=tf.track_path, **tf.audio_metadata | tf.tags
|
||||
)
|
||||
except Exception as e:
|
||||
self.signals.show_warning_signal.emit(
|
||||
"Error importing track", str(e)
|
||||
)
|
||||
return
|
||||
helpers.normalise_track(tf.track_path)
|
||||
# We're importing potentially multiple tracks in a loop.
|
||||
# If there's an error adding the track to the Tracks
|
||||
# table, the session will rollback, thus losing any
|
||||
# previous additions in this loop. So, commit now to
|
||||
# lock in what we've just done.
|
||||
session.commit()
|
||||
self.source_model.insert_row(self.next_row_number, track.id, "")
|
||||
self.next_row_number += 1
|
||||
self.signals.status_message_signal.emit(
|
||||
f"{len(self.track_files)} tracks imported", 10000
|
||||
)
|
||||
self.import_finished.emit()
|
||||
|
||||
|
||||
class PreviewManager:
|
||||
"""
|
||||
Manage track preview player
|
||||
@ -301,6 +218,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.signals = MusicMusterSignals()
|
||||
self.connect_signals_slots()
|
||||
self.catch_return_key = False
|
||||
self.importer: Optional[FileImporter] = None
|
||||
|
||||
if not Config.USE_INTERNAL_BROWSER:
|
||||
webbrowser.register(
|
||||
@ -458,7 +376,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
)
|
||||
self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
|
||||
self.actionFade.triggered.connect(self.fade)
|
||||
self.actionImport.triggered.connect(self.import_track)
|
||||
self.actionInsertSectionHeader.triggered.connect(self.insert_header)
|
||||
self.actionInsertTrack.triggered.connect(self.insert_track)
|
||||
self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving)
|
||||
@ -469,7 +386,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.actionPaste.triggered.connect(self.paste_rows)
|
||||
self.actionPlay_next.triggered.connect(self.play_next)
|
||||
self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
|
||||
self.actionReplace_files.triggered.connect(self.import_files)
|
||||
self.actionReplace_files.triggered.connect(self.import_files_wrapper)
|
||||
self.actionResume.triggered.connect(self.resume)
|
||||
self.actionSave_as_template.triggered.connect(self.save_as_template)
|
||||
self.actionSearch_title_in_Songfacts.triggered.connect(
|
||||
@ -742,103 +659,18 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
# Reset row heights
|
||||
self.active_tab().resize_rows()
|
||||
|
||||
def import_track(self) -> None:
|
||||
"""Import track file"""
|
||||
|
||||
dlg = QFileDialog()
|
||||
dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
|
||||
dlg.setViewMode(QFileDialog.ViewMode.Detail)
|
||||
dlg.setDirectory(Config.IMPORT_DESTINATION)
|
||||
dlg.setNameFilter("Music files (*.flac *.mp3)")
|
||||
|
||||
if not dlg.exec():
|
||||
return
|
||||
|
||||
with db.Session() as session:
|
||||
track_files: list[TrackFileData] = []
|
||||
for fpath in dlg.selectedFiles():
|
||||
tf = TrackFileData(fpath)
|
||||
tf.tags = helpers.get_tags(fpath)
|
||||
do_import = self.ok_to_import(session, fpath, tf.tags)
|
||||
if do_import:
|
||||
tf.track_path = os.path.join(
|
||||
Config.IMPORT_DESTINATION, os.path.basename(fpath)
|
||||
)
|
||||
tf.audio_metadata = helpers.get_audio_metadata(fpath)
|
||||
track_files.append(tf)
|
||||
|
||||
self.import_filenames(track_files)
|
||||
|
||||
def import_filenames(self, track_files: list[TrackFileData]) -> None:
|
||||
def import_files_wrapper(self) -> None:
|
||||
"""
|
||||
Import the list of filenames as new tracks
|
||||
Pass import files call to file_importer module
|
||||
"""
|
||||
|
||||
# Import in separate thread
|
||||
self.import_thread = QThread()
|
||||
self.worker = ImportTrack(
|
||||
track_files,
|
||||
# We need to keep a referent to the FileImporter else it will be
|
||||
# garbage collected while import threads are still running
|
||||
self.importer = FileImporter(
|
||||
self.active_proxy_model(),
|
||||
self.active_tab().source_model_selected_row_number(),
|
||||
)
|
||||
self.worker.moveToThread(self.import_thread)
|
||||
self.import_thread.started.connect(self.worker.run)
|
||||
self.worker.import_finished.connect(self.import_thread.quit)
|
||||
self.worker.import_finished.connect(self.worker.deleteLater)
|
||||
self.import_thread.finished.connect(self.import_thread.deleteLater)
|
||||
self.import_thread.start()
|
||||
|
||||
def ok_to_import(self, session: Session, fname: str, tags: dict[str, str]) -> bool:
|
||||
"""
|
||||
Check file has tags, check it's not a duplicate. Return True if this filenam
|
||||
is OK to import, False if not.
|
||||
"""
|
||||
|
||||
title = tags["title"]
|
||||
if not title:
|
||||
helpers.show_warning(
|
||||
self,
|
||||
"Problem with track file",
|
||||
f"{fname} does not have a title tag",
|
||||
)
|
||||
return False
|
||||
|
||||
artist = tags["artist"]
|
||||
if not artist:
|
||||
helpers.show_warning(
|
||||
self,
|
||||
"Problem with track file",
|
||||
f"{fname} does not have an artist tag",
|
||||
)
|
||||
return False
|
||||
|
||||
txt = ""
|
||||
count = 0
|
||||
possible_matches = Tracks.search_titles(session, title)
|
||||
if possible_matches:
|
||||
txt += "Similar to new track "
|
||||
txt += f'"{title}" by "{artist} ({fname})":\n\n'
|
||||
for track in possible_matches:
|
||||
txt += f' "{track.title}" by {track.artist}'
|
||||
txt += f" ({track.path})\n\n"
|
||||
count += 1
|
||||
if count >= Config.MAX_IMPORT_MATCHES:
|
||||
txt += "\nThere are more similar-looking tracks"
|
||||
break
|
||||
txt += "\n"
|
||||
# Check whether to proceed if there were potential matches
|
||||
txt += "Proceed with import?"
|
||||
result = QMessageBox.question(
|
||||
self,
|
||||
"Possible duplicates",
|
||||
txt,
|
||||
QMessageBox.StandardButton.Ok,
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
if result == QMessageBox.StandardButton.Cancel:
|
||||
return False
|
||||
|
||||
return True
|
||||
self.importer.do_import()
|
||||
|
||||
def insert_header(self) -> None:
|
||||
"""Show dialog box to enter header text and add to playlist"""
|
||||
@ -931,7 +763,9 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.move_source_rows = self.active_tab().get_selected_rows()
|
||||
self.move_source_model = self.active_proxy_model()
|
||||
|
||||
log.debug(f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}")
|
||||
log.debug(
|
||||
f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}"
|
||||
)
|
||||
|
||||
def move_playlist_rows(self, row_numbers: List[int]) -> None:
|
||||
"""
|
||||
@ -1306,79 +1140,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.tabBar.setTabText(idx, new_name)
|
||||
session.commit()
|
||||
|
||||
def import_files(self) -> None:
|
||||
"""
|
||||
Scan source directory and offer to replace existing files with "similar"
|
||||
files, or import the source file as a new track.
|
||||
"""
|
||||
|
||||
import_files: list[TrackFileData] = []
|
||||
|
||||
with db.Session() as session:
|
||||
dlg = ReplaceFilesDialog(
|
||||
session=session,
|
||||
main_window=self,
|
||||
)
|
||||
status = dlg.exec()
|
||||
if status:
|
||||
for rf in dlg.replacement_files:
|
||||
if rf.track_id:
|
||||
# We're updating an existing track
|
||||
# If the filename has changed, remove the
|
||||
# existing file
|
||||
if rf.obsolete_path is not None:
|
||||
if os.path.exists(rf.obsolete_path):
|
||||
os.unlink(rf.obsolete_path)
|
||||
else:
|
||||
log.error(
|
||||
f"replace_files: could not unlink {rf.obsolete_path=}"
|
||||
)
|
||||
continue
|
||||
if rf.track_path:
|
||||
if os.path.exists(rf.track_path):
|
||||
os.unlink(rf.track_path)
|
||||
shutil.move(rf.new_file_path, rf.track_path)
|
||||
track = session.get(Tracks, rf.track_id)
|
||||
if not track:
|
||||
raise Exception(
|
||||
f"replace_files: could not retrieve track {rf.track_id}"
|
||||
)
|
||||
|
||||
track.artist = rf.tags["artist"]
|
||||
track.title = rf.tags["title"]
|
||||
if track.path != rf.track_path:
|
||||
track.path = rf.track_path
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
# https://jira.mariadb.org/browse/MDEV-29345 workaround
|
||||
log.debug(
|
||||
"Working around https://jira.mariadb.org/browse/MDEV-29345"
|
||||
)
|
||||
session.rollback()
|
||||
track.path = "DUMMY"
|
||||
session.commit()
|
||||
track.path = rf.track_path
|
||||
session.commit()
|
||||
else:
|
||||
session.commit()
|
||||
else:
|
||||
# We're importing a new track
|
||||
do_import = self.ok_to_import(
|
||||
session, os.path.basename(rf.new_file_path), rf.tags
|
||||
)
|
||||
if do_import:
|
||||
rf.audio_metadata = helpers.get_audio_metadata(
|
||||
rf.new_file_path
|
||||
)
|
||||
import_files.append(rf)
|
||||
|
||||
# self.import_filenames(dlg.replacement_files)
|
||||
self.import_filenames(import_files)
|
||||
else:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
def return_pressed_in_error(self) -> bool:
|
||||
"""
|
||||
Check whether Return key has been pressed in error.
|
||||
|
||||
@ -35,8 +35,6 @@ import obswebsocket # type: ignore
|
||||
from classes import (
|
||||
Col,
|
||||
MusicMusterSignals,
|
||||
RowAndTrack,
|
||||
track_sequence,
|
||||
)
|
||||
from config import Config
|
||||
from helpers import (
|
||||
@ -50,6 +48,7 @@ from helpers import (
|
||||
)
|
||||
from log import log
|
||||
from models import db, NoteColours, Playdates, PlaylistRows, Tracks
|
||||
from music_manager import RowAndTrack, track_sequence
|
||||
|
||||
|
||||
HEADER_NOTES_COLUMN = 1
|
||||
@ -125,7 +124,11 @@ class PlaylistModel(QAbstractTableModel):
|
||||
track_sequence.next,
|
||||
track_sequence.current,
|
||||
]:
|
||||
if ts and ts.row_number == row_number and ts.playlist_id == self.playlist_id:
|
||||
if (
|
||||
ts
|
||||
and ts.row_number == row_number
|
||||
and ts.playlist_id == self.playlist_id
|
||||
):
|
||||
break
|
||||
else:
|
||||
continue # continue iterating over playlist_rows
|
||||
|
||||
@ -37,7 +37,7 @@ import line_profiler
|
||||
|
||||
# App imports
|
||||
from audacity_controller import AudacityController
|
||||
from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo, track_sequence
|
||||
from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo
|
||||
from config import Config
|
||||
from dialogs import TrackSelectDialog
|
||||
from helpers import (
|
||||
@ -48,6 +48,7 @@ from helpers import (
|
||||
)
|
||||
from log import log
|
||||
from models import db, Settings
|
||||
from music_manager import track_sequence
|
||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -663,7 +664,8 @@ class PlaylistTab(QTableView):
|
||||
that we have an edit open.
|
||||
"""
|
||||
|
||||
self.ac.path = None
|
||||
if self.ac:
|
||||
self.ac.path = None
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
"""Unselect all tracks and reset drag mode"""
|
||||
|
||||
@ -1003,7 +1003,6 @@ padding-left: 8px;</string>
|
||||
<addaction name="actionInsertSectionHeader"/>
|
||||
<addaction name="actionInsertTrack"/>
|
||||
<addaction name="actionRemove"/>
|
||||
<addaction name="actionImport"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSetNext"/>
|
||||
<addaction name="action_Clear_selection"/>
|
||||
|
||||
@ -686,7 +686,6 @@ class Ui_MainWindow(object):
|
||||
self.menuPlaylist.addAction(self.actionInsertSectionHeader)
|
||||
self.menuPlaylist.addAction(self.actionInsertTrack)
|
||||
self.menuPlaylist.addAction(self.actionRemove)
|
||||
self.menuPlaylist.addAction(self.actionImport)
|
||||
self.menuPlaylist.addSeparator()
|
||||
self.menuPlaylist.addAction(self.actionSetNext)
|
||||
self.menuPlaylist.addAction(self.action_Clear_selection)
|
||||
|
||||
@ -92,6 +92,6 @@ def update_bitrates(session: Session) -> None:
|
||||
for track in Tracks.get_all(session):
|
||||
try:
|
||||
t = get_tags(track.path)
|
||||
track.bitrate = t["bitrate"]
|
||||
track.bitrate = t.bitrate
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
@ -22,8 +22,9 @@ def fade_point(audio_segment, fade_threshold=-12, chunk_size=10):
|
||||
print(f"{max_vol=}")
|
||||
fade_threshold = max_vol
|
||||
while (
|
||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
|
||||
and trim_ms > 0): # noqa W503
|
||||
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
|
||||
and trim_ms > 0
|
||||
): # noqa W503
|
||||
trim_ms -= chunk_size
|
||||
|
||||
# if there is no trailing silence, return lenght of track (it's less
|
||||
|
||||
230
poetry.lock
generated
230
poetry.lock
generated
@ -289,6 +289,20 @@ urllib3 = "*"
|
||||
dev = ["dlint", "flake8-2020", "flake8-aaa", "flake8-absolute-import", "flake8-alfred", "flake8-annotations-complexity", "flake8-bandit", "flake8-black", "flake8-broken-line", "flake8-bugbear", "flake8-builtins", "flake8-coding", "flake8-cognitive-complexity", "flake8-commas", "flake8-comprehensions", "flake8-debugger", "flake8-django", "flake8-docstrings", "flake8-eradicate", "flake8-executable", "flake8-expression-complexity", "flake8-fixme", "flake8-functions", "flake8-future-import", "flake8-import-order", "flake8-isort", "flake8-logging-format", "flake8-mock", "flake8-mutable", "flake8-mypy", "flake8-pep3101", "flake8-pie", "flake8-print", "flake8-printf-formatting", "flake8-pyi", "flake8-pytest", "flake8-pytest-style", "flake8-quotes", "flake8-requirements", "flake8-rst-docstrings", "flake8-scrapy", "flake8-spellcheck", "flake8-sql", "flake8-strict", "flake8-string-format", "flake8-tidy-imports", "flake8-todo", "flake8-use-fstring", "flake8-variables-names", "isort[pyproject]", "mccabe", "pandas-vet", "pep8-naming", "pylint", "pytest", "typing-extensions", "wemake-python-styleguide"]
|
||||
docs = ["alabaster", "pygments-github-lexers", "recommonmark", "sphinx"]
|
||||
|
||||
[[package]]
|
||||
name = "fuzzywuzzy"
|
||||
version = "0.18.0"
|
||||
description = "Fuzzy string matching in python"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "fuzzywuzzy-0.18.0-py2.py3-none-any.whl", hash = "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993"},
|
||||
{file = "fuzzywuzzy-0.18.0.tar.gz", hash = "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
speedup = ["python-levenshtein (>=0.12)"]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.1.1"
|
||||
@ -457,6 +471,106 @@ docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alab
|
||||
qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
|
||||
testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "levenshtein"
|
||||
version = "0.26.1"
|
||||
description = "Python extension for computing string edit distances and similarities."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8dc4a4aecad538d944a1264c12769c99e3c0bf8e741fc5e454cc954913befb2e"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec108f368c12b25787c8b1a4537a1452bc53861c3ee4abc810cc74098278edcd"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69229d651c97ed5b55b7ce92481ed00635cdbb80fbfb282a22636e6945dc52d5"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79dcd157046d62482a7719b08ba9e3ce9ed3fc5b015af8ea989c734c702aedd4"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f53f9173ae21b650b4ed8aef1d0ad0c37821f367c221a982f4d2922b3044e0d"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3956f3c5c229257dbeabe0b6aacd2c083ebcc1e335842a6ff2217fe6cc03b6b"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1e83af732726987d2c4cd736f415dae8b966ba17b7a2239c8b7ffe70bfb5543"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4f052c55046c2a9c9b5f742f39e02fa6e8db8039048b8c1c9e9fdd27c8a240a1"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9895b3a98f6709e293615fde0dcd1bb0982364278fa2072361a1a31b3e388b7a"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a3777de1d8bfca054465229beed23994f926311ce666f5a392c8859bb2722f16"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:81c57e1135c38c5e6e3675b5e2077d8a8d3be32bf0a46c57276c092b1dffc697"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:91d5e7d984891df3eff7ea9fec8cf06fdfacc03cd074fd1a410435706f73b079"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-win32.whl", hash = "sha256:f48abff54054b4142ad03b323e80aa89b1d15cabc48ff49eb7a6ff7621829a56"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-win_amd64.whl", hash = "sha256:79dd6ad799784ea7b23edd56e3bf94b3ca866c4c6dee845658ee75bb4aefdabf"},
|
||||
{file = "levenshtein-0.26.1-cp310-cp310-win_arm64.whl", hash = "sha256:3351ddb105ef010cc2ce474894c5d213c83dddb7abb96400beaa4926b0b745bd"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44c51f5d33b3cfb9db518b36f1288437a509edd82da94c4400f6a681758e0cb6"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56b93203e725f9df660e2afe3d26ba07d71871b6d6e05b8b767e688e23dfb076"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:270d36c5da04a0d89990660aea8542227cbd8f5bc34e9fdfadd34916ff904520"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:480674c05077eeb0b0f748546d4fcbb386d7c737f9fff0010400da3e8b552942"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13946e37323728695ba7a22f3345c2e907d23f4600bc700bf9b4352fb0c72a48"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceb673f572d1d0dc9b1cd75792bb8bad2ae8eb78a7c6721e23a3867d318cb6f2"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42d6fa242e3b310ce6bfd5af0c83e65ef10b608b885b3bb69863c01fb2fcff98"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8b68295808893a81e0a1dbc2274c30dd90880f14d23078e8eb4325ee615fc68"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b01061d377d1944eb67bc40bef5d4d2f762c6ab01598efd9297ce5d0047eb1b5"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9d12c8390f156745e533d01b30773b9753e41d8bbf8bf9dac4b97628cdf16314"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:48825c9f967f922061329d1481b70e9fee937fc68322d6979bc623f69f75bc91"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d8ec137170b95736842f99c0e7a9fd8f5641d0c1b63b08ce027198545d983e2b"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-win32.whl", hash = "sha256:798f2b525a2e90562f1ba9da21010dde0d73730e277acaa5c52d2a6364fd3e2a"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-win_amd64.whl", hash = "sha256:55b1024516c59df55f1cf1a8651659a568f2c5929d863d3da1ce8893753153bd"},
|
||||
{file = "levenshtein-0.26.1-cp311-cp311-win_arm64.whl", hash = "sha256:e52575cbc6b9764ea138a6f82d73d3b1bc685fe62e207ff46a963d4c773799f6"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc741ca406d3704dc331a69c04b061fc952509a069b79cab8287413f434684bd"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:821ace3b4e1c2e02b43cf5dc61aac2ea43bdb39837ac890919c225a2c3f2fea4"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92694c9396f55d4c91087efacf81297bef152893806fc54c289fc0254b45384"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51ba374de7a1797d04a14a4f0ad3602d2d71fef4206bb20a6baaa6b6a502da58"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7aa5c3327dda4ef952769bacec09c09ff5bf426e07fdc94478c37955681885b"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e2517e8d3c221de2d1183f400aed64211fcfc77077b291ed9f3bb64f141cdc"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9092b622765c7649dd1d8af0f43354723dd6f4e570ac079ffd90b41033957438"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fc16796c85d7d8b259881d59cc8b5e22e940901928c2ff6924b2c967924e8a0b"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4370733967f5994ceeed8dc211089bedd45832ee688cecea17bfd35a9eb22b9"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3535ecfd88c9b283976b5bc61265855f59bba361881e92ed2b5367b6990c93fe"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:90236e93d98bdfd708883a6767826fafd976dac8af8fc4a0fb423d4fa08e1bf0"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:04b7cabb82edf566b1579b3ed60aac0eec116655af75a3c551fee8754ffce2ea"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-win32.whl", hash = "sha256:ae382af8c76f6d2a040c0d9ca978baf461702ceb3f79a0a3f6da8d596a484c5b"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-win_amd64.whl", hash = "sha256:fd091209798cfdce53746f5769987b4108fe941c54fb2e058c016ffc47872918"},
|
||||
{file = "levenshtein-0.26.1-cp312-cp312-win_arm64.whl", hash = "sha256:7e82f2ea44a81ad6b30d92a110e04cd3c8c7c6034b629aca30a3067fa174ae89"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:790374a9f5d2cbdb30ee780403a62e59bef51453ac020668c1564d1e43438f0e"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7b05c0415c386d00efda83d48db9db68edd02878d6dbc6df01194f12062be1bb"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3114586032361722ddededf28401ce5baf1cf617f9f49fb86b8766a45a423ff"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2532f8a13b68bf09f152d906f118a88da2063da22f44c90e904b142b0a53d534"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:219c30be6aa734bf927188d1208b7d78d202a3eb017b1c5f01ab2034d2d4ccca"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397e245e77f87836308bd56305bba630010cd8298c34c4c44bd94990cdb3b7b1"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeff6ea3576f72e26901544c6c55c72a7b79b9983b6f913cba0e9edbf2f87a97"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a19862e3539a697df722a08793994e334cd12791e8144851e8a1dee95a17ff63"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:dc3b5a64f57c3c078d58b1e447f7d68cad7ae1b23abe689215d03fc434f8f176"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bb6c7347424a91317c5e1b68041677e4c8ed3e7823b5bbaedb95bffb3c3497ea"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b817376de4195a207cc0e4ca37754c0e1e1078c2a2d35a6ae502afde87212f9e"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b50c3620ff47c9887debbb4c154aaaac3e46be7fc2e5789ee8dbe128bce6a17"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-win32.whl", hash = "sha256:9fb859da90262eb474c190b3ca1e61dee83add022c676520f5c05fdd60df902a"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-win_amd64.whl", hash = "sha256:8adcc90e3a5bfb0a463581d85e599d950fe3c2938ac6247b29388b64997f6e2d"},
|
||||
{file = "levenshtein-0.26.1-cp313-cp313-win_arm64.whl", hash = "sha256:c2599407e029865dc66d210b8804c7768cbdbf60f061d993bb488d5242b0b73e"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc54ced948fc3feafce8ad4ba4239d8ffc733a0d70e40c0363ac2a7ab2b7251e"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6516f69213ae393a220e904332f1a6bfc299ba22cf27a6520a1663a08eba0fb"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4cfea4eada1746d0c75a864bc7e9e63d4a6e987c852d6cec8d9cb0c83afe25b"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a323161dfeeac6800eb13cfe76a8194aec589cd948bcf1cdc03f66cc3ec26b72"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c23e749b68ebc9a20b9047317b5cd2053b5856315bc8636037a8adcbb98bed1"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f80dd7432d4b6cf493d012d22148db7af769017deb31273e43406b1fb7f091c"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ae7cd6e4312c6ef34b2e273836d18f9fff518d84d823feff5ad7c49668256e0"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dcdad740e841d791b805421c2b20e859b4ed556396d3063b3aa64cd055be648c"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e07afb1613d6f5fd99abd4e53ad3b446b4efaa0f0d8e9dfb1d6d1b9f3f884d32"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f1add8f1d83099a98ae4ac472d896b7e36db48c39d3db25adf12b373823cdeff"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1010814b1d7a60833a951f2756dfc5c10b61d09976ce96a0edae8fecdfb0ea7c"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:33fa329d1bb65ce85e83ceda281aea31cee9f2f6e167092cea54f922080bcc66"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-win32.whl", hash = "sha256:488a945312f2f16460ab61df5b4beb1ea2254c521668fd142ce6298006296c98"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-win_amd64.whl", hash = "sha256:9f942104adfddd4b336c3997050121328c39479f69de702d7d144abb69ea7ab9"},
|
||||
{file = "levenshtein-0.26.1-cp39-cp39-win_arm64.whl", hash = "sha256:c1d8f85b2672939f85086ed75effcf768f6077516a3e299c2ba1f91bc4644c22"},
|
||||
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6cf8f1efaf90ca585640c5d418c30b7d66d9ac215cee114593957161f63acde0"},
|
||||
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d5b2953978b8c158dd5cd93af8216a5cfddbf9de66cf5481c2955f44bb20767a"},
|
||||
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b952b3732c4631c49917d4b15d78cb4a2aa006c1d5c12e2a23ba8e18a307a055"},
|
||||
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07227281e12071168e6ae59238918a56d2a0682e529f747b5431664f302c0b42"},
|
||||
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8191241cd8934feaf4d05d0cc0e5e72877cbb17c53bbf8c92af9f1aedaa247e9"},
|
||||
{file = "levenshtein-0.26.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9e70d7ee157a9b698c73014f6e2b160830e7d2d64d2e342fefc3079af3c356fc"},
|
||||
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0eb3059f826f6cb0a5bca4a85928070f01e8202e7ccafcba94453470f83e49d4"},
|
||||
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:6c389e44da12d6fb1d7ba0a709a32a96c9391e9be4160ccb9269f37e040599ee"},
|
||||
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e9de292f2c51a7d34a0ae23bec05391b8f61f35781cd3e4c6d0533e06250c55"},
|
||||
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d87215113259efdca8716e53b6d59ab6d6009e119d95d45eccc083148855f33"},
|
||||
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f00a3eebf68a82fb651d8d0e810c10bfaa60c555d21dde3ff81350c74fb4c2"},
|
||||
{file = "levenshtein-0.26.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b3554c1b59de63d05075577380340c185ff41b028e541c0888fddab3c259a2b4"},
|
||||
{file = "levenshtein-0.26.1.tar.gz", hash = "sha256:0d19ba22330d50609b2349021ec3cf7d905c6fe21195a2d0d876a146e7ed2575"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
rapidfuzz = ">=3.9.0,<4.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "line-profiler"
|
||||
version = "4.1.3"
|
||||
@ -1388,6 +1502,20 @@ pytest = "*"
|
||||
dev = ["pre-commit", "tox"]
|
||||
doc = ["sphinx", "sphinx-rtd-theme"]
|
||||
|
||||
[[package]]
|
||||
name = "python-levenshtein"
|
||||
version = "0.26.1"
|
||||
description = "Python extension for computing string edit distances and similarities."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "python_Levenshtein-0.26.1-py3-none-any.whl", hash = "sha256:8ef5e529dd640fb00f05ee62d998d2ee862f19566b641ace775d5ae16167b2ef"},
|
||||
{file = "python_levenshtein-0.26.1.tar.gz", hash = "sha256:24ba578e28058ebb4afa2700057e1678d7adf27e43cd1f17700c09a9009d5d3a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Levenshtein = "0.26.1"
|
||||
|
||||
[[package]]
|
||||
name = "python-slugify"
|
||||
version = "8.0.4"
|
||||
@ -1416,6 +1544,106 @@ files = [
|
||||
{file = "python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidfuzz"
|
||||
version = "3.11.0"
|
||||
description = "rapid fuzzy string matching"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb8a54543d16ab1b69e2c5ed96cabbff16db044a50eddfc028000138ca9ddf33"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:231c8b2efbd7f8d2ecd1ae900363ba168b8870644bb8f2b5aa96e4a7573bde19"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54e7f442fb9cca81e9df32333fb075ef729052bcabe05b0afc0441f462299114"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:906f1f2a1b91c06599b3dd1be207449c5d4fc7bd1e1fa2f6aef161ea6223f165"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed59044aea9eb6c663112170f2399b040d5d7b162828b141f2673e822093fa8"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cb1965a28b0fa64abdee130c788a0bc0bb3cf9ef7e3a70bf055c086c14a3d7e"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b488b244931d0291412917e6e46ee9f6a14376625e150056fe7c4426ef28225"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f0ba13557fec9d5ffc0a22826754a7457cc77f1b25145be10b7bb1d143ce84c6"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3871fa7dfcef00bad3c7e8ae8d8fd58089bad6fb21f608d2bf42832267ca9663"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b2669eafee38c5884a6e7cc9769d25c19428549dcdf57de8541cf9e82822e7db"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ffa1bb0e26297b0f22881b219ffc82a33a3c84ce6174a9d69406239b14575bd5"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:45b15b8a118856ac9caac6877f70f38b8a0d310475d50bc814698659eabc1cdb"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-win32.whl", hash = "sha256:22033677982b9c4c49676f215b794b0404073f8974f98739cb7234e4a9ade9ad"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:be15496e7244361ff0efcd86e52559bacda9cd975eccf19426a0025f9547c792"},
|
||||
{file = "rapidfuzz-3.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:714a7ba31ba46b64d30fccfe95f8013ea41a2e6237ba11a805a27cdd3bce2573"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8724a978f8af7059c5323d523870bf272a097478e1471295511cf58b2642ff83"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b63cb1f2eb371ef20fb155e95efd96e060147bdd4ab9fc400c97325dfee9fe1"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82497f244aac10b20710448645f347d862364cc4f7d8b9ba14bd66b5ce4dec18"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:339607394941801e6e3f6c1ecd413a36e18454e7136ed1161388de674f47f9d9"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84819390a36d6166cec706b9d8f0941f115f700b7faecab5a7e22fc367408bc3"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eea8d9e20632d68f653455265b18c35f90965e26f30d4d92f831899d6682149b"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b659e1e2ea2784a9a397075a7fc395bfa4fe66424042161c4bcaf6e4f637b38"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1315cd2a351144572e31fe3df68340d4b83ddec0af8b2e207cd32930c6acd037"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a7743cca45b4684c54407e8638f6d07b910d8d811347b9d42ff21262c7c23245"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5bb636b0150daa6d3331b738f7c0f8b25eadc47f04a40e5c23c4bfb4c4e20ae3"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:42f4dd264ada7a9aa0805ea0da776dc063533917773cf2df5217f14eb4429eae"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51f24cb39e64256221e6952f22545b8ce21cacd59c0d3e367225da8fc4b868d8"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-win32.whl", hash = "sha256:aaf391fb6715866bc14681c76dc0308f46877f7c06f61d62cc993b79fc3c4a2a"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:ebadd5b8624d8ad503e505a99b8eb26fe3ea9f8e9c2234e805a27b269e585842"},
|
||||
{file = "rapidfuzz-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:d895998fec712544c13cfe833890e0226585cf0391dd3948412441d5d68a2b8c"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f382fec4a7891d66fb7163c90754454030bb9200a13f82ee7860b6359f3f2fa8"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dfaefe08af2a928e72344c800dcbaf6508e86a4ed481e28355e8d4b6a6a5230e"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92ebb7c12f682b5906ed98429f48a3dd80dd0f9721de30c97a01473d1a346576"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a1b3ebc62d4bcdfdeba110944a25ab40916d5383c5e57e7c4a8dc0b6c17211a"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c6d7fea39cb33e71de86397d38bf7ff1a6273e40367f31d05761662ffda49e4"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99aebef8268f2bc0b445b5640fd3312e080bd17efd3fbae4486b20ac00466308"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4469307f464ae3089acf3210b8fc279110d26d10f79e576f385a98f4429f7d97"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:eb97c53112b593f89a90b4f6218635a9d1eea1d7f9521a3b7d24864228bbc0aa"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef8937dae823b889c0273dfa0f0f6c46a3658ac0d851349c464d1b00e7ff4252"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d95f9e9f3777b96241d8a00d6377cc9c716981d828b5091082d0fe3a2924b43e"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b1d67d67f89e4e013a5295e7523bc34a7a96f2dba5dd812c7c8cb65d113cbf28"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d994cf27e2f874069884d9bddf0864f9b90ad201fcc9cb2f5b82bacc17c8d5f2"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-win32.whl", hash = "sha256:ba26d87fe7fcb56c4a53b549a9e0e9143f6b0df56d35fe6ad800c902447acd5b"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1f7efdd7b7adb32102c2fa481ad6f11923e2deb191f651274be559d56fc913b"},
|
||||
{file = "rapidfuzz-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:ed78c8e94f57b44292c1a0350f580e18d3a3c5c0800e253f1583580c1b417ad2"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e60814edd0c9b511b5f377d48b9782b88cfe8be07a98f99973669299c8bb318a"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f28952da055dbfe75828891cd3c9abf0984edc8640573c18b48c14c68ca5e06"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e8f93bc736020351a6f8e71666e1f486bb8bd5ce8112c443a30c77bfde0eb68"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76a4a11ba8f678c9e5876a7d465ab86def047a4fcc043617578368755d63a1bc"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc0e0d41ad8a056a9886bac91ff9d9978e54a244deb61c2972cc76b66752de9c"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e8ea35f2419c7d56b3e75fbde2698766daedb374f20eea28ac9b1f668ef4f74"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd340bbd025302276b5aa221dccfe43040c7babfc32f107c36ad783f2ffd8775"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:494eef2c68305ab75139034ea25328a04a548d297712d9cf887bf27c158c388b"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a167344c1d6db06915fb0225592afdc24d8bafaaf02de07d4788ddd37f4bc2f"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c7af25bda96ac799378ac8aba54a8ece732835c7b74cfc201b688a87ed11152"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d2a0f7e17f33e7890257367a1662b05fecaf56625f7dbb6446227aaa2b86448b"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d0d26c7172bdb64f86ee0765c5b26ea1dc45c52389175888ec073b9b28f4305"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-win32.whl", hash = "sha256:6ad02bab756751c90fa27f3069d7b12146613061341459abf55f8190d899649f"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:b1472986fd9c5d318399a01a0881f4a0bf4950264131bb8e2deba9df6d8c362b"},
|
||||
{file = "rapidfuzz-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c408f09649cbff8da76f8d3ad878b64ba7f7abdad1471efb293d2c075e80c822"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1bac4873f6186f5233b0084b266bfb459e997f4c21fc9f029918f44a9eccd304"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f9f12c2d0aa52b86206d2059916153876a9b1cf9dfb3cf2f344913167f1c3d4"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dd501de6f7a8f83557d20613b58734d1cb5f0be78d794cde64fe43cfc63f5f2"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4416ca69af933d4a8ad30910149d3db6d084781d5c5fdedb713205389f535385"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0821b9bdf18c5b7d51722b906b233a39b17f602501a966cfbd9b285f8ab83cd"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0edecc3f90c2653298d380f6ea73b536944b767520c2179ec5d40b9145e47aa"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4513dd01cee11e354c31b75f652d4d466c9440b6859f84e600bdebfccb17735a"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9727b85511b912571a76ce53c7640ba2c44c364e71cef6d7359b5412739c570"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ab9eab33ee3213f7751dc07a1a61b8d9a3d748ca4458fffddd9defa6f0493c16"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6b01c1ddbb054283797967ddc5433d5c108d680e8fa2684cf368be05407b07e4"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3857e335f97058c4b46fa39ca831290b70de554a5c5af0323d2f163b19c5f2a6"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d98a46cf07c0c875d27e8a7ed50f304d83063e49b9ab63f21c19c154b4c0d08d"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-win32.whl", hash = "sha256:c36539ed2c0173b053dafb221458812e178cfa3224ade0960599bec194637048"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:ec8d7d8567e14af34a7911c98f5ac74a3d4a743cd848643341fc92b12b3784ff"},
|
||||
{file = "rapidfuzz-3.11.0-cp39-cp39-win_arm64.whl", hash = "sha256:62171b270ecc4071be1c1f99960317db261d4c8c83c169e7f8ad119211fe7397"},
|
||||
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f06e3c4c0a8badfc4910b9fd15beb1ad8f3b8fafa8ea82c023e5e607b66a78e4"},
|
||||
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fe7aaf5a54821d340d21412f7f6e6272a9b17a0cbafc1d68f77f2fc11009dcd5"},
|
||||
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25398d9ac7294e99876a3027ffc52c6bebeb2d702b1895af6ae9c541ee676702"},
|
||||
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a52eea839e4bdc72c5e60a444d26004da00bb5bc6301e99b3dde18212e41465"},
|
||||
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c87319b0ab9d269ab84f6453601fd49b35d9e4a601bbaef43743f26fabf496c"},
|
||||
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3048c6ed29d693fba7d2a7caf165f5e0bb2b9743a0989012a98a47b975355cca"},
|
||||
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b04f29735bad9f06bb731c214f27253bd8bedb248ef9b8a1b4c5bde65b838454"},
|
||||
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7864e80a0d4e23eb6194254a81ee1216abdc53f9dc85b7f4d56668eced022eb8"},
|
||||
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3794df87313dfb56fafd679b962e0613c88a293fd9bd5dd5c2793d66bf06a101"},
|
||||
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d71da0012face6f45432a11bc59af19e62fac5a41f8ce489e80c0add8153c3d1"},
|
||||
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff38378346b7018f42cbc1f6d1d3778e36e16d8595f79a312b31e7c25c50bd08"},
|
||||
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:6668321f90aa02a5a789d4e16058f2e4f2692c5230252425c3532a8a62bc3424"},
|
||||
{file = "rapidfuzz-3.11.0.tar.gz", hash = "sha256:a53ca4d3f52f00b393fab9b5913c5bafb9afc27d030c8a1db1283da6917a860f"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["numpy"]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "13.9.4"
|
||||
@ -1745,4 +1973,4 @@ test = ["websockets"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "f1b96a77f00820c0db315ba2db9f40aa918a47770317ce54efaa670eea41e83d"
|
||||
content-hash = "6a887314789a17a0d0875f1c7e6ce169c90142164de98e386131a7836e3db3b5"
|
||||
|
||||
@ -25,6 +25,8 @@ obs-websocket-py = "^1.0"
|
||||
pygame = "^2.6.1"
|
||||
psutil = "^6.1.0"
|
||||
pyqt6-webengine = "^6.7.0"
|
||||
fuzzywuzzy = "^0.18.0"
|
||||
python-levenshtein = "^0.26.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
ipdb = "^0.13.9"
|
||||
|
||||
@ -55,8 +55,8 @@ class TestMMHelpers(unittest.TestCase):
|
||||
with open(test_track_data) as f:
|
||||
testdata = eval(f.read())
|
||||
|
||||
assert tags["artist"] == testdata["artist"]
|
||||
assert tags["title"] == testdata["title"]
|
||||
assert tags.artist == testdata["artist"]
|
||||
assert tags.title == testdata["title"]
|
||||
|
||||
def test_get_relative_date(self):
|
||||
assert get_relative_date(None) == "Never"
|
||||
@ -64,9 +64,9 @@ class TestMMHelpers(unittest.TestCase):
|
||||
today_at_11 = dt.datetime.now().replace(hour=11, minute=0)
|
||||
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
|
||||
eight_days_ago = today_at_10 - dt.timedelta(days=8)
|
||||
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
|
||||
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day"
|
||||
sixteen_days_ago = today_at_10 - dt.timedelta(days=16)
|
||||
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
|
||||
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days"
|
||||
|
||||
def test_leading_silence(self):
|
||||
test_track_path = "testdata/isa.mp3"
|
||||
|
||||
@ -82,7 +82,7 @@ class MyTestCase(unittest.TestCase):
|
||||
for track in self.tracks.values():
|
||||
db_track = Tracks(session=session, **track)
|
||||
session.add(db_track)
|
||||
track['id'] = db_track.id
|
||||
track["id"] = db_track.id
|
||||
|
||||
session.commit()
|
||||
|
||||
@ -136,12 +136,13 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
from config import Config
|
||||
|
||||
Config.ROOT = os.path.join(os.path.dirname(__file__), 'testdata')
|
||||
Config.ROOT = os.path.join(os.path.dirname(__file__), "testdata")
|
||||
|
||||
with db.Session() as session:
|
||||
utilities.check_db(session)
|
||||
utilities.update_bitrates(session)
|
||||
|
||||
|
||||
# def test_meta_all_clear(qtbot, session):
|
||||
# # Create playlist
|
||||
# playlist = models.Playlists(session, "my playlist")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user