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")