Compare commits

..

11 Commits

Author SHA1 Message Date
Keith Edmunds
a0c074adad Checked all queries are SQLAlchemy V2 format 2022-08-22 17:46:04 +01:00
Keith Edmunds
140722217b Add bitrates to database and display 2022-08-22 17:30:30 +01:00
Keith Edmunds
0e9461e0df Merge branch 'replacing_files' into v3_play 2022-08-22 16:09:04 +01:00
Keith Edmunds
f851fdcafe First draft of rename_singles.py 2022-08-22 16:08:24 +01:00
Keith Edmunds
26358761e5 Default to no processing in replace_files.py 2022-08-22 16:07:44 +01:00
Keith Edmunds
6ce41d3314 Check replace_files is run against production db 2022-08-22 16:01:56 +01:00
Keith Edmunds
62c5fa178c Work around MariaDB bug in replace_files.py 2022-08-22 14:39:18 +01:00
Keith Edmunds
5f8d8572ad Don't allow duplicate track paths 2022-08-21 19:47:47 +01:00
Keith Edmunds
16b9ac19f0 Reset colours for each track on update_display 2022-08-21 17:00:42 +01:00
Keith Edmunds
503ba36a88 Replacing files fine tuning 2022-08-17 17:09:19 +01:00
Keith Edmunds
bcc6634e34 Work on replacing existing music files 2022-08-17 11:28:10 +01:00
12 changed files with 464 additions and 23 deletions

View File

@ -6,7 +6,12 @@ from typing import List, Optional
class Config(object): class Config(object):
AUDACITY_COMMAND = "/usr/bin/audacity" AUDACITY_COMMAND = "/usr/bin/audacity"
AUDIO_SEGMENT_CHUNK_SIZE = 10 AUDIO_SEGMENT_CHUNK_SIZE = 10
BITRATE_LOW_THRESHOLD = 192
BITRATE_OK_THRESHOLD = 300
CHECK_AUDACITY_AT_STARTUP = True CHECK_AUDACITY_AT_STARTUP = True
COLOUR_BITRATE_LOW = "#ffcdd2"
COLOUR_BITRATE_MEDIUM = "#ffeb6f"
COLOUR_BITRATE_OK = "#dcedc8"
COLOUR_CURRENT_HEADER = "#d4edda" COLOUR_CURRENT_HEADER = "#d4edda"
COLOUR_CURRENT_PLAYLIST = "#7eca8f" COLOUR_CURRENT_PLAYLIST = "#7eca8f"
COLOUR_CURRENT_TAB = "#248f24" COLOUR_CURRENT_TAB = "#248f24"
@ -24,6 +29,7 @@ class Config(object):
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
COLUMN_NAME_ARTIST = "Artist" COLUMN_NAME_ARTIST = "Artist"
COLUMN_NAME_AUTOPLAY = "A" COLUMN_NAME_AUTOPLAY = "A"
COLUMN_NAME_BITRATE = "bps"
COLUMN_NAME_END_TIME = "End" COLUMN_NAME_END_TIME = "End"
COLUMN_NAME_LAST_PLAYED = "Last played" COLUMN_NAME_LAST_PLAYED = "Last played"
COLUMN_NAME_LEADING_SILENCE = "Gap" COLUMN_NAME_LEADING_SILENCE = "Gap"

View File

@ -86,6 +86,7 @@ def get_tags(path: str) -> Dict[str, Union[str, int]]:
return dict( return dict(
title=tag.title, title=tag.title,
artist=tag.artist, artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000), duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path path=path
) )

View File

