Rewrite file importer

This commit is contained in:
Keith Edmunds 2024-12-22 15:22:21 +00:00
parent d400ba3957
commit 4c53791f4d
16 changed files with 853 additions and 505 deletions

View File

@ -39,6 +39,7 @@ class Config(object):
DEBUG_MODULES: List[Optional[str]] = [] DEBUG_MODULES: List[Optional[str]] = []
DEFAULT_COLUMN_WIDTH = 200 DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False DISPLAY_SQL = False
DO_NOT_IMPORT = "Do not import"
ENGINE_OPTIONS = dict(pool_pre_ping=True) ENGINE_OPTIONS = dict(pool_pre_ping=True)
EPOCH = dt.datetime(1970, 1, 1) EPOCH = dt.datetime(1970, 1, 1)
ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_FROM = ["noreply@midnighthax.com"]
@ -63,6 +64,7 @@ class Config(object):
HIDE_AFTER_PLAYING_OFFSET = 5000 HIDE_AFTER_PLAYING_OFFSET = 5000
HIDE_PLAYED_MODE_TRACKS = "TRACKS" HIDE_PLAYED_MODE_TRACKS = "TRACKS"
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS" HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
IMPORT_AS_NEW = "Import as new track"
INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_TITLE_LENGTH = 15
INTRO_SECONDS_FORMAT = ".1f" INTRO_SECONDS_FORMAT = ".1f"
INTRO_SECONDS_WARNING_MS = 3000 INTRO_SECONDS_WARNING_MS = 3000
@ -80,6 +82,7 @@ class Config(object):
MAX_INFO_TABS = 5 MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10 MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0 MILLISECOND_SIGFIGS = 0
MINIMUM_FUZZYMATCH = 60.0
MINIMUM_ROW_HEIGHT = 30 MINIMUM_ROW_HEIGHT = 30
NOTE_TIME_FORMAT = "%H:%M" NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost" OBS_HOST = "localhost"

View File

@ -17,7 +17,7 @@ import pydymenu # type: ignore
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
# App imports # App imports
from classes import MusicMusterSignals, TrackFileData from classes import MusicMusterSignals
from config import Config from config import Config
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
@ -32,203 +32,6 @@ from playlistmodel import PlaylistModel
from ui import dlg_TrackSelect_ui, dlg_replace_files_ui 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): class TrackSelectDialog(QDialog):
"""Select track from database""" """Select track from database"""

533
app/file_importer.py Normal file
View File

