diff --git a/app/config.py b/app/config.py index f800eef..29f59ba 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/dialogs.py b/app/dialogs.py index a05349f..b0fadf9 100644 --- a/app/dialogs.py +++ b/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""" diff --git a/app/file_importer.py b/app/file_importer.py new file mode 100644 index 0000000..c485075 --- /dev/null +++ b/app/file_importer.py @@ -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 diff --git a/app/helpers.py b/app/helpers.py index 5bbdf92..0cd263e 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -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: diff --git a/app/models.py b/app/models.py index 1e5ef01..79aac49 100644 --- a/app/models.py +++ b/app/models.py @@ -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"]: diff --git a/app/musicmuster.py b/app/musicmuster.py index 4d2df7a..e143172 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -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. diff --git a/app/playlistmodel.py b/app/playlistmodel.py index f5f8a60..8b19bf2 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -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 diff --git a/app/playlists.py b/app/playlists.py index 8b3a45c..bb207f5 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -37,7 +37,7 @@ import line_profiler # App imports from audacity_controller import AudacityController -from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo, track_sequence +from classes import ApplicationError, Col, MusicMusterSignals, 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""" diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui index 7a26f6f..e124c91 100644 --- a/app/ui/main_window.ui +++ b/app/ui/main_window.ui @@ -1003,7 +1003,6 @@ padding-left: 8px; - diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index 7b3c1b9..edf87e3 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -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) diff --git a/app/utilities.py b/app/utilities.py index c367666..8c42a8a 100755 --- a/app/utilities.py +++ b/app/utilities.py @@ -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 diff --git a/archive/play.py b/archive/play.py index 07503f7..a53ced6 100755 --- a/archive/play.py +++ b/archive/play.py @@ -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 diff --git a/poetry.lock b/poetry.lock index 246b3ac..f8dbf8a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index d7ac239..852a8ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 8c9181e..e7cd365 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -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" diff --git a/tests/test_ui.py b/tests/test_ui.py index 7ff1d2b..d7cf1bf 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -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")