@ -508,8 +508,9 @@ class Tracks(Base):
start_gap = Column(Integer, index=False) start_gap = Column(Integer, index=False)
fade_at = Column(Integer, index=False) fade_at = Column(Integer, index=False)
silence_at = Column(Integer, index=False) silence_at = Column(Integer, index=False)
path = Column(String(2048), index=False, nullable=False) path = Column(String(2048), index=False, nullable=False, unique=True)
mtime = Column(Float, index=True) mtime = Column(Float, index=True)
bitrate = Column(Integer, nullable=True, default=None)
playlistrows = relationship("PlaylistRows", back_populates="track") playlistrows = relationship("PlaylistRows", back_populates="track")
playlists = association_proxy("playlistrows", "playlist") playlists = association_proxy("playlistrows", "playlist")
playdates = relationship("Playdates", back_populates="track") playdates = relationship("Playdates", back_populates="track")
@ -546,11 +547,11 @@ class Tracks(Base):
session.add(self) session.add(self)
session.commit() session.commit()
@staticmethod @classmethod
def get_all_paths(session) -> List[str]: def get_all(cls, session) -> List["Tracks"]:
"""Return a list of paths of all tracks""" """Return a list of all tracks"""
return session.execute(select(Tracks.path)).scalars().all() return session.execute(select(cls)).scalars().all()
@classmethod @classmethod
def get_or_create(cls, session: Session, path: str) -> "Tracks": def get_or_create(cls, session: Session, path: str) -> "Tracks":
@ -559,9 +560,8 @@ class Tracks(Base):
else created new track and return it else created new track and return it
""" """
try: track = cls.get_by_path(session, path)
track = session.query(cls).filter(cls.path == path).one() if not track:
except NoResultFound:
track = Tracks(session, path) track = Tracks(session, path)
return track return track
@ -572,12 +572,15 @@ class Tracks(Base):
Return track with passed path, or None. Return track with passed path, or None.
""" """
try:
return ( return (
session.execute( session.execute(
select(Tracks) select(Tracks)
.where(Tracks.path == path) .where(Tracks.path == path)
).scalar_one() ).scalar_one()
) )
except NoResultFound:
return None
def rescan(self, session: Session) -> None: def rescan(self, session: Session) -> None:
""" """
@ -593,7 +596,7 @@ class Tracks(Base):
Config.MILLISECOND_SIGFIGS) * 1000 Config.MILLISECOND_SIGFIGS) * 1000
self.start_gap = leading_silence(audio) self.start_gap = leading_silence(audio)
session.add(self) session.add(self)
session.flush() session.commit()
# @staticmethod # @staticmethod
# def remove_by_path(session: Session, path: str) -> None: # def remove_by_path(session: Session, path: str) -> None:

View File

@ -41,7 +41,7 @@ from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from config import Config from config import Config
from ui.main_window_ui import Ui_MainWindow # type: ignore from ui.main_window_ui import Ui_MainWindow # type: ignore
from utilities import create_track_from_file, check_db from utilities import create_track_from_file, check_db, update_bitrates
class TrackData: class TrackData:
@ -1134,6 +1134,9 @@ if __name__ == "__main__":
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
# Only allow at most one option to be specified # Only allow at most one option to be specified
group = p.add_mutually_exclusive_group() group = p.add_mutually_exclusive_group()
group.add_argument('-b', '--bitrates',
action="store_true", dest="update_bitrates",
default=False, help="Update bitrates in database")
group.add_argument('-c', '--check-database', group.add_argument('-c', '--check-database',
action="store_true", dest="check_db", action="store_true", dest="check_db",
default=False, help="Check and report on database") default=False, help="Check and report on database")
@ -1144,6 +1147,10 @@ if __name__ == "__main__":
log.debug("Updating database") log.debug("Updating database")
with Session() as session: with Session() as session:
check_db(session) check_db(session)
elif args.update_bitrates:
log.debug("Update bitrates")
with Session() as session:
update_bitrates(session)
else: else:
# Normal run # Normal run
try: try:

View File