@ -0,0 +1,533 @@
# Standard library imports
from __future__ import annotations
from dataclasses import dataclass
from fuzzywuzzy import fuzz # type: ignore
import os.path
from typing import Optional
import os
import shutil
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
Qt,
QThread,
)
from PyQt6.QtWidgets import (
QButtonGroup,
QDialog,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QRadioButton,
QVBoxLayout,
)
# Third party imports
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session
# App imports
from classes import (
ApplicationError,
AudioMetadata,
FileErrors,
MusicMusterSignals,
Tags,
)
from config import Config
from helpers import (
file_is_unreadable,
get_tags,
show_warning,
)
from log import log
from models import db, Tracks
from music_manager import track_sequence
from playlistmodel import PlaylistModel
import helpers
class DoTrackImport(QObject):
import_finished = pyqtSignal()
def __init__(
self,
import_file_path: str,
tags: Tags,
destination_track_path: str,
track_id: int,
audio_metadata: AudioMetadata,
source_model: PlaylistModel,
row_number: Optional[int],
) -> None:
"""
Save parameters
"""
super().__init__()
self.import_file_path = import_file_path
self.tags = tags
self.destination_track_path = destination_track_path
self.track_id = track_id
self.audio_metadata = audio_metadata
self.source_model = source_model
if row_number is None:
self.next_row_number = source_model.rowCount()
else:
self.next_row_number = row_number
self.signals = MusicMusterSignals()
def run(self) -> None:
"""
Either create track objects from passed files or update exising track
objects.
And add to visible playlist or update playlist if track already present.
"""
temp_file: Optional[str] = None
# If destination exists, move it out of the way
if os.path.exists(self.destination_track_path):
temp_file = self.destination_track_path + ".TMP"
shutil.move(self.destination_track_path, temp_file)
# Move file to destination
shutil.move(self.import_file_path, self.destination_track_path)
with db.Session() as session:
self.signals.status_message_signal.emit(
f"Importing {os.path.basename(self.import_file_path)}", 5000
)
if self.track_id == 0:
# Import new track
try:
track = Tracks(
session,
path=self.destination_track_path,
**self.tags._asdict(),
**self.audio_metadata._asdict(),
)
except Exception as e:
self.signals.show_warning_signal.emit(
"Error importing track", str(e)
)
return
else:
track = session.get(Tracks, self.track_id)
if track:
for key, value in self.tags._asdict().items():
if hasattr(track, key):
setattr(track, key, value)
for key, value in self.audio_metadata._asdict().items():
if hasattr(track, key):
setattr(track, key, value)
track.path = self.destination_track_path
session.commit()
helpers.normalise_track(self.destination_track_path)
self.source_model.insert_row(self.next_row_number, track.id, "imported")
self.next_row_number += 1
self.signals.status_message_signal.emit(
f"{os.path.basename(self.import_file_path)} imported", 10000
)
self.import_finished.emit()
class FileImporter:
"""
Manage importing of files
"""
def __init__(self, active_proxy_model: PlaylistModel, row_number: int) -> None:
"""
Set up class
"""
# Save parameters
self.active_proxy_model = active_proxy_model
self.row_number = row_number
# Data structure to track files to import
self.import_files_data: list[TrackFileData] = []
# Dictionary of exsting tracks
self.existing_tracks = self._get_existing_tracks()
# List of track_id, title tuples
self.track_idx_and_title = [
((a.id, a.title)) for a in self.existing_tracks.values()
]
# Files to import
self.import_files_paths = [
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
for f in os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE)
if f.endswith((".mp3", ".flac"))
]
# Files we can't import
self.unimportable_files: list[FileErrors] = []
# Files user doesn't want imported
self.do_not_import: list[str] = []
def do_import(self) -> None:
"""
Scan source directory and:
- check all file are readable
- load readable files and tags into self.import_files
- check all files are tagged
- check for exact match of existing file
- check for duplicates and replacements
- allow deselection of import for any one file
- import files and either replace existing or add to pool
"""
# check all file are readable
self.check_files_are_readable()
# load readable files and tags into self.import_files
for import_file in self.import_files_paths:
try:
tags = get_tags(import_file)
except ApplicationError as e:
self.unimportable_files.append(
FileErrors(path=import_file, error=str(e))
)
self.import_files_paths.remove(import_file)
try:
self.import_files_data.append(
TrackFileData(import_file_path=import_file, tags=tags)
)
except Exception as e:
self.unimportable_files.append(
FileErrors(path=import_file, error=str(e))
)
self.import_files_paths.remove(import_file)
if self.unimportable_files:
msg = "The following files could not be read and won't be imported:\n"
for unimportable_file in self.unimportable_files:
msg += f"\n\t{unimportable_file.path} ({unimportable_file.error})"
show_warning(None, "Unimportable files", msg)
# check for close matches.
for idx in range(len(self.import_files_data)):
self.check_match(idx=idx)
self.import_files_data = [
x
for x in self.import_files_data
if x.import_file_path not in self.do_not_import
]
# Import all that's left.
for idx in range(len(self.import_files_data)):
self._import_file(idx)
def check_match(self, idx: int) -> None:
"""
Work on and update the idx element of self.import_file_data.
Check for similar existing titles. If none found, set up to
import this as a new track. If one is found, check with user
whether this is a new track or replacement. If more than one
is found, as for one but order the tracks in
artist-similarity order.
"""
similar_track_ids = self._find_similar_strings(
self.import_files_data[idx].tags.title, self.track_idx_and_title
)
if len(similar_track_ids) == 0:
matching_track = 0
elif len(similar_track_ids) == 1:
matching_track = self._pick_match(idx, similar_track_ids)
else:
matching_track = self._pick_match(
idx, self.order_by_artist(idx, similar_track_ids)
)
if matching_track < 0: # User cancelled
return
if matching_track == 0:
self.import_files_data[idx].destination_track_path = os.path.join(
Config.IMPORT_DESTINATION,
os.path.basename(self.import_files_data[idx].import_file_path),
)
else:
self.import_files_data[idx].destination_track_path = self.existing_tracks[
matching_track
].path
self.import_files_data[idx].track_id = matching_track
def _import_file(self, idx: int) -> None:
"""
Import the file specified at self.import_files_data[idx]
"""
log.debug(f"_import_file({idx=}), {self.import_files_data[idx]=}")
f = self.import_files_data[idx]
# Import in separate thread
self.import_thread = QThread()
self.worker = DoTrackImport(
import_file_path=f.import_file_path,
tags=f.tags,
destination_track_path=f.destination_track_path,
track_id=f.track_id,
audio_metadata=helpers.get_audio_metadata(f.import_file_path),
source_model=self.active_proxy_model,
row_number=self.row_number,
)
self.worker.moveToThread(self.import_thread)
self.import_thread.started.connect(self.worker.run)
self.worker.import_finished.connect(self.import_thread.quit)
self.worker.import_finished.connect(self.worker.deleteLater)
self.import_thread.finished.connect(self.import_thread.deleteLater)
self.import_thread.start()
def order_by_artist(self, idx: int, track_ids_to_check: list[int]) -> list[int]:
"""
Return the list of track_ids sorted by how well the artist at idx matches the
track artist.
"""
track_idx_and_artist = [
((key, a.artist))
for key, a in self.existing_tracks.items()
if key in track_ids_to_check
]
# We want to return all of the passed tracks so set minimum_score
# to zero
return self._find_similar_strings(
self.import_files_data[idx].tags.artist,
track_idx_and_artist,
minimum_score=0.0,
)
def _pick_match(self, idx: int, track_ids: list[int]) -> int:
"""
Return the track_id selected by the user, including "import as new" which will be
track_id 0. Return -1 if user cancels.
If user chooses not to import this track, remove it from the list of tracks to
import and return -1.
"""
log.debug(f"_pick_match({idx=}, {track_ids=})")
new_track_details = (
f"{self.import_files_data[idx].tags.title} "
f"({self.import_files_data[idx].tags.artist})"
)
# Build a list of (track title and artise, track_id)
choices: list[tuple[str, int]] = []
# First choice is always to import as a new track
choices.append((Config.DO_NOT_IMPORT, -2))
choices.append((Config.IMPORT_AS_NEW, 0))
for track_id in track_ids:
choices.append(
(
f"{self.existing_tracks[track_id].title} "
f"({self.existing_tracks[track_id].artist})",
track_id,
)
)
dialog = PickMatch(new_track_details, choices)
if dialog.exec() and dialog.selected_id >= 0:
return dialog.selected_id
else:
self.do_not_import.append(self.import_files_data[idx].import_file_path)
return -1
def check_files_are_readable(self) -> None:
"""
Check files to be imported are readable. If not, remove them from the
import list and add them to the file errors list.
"""
for path in self.import_files_paths:
if file_is_unreadable(path):
self.unimportable_files.append(
FileErrors(path=os.path.basename(path), error="File is unreadable")
)
self.import_files_paths.remove(path)
def import_files_are_tagged(self) -> list:
"""
Return a (possibly empty) list of all untagged files in the
import directory. Add tags to file_data
"""
untagged_files: list[str] = []
for fullpath in self.import_files_paths:
tags = get_tags(fullpath)
if not tags:
untagged_files.append(os.path.basename(fullpath))
# Remove from import list
del self.import_files_data[fullpath]
log.warning(f"Import: no tags found, {fullpath=}")
else:
self.import_files_data.append(
TrackFileData(import_file_path=fullpath, tags=tags)
)
return untagged_files
def _get_existing_tracks(self):
"""
Return a dictionary {title: Track} for all existing tracks
"""
with db.Session() as session:
return Tracks.all_tracks_indexed_by_id(session)
def _find_similar_strings(
self,
needle: str,
haystack: list[tuple[int, str]],
minimum_score: float = Config.MINIMUM_FUZZYMATCH,
) -> list[int]:
"""
Search for the needle in the string element of the haystack.
Discard similarities less that minimum_score. Return a list of
the int element of the haystack in order of decreasing score (ie,
best match first).
"""
# Create a dictionary to store similarities
similarities: dict[int, float] = {}
for hayblade in haystack:
# Calculate similarity using multiple metrics
ratio = fuzz.ratio(needle, hayblade[1])
partial_ratio = fuzz.partial_ratio(needle, hayblade[1])
token_sort_ratio = fuzz.token_sort_ratio(needle, hayblade[1])
token_set_ratio = fuzz.token_set_ratio(needle, hayblade[1])
# Combine scores
combined_score = (
ratio * 0.25
+ partial_ratio * 0.25
+ token_sort_ratio * 0.25
+ token_set_ratio * 0.25
)
if combined_score >= minimum_score:
similarities[hayblade[0]] = combined_score
log.debug(
f"_find_similar_strings({needle=}), {len(haystack)=}, "
f"{minimum_score=}, {hayblade=}, {combined_score=}"
)
# Sort matches by score
sorted_matches = sorted(similarities.items(), key=lambda x: x[1], reverse=True)
# Return list of indexes, highest score first
return [a[0] for a in sorted_matches]
class PickMatch(QDialog):
"""
Dialog for user to select which existing track to replace or to
import to a new track
"""
def __init__(
self, new_track_details: str, items_with_ids: list[tuple[str, int]]
) -> None:
super().__init__()
self.new_track_details = new_track_details
self.init_ui(items_with_ids)
self.selected_id = -1
def init_ui(self, items_with_ids: list[tuple[str, int]]) -> None:
"""
Set up dialog
"""
self.setWindowTitle("New or replace")
layout = QVBoxLayout()
# Add instructions
instructions = (
f"Importing {self.new_track_details}.\n"
"Import as a new track or replace existing track?"
)
instructions_label = QLabel(instructions)
layout.addWidget(instructions_label)
# Create a button group for radio buttons
self.button_group = QButtonGroup()
# Add radio buttons for each item
for idx, (text, track_id) in enumerate(items_with_ids):
if (
track_sequence.current
and track_id
and track_sequence.current.track_id == track_id
):
# Don't allow current track to be replaced
text = "(Currently playing) " + text
radio_button = QRadioButton(text)
radio_button.setDisabled(True)
self.button_group.addButton(radio_button, -1)
else:
radio_button = QRadioButton(text)
self.button_group.addButton(radio_button, track_id)
layout.addWidget(radio_button)
# Select the second item by default (import as new)
if idx == 1:
radio_button.setChecked(True)
# Add OK and Cancel buttons
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
cancel_button = QPushButton("Cancel")
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
# Connect buttons to actions
ok_button.clicked.connect(self.on_ok)
cancel_button.clicked.connect(self.reject)
def on_ok(self):
# Get the ID of the selected button
self.selected_id = self.button_group.checkedId()
self.accept()
@dataclass
class TrackFileData:
"""
Simple class to track details changes to a track file
"""
import_file_path: str
tags: Tags
destination_track_path: str = ""
file_path_to_removed: Optional[str] = None
track_id: int = 0
audio_metadata: Optional[AudioMetadata] = None
def set_destination_track_path(self, path: str) -> None:
"""
Assigned the passed path
"""
self.destination_track_path = path

