diff --git a/app/classes.py b/app/classes.py
index 713eb86..a93c230 100644
--- a/app/classes.py
+++ b/app/classes.py
@@ -94,10 +94,10 @@ class MusicMusterSignals(QObject):
class Tags(NamedTuple):
- artist: str
- title: str
- bitrate: int
- duration: int
+ artist: str = ""
+ title: str = ""
+ bitrate: int = 0
+ duration: int = 0
class TrackInfo(NamedTuple):
diff --git a/app/config.py b/app/config.py
index 725a459..ea2c7e2 100644
--- a/app/config.py
+++ b/app/config.py
@@ -51,6 +51,10 @@ class Config(object):
FADEOUT_DB = -10
FADEOUT_SECONDS = 5
FADEOUT_STEPS_PER_SECOND = 5
+ FUZZYMATCH_MINIMUM_LIST = 60.0
+ FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0
+ FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0
+ FUZZYMATCH_SHOW_SCORES = True
HEADER_ARTIST = "Artist"
HEADER_BITRATE = "bps"
HEADER_DURATION = "Length"
@@ -62,8 +66,8 @@ class Config(object):
HEADER_START_TIME = "Start"
HEADER_TITLE = "Title"
HIDE_AFTER_PLAYING_OFFSET = 5000
- HIDE_PLAYED_MODE_TRACKS = "TRACKS"
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
+ HIDE_PLAYED_MODE_TRACKS = "TRACKS"
IMPORT_AS_NEW = "Import as new track"
INFO_TAB_TITLE_LENGTH = 15
INTRO_SECONDS_FORMAT = ".1f"
@@ -82,7 +86,6 @@ class Config(object):
MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0
- MINIMUM_FUZZYMATCH = 60.0
MINIMUM_ROW_HEIGHT = 30
NO_TEMPLATE_NAME = "None"
NOTE_TIME_FORMAT = "%H:%M"
diff --git a/app/dialogs.py b/app/dialogs.py
index e505218..b9fa083 100644
--- a/app/dialogs.py
+++ b/app/dialogs.py
@@ -1,6 +1,5 @@
# Standard library imports
from typing import Optional
-import os
# PyQt imports
from PyQt6.QtCore import QEvent, Qt
@@ -9,27 +8,22 @@ from PyQt6.QtWidgets import (
QDialog,
QListWidgetItem,
QMainWindow,
- QTableWidgetItem,
)
# Third party imports
-import pydymenu # type: ignore
from sqlalchemy.orm.session import Session
# App imports
from classes import MusicMusterSignals
-from config import Config
from helpers import (
ask_yes_no,
get_relative_date,
- get_tags,
ms_to_mmss,
- show_warning,
)
from log import log
-from models import db, Settings, Tracks
+from models import Settings, Tracks
from playlistmodel import PlaylistModel
-from ui import dlg_TrackSelect_ui, dlg_replace_files_ui
+from ui import dlg_TrackSelect_ui
class TrackSelectDialog(QDialog):
diff --git a/app/file_importer.py b/app/file_importer.py
index 9b25746..3fde1f2 100644
--- a/app/file_importer.py
+++ b/app/file_importer.py
@@ -1,7 +1,7 @@
# Standard library imports
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from fuzzywuzzy import fuzz # type: ignore
import os.path
from typing import Optional
@@ -21,7 +21,6 @@ from PyQt6.QtWidgets import (
QLabel,
QPushButton,
QRadioButton,
- QStatusBar,
QVBoxLayout,
)
@@ -30,8 +29,6 @@ from PyQt6.QtWidgets import (
# App imports
from classes import (
ApplicationError,
- AudioMetadata,
- FileErrors,
MusicMusterSignals,
Tags,
)
@@ -49,35 +46,26 @@ import helpers
class DoTrackImport(QObject):
- import_finished = pyqtSignal()
+ import_finished = pyqtSignal(int, QThread)
def __init__(
self,
+ associated_thread: QThread,
import_file_path: str,
tags: Tags,
- destination_track_path: str,
+ destination_path: str,
track_id: int,
- audio_metadata: AudioMetadata,
- base_model: PlaylistModel,
- row_number: Optional[int],
) -> None:
"""
Save parameters
"""
super().__init__()
-
+ self.associated_thread = associated_thread
self.import_file_path = import_file_path
self.tags = tags
- self.destination_track_path = destination_track_path
+ self.destination_track_path = destination_path
self.track_id = track_id
- self.audio_metadata = audio_metadata
- self.base_model = base_model
-
- if row_number is None:
- self.next_row_number = base_model.rowCount()
- else:
- self.next_row_number = row_number
self.signals = MusicMusterSignals()
@@ -91,6 +79,9 @@ class DoTrackImport(QObject):
temp_file: Optional[str] = None
+ # Get audio metadata in this thread rather than calling function to save interactive time
+ self.audio_metadata = helpers.get_audio_metadata(self.import_file_path)
+
# If destination exists, move it out of the way
if os.path.exists(self.destination_track_path):
temp_file = self.destination_track_path + ".TMP"
@@ -130,13 +121,11 @@ class DoTrackImport(QObject):
session.commit()
helpers.normalise_track(self.destination_track_path)
- self.base_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()
+ self.signals.status_message_signal.emit(
+ f"{os.path.basename(self.import_file_path)} imported", 10000
+ )
+ self.import_finished.emit(track.id, self.associated_thread)
class FileImporter:
@@ -151,32 +140,27 @@ class FileImporter:
Set up class
"""
- # Save parameters
- self.base_model = base_model
- if row_number:
- self.row_number = row_number
- else:
- self.row_number = base_model.rowCount()
+ # Create ModelData
+ if not row_number:
+ row_number = base_model.rowCount()
+ self.model_data = ModelData(base_model=base_model, row_number=row_number)
+
+ # Place to keep reference to importer threads and data
+ self.thread_data: dict[QThread, ModelData] = {}
+
# Data structure to track files to import
- self.import_files_data: list[TrackFileData] = []
- # Dictionary of exsting tracks
+ self.import_files_data: dict[str, TrackFileData] = {}
+
+ # Dictionary of exsting tracks indexed by track.id
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 = [
+
+ # Populate self.import_files_data
+ for infile in [
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] = []
- # Place to keep reference to importer while it runs
- self.import_thread: dict[int, QThread] = {}
+ ]:
+ self.import_files_data[infile] = TrackFileData()
def do_import(self) -> None:
"""
@@ -190,206 +174,108 @@ class FileImporter:
- 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
- or x.track_id != -1
- )
- ]
-
- # 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
- matching_track = -1
-
- elif 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[idx] = 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),
- base_model=self.base_model,
- row_number=self.row_number,
- )
-
- self.worker.moveToThread(self.import_thread[idx])
- self.import_thread[idx].started.connect(self.worker.run)
- self.worker.import_finished.connect(self.import_thread[idx].quit)
- self.worker.import_finished.connect(self.worker.deleteLater)
- self.import_thread[idx].finished.connect(self.import_thread[idx].deleteLater)
- self.import_thread[idx].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 artist, track_id, track path)
- choices: list[tuple[str, int, str]] = []
- # 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,
- str(self.existing_tracks[track_id].path),
- )
- )
-
- 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:
+ # Check all file are readable and have tags. Mark failures not to
+ # be imported and populate error text.
+ for path in self.import_files_data.keys():
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)
+ self.import_files_data[path].import_this_file = False
+ self.import_files_data[path].error = f"{path} is unreadable"
+ continue
- def import_files_are_tagged(self) -> list:
+ # Get tags
+ try:
+ self.import_files_data[path].tags = get_tags(path)
+ except ApplicationError as e:
+ self.import_files_data[path].import_this_file = False
+ self.import_files_data[path].error = f"{path} tag errors ({str(e)})"
+ continue
+
+ # Get track match data
+ self.populate_track_match_data(path)
+ # Sort with best artist match first
+ self.import_files_data[path].track_match_data.sort(
+ key=lambda rec: rec.artist_match, reverse=True
+ )
+
+ # Process user choices
+ self.process_user_choices(path)
+
+ # Tell users about files that won't be imported
+ for path in [
+ a
+ for a in self.import_files_data.keys()
+ if not self.import_files_data[a].import_this_file
+ ]:
+ msg = (
+ f"{os.path.basename(path)} will not be imported because "
+ f"{self.import_files_data[path].error}"
+ )
+ show_warning(None, "File not imported", msg)
+
+ # Import files once we have data for all of them (to avoid long
+ # drawn-out user interaction while we import files)
+ for path in [
+ a
+ for a in self.import_files_data.keys()
+ if self.import_files_data[a].import_this_file
+ ]:
+ self._start_thread(path)
+
+ def _start_thread(self, path: str) -> None:
"""
- Return a (possibly empty) list of all untagged files in the
- import directory. Add tags to file_data
+ Import the file specified by path
"""
- 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)
- )
+ log.debug(f"_start_thread({path=})")
- return untagged_files
+ # Create thread and worker
+ thread = QThread()
+ self.worker = DoTrackImport(
+ associated_thread=thread,
+ import_file_path=path,
+ tags=self.import_files_data[path].tags,
+ destination_path=self.import_files_data[path].destination_path,
+ track_id=self.import_files_data[path].track_id
+ )
+
+ # Associate data with the thread
+ self.thread_data[thread] = self.model_data
+
+ # Move self.worker to thread
+ self.worker.moveToThread(thread)
+
+ # Connect signals
+ thread.started.connect(self.worker.run)
+ self.worker.import_finished.connect(self._thread_finished)
+ self.worker.import_finished.connect(thread.quit)
+ self.worker.import_finished.connect(self.worker.deleteLater)
+ thread.finished.connect(thread.deleteLater)
+
+ # Start thread
+ thread.start()
+
+ def _thread_finished(self, track_id: int, thread: QThread) -> None:
+ """
+ If track already in playlist, refresh it else insert it
+ """
+
+ model_data = self.thread_data.pop(thread, None)
+ if model_data:
+ if model_data.base_model:
+ model_data.base_model.update_or_insert(track_id, model_data.row_number)
+
+ def _get_existing_track(self, track_id: int) -> Tracks:
+ """
+ Lookup in existing track in the local cache and return it
+ """
+
+ existing_track_records = [a for a in self.existing_tracks if a.id == track_id]
+ if len(existing_track_records) != 1:
+ raise ApplicationError(
+ f"Internal error in _get_existing_track: {existing_track_records=}"
+ )
+
+ return existing_track_records[0]
def _get_existing_tracks(self):
"""
@@ -397,51 +283,161 @@ class FileImporter:
"""
with db.Session() as session:
- return Tracks.all_tracks_indexed_by_id(session)
+ return Tracks.get_all(session)
- def _find_similar_strings(
- self,
- needle: str,
- haystack: list[tuple[int, str]],
- minimum_score: float = Config.MINIMUM_FUZZYMATCH,
- ) -> list[int]:
+ def _get_match_score(self, str1: str, str2: str) -> float:
"""
- 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).
+ Return the score of how well str1 matches str2.
"""
- # Create a dictionary to store similarities
- similarities: dict[int, float] = {}
+ ratio = fuzz.ratio(str1, str2)
+ partial_ratio = fuzz.partial_ratio(str1, str2)
+ token_sort_ratio = fuzz.token_sort_ratio(str1, str2)
+ token_set_ratio = fuzz.token_set_ratio(str1, str2)
- 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
+ )
- # Combine scores
- combined_score = (
- ratio * 0.25
- + partial_ratio * 0.25
- + token_sort_ratio * 0.25
- + token_set_ratio * 0.25
- )
+ return combined_score
- 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=}"
+ def populate_track_match_data(self, path: str) -> None:
+ """
+ Populate self.import_files_data[path].track_match_data
+
+ - Search title in existing tracks
+ - if score >= Config.FUZZYMATCH_MINIMUM_LIST:
+ - get artist score
+ - add TrackMatchData to self.import_files_data[path].track_match_data
+ """
+
+ title = self.import_files_data[path].tags.title
+ artist = self.import_files_data[path].tags.artist
+
+ for track in self.existing_tracks:
+ title_score = self._get_match_score(title, track.title)
+ if title_score >= Config.FUZZYMATCH_MINIMUM_LIST:
+ artist_score = self._get_match_score(artist, track.artist)
+ self.import_files_data[path].track_match_data.append(
+ TrackMatchData(
+ artist=track.artist,
+ artist_match=artist_score,
+ title=track.title,
+ title_match=title_score,
+ track_id=track.id,
+ )
)
- # Sort matches by score
- sorted_matches = sorted(similarities.items(), key=lambda x: x[1], reverse=True)
+ def process_user_choices(self, path: str) -> None:
+ """
+ Find out whether user wants to import this as a new track,
+ overwrite an existing track or not import it at all.
+ """
- # Return list of indexes, highest score first
- return [a[0] for a in sorted_matches]
+ # Build a list of (track title and artist, track_id, track path)
+ choices: list[tuple[str, int, str]] = []
+
+ # First choice is always to import as a new track
+ choices.append((Config.DO_NOT_IMPORT, -1, ""))
+ choices.append((Config.IMPORT_AS_NEW, 0, ""))
+
+ # New track details
+ importing_track_description = (
+ f"{self.import_files_data[path].tags.title} "
+ f"({self.import_files_data[path].tags.artist})"
+ )
+
+ # Select 'import as new' as default unless the top match is good
+ # enough
+ default = 1 # default choice is import as new
+ track_match_data = self.import_files_data[path].track_match_data
+ if (
+ track_match_data[0].artist_match >= Config.FUZZYMATCH_MINIMUM_SELECT_ARTIST
+ and track_match_data[0].title_match
+ >= Config.FUZZYMATCH_MINIMUM_SELECT_TITLE
+ ):
+ default = 2
+
+ for rec in track_match_data:
+ existing_track_description = (f"{rec.title} ({rec.artist})")
+ if Config.FUZZYMATCH_SHOW_SCORES:
+ existing_track_description += f" ({rec.title_match:.0f}%)"
+ existing_track_path = self._get_existing_track(rec.track_id).path
+ choices.append(
+ (existing_track_description, rec.track_id, existing_track_path)
+ )
+
+ dialog = PickMatch(
+ new_track_description=importing_track_description,
+ choices=choices,
+ default=default,
+ )
+ if dialog.exec():
+ if dialog.selected_track_id < 0:
+ self.import_files_data[path].import_this_file = False
+ self.import_files_data[path].error = "you asked not to import this file"
+ elif dialog.selected_track_id > 0:
+ self.replace_file(path=path, track_id=dialog.selected_track_id)
+ else:
+ self.import_as_new(path=path)
+ else:
+ # User cancelled dialog
+ self.import_files_data[path].import_this_file = False
+ self.import_files_data[path].error = "you cancelled the import of this file"
+
+ def import_as_new(self, path: str) -> None:
+ """
+ Import passed path as a new file
+ """
+
+ log.debug(f"Import as new, {path=}")
+
+ tfd = self.import_files_data[path]
+
+ destination_path = os.path.join(Config.IMPORT_DESTINATION, os.path.basename(path))
+ if os.path.exists(destination_path):
+ tfd.import_this_file = False
+ tfd.error = (
+ f"this is a new import but destination file already exists ({destination_path})"
+ )
+ return
+
+ tfd.destination_path = destination_path
+
+ def replace_file(self, path: str, track_id: int) -> None:
+ """
+ Replace existing track {track_id=} with passed path
+ """
+
+ log.debug(f"Replace {track_id=} with {path=}")
+
+ tfd = self.import_files_data[path]
+
+ existing_track_path = self._get_existing_track(track_id).path
+ proposed_destination_path = os.path.join(
+ os.path.dirname(existing_track_path), os.path.basename(path)
+ )
+ # if the destination path exists and it's not the path the
+ # track_id points to, abort
+ if existing_track_path != proposed_destination_path and os.path.exists(
+ proposed_destination_path
+ ):
+ tfd.import_this_file = False
+ tfd.error = f"New import would overwrite existing file ({proposed_destination_path})"
+ return
+ tfd.file_path_to_remove = existing_track_path
+ tfd.destination_path = proposed_destination_path
+ tfd.track_id = track_id
+
+
+@dataclass
+class ModelData:
+ base_model: PlaylistModel
+ row_number: int
class PickMatch(QDialog):
@@ -451,14 +447,18 @@ class PickMatch(QDialog):
"""
def __init__(
- self, new_track_details: str, items_with_ids: list[tuple[str, int, str]]
+ self,
+ new_track_description: str,
+ choices: list[tuple[str, int, str]],
+ default: int,
) -> None:
super().__init__()
- self.new_track_details = new_track_details
- self.init_ui(items_with_ids)
- self.selected_id = -1
+ self.new_track_description = new_track_description
+ self.default = default
+ self.init_ui(choices)
+ self.selected_track_id = -1
- def init_ui(self, items_with_ids: list[tuple[str, int, str]]) -> None:
+ def init_ui(self, choices: list[tuple[str, int, str]]) -> None:
"""
Set up dialog
"""
@@ -469,7 +469,7 @@ class PickMatch(QDialog):
# Add instructions
instructions = (
- f"Importing {self.new_track_details}.\n"
+ f"Importing {self.new_track_description}.\n"
"Import as a new track or replace existing track?"
)
instructions_label = QLabel(instructions)
@@ -479,25 +479,25 @@ class PickMatch(QDialog):
self.button_group = QButtonGroup()
# Add radio buttons for each item
- for idx, (text, track_id, track_path) in enumerate(items_with_ids):
+ for idx, (track_description, track_id, track_path) in enumerate(choices):
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)
+ track_description = "(Currently playing) " + track_description
+ radio_button = QRadioButton(track_description)
radio_button.setDisabled(True)
self.button_group.addButton(radio_button, -1)
else:
- radio_button = QRadioButton(text)
+ radio_button = QRadioButton(track_description)
radio_button.setToolTip(track_path)
self.button_group.addButton(radio_button, track_id)
layout.addWidget(radio_button)
# Select the second item by default (import as new)
- if idx == 1:
+ if idx == self.default:
radio_button.setChecked(True)
# Add OK and Cancel buttons
@@ -516,7 +516,7 @@ class PickMatch(QDialog):
def on_ok(self):
# Get the ID of the selected button
- self.selected_id = self.button_group.checkedId()
+ self.selected_track_id = self.button_group.checkedId()
self.accept()
@@ -526,16 +526,19 @@ 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
+ tags: Tags = Tags()
+ destination_path: str = ""
+ import_this_file: bool = True
+ error: str = ""
+ file_path_to_remove: Optional[str] = None
track_id: int = 0
- audio_metadata: Optional[AudioMetadata] = None
+ track_match_data: list[TrackMatchData] = field(default_factory=list)
- def set_destination_track_path(self, path: str) -> None:
- """
- Assigned the passed path
- """
- self.destination_track_path = path
+@dataclass
+class TrackMatchData:
+ artist: str
+ artist_match: float
+ title: str
+ title_match: float
+ track_id: int
diff --git a/app/helpers.py b/app/helpers.py
index 4e94a98..637f28f 100644
--- a/app/helpers.py
+++ b/app/helpers.py
@@ -204,6 +204,14 @@ def get_tags(path: str) -> Tags:
except TinyTagException:
raise ApplicationError(f"Can't read tags: get_tags({path=})")
+ if not tag.title:
+ tag.title = ""
+ if not tag.artist:
+ tag.artist = ""
+ if not tag.bitrate:
+ tag.bitrate = 0.0
+ if not tag.duration:
+ tag.duration = 0.0
return Tags(
title=tag.title,
artist=tag.artist,
diff --git a/app/musicmuster.py b/app/musicmuster.py
index fdd0981..a0b05fc 100755
--- a/app/musicmuster.py
+++ b/app/musicmuster.py
@@ -571,18 +571,18 @@ class Window(QMainWindow, Ui_MainWindow):
)
self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
self.actionFade.triggered.connect(self.fade)
+ self.actionImport_files.triggered.connect(self.import_files_wrapper)
self.actionInsertSectionHeader.triggered.connect(self.insert_header)
self.actionInsertTrack.triggered.connect(self.insert_track)
+ self.actionManage_templates.triggered.connect(self.manage_templates)
self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving)
self.actionMoveSelected.triggered.connect(self.move_selected)
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
- self.actionManage_templates.triggered.connect(self.manage_templates)
self.actionNewPlaylist.triggered.connect(self.new_playlist)
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
self.actionPaste.triggered.connect(self.paste_rows)
self.actionPlay_next.triggered.connect(self.play_next)
self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
- self.actionReplace_files.triggered.connect(self.import_files_wrapper)
self.actionResume.triggered.connect(self.resume)
self.actionSave_as_template.triggered.connect(self.save_as_template)
self.actionSearch_title_in_Songfacts.triggered.connect(
diff --git a/app/playlistmodel.py b/app/playlistmodel.py
index 5cc74e6..70232f9 100644
--- a/app/playlistmodel.py
+++ b/app/playlistmodel.py
@@ -1582,6 +1582,21 @@ class PlaylistModel(QAbstractTableModel):
)
)
+ def update_or_insert(self, track_id: int, row_number: int) -> None:
+ """
+ If the passed track_id exists in this playlist, update the
+ row(s), otherwise insert this track at row_number.
+ """
+
+ track_rows = [a.row_number for a in self.playlist_rows.values() if a.track_id == track_id]
+ if track_rows:
+ with db.Session() as session:
+ for row in track_rows:
+ self.refresh_row(session, row)
+ self.invalidate_rows(track_rows)
+ else:
+ self.insert_row(proposed_row_number=row_number, track_id=track_id)
+
def update_track_times(self) -> None:
"""
Update track start/end times in self.playlist_rows
diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui
index a6dc827..425bc93 100644
--- a/app/ui/main_window.ui
+++ b/app/ui/main_window.ui
@@ -1000,7 +1000,7 @@ padding-left: 8px;
-
+
@@ -1364,7 +1364,7 @@ padding-left: 8px;
Select duplicate rows...
-
+
Import files...
diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py
index a763a63..a33a770 100644
--- a/app/ui/main_window_ui.py
+++ b/app/ui/main_window_ui.py
@@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
#
-# Created by: PyQt6 UI code generator 6.7.1
+# Created by: PyQt6 UI code generator 6.8.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
@@ -529,8 +529,8 @@ class Ui_MainWindow(object):
self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts")
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
- self.actionReplace_files = QtGui.QAction(parent=MainWindow)
- self.actionReplace_files.setObjectName("actionReplace_files")
+ self.actionImport_files = QtGui.QAction(parent=MainWindow)
+ self.actionImport_files.setObjectName("actionImport_files")
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionInsertTrack)
self.menuFile.addAction(self.actionRemove)
@@ -557,7 +557,7 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionSave_as_template)
self.menuPlaylist.addAction(self.actionManage_templates)
self.menuPlaylist.addSeparator()
- self.menuPlaylist.addAction(self.actionReplace_files)
+ self.menuPlaylist.addAction(self.actionImport_files)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionE_xit)
self.menuSearc_h.addAction(self.actionSetNext)
@@ -676,6 +676,6 @@ class Ui_MainWindow(object):
self.actionSearch_title_in_Songfacts.setText(_translate("MainWindow", "Search title in Songfacts"))
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
- self.actionReplace_files.setText(_translate("MainWindow", "Import files..."))
-from infotabs import InfoTabs # type: ignore
-from pyqtgraph import PlotWidget # type: ignore
+ self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
+from infotabs import InfoTabs
+from pyqtgraph import PlotWidget