@ -77,7 +77,8 @@ columns["duration"] = Column(idx=4, heading=Config.COLUMN_NAME_LENGTH)
columns["start_time"] = Column(idx=5, heading=Config.COLUMN_NAME_START_TIME) columns["start_time"] = Column(idx=5, heading=Config.COLUMN_NAME_START_TIME)
columns["end_time"] = Column(idx=6, heading=Config.COLUMN_NAME_END_TIME) columns["end_time"] = Column(idx=6, heading=Config.COLUMN_NAME_END_TIME)
columns["lastplayed"] = Column(idx=7, heading=Config.COLUMN_NAME_LAST_PLAYED) columns["lastplayed"] = Column(idx=7, heading=Config.COLUMN_NAME_LAST_PLAYED)
columns["row_notes"] = Column(idx=8, heading=Config.COLUMN_NAME_NOTES) columns["bitrate"] = Column(idx=8, heading=Config.COLUMN_NAME_BITRATE)
columns["row_notes"] = Column(idx=9, heading=Config.COLUMN_NAME_NOTES)
class NoSelectDelegate(QStyledItemDelegate): class NoSelectDelegate(QStyledItemDelegate):
@ -95,6 +96,7 @@ class NoSelectDelegate(QStyledItemDelegate):
return QPlainTextEdit(parent) return QPlainTextEdit(parent)
return super().createEditor(parent, option, index) return super().createEditor(parent, option, index)
class PlaylistTab(QTableWidget): class PlaylistTab(QTableWidget):
# Qt.UserRoles # Qt.UserRoles
ROW_FLAGS = Qt.UserRole ROW_FLAGS = Qt.UserRole
@ -582,6 +584,13 @@ class PlaylistTab(QTableWidget):
end_item = QTableWidgetItem() end_item = QTableWidgetItem()
self.setItem(row, columns['end_time'].idx, end_item) self.setItem(row, columns['end_time'].idx, end_item)
if row_data.track.bitrate:
bitrate = str(row_data.track.bitrate)
else:
bitrate = ""
bitrate_item = QTableWidgetItem(bitrate)
self.setItem(row, columns['bitrate'].idx, bitrate_item)
# As we have track info, any notes should be contained in # As we have track info, any notes should be contained in
# the notes column # the notes column
notes_item = QTableWidgetItem(row_data.note) notes_item = QTableWidgetItem(row_data.note)
@ -974,6 +983,9 @@ class PlaylistTab(QTableWidget):
track = session.get(Tracks, track_id) track = session.get(Tracks, track_id)
if track: if track:
# Reset colour in case it was current/next/unplayable
self._set_row_colour(row, None)
# Render unplayable tracks in correct colour # Render unplayable tracks in correct colour
if not file_is_readable(track.path): if not file_is_readable(track.path):
self._set_row_colour(row, QColor(Config.COLOUR_UNREADABLE)) self._set_row_colour(row, QColor(Config.COLOUR_UNREADABLE))
@ -992,6 +1004,17 @@ class PlaylistTab(QTableWidget):
# Ensure content is visible by wrapping cells # Ensure content is visible by wrapping cells
self.resizeRowToContents(row) self.resizeRowToContents(row)
# Highlight low bitrates
if track.bitrate:
if track.bitrate < Config.BITRATE_LOW_THRESHOLD:
cell_colour = Config.COLOUR_BITRATE_LOW
elif track.bitrate < Config.BITRATE_OK_THRESHOLD:
cell_colour = Config.COLOUR_BITRATE_MEDIUM
else:
cell_colour = Config.COLOUR_BITRATE_OK
brush = QBrush(QColor(cell_colour))
self.item(row, columns['bitrate'].idx).setBackground(brush)
# Render playing track # Render playing track
if row == current_row: if row == current_row:
# Set start time # Set start time
@ -1037,10 +1060,6 @@ class PlaylistTab(QTableWidget):
self._set_row_bold(row) self._set_row_bold(row)
continue continue
# This is a track row other than next or current
# Reset colour in case it was current/next
self._set_row_colour(row, None)
if row in played: if row in played:
# Played today, so update last played column # Played today, so update last played column
self.item(row, columns['lastplayed'].idx).setText( self.item(row, columns['lastplayed'].idx).setText(
@ -1422,6 +1441,7 @@ class PlaylistTab(QTableWidget):
f"Artist: {track.artist}\n" f"Artist: {track.artist}\n"
f"Track ID: {track.id}\n" f"Track ID: {track.id}\n"
f"Track duration: {ms_to_mmss(track.duration)}\n" f"Track duration: {ms_to_mmss(track.duration)}\n"
f"Track bitrate: {track.bitrate}\n"
f"Track fade at: {ms_to_mmss(track.fade_at)}\n" f"Track fade at: {ms_to_mmss(track.fade_at)}\n"
f"Track silence at: {ms_to_mmss(track.silence_at)}" f"Track silence at: {ms_to_mmss(track.silence_at)}"
"\n\n" "\n\n"

54
app/rename_singles.py Executable file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python
#
# Script to manage renaming existing files in given directory and
# propagating that change to database. Typical usage: renaming files
# from 'title.mp3' to title - artist.mp3'
#
# Actions:
#
# - record all filenames and inode numbers
# - external: rename the files
# - update records with new filenames for each inode number
# - update external database with new paths
import os
import sqlite3
PHASE = 2
# Check file of same name exists in parent directory
source_dir = '/home/kae/tmp/Singles' # os.getcwd()
db = "/home/kae/tmp/singles.sqlite"
def main():
with sqlite3.connect(db) as connection:
cursor = connection.cursor()
if PHASE == 1:
cursor.execute(
"CREATE TABLE IF NOT EXISTS mp3s "
"(inode INTEGER, oldname TEXT, newname TEXT)"
)
for fname in os.listdir(source_dir):
fullpath = os.path.join(source_dir, fname)
inode = os.stat(fullpath).st_ino
sql = f'INSERT INTO mp3s VALUES ({inode}, "{fname}", "")'
cursor.execute(sql)
if PHASE == 2:
for fname in os.listdir(source_dir):
fullpath = os.path.join(source_dir, fname)
inode = os.stat(fullpath).st_ino
sql = (
f'UPDATE mp3s SET newname = "{fname}" WHERE inode={inode}'
)
try:
cursor.execute(sql)
except sqlite3.OperationalError:
print(f"Error with {inode} -> {fname}")
cursor.close()
main()

247
app/replace_files.py Executable file
View File

@ -0,0 +1,247 @@
#!/usr/bin/env python
#
# Script to replace existing files in parent directory. Typical usage:
# the current directory contains a "better" version of the file than the
# parent (eg, bettet bitrate).
#
# Actions:
#
# - check that the same filename is present in the parent directory
# - check that the artist and title tags are the same
# - append ".bak" to the version in the parent directory
# - move file to parent directory
# - normalise file
# - update duration, start_gap, fade_at, silence_at, mtime in database
import glob
import os
import shutil
import sys
from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
trailing_silence,
)
from models import Tracks
from dbconfig import Session
from thefuzz import process # type: ignore
from sqlalchemy.exc import IntegrityError
from typing import List
# ###################### SETTINGS #########################
process_multiple_matches = False
do_processing = False
process_no_matches = False
# #########################################################
def insensitive_glob(pattern):
def either(c):
return '[%s%s]' % (c.lower(), c.upper()) if c.isalpha() else c
return glob.glob(''.join(map(either, pattern)))
# Check file of same name exists in parent directory
source_dir = '/home/kae/music/Singles/tmp' # os.getcwd()
parent_dir = os.path.dirname(source_dir)
assert source_dir != parent_dir
name_and_tags: List[str] = []
name_not_tags: List[str] = []
tags_not_name: List[str] = []
multiple_similar: List[str] = []
no_match: List[str] = []
possibles: List[str] = []
print(f"{source_dir=}, {parent_dir=}")
def main():
if 'musicmuster_prod' not in os.environ.get('MM_DB'):
response = input("Not on production database - c to continue: ")
if response != "c":
sys.exit(0)
tracks = os.listdir(parent_dir)
for fname in os.listdir(source_dir):
parent_file = os.path.join(parent_dir, fname)
new_file = os.path.join(source_dir, fname)
us = get_tags(new_file)
us_t = us['title']
us_a = us['artist']
if os.path.exists(parent_file):
# File exists, check tags
p = get_tags(parent_file)
p_t = p['title']
p_a = p['artist']
if (
(str(p_t).lower() != str(us_t).lower()) or
(str(p_a).lower() != str(us_a).lower())
):
name_not_tags.append(
f" {fname=}, {p_t}{us_t}, {p_a}{us_a}")
process_track(new_file, parent_file, us_t, us_a)
continue
name_and_tags.append(new_file)
process_track(new_file, parent_file, us_t, us_a)
continue
# Try to find a near match
stem = fname.split(".")[0]
matches = insensitive_glob(os.path.join(parent_dir, stem) + '*')
match_count = len(matches)
if match_count == 0:
if process_no_matches:
print(f"\n file={fname}\n title={us_t}\n artist={us_a}\n")
# Try fuzzy search
d = {}
while True:
for i, match in enumerate(
[a[0] for a in process.extract(fname, tracks, limit=5)]
):
d[i] = match
for k, v in d.items():
print(f"{k}: {v}")
data = input("pick one, return to quit: ")
if data == "":
no_match.append(f"{fname}, {us_t=}, {us_a=}")
break
try:
key = int(data)
except ValueError:
continue
if key in d:
old_file = os.path.join(parent_dir, d[key])
oldtags = get_tags(old_file)
old_title = oldtags['title']
old_artist = oldtags['artist']
print()
print(f" Title tag will change {old_title}{us_t}")
print(f" Artist tag will change {old_artist}{us_a}")
print()
data = input("Go ahead (y to accept)? ")
if data == "y":
process_track(new_file, old_file, us_t, us_a)
break
else:
no_match.append(f"{fname}, {us_t=}, {us_a=}")
continue
no_match.append(f"{fname}, {us_t=}, {us_a=}")
continue
else:
no_match.append(f"{fname}, {us_t=}, {us_a=}")
continue
if match_count > 1:
multiple_similar.append(fname + "\n " + "\n ".join(matches))
if match_count <= 26 and process_multiple_matches:
print(f"\n file={fname}\n title={us_t}\n artist={us_a}\n")
d = {}
while True:
for i, match in enumerate(matches):
d[i] = match
for k, v in d.items():
print(f"{k}: {v}")
data = input("pick one, return to quit: ")
if data == "":
break
try:
key = int(data)
except ValueError:
continue
if key in d:
dst = d[key]
process_track(new_file, dst, us_t, us_a)
break
else:
continue
continue # from break after testing for "" in data
# One match, check tags
sim_name = matches[0]
p = get_tags(sim_name)
p_t = p['title']
p_a = p['artist']
if (
(str(p_t).lower() != str(us_t).lower()) or
(str(p_a).lower() != str(us_a).lower())
):
possibles.append(
f"File: {os.path.basename(sim_name)}{fname}"
f"\n {p_t}{us_t}\n {p_a}{us_a}"
)
process_track(new_file, sim_name, us_t, us_a)
continue
tags_not_name.append(f"Rename {os.path.basename(sim_name)}{fname}")
process_track(new_file, sim_name, us_t, us_a)
print(f"Name and tags match ({len(name_and_tags)}):")
# print(" \n".join(name_and_tags))
# print()
print(f"Name but not tags match ({len(name_not_tags)}):")
print(" \n".join(name_not_tags))
print()
print(f"Tags but not name match ({len(tags_not_name)}):")
# print(" \n".join(tags_not_name))
# print()
print(f"Multiple similar names ({len(multiple_similar)}):")
print(" \n".join(multiple_similar))
print()
print(f"Possibles: ({len(possibles)}):")
print(" \n".join(possibles))
print()
print(f"No match ({len(no_match)}):")
print(" \n".join(no_match))
print()
def process_track(src, dst, title, artist):
new_path = os.path.join(os.path.dirname(dst), os.path.basename(src))
print(
f"process_track:\n {src=}\n {new_path=}\n "
f"{dst=}\n {title=}, {artist=}\n"
)
if not do_processing:
return
with Session() as session:
track = Tracks.get_by_path(session, dst)
if track:
track.title = title
track.artist = artist
track.path = new_path
try:
session.commit()
except IntegrityError:
# https://jira.mariadb.org/browse/MDEV-29345 workaround
session.rollback()
track.title = title
track.artist = artist
track.path = "DUMMY"
session.commit()
track.path = new_path
session.commit()
print(f"os.unlink({dst}")
print(f"shutil.move({src}, {new_path}")
os.unlink(dst)
shutil.move(src, new_path)
track = Tracks.get_by_path(session, new_path)
if track:
track.rescan(session)
else:
print(f"Can't find copied track {src=}, {dst=}")
main()

View File

@ -43,6 +43,7 @@ def create_track_from_file(session, path, normalise=None, tags=None):
track.silence_at = round(trailing_silence(audio) / 1000, track.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000 Config.MILLISECOND_SIGFIGS) * 1000
track.mtime = os.path.getmtime(path) track.mtime = os.path.getmtime(path)
track.bitrate = t['bitrate']
session.commit() session.commit()
if normalise or normalise is None and Config.NORMALISE_ON_IMPORT: if normalise or normalise is None and Config.NORMALISE_ON_IMPORT:
@ -107,7 +108,7 @@ def check_db(session):
Check all paths in database exist Check all paths in database exist
""" """
db_paths = set(Tracks.get_all_paths(session)) db_paths = set([a.path for a in Tracks.get_all(session)])
os_paths_list = [] os_paths_list = []
for root, dirs, files in os.walk(Config.ROOT): for root, dirs, files in os.walk(Config.ROOT):
@ -162,6 +163,19 @@ def check_db(session):
print("There were more paths than listed that were not found") print("There were more paths than listed that were not found")
def update_bitrates(session):
"""
Update bitrates on all tracks in database
"""
for track in Tracks.get_all(session):
try:
t = get_tags(track.path)
track.bitrate = t["bitrate"]
except FileNotFoundError:
continue
# # Spike # # Spike
# # # #
# # # Manage tracks listed in database but where path is invalid # # # Manage tracks listed in database but where path is invalid

View File

@ -0,0 +1,28 @@
"""Add column for bitrate in Tracks
Revision ID: ed3100326c38
Revises: fe2e127b3332
Create Date: 2022-08-22 16:16:42.181848
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ed3100326c38'
down_revision = 'fe2e127b3332'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('bitrate', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tracks', 'bitrate')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""Don't allow duplicate track paths
Revision ID: fe2e127b3332
Revises: 0c604bf490f8
Create Date: 2022-08-21 19:46:35.768659
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fe2e127b3332'
down_revision = '0c604bf490f8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, 'tracks', ['path'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'tracks', type_='unique')
# ### end Alembic commands ###

32
poetry.lock generated
View File

@ -366,6 +366,14 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.8,<4.0" python-versions = ">=3.8,<4.0"
[[package]]
name = "pyfzf"
version = "0.3.1"
description = "Python wrapper for junegunn's fuzzyfinder (fzf)"
category = "main"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.11.2" version = "2.11.2"
@ -481,6 +489,14 @@ pytest = ">=3.0.0"
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx-rtd-theme"] doc = ["sphinx", "sphinx-rtd-theme"]
[[package]]
name = "python-levenshtein"
version = "0.12.2"
description = "Python extension for computing string edit distances and similarities."
category = "main"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "python-slugify" name = "python-slugify"
version = "6.1.2" version = "6.1.2"
@ -579,6 +595,17 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "thefuzz"
version = "0.19.0"
description = "Fuzzy string matching in python"
category = "main"
optional = false
python-versions = "*"
[package.extras]
speedup = ["python-levenshtein (>=0.12)"]
[[package]] [[package]]
name = "tinytag" name = "tinytag"
version = "1.8.1" version = "1.8.1"
@ -644,7 +671,7 @@ python-versions = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "9b4cf9915bf250afd948596a6ba82794f82abf6a6d4891bc51845409632c15fb" content-hash = "b181eb743e8b6c9cb7e03c4db0bcef425fe410d2ec3c4c801ce20e448a26f166"
[metadata.files] [metadata.files]
alembic = [ alembic = [
@ -917,6 +944,7 @@ pydub = [
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"}, {file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
] ]
pydub-stubs = [] pydub-stubs = []
pyfzf = []
pygments = [ pygments = [
{file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
{file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
@ -986,6 +1014,7 @@ pytest-qt = [
{file = "pytest-qt-4.0.2.tar.gz", hash = "sha256:dfc5240dec7eb43b76bcb5f9a87eecae6ef83592af49f3af5f1d5d093acaa93e"}, {file = "pytest-qt-4.0.2.tar.gz", hash = "sha256:dfc5240dec7eb43b76bcb5f9a87eecae6ef83592af49f3af5f1d5d093acaa93e"},
{file = "pytest_qt-4.0.2-py2.py3-none-any.whl", hash = "sha256:e03847ac02a890ccaac0fde1748855b9dce425aceba62005c6cfced6cf7d5456"}, {file = "pytest_qt-4.0.2-py2.py3-none-any.whl", hash = "sha256:e03847ac02a890ccaac0fde1748855b9dce425aceba62005c6cfced6cf7d5456"},
] ]
python-levenshtein = []
python-slugify = [] python-slugify = []
python-vlc = [ python-vlc = [
{file = "python-vlc-3.0.16120.tar.gz", hash = "sha256:92f98fee088f72bd6d063b3b3312d0bd29b37e7ad65ddeb3a7303320300c2807"}, {file = "python-vlc-3.0.16120.tar.gz", hash = "sha256:92f98fee088f72bd6d063b3b3312d0bd29b37e7ad65ddeb3a7303320300c2807"},
@ -1005,6 +1034,7 @@ stack-data = [
{file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"}, {file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"},
] ]
text-unidecode = [] text-unidecode = []
thefuzz = []
tinytag = [ tinytag = [
{file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"}, {file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"},
] ]

View File

@ -19,6 +19,9 @@ pydub = "^0.25.1"
PyQt5-sip = "^12.9.1" PyQt5-sip = "^12.9.1"
types-psutil = "^5.8.22" types-psutil = "^5.8.22"
python-slugify = "^6.1.2" python-slugify = "^6.1.2"
thefuzz = "^0.19.0"
python-Levenshtein = "^0.12.2"
pyfzf = "^0.3.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
ipdb = "^0.13.9" ipdb = "^0.13.9"