View File

@ -1,8 +1,7 @@
# Standard library imports # Standard library imports
import datetime as dt import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import Any, Dict, Optional from typing import Optional
import functools
import os import os
import re import re
import shutil import shutil
@ -18,9 +17,10 @@ from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore from mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects from pydub import AudioSegment, effects
from pydub.utils import mediainfo from pydub.utils import mediainfo
from tinytag import TinyTag # type: ignore from tinytag import TinyTag, TinyTagException # type: ignore
# App imports # App imports
from classes import AudioMetadata, ApplicationError, Tags
from config import Config from config import Config
from log import log from log import log
from models import Tracks from models import Tracks
@ -121,29 +121,25 @@ def get_embedded_time(text: str) -> Optional[dt.datetime]:
return None 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 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""" """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 # Set start_gap, fade_at and silence_at
audio = get_audio_segment(filepath) audio = get_audio_segment(filepath)
if not audio: if not audio:
audio_values = dict(start_gap=0, fade_at=0, silence_at=0) return AudioMetadata()
else: else:
audio_values = dict( return AudioMetadata(
start_gap=leading_silence(audio), start_gap=leading_silence(audio),
fade_at=int( fade_at=int(
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 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 round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
), ),
) )
metadata |= audio_values
return metadata
def get_relative_date( def get_relative_date(
@ -199,17 +192,19 @@ def get_relative_date(
return f"{weeks} {weeks_str}, {days} {days_str}" 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: try:
tag = TinyTag.get(path) tag = TinyTag.get(path)
except FileNotFoundError: 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, title=tag.title,
artist=tag.artist, artist=tag.artist,
bitrate=round(tag.bitrate), bitrate=round(tag.bitrate),
@ -391,10 +386,10 @@ def set_track_metadata(track: Tracks) -> None:
audio_metadata = get_audio_metadata(track.path) audio_metadata = get_audio_metadata(track.path)
tags = get_tags(track.path) tags = get_tags(track.path)
for audio_key in audio_metadata: for audio_key in AudioMetadata._fields:
setattr(track, audio_key, audio_metadata[audio_key]) setattr(track, audio_key, getattr(audio_metadata, audio_key))
for tag_key in tags: for tag_key in Tags._fields:
setattr(track, tag_key, tags[tag_key]) setattr(track, tag_key, getattr(tags, tag_key))
def show_OK(parent: QMainWindow, title: str, msg: str) -> None: def show_OK(parent: QMainWindow, title: str, msg: str) -> None:

View File

@ -1,4 +1,6 @@
# Standard library imports # Standard library imports
from __future__ import annotations
from typing import List, Optional, Sequence from typing import List, Optional, Sequence
import datetime as dt import datetime as dt
import os import os
@ -657,19 +659,35 @@ class Tracks(dbtables.TracksTable):
return session.scalars(select(cls)).unique().all() return session.scalars(select(cls)).unique().all()
@classmethod @classmethod
def get_by_basename( def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
cls, session: Session, basename: str
) -> Optional[Sequence["Tracks"]]:
""" """
Return track(s) with passed basename, or None. Return a dictionary of all tracks, keyed by title
""" """
try: result: dict[int, Tracks] = {}
return session.scalars(
Tracks.select().where(Tracks.path.like("%/" + basename)) for track in cls.get_all(session):
).all() result[track.id] = track
except NoResultFound:
return None 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 @classmethod
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]: def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:

View File

@ -1,12 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Standard library imports # Standard library imports
from os.path import basename
from typing import List, Optional from typing import List, Optional
import argparse import argparse
import datetime as dt import datetime as dt
import os import os
import shutil
from slugify import slugify # type: ignore from slugify import slugify # type: ignore
import subprocess import subprocess
import sys import sys
@ -15,11 +13,8 @@ import webbrowser
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal,
QDate, QDate,
QObject,
Qt, Qt,
QThread,
QTime, QTime,
QTimer, QTimer,
) )
@ -47,23 +42,21 @@ from PyQt6.QtWidgets import (
# Third party imports # Third party imports
import line_profiler import line_profiler
from pygame import mixer from pygame import mixer
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
import stackprinter # type: ignore import stackprinter # type: ignore
# App imports # App imports
from classes import ( from classes import (
MusicMusterSignals, MusicMusterSignals,
RowAndTrack,
TrackFileData,
TrackInfo, TrackInfo,
track_sequence,
) )
from config import Config 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 helpers import file_is_unreadable
from log import log from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
from music_manager import RowAndTrack, track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab from playlists import PlaylistTab
from ui import icons_rc # noqa F401 from ui import icons_rc # noqa F401
@ -74,82 +67,6 @@ from utilities import check_db, update_bitrates
import helpers 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: class PreviewManager:
""" """
Manage track preview player Manage track preview player
@ -301,6 +218,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.connect_signals_slots() self.connect_signals_slots()
self.catch_return_key = False self.catch_return_key = False
self.importer: Optional[FileImporter] = None
if not Config.USE_INTERNAL_BROWSER: if not Config.USE_INTERNAL_BROWSER:
webbrowser.register( webbrowser.register(
@ -458,7 +376,6 @@ class Window(QMainWindow, Ui_MainWindow):
) )
self.actionExport_playlist.triggered.connect(self.export_playlist_tab) self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
self.actionFade.triggered.connect(self.fade) self.actionFade.triggered.connect(self.fade)
self.actionImport.triggered.connect(self.import_track)
self.actionInsertSectionHeader.triggered.connect(self.insert_header) self.actionInsertSectionHeader.triggered.connect(self.insert_header)
self.actionInsertTrack.triggered.connect(self.insert_track) self.actionInsertTrack.triggered.connect(self.insert_track)
self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving) 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.actionPaste.triggered.connect(self.paste_rows)
self.actionPlay_next.triggered.connect(self.play_next) self.actionPlay_next.triggered.connect(self.play_next)
self.actionRenamePlaylist.triggered.connect(self.rename_playlist) 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.actionResume.triggered.connect(self.resume)
self.actionSave_as_template.triggered.connect(self.save_as_template) self.actionSave_as_template.triggered.connect(self.save_as_template)
self.actionSearch_title_in_Songfacts.triggered.connect( self.actionSearch_title_in_Songfacts.triggered.connect(
@ -742,103 +659,18 @@ class Window(QMainWindow, Ui_MainWindow):
# Reset row heights # Reset row heights
self.active_tab().resize_rows() self.active_tab().resize_rows()
def import_track(self) -> None: def import_files_wrapper(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:
""" """
Import the list of filenames as new tracks Pass import files call to file_importer module
""" """
# Import in separate thread # We need to keep a referent to the FileImporter else it will be
self.import_thread = QThread() # garbage collected while import threads are still running
self.worker = ImportTrack( self.importer = FileImporter(
track_files,
self.active_proxy_model(), self.active_proxy_model(),
self.active_tab().source_model_selected_row_number(), self.active_tab().source_model_selected_row_number(),
) )
self.worker.moveToThread(self.import_thread) self.importer.do_import()
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
def insert_header(self) -> None: def insert_header(self) -> None:
"""Show dialog box to enter header text and add to playlist""" """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_rows = self.active_tab().get_selected_rows()
self.move_source_model = self.active_proxy_model() 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: 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) self.tabBar.setTabText(idx, new_name)
session.commit() 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: def return_pressed_in_error(self) -> bool:
""" """
Check whether Return key has been pressed in error. Check whether Return key has been pressed in error.

View File

@ -35,8 +35,6 @@ import obswebsocket # type: ignore
from classes import ( from classes import (
Col, Col,
MusicMusterSignals, MusicMusterSignals,
RowAndTrack,
track_sequence,
) )
from config import Config from config import Config
from helpers import ( from helpers import (
@ -50,6 +48,7 @@ from helpers import (
) )
from log import log from log import log
from models import db, NoteColours, Playdates, PlaylistRows, Tracks from models import db, NoteColours, Playdates, PlaylistRows, Tracks
from music_manager import RowAndTrack, track_sequence
HEADER_NOTES_COLUMN = 1 HEADER_NOTES_COLUMN = 1
@ -125,7 +124,11 @@ class PlaylistModel(QAbstractTableModel):
track_sequence.next, track_sequence.next,
track_sequence.current, 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 break
else: else:
continue # continue iterating over playlist_rows continue # continue iterating over playlist_rows

View File

@ -37,7 +37,7 @@ import line_profiler
# App imports # App imports
from audacity_controller import AudacityController 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 config import Config
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from helpers import ( from helpers import (
@ -48,6 +48,7 @@ from helpers import (
) )
from log import log from log import log
from models import db, Settings from models import db, Settings
from music_manager import track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
if TYPE_CHECKING: if TYPE_CHECKING:
@ -663,6 +664,7 @@ class PlaylistTab(QTableView):
that we have an edit open. that we have an edit open.
""" """
if self.ac:
self.ac.path = None self.ac.path = None
def clear_selection(self) -> None: def clear_selection(self) -> None:

View File

@ -1003,7 +1003,6 @@ padding-left: 8px;</string>
<addaction name="actionInsertSectionHeader"/> <addaction name="actionInsertSectionHeader"/>
<addaction name="actionInsertTrack"/> <addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/> <addaction name="actionRemove"/>
<addaction name="actionImport"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSetNext"/> <addaction name="actionSetNext"/>
<addaction name="action_Clear_selection"/> <addaction name="action_Clear_selection"/>

View File

@ -686,7 +686,6 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionInsertSectionHeader) self.menuPlaylist.addAction(self.actionInsertSectionHeader)
self.menuPlaylist.addAction(self.actionInsertTrack) self.menuPlaylist.addAction(self.actionInsertTrack)
self.menuPlaylist.addAction(self.actionRemove) self.menuPlaylist.addAction(self.actionRemove)
self.menuPlaylist.addAction(self.actionImport)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSetNext) self.menuPlaylist.addAction(self.actionSetNext)
self.menuPlaylist.addAction(self.action_Clear_selection) self.menuPlaylist.addAction(self.action_Clear_selection)

View File

@ -92,6 +92,6 @@ def update_bitrates(session: Session) -> None:
for track in Tracks.get_all(session): for track in Tracks.get_all(session):
try: try:
t = get_tags(track.path) t = get_tags(track.path)
track.bitrate = t["bitrate"] track.bitrate = t.bitrate
except FileNotFoundError: except FileNotFoundError:
continue continue

View File

@ -23,7 +23,8 @@ def fade_point(audio_segment, fade_threshold=-12, chunk_size=10):
fade_threshold = max_vol fade_threshold = max_vol
while ( while (
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0): # noqa W503 and trim_ms > 0
): # noqa W503
trim_ms -= chunk_size trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less # if there is no trailing silence, return lenght of track (it's less

230
poetry.lock generated
View File

@ -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"] 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"] 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]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.1.1" 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)"] qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] 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]] [[package]]
name = "line-profiler" name = "line-profiler"
version = "4.1.3" version = "4.1.3"
@ -1388,6 +1502,20 @@ pytest = "*"
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx-rtd-theme"] 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]] [[package]]
name = "python-slugify" name = "python-slugify"
version = "8.0.4" version = "8.0.4"
@ -1416,6 +1544,106 @@ files = [
{file = "python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec"}, {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]] [[package]]
name = "rich" name = "rich"
version = "13.9.4" version = "13.9.4"
@ -1745,4 +1973,4 @@ test = ["websockets"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "f1b96a77f00820c0db315ba2db9f40aa918a47770317ce54efaa670eea41e83d" content-hash = "6a887314789a17a0d0875f1c7e6ce169c90142164de98e386131a7836e3db3b5"

View File

@ -25,6 +25,8 @@ obs-websocket-py = "^1.0"
pygame = "^2.6.1" pygame = "^2.6.1"
psutil = "^6.1.0" psutil = "^6.1.0"
pyqt6-webengine = "^6.7.0" pyqt6-webengine = "^6.7.0"
fuzzywuzzy = "^0.18.0"
python-levenshtein = "^0.26.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
ipdb = "^0.13.9" ipdb = "^0.13.9"

View File

@ -55,8 +55,8 @@ class TestMMHelpers(unittest.TestCase):
with open(test_track_data) as f: with open(test_track_data) as f:
testdata = eval(f.read()) testdata = eval(f.read())
assert tags["artist"] == testdata["artist"] assert tags.artist == testdata["artist"]
assert tags["title"] == testdata["title"] assert tags.title == testdata["title"]
def test_get_relative_date(self): def test_get_relative_date(self):
assert get_relative_date(None) == "Never" 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) today_at_11 = dt.datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00" assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
eight_days_ago = today_at_10 - dt.timedelta(days=8) 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) 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): def test_leading_silence(self):
test_track_path = "testdata/isa.mp3" test_track_path = "testdata/isa.mp3"

View File

@ -82,7 +82,7 @@ class MyTestCase(unittest.TestCase):
for track in self.tracks.values(): for track in self.tracks.values():
db_track = Tracks(session=session, **track) db_track = Tracks(session=session, **track)
session.add(db_track) session.add(db_track)
track['id'] = db_track.id track["id"] = db_track.id
session.commit() session.commit()
@ -136,12 +136,13 @@ class MyTestCase(unittest.TestCase):
from config import Config 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: with db.Session() as session:
utilities.check_db(session) utilities.check_db(session)
utilities.update_bitrates(session) utilities.update_bitrates(session)
# def test_meta_all_clear(qtbot, session): # def test_meta_all_clear(qtbot, session):
# # Create playlist # # Create playlist
# playlist = models.Playlists(session, "my playlist") # playlist = models.Playlists(session, "my playlist")