Compare commits
6 Commits
206a02214e
...
2f9fcae05f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9fcae05f | ||
|
|
4978dcf5c3 | ||
|
|
19b1bf3fde | ||
|
|
316b4708c6 | ||
|
|
4fd9a0381f | ||
|
|
88cce738d7 |
@ -5,7 +5,7 @@
|
||||
# there are two components separated by a colon:
|
||||
# the left part is the import path to the module containing the database instance
|
||||
# the right part is the name of the database instance, typically 'db'
|
||||
alchemical_db = models:db
|
||||
alchemical_db = ds:db
|
||||
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
190
app/classes.py
190
app/classes.py
@ -1,7 +1,7 @@
|
||||
# Standard library imports
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
from enum import auto, Enum
|
||||
import functools
|
||||
import threading
|
||||
@ -46,6 +46,68 @@ def singleton(cls):
|
||||
return wrapper_singleton
|
||||
|
||||
|
||||
# DTOs
|
||||
@dataclass
|
||||
class PlaylistDTO:
|
||||
playlist_id: int
|
||||
name: str
|
||||
open: bool = False
|
||||
favourite: bool = False
|
||||
is_template: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryDTO:
|
||||
query_id: int
|
||||
name: str
|
||||
favourite: bool
|
||||
filter: Filter
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackDTO:
|
||||
track_id: int
|
||||
artist: str
|
||||
bitrate: int
|
||||
duration: int
|
||||
fade_at: int
|
||||
intro: int | None
|
||||
path: str
|
||||
silence_at: int
|
||||
start_gap: int
|
||||
title: str
|
||||
lastplayed: dt.datetime | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistRowDTO:
|
||||
note: str
|
||||
played: bool
|
||||
playlist_id: int
|
||||
playlistrow_id: int
|
||||
row_number: int
|
||||
track: TrackDTO | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaydatesDTO(TrackDTO):
|
||||
playdate_id: int
|
||||
lastplayed: dt.datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class NoteColoursDTO:
|
||||
notecolour_id: int
|
||||
substring: str
|
||||
colour: str
|
||||
enabled: bool = True
|
||||
foreground: str | None = None
|
||||
is_regex: bool = False
|
||||
is_casesensitive: bool = False
|
||||
order: int | None = None
|
||||
strip_substring: bool = True
|
||||
|
||||
|
||||
class ApplicationError(Exception):
|
||||
"""
|
||||
Custom exception
|
||||
@ -61,6 +123,10 @@ class AudioMetadata(NamedTuple):
|
||||
|
||||
|
||||
class Col(Enum):
|
||||
"""
|
||||
Columns in playlist
|
||||
"""
|
||||
|
||||
START_GAP = 0
|
||||
TITLE = auto()
|
||||
ARTIST = auto()
|
||||
@ -80,6 +146,10 @@ class FileErrors(NamedTuple):
|
||||
|
||||
@dataclass
|
||||
class Filter:
|
||||
"""
|
||||
Filter used in queries to select tracks
|
||||
"""
|
||||
|
||||
version: int = 1
|
||||
path_type: str = "contains"
|
||||
path: str = ""
|
||||
@ -91,31 +161,6 @@ class Filter:
|
||||
duration_unit: str = "minutes"
|
||||
|
||||
|
||||
@singleton
|
||||
@dataclass
|
||||
class MusicMusterSignals(QObject):
|
||||
"""
|
||||
Class for all MusicMuster signals. See:
|
||||
- https://zetcode.com/gui/pyqt5/eventssignals/
|
||||
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
|
||||
"""
|
||||
|
||||
begin_reset_model_signal = pyqtSignal(int)
|
||||
enable_escape_signal = pyqtSignal(bool)
|
||||
end_reset_model_signal = pyqtSignal(int)
|
||||
next_track_changed_signal = pyqtSignal()
|
||||
resize_rows_signal = pyqtSignal(int)
|
||||
search_songfacts_signal = pyqtSignal(str)
|
||||
search_wikipedia_signal = pyqtSignal(str)
|
||||
show_warning_signal = pyqtSignal(str, str)
|
||||
span_cells_signal = pyqtSignal(int, int, int, int, int)
|
||||
status_message_signal = pyqtSignal(str, int)
|
||||
track_ended_signal = pyqtSignal()
|
||||
|
||||
def __post_init__(self):
|
||||
super().__init__()
|
||||
|
||||
|
||||
class PlaylistStyle(QProxyStyle):
|
||||
def drawPrimitive(self, element, option, painter, widget=None):
|
||||
"""
|
||||
@ -135,6 +180,10 @@ class PlaylistStyle(QProxyStyle):
|
||||
|
||||
|
||||
class QueryCol(Enum):
|
||||
"""
|
||||
Columns in querylist
|
||||
"""
|
||||
|
||||
TITLE = 0
|
||||
ARTIST = auto()
|
||||
DURATION = auto()
|
||||
@ -152,3 +201,92 @@ class Tags(NamedTuple):
|
||||
class TrackInfo(NamedTuple):
|
||||
track_id: int
|
||||
row_number: int
|
||||
|
||||
|
||||
# Classes for signals
|
||||
@dataclass
|
||||
class InsertRows:
|
||||
playlist_id: int
|
||||
from_row: int
|
||||
to_row: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InsertTrack:
|
||||
playlist_id: int
|
||||
track_id: int | None
|
||||
note: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectedRows:
|
||||
playlist_id: int
|
||||
rows: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackAndPlaylist:
|
||||
playlist_id: int
|
||||
track_id: int
|
||||
|
||||
|
||||
@singleton
|
||||
@dataclass
|
||||
class MusicMusterSignals(QObject):
|
||||
"""
|
||||
Class for all MusicMuster signals. See:
|
||||
- https://zetcode.com/gui/pyqt5/eventssignals/
|
||||
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
|
||||
"""
|
||||
|
||||
# Used to en/disable escape as a shortcut key to "clear selection".
|
||||
# We disable it when editing a field in the playlist because we use
|
||||
# escape there to abandon an edit.
|
||||
enable_escape_signal = pyqtSignal(bool)
|
||||
|
||||
# Signals that the playlist_id passed should resize all rows.
|
||||
resize_rows_signal = pyqtSignal(int)
|
||||
|
||||
# Displays a warning dialog
|
||||
show_warning_signal = pyqtSignal(str, str)
|
||||
|
||||
# Signal to add a track to a header row
|
||||
signal_add_track_to_header = pyqtSignal(TrackAndPlaylist)
|
||||
|
||||
# Signal to receving model that rows will be / have been inserter
|
||||
signal_begin_insert_rows = pyqtSignal(InsertRows)
|
||||
signal_end_insert_rows = pyqtSignal(int)
|
||||
|
||||
# TBD
|
||||
signal_insert_track = pyqtSignal(InsertTrack)
|
||||
|
||||
# Keep track of which rows are selected (between playlist and model)
|
||||
signal_playlist_selected_rows = pyqtSignal(SelectedRows)
|
||||
|
||||
# Signal to model that selected row is to be next row
|
||||
signal_set_next_row = pyqtSignal(int)
|
||||
|
||||
# signal_set_next_track takes a PlaylistRow as an argument. We can't
|
||||
# specify that here as it requires us to import PlaylistRow from
|
||||
# playlistrow.py, which itself imports MusicMusterSignals. It tells
|
||||
# musicmuster to set the passed track as the next one.
|
||||
signal_set_next_track = pyqtSignal(object)
|
||||
|
||||
# Signals that the next-cued track has changed. Used to update
|
||||
# playlist headers and track timings.
|
||||
signal_next_track_changed = pyqtSignal()
|
||||
|
||||
# Emited when a track starts playing
|
||||
signal_track_started = pyqtSignal()
|
||||
|
||||
# Emitted when track ends or is manually faded
|
||||
signal_track_ended = pyqtSignal(int)
|
||||
|
||||
# Used by model to signal spanning of cells to playlist for headers
|
||||
span_cells_signal = pyqtSignal(int, int, int, int, int)
|
||||
|
||||
# Dispay status message to user
|
||||
status_message_signal = pyqtSignal(str, int)
|
||||
|
||||
def __post_init__(self):
|
||||
super().__init__()
|
||||
|
||||
@ -34,6 +34,7 @@ class Config(object):
|
||||
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
|
||||
COLOUR_UNREADABLE = "#dc3545"
|
||||
COLOUR_WARNING_TIMER = "#ffc107"
|
||||
DB_NOT_FOUND = "Database not found"
|
||||
DBFS_SILENCE = -50
|
||||
DEFAULT_COLUMN_WIDTH = 200
|
||||
DISPLAY_SQL = False
|
||||
@ -112,6 +113,8 @@ class Config(object):
|
||||
PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png"
|
||||
PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png"
|
||||
PLAYLIST_ICON_TEMPLATE = ":/icons/redstar.png"
|
||||
PLAYLIST_PENDING_MOVE = -1
|
||||
PLAYLIST_FAILED_MOVE = -2
|
||||
PREVIEW_ADVANCE_MS = 5000
|
||||
PREVIEW_BACK_MS = 5000
|
||||
PREVIEW_END_BUFFER_MS = 1000
|
||||
@ -140,6 +143,6 @@ class Config(object):
|
||||
WIKIPEDIA_ON_NEXT = False
|
||||
|
||||
# These rely on earlier definitions
|
||||
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_SECTIONS
|
||||
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_TRACKS
|
||||
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
|
||||
REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE)
|
||||
|
||||
195
app/dbtables.py
195
app/dbtables.py
@ -15,13 +15,13 @@ from sqlalchemy import (
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.engine.interfaces import Dialect
|
||||
from sqlalchemy.orm import (
|
||||
Mapped,
|
||||
mapped_column,
|
||||
relationship,
|
||||
)
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
|
||||
# App imports
|
||||
@ -49,10 +49,10 @@ class JSONEncodedDict(TypeDecorator):
|
||||
|
||||
|
||||
# Database classes
|
||||
class NoteColoursTable(Model):
|
||||
class NoteColours(Model):
|
||||
__tablename__ = "notecolours"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
notecolour_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
substring: Mapped[str] = mapped_column(String(256), index=True, unique=True)
|
||||
colour: Mapped[str] = mapped_column(String(21), index=False)
|
||||
enabled: Mapped[bool] = mapped_column(default=True, index=True)
|
||||
@ -64,47 +64,83 @@ class NoteColoursTable(Model):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<NoteColours(id={self.id}, substring={self.substring}, "
|
||||
f"<NoteColours(id={self.notecolour_id}, substring={self.substring}, "
|
||||
f"colour={self.colour}>"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
substring: str,
|
||||
colour: str,
|
||||
enabled: bool = True,
|
||||
is_regex: bool = False,
|
||||
is_casesensitive: bool = False,
|
||||
order: Optional[int] = 0,
|
||||
) -> None:
|
||||
self.substring = substring
|
||||
self.colour = colour
|
||||
self.enabled = enabled
|
||||
self.is_regex = is_regex
|
||||
self.is_casesensitive = is_casesensitive
|
||||
self.order = order
|
||||
|
||||
class PlaydatesTable(Model):
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
|
||||
class Playdates(Model):
|
||||
__tablename__ = "playdates"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
playdate_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
|
||||
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
|
||||
track: Mapped["TracksTable"] = relationship(
|
||||
"TracksTable",
|
||||
track_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tracks.track_id", ondelete="CASCADE")
|
||||
)
|
||||
track: Mapped["Tracks"] = relationship(
|
||||
"Tracks",
|
||||
back_populates="playdates",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, session: Session, track_id: int, when: dt.datetime | None = None
|
||||
) -> None:
|
||||
"""Record that track was played"""
|
||||
|
||||
if not when:
|
||||
self.lastplayed = dt.datetime.now()
|
||||
else:
|
||||
self.lastplayed = when
|
||||
self.track_id = track_id
|
||||
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Playdates(id={self.id}, track_id={self.track_id} "
|
||||
f"<Playdates(id={self.playdate_id}, track_id={self.track_id} "
|
||||
f"lastplayed={self.lastplayed}>"
|
||||
)
|
||||
|
||||
|
||||
class PlaylistsTable(Model):
|
||||
class Playlists(Model):
|
||||
"""
|
||||
Manage playlists
|
||||
"""
|
||||
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
playlist_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(32), unique=True)
|
||||
last_used: Mapped[Optional[dt.datetime]] = mapped_column(DateTime, default=None)
|
||||
tab: Mapped[Optional[int]] = mapped_column(default=None)
|
||||
open: Mapped[bool] = mapped_column(default=False)
|
||||
is_template: Mapped[bool] = mapped_column(default=False)
|
||||
rows: Mapped[list["PlaylistRowsTable"]] = relationship(
|
||||
"PlaylistRowsTable",
|
||||
rows: Mapped[list["PlaylistRows"]] = relationship(
|
||||
"PlaylistRows",
|
||||
back_populates="playlist",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PlaylistRowsTable.row_number",
|
||||
order_by="PlaylistRows.row_number",
|
||||
)
|
||||
favourite: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, index=False, default=False
|
||||
@ -112,29 +148,42 @@ class PlaylistsTable(Model):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Playlists(id={self.id}, name={self.name}, "
|
||||
f"<Playlists(id={self.playlist_id}, name={self.name}, "
|
||||
f"is_templatee={self.is_template}, open={self.open}>"
|
||||
)
|
||||
|
||||
def __init__(self, session: Session, name: str, template_id: int) -> None:
|
||||
"""Create playlist with passed name"""
|
||||
|
||||
class PlaylistRowsTable(Model):
|
||||
self.name = name
|
||||
self.last_used = dt.datetime.now()
|
||||
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
# If a template is specified, copy from it
|
||||
if template_id:
|
||||
PlaylistRows.copy_playlist(session, template_id, self.playlist_id)
|
||||
|
||||
|
||||
class PlaylistRows(Model):
|
||||
__tablename__ = "playlist_rows"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
playlistrow_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
row_number: Mapped[int] = mapped_column(index=True)
|
||||
note: Mapped[str] = mapped_column(
|
||||
String(2048), index=False, default="", nullable=False
|
||||
)
|
||||
playlist_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
|
||||
ForeignKey("playlists.playlist_id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
|
||||
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
|
||||
playlist: Mapped[Playlists] = relationship(back_populates="rows")
|
||||
track_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("tracks.id", ondelete="CASCADE")
|
||||
ForeignKey("tracks.track_id", ondelete="CASCADE")
|
||||
)
|
||||
track: Mapped["TracksTable"] = relationship(
|
||||
"TracksTable",
|
||||
track: Mapped["Tracks"] = relationship(
|
||||
"Tracks",
|
||||
back_populates="playlistrows",
|
||||
)
|
||||
played: Mapped[bool] = mapped_column(
|
||||
@ -143,19 +192,41 @@ class PlaylistRowsTable(Model):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<PlaylistRows(id={self.id}, playlist_id={self.playlist_id}, "
|
||||
f"<PlaylistRows(id={self.playlistrow_id}, playlist_id={self.playlist_id}, "
|
||||
f"track_id={self.track_id}, "
|
||||
f"note={self.note}, row_number={self.row_number}>"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
playlist_id: int,
|
||||
row_number: int,
|
||||
note: str = "",
|
||||
track_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Create PlaylistRows object"""
|
||||
|
||||
class QueriesTable(Model):
|
||||
self.playlist_id = playlist_id
|
||||
self.track_id = track_id
|
||||
self.row_number = row_number
|
||||
self.note = note
|
||||
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
|
||||
class Queries(Model):
|
||||
__tablename__ = "queries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
query_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
_filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=False)
|
||||
favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False)
|
||||
_filter_data: Mapped[dict | None] = mapped_column(
|
||||
"filter_data", JSONEncodedDict, nullable=False
|
||||
)
|
||||
favourite: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, index=False, default=False
|
||||
)
|
||||
|
||||
def _get_filter(self) -> Filter:
|
||||
"""Convert stored JSON dictionary to a Filter object."""
|
||||
@ -171,15 +242,31 @@ class QueriesTable(Model):
|
||||
filter = property(_get_filter, _set_filter)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<QueriesTable(id={self.id}, name={self.name}, filter={self.filter})>"
|
||||
return f"<Queries(id={self.query_id}, name={self.name}, filter={self.filter})>"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
name: str,
|
||||
filter: Filter,
|
||||
favourite: bool = False,
|
||||
) -> None:
|
||||
"""Create new query"""
|
||||
|
||||
self.name = name
|
||||
self.filter = filter
|
||||
self.favourite = favourite
|
||||
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
|
||||
class SettingsTable(Model):
|
||||
class Settings(Model):
|
||||
"""Manage settings"""
|
||||
|
||||
__tablename__ = "settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
setting_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
f_datetime: Mapped[Optional[dt.datetime]] = mapped_column(default=None)
|
||||
f_int: Mapped[Optional[int]] = mapped_column(default=None)
|
||||
@ -187,15 +274,21 @@ class SettingsTable(Model):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Settings(id={self.id}, name={self.name}, "
|
||||
f"<Settings(id={self.setting_id}, name={self.name}, "
|
||||
f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>"
|
||||
)
|
||||
|
||||
def __init__(self, session: Session, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
class TracksTable(Model):
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
|
||||
class Tracks(Model):
|
||||
__tablename__ = "tracks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
track_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
artist: Mapped[str] = mapped_column(String(256), index=True)
|
||||
bitrate: Mapped[int] = mapped_column(default=None)
|
||||
duration: Mapped[int] = mapped_column(index=True)
|
||||
@ -206,14 +299,14 @@ class TracksTable(Model):
|
||||
start_gap: Mapped[int] = mapped_column(index=False)
|
||||
title: Mapped[str] = mapped_column(String(256), index=True)
|
||||
|
||||
playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
|
||||
"PlaylistRowsTable",
|
||||
playlistrows: Mapped[list[PlaylistRows]] = relationship(
|
||||
"PlaylistRows",
|
||||
back_populates="track",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
playlists = association_proxy("playlistrows", "playlist")
|
||||
playdates: Mapped[list[PlaydatesTable]] = relationship(
|
||||
"PlaydatesTable",
|
||||
playdates: Mapped[list[Playdates]] = relationship(
|
||||
"Playdates",
|
||||
back_populates="track",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="joined",
|
||||
@ -221,6 +314,30 @@ class TracksTable(Model):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Track(id={self.id}, title={self.title}, "
|
||||
f"<Track(id={self.track_id}, title={self.title}, "
|
||||
f"artist={self.artist}, path={self.path}>"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
path: str,
|
||||
title: str,
|
||||
artist: str,
|
||||
duration: int,
|
||||
start_gap: int,
|
||||
fade_at: int,
|
||||
silence_at: int,
|
||||
bitrate: int,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
self.bitrate = bitrate
|
||||
self.duration = duration
|
||||
self.start_gap = start_gap
|
||||
self.fade_at = fade_at
|
||||
self.silence_at = silence_at
|
||||
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
306
app/dialogs.py
306
app/dialogs.py
@ -2,230 +2,178 @@
|
||||
from typing import Optional
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import QEvent, Qt
|
||||
from PyQt6.QtGui import QKeyEvent
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
# Third party imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
from classes import MusicMusterSignals
|
||||
from classes import (
|
||||
ApplicationError,
|
||||
InsertTrack,
|
||||
MusicMusterSignals,
|
||||
TrackAndPlaylist,
|
||||
)
|
||||
from helpers import (
|
||||
ask_yes_no,
|
||||
get_relative_date,
|
||||
ms_to_mmss,
|
||||
)
|
||||
from log import log
|
||||
from models import Settings, Tracks
|
||||
from playlistmodel import PlaylistModel
|
||||
from ui import dlg_TrackSelect_ui
|
||||
import ds
|
||||
|
||||
|
||||
class TrackSelectDialog(QDialog):
|
||||
"""Select track from database"""
|
||||
|
||||
class TrackInsertDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QMainWindow,
|
||||
session: Session,
|
||||
new_row_number: int,
|
||||
base_model: PlaylistModel,
|
||||
playlist_id: int,
|
||||
add_to_header: Optional[bool] = False,
|
||||
*args: Qt.WindowType,
|
||||
**kwargs: Qt.WindowType,
|
||||
) -> None:
|
||||
"""
|
||||
Subclassed QDialog to manage track selection
|
||||
"""
|
||||
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
self.session = session
|
||||
self.new_row_number = new_row_number
|
||||
self.base_model = base_model
|
||||
super().__init__(parent)
|
||||
self.playlist_id = playlist_id
|
||||
self.add_to_header = add_to_header
|
||||
self.ui = dlg_TrackSelect_ui.Ui_Dialog()
|
||||
self.ui.setupUi(self)
|
||||
self.ui.btnAdd.clicked.connect(self.add_selected)
|
||||
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
|
||||
self.ui.btnClose.clicked.connect(self.close)
|
||||
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
|
||||
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
|
||||
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
|
||||
self.ui.searchString.textEdited.connect(self.chars_typed)
|
||||
self.track: Optional[Tracks] = None
|
||||
self.signals = MusicMusterSignals()
|
||||
self.setWindowTitle("Insert Track")
|
||||
|
||||
record = Settings.get_setting(self.session, "dbdialog_width")
|
||||
width = record.f_int or 800
|
||||
record = Settings.get_setting(self.session, "dbdialog_height")
|
||||
height = record.f_int or 600
|
||||
# Title input on one line
|
||||
self.title_label = QLabel("Title:")
|
||||
self.title_edit = QLineEdit()
|
||||
self.title_edit.textChanged.connect(self.update_list)
|
||||
|
||||
title_layout = QHBoxLayout()
|
||||
title_layout.addWidget(self.title_label)
|
||||
title_layout.addWidget(self.title_edit)
|
||||
|
||||
# Track list
|
||||
self.track_list = QListWidget()
|
||||
self.track_list.itemDoubleClicked.connect(self.add_clicked)
|
||||
self.track_list.itemSelectionChanged.connect(self.selection_changed)
|
||||
|
||||
# Note input on one line
|
||||
self.note_label = QLabel("Note:")
|
||||
self.note_edit = QLineEdit()
|
||||
|
||||
note_layout = QHBoxLayout()
|
||||
note_layout.addWidget(self.note_label)
|
||||
note_layout.addWidget(self.note_edit)
|
||||
|
||||
# Track path
|
||||
self.path = QLabel()
|
||||
path_layout = QHBoxLayout()
|
||||
path_layout.addWidget(self.path)
|
||||
|
||||
# Buttons
|
||||
self.add_btn = QPushButton("Add")
|
||||
self.add_close_btn = QPushButton("Add and close")
|
||||
self.close_btn = QPushButton("Close")
|
||||
|
||||
self.add_btn.clicked.connect(self.add_clicked)
|
||||
self.add_close_btn.clicked.connect(self.add_and_close_clicked)
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addWidget(self.add_btn)
|
||||
btn_layout.addWidget(self.add_close_btn)
|
||||
btn_layout.addWidget(self.close_btn)
|
||||
|
||||
# Main layout
|
||||
layout = QVBoxLayout()
|
||||
layout.addLayout(title_layout)
|
||||
layout.addWidget(self.track_list)
|
||||
layout.addLayout(note_layout)
|
||||
layout.addLayout(path_layout)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
self.resize(800, 600)
|
||||
|
||||
width = ds.setting_get("dbdialog_width") or 800
|
||||
height = ds.setting_get("dbdialog_height") or 800
|
||||
self.resize(width, height)
|
||||
|
||||
if add_to_header:
|
||||
self.ui.lblNote.setVisible(False)
|
||||
self.ui.txtNote.setVisible(False)
|
||||
self.signals = MusicMusterSignals()
|
||||
|
||||
def add_selected(self) -> None:
|
||||
"""Handle Add button"""
|
||||
|
||||
track = None
|
||||
|
||||
if self.ui.matchList.selectedItems():
|
||||
item = self.ui.matchList.currentItem()
|
||||
if item:
|
||||
track = item.data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
note = self.ui.txtNote.text()
|
||||
|
||||
if not (track or note):
|
||||
def update_list(self, text: str) -> None:
|
||||
self.track_list.clear()
|
||||
if text.strip() == "":
|
||||
# Do not search or populate list if input is empty
|
||||
return
|
||||
|
||||
track_id = None
|
||||
if track:
|
||||
track_id = track.id
|
||||
if text.startswith("a/") and len(text) > 2:
|
||||
self.tracks = ds.tracks_by_artist(text[2:])
|
||||
else:
|
||||
self.tracks = ds.tracks_by_title(text)
|
||||
|
||||
if note and not track_id:
|
||||
self.base_model.insert_row(self.new_row_number, track_id, note)
|
||||
self.ui.txtNote.clear()
|
||||
self.new_row_number += 1
|
||||
for track in self.tracks:
|
||||
duration_str = ms_to_mmss(track.duration)
|
||||
last_played_str = get_relative_date(track.lastplayed)
|
||||
item_str = (
|
||||
f"{track.title} - {track.artist} [{duration_str}] {last_played_str}"
|
||||
)
|
||||
item = QListWidgetItem(item_str)
|
||||
item.setData(Qt.ItemDataRole.UserRole, track.track_id)
|
||||
self.track_list.addItem(item)
|
||||
|
||||
def get_selected_track_id(self) -> int | None:
|
||||
selected_items = self.track_list.selectedItems()
|
||||
if selected_items:
|
||||
return selected_items[0].data(Qt.ItemDataRole.UserRole)
|
||||
return None
|
||||
|
||||
def add_clicked(self):
|
||||
track_id = self.get_selected_track_id()
|
||||
note_text = self.note_edit.text()
|
||||
if track_id is None and not note_text:
|
||||
return
|
||||
|
||||
self.ui.txtNote.clear()
|
||||
self.select_searchtext()
|
||||
insert_track_data = InsertTrack(self.playlist_id, track_id, note_text)
|
||||
|
||||
if track_id is None:
|
||||
log.error("track_id is None and should not be")
|
||||
return
|
||||
|
||||
# Check whether track is already in playlist
|
||||
move_existing = False
|
||||
existing_prd = self.base_model.is_track_in_playlist(track_id)
|
||||
if existing_prd is not None:
|
||||
if ask_yes_no(
|
||||
"Duplicate row",
|
||||
"Track already in playlist. " "Move to new location?",
|
||||
default_yes=True,
|
||||
):
|
||||
move_existing = True
|
||||
self.title_edit.selectAll()
|
||||
self.title_edit.setFocus()
|
||||
self.note_edit.clear()
|
||||
self.title_edit.setFocus()
|
||||
|
||||
if self.add_to_header:
|
||||
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
|
||||
self.base_model.move_track_to_header(
|
||||
self.new_row_number, existing_prd, note
|
||||
)
|
||||
else:
|
||||
self.base_model.add_track_to_header(self.new_row_number, track_id)
|
||||
# Close dialog - we can only add one track to a header
|
||||
# The model will have the right-clicked row marked as a
|
||||
# selected_row so we only need to pass the playlist_id and
|
||||
# track_id.
|
||||
self.signals.signal_add_track_to_header.emit(
|
||||
TrackAndPlaylist(playlist_id=self.playlist_id, track_id=track_id)
|
||||
)
|
||||
self.accept()
|
||||
else:
|
||||
# Adding a new track row
|
||||
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
|
||||
self.base_model.move_track_add_note(
|
||||
self.new_row_number, existing_prd, note
|
||||
)
|
||||
else:
|
||||
self.base_model.insert_row(self.new_row_number, track_id, note)
|
||||
self.signals.signal_insert_track.emit(insert_track_data)
|
||||
|
||||
self.new_row_number += 1
|
||||
|
||||
def add_selected_and_close(self) -> None:
|
||||
"""Handle Add and Close button"""
|
||||
|
||||
self.add_selected()
|
||||
def add_and_close_clicked(self):
|
||||
self.add_clicked()
|
||||
self.accept()
|
||||
|
||||
def chars_typed(self, s: str) -> None:
|
||||
"""Handle text typed in search box"""
|
||||
|
||||
self.ui.matchList.clear()
|
||||
if len(s) > 0:
|
||||
if s.startswith("a/") and len(s) > 2:
|
||||
matches = Tracks.search_artists(self.session, "%" + s[2:])
|
||||
elif self.ui.radioTitle.isChecked():
|
||||
matches = Tracks.search_titles(self.session, "%" + s)
|
||||
else:
|
||||
matches = Tracks.search_artists(self.session, "%" + s)
|
||||
if matches:
|
||||
for track in matches:
|
||||
last_played = None
|
||||
last_playdate = max(
|
||||
track.playdates, key=lambda p: p.lastplayed, default=None
|
||||
)
|
||||
if last_playdate:
|
||||
last_played = last_playdate.lastplayed
|
||||
t = QListWidgetItem()
|
||||
track_text = (
|
||||
f"{track.title} - {track.artist} "
|
||||
f"[{ms_to_mmss(track.duration)}] "
|
||||
f"({get_relative_date(last_played)})"
|
||||
)
|
||||
t.setText(track_text)
|
||||
t.setData(Qt.ItemDataRole.UserRole, track)
|
||||
self.ui.matchList.addItem(t)
|
||||
|
||||
def closeEvent(self, event: Optional[QEvent]) -> None:
|
||||
"""
|
||||
Override close and save dialog coordinates
|
||||
"""
|
||||
|
||||
if not event:
|
||||
return
|
||||
|
||||
record = Settings.get_setting(self.session, "dbdialog_height")
|
||||
record.f_int = self.height()
|
||||
|
||||
record = Settings.get_setting(self.session, "dbdialog_width")
|
||||
record.f_int = self.width()
|
||||
|
||||
self.session.commit()
|
||||
|
||||
event.accept()
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent | None) -> None:
|
||||
"""
|
||||
Clear selection on ESC if there is one
|
||||
"""
|
||||
|
||||
if event and event.key() == Qt.Key.Key_Escape:
|
||||
if self.ui.matchList.selectedItems():
|
||||
self.ui.matchList.clearSelection()
|
||||
return
|
||||
|
||||
super(TrackSelectDialog, self).keyPressEvent(event)
|
||||
|
||||
def select_searchtext(self) -> None:
|
||||
"""Select the searchbox"""
|
||||
|
||||
self.ui.searchString.selectAll()
|
||||
self.ui.searchString.setFocus()
|
||||
|
||||
def selection_changed(self) -> None:
|
||||
"""Display selected track path in dialog box"""
|
||||
|
||||
if not self.ui.matchList.selectedItems():
|
||||
self.path.setText("")
|
||||
|
||||
track_id = self.get_selected_track_id()
|
||||
if track_id is None:
|
||||
return
|
||||
|
||||
item = self.ui.matchList.currentItem()
|
||||
track = item.data(Qt.ItemDataRole.UserRole)
|
||||
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
|
||||
if last_playdate:
|
||||
last_played = last_playdate.lastplayed
|
||||
else:
|
||||
last_played = None
|
||||
path_text = f"{track.path} ({get_relative_date(last_played)})"
|
||||
tracklist = [t for t in self.tracks if t.track_id == track_id]
|
||||
if not tracklist:
|
||||
return
|
||||
if len(tracklist) > 1:
|
||||
raise ApplicationError("More than one track returned")
|
||||
track = tracklist[0]
|
||||
|
||||
self.ui.dbPath.setText(path_text)
|
||||
|
||||
def title_artist_toggle(self) -> None:
|
||||
"""
|
||||
Handle switching between searching for artists and searching for
|
||||
titles
|
||||
"""
|
||||
|
||||
# Logic is handled already in chars_typed(), so just call that.
|
||||
self.chars_typed(self.ui.searchString.text())
|
||||
self.path.setText(track.path)
|
||||
|
||||
@ -4,7 +4,6 @@ from dataclasses import dataclass, field
|
||||
from fuzzywuzzy import fuzz # type: ignore
|
||||
import os.path
|
||||
import threading
|
||||
from typing import Optional, Sequence
|
||||
import os
|
||||
import shutil
|
||||
|
||||
@ -32,18 +31,22 @@ from classes import (
|
||||
MusicMusterSignals,
|
||||
singleton,
|
||||
Tags,
|
||||
TrackDTO,
|
||||
)
|
||||
from config import Config
|
||||
from helpers import (
|
||||
audio_file_extension,
|
||||
file_is_unreadable,
|
||||
get_all_track_metadata,
|
||||
get_audio_metadata,
|
||||
get_tags,
|
||||
normalise_track,
|
||||
show_OK,
|
||||
)
|
||||
from log import log
|
||||
from models import db, Tracks
|
||||
from music_manager import track_sequence
|
||||
from playlistrow import TrackSequence
|
||||
from playlistmodel import PlaylistModel
|
||||
import helpers
|
||||
import ds
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -67,7 +70,7 @@ class TrackFileData:
|
||||
destination_path: str = ""
|
||||
import_this_file: bool = False
|
||||
error: str = ""
|
||||
file_path_to_remove: Optional[str] = None
|
||||
file_path_to_remove: str | None = None
|
||||
track_id: int = 0
|
||||
track_match_data: list[TrackMatchData] = field(default_factory=list)
|
||||
|
||||
@ -104,9 +107,7 @@ class FileImporter:
|
||||
# variable or an instance variable are effectively the same thing.
|
||||
workers: dict[str, DoTrackImport] = {}
|
||||
|
||||
def __init__(
|
||||
self, base_model: PlaylistModel, row_number: int = None
|
||||
) -> None:
|
||||
def __init__(self, base_model: PlaylistModel, row_number: int) -> None:
|
||||
"""
|
||||
Initialise the FileImporter singleton instance.
|
||||
"""
|
||||
@ -122,13 +123,7 @@ class FileImporter:
|
||||
# Get signals
|
||||
self.signals = MusicMusterSignals()
|
||||
|
||||
def _get_existing_tracks(self) -> Sequence[Tracks]:
|
||||
"""
|
||||
Return a list of all existing Tracks
|
||||
"""
|
||||
|
||||
with db.Session() as session:
|
||||
return Tracks.get_all(session)
|
||||
self.existing_tracks: list[TrackDTO] = []
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
@ -148,7 +143,7 @@ class FileImporter:
|
||||
|
||||
# Refresh list of existing tracks as they may have been updated
|
||||
# by previous imports
|
||||
self.existing_tracks = self._get_existing_tracks()
|
||||
self.existing_tracks = ds.tracks_all()
|
||||
|
||||
for infile in [
|
||||
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
|
||||
@ -202,8 +197,9 @@ class FileImporter:
|
||||
self.sort_track_match_data(tfd)
|
||||
selection = self.get_user_choices(tfd)
|
||||
if self.process_selection(tfd, selection):
|
||||
if self.validate_file_data(tfd):
|
||||
tfd.import_this_file = True
|
||||
if self.extension_check(tfd):
|
||||
if self.validate_file_data(tfd):
|
||||
tfd.import_this_file = True
|
||||
|
||||
return tfd
|
||||
|
||||
@ -237,6 +233,27 @@ class FileImporter:
|
||||
|
||||
return True
|
||||
|
||||
def extension_check(self, tfd: TrackFileData) -> bool:
|
||||
"""
|
||||
If we are replacing an existing file, check that the correct file
|
||||
extension of the replacement file matches the existing file
|
||||
extension and return True if it does (or if there is no exsting
|
||||
file), else False.
|
||||
"""
|
||||
|
||||
if not tfd.file_path_to_remove:
|
||||
return True
|
||||
|
||||
extension = audio_file_extension(tfd.source_path)
|
||||
if extension and tfd.file_path_to_remove.endswith(extension):
|
||||
return True
|
||||
|
||||
tfd.error = (
|
||||
f"Existing file ({tfd.file_path_to_remove}) has a different "
|
||||
f"extension to replacement file ({tfd.source_path})"
|
||||
)
|
||||
return False
|
||||
|
||||
def find_similar(self, tfd: TrackFileData) -> None:
|
||||
"""
|
||||
- Search title in existing tracks
|
||||
@ -258,7 +275,7 @@ class FileImporter:
|
||||
artist_match=artist_score,
|
||||
title=existing_track.title,
|
||||
title_match=title_score,
|
||||
track_id=existing_track.id,
|
||||
track_id=existing_track.track_id,
|
||||
)
|
||||
)
|
||||
|
||||
@ -391,12 +408,14 @@ class FileImporter:
|
||||
else:
|
||||
tfd.destination_path = existing_track_path
|
||||
|
||||
def _get_existing_track(self, track_id: int) -> Tracks:
|
||||
def _get_existing_track(self, track_id: int) -> TrackDTO:
|
||||
"""
|
||||
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]
|
||||
existing_track_records = [
|
||||
a for a in self.existing_tracks if a.track_id == track_id
|
||||
]
|
||||
if len(existing_track_records) != 1:
|
||||
raise ApplicationError(
|
||||
f"Internal error in _get_existing_track: {existing_track_records=}"
|
||||
@ -445,7 +464,8 @@ class FileImporter:
|
||||
if tfd.track_id == 0 and tfd.destination_path != tfd.file_path_to_remove:
|
||||
while os.path.exists(tfd.destination_path):
|
||||
msg = (
|
||||
f"New import requested but default destination path ({tfd.destination_path})"
|
||||
"New import requested but default destination path"
|
||||
f" ({tfd.destination_path})"
|
||||
" already exists. Click OK and choose where to save this track"
|
||||
)
|
||||
show_OK(title="Desintation path exists", msg=msg, parent=None)
|
||||
@ -469,13 +489,12 @@ class FileImporter:
|
||||
# file). Check that because the path field in the database is
|
||||
# unique and so adding a duplicate will give a db integrity
|
||||
# error.
|
||||
with db.Session() as session:
|
||||
if Tracks.get_by_path(session, tfd.destination_path):
|
||||
tfd.error = (
|
||||
"Importing a new track but destination path already exists "
|
||||
f"in database ({tfd.destination_path})"
|
||||
)
|
||||
return False
|
||||
if ds.track_by_path(tfd.destination_path):
|
||||
tfd.error = (
|
||||
"Importing a new track but destination path already exists "
|
||||
f"in database ({tfd.destination_path})"
|
||||
)
|
||||
return False
|
||||
|
||||
# Check track_id
|
||||
if tfd.track_id < 0:
|
||||
@ -493,7 +512,8 @@ class FileImporter:
|
||||
msgs: list[str] = []
|
||||
for tfd in tfds:
|
||||
msgs.append(
|
||||
f"{os.path.basename(tfd.source_path)} will not be imported because {tfd.error}"
|
||||
f"{os.path.basename(tfd.source_path)} will not be imported "
|
||||
f"because {tfd.error}"
|
||||
)
|
||||
if msgs:
|
||||
show_OK("File not imported", "\r\r".join(msgs))
|
||||
@ -516,7 +536,8 @@ class FileImporter:
|
||||
filename = os.path.basename(tfd.source_path)
|
||||
log.debug(f"Processing {filename}")
|
||||
log.debug(
|
||||
f"remaining files: {[a.source_path for a in self.import_files_data]}"
|
||||
"remaining files: "
|
||||
f"{[a.source_path for a in self.import_files_data]}"
|
||||
)
|
||||
self.signals.status_message_signal.emit(
|
||||
f"Importing {filename}", 10000
|
||||
@ -597,7 +618,7 @@ class DoTrackImport(QThread):
|
||||
tags: Tags,
|
||||
destination_path: str,
|
||||
track_id: int,
|
||||
file_path_to_remove: Optional[str] = None,
|
||||
file_path_to_remove: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Save parameters
|
||||
@ -613,7 +634,10 @@ class DoTrackImport(QThread):
|
||||
self.signals = MusicMusterSignals()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DoTrackImport(id={hex(id(self))}, import_file_path={self.import_file_path}"
|
||||
return (
|
||||
f"<DoTrackImport(id={hex(id(self))}, "
|
||||
f"import_file_path={self.import_file_path}"
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
@ -627,8 +651,9 @@ class DoTrackImport(QThread):
|
||||
f"Importing {os.path.basename(self.import_file_path)}", 5000
|
||||
)
|
||||
|
||||
# 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)
|
||||
# Get audio metadata in this thread rather than calling
|
||||
# function to save interactive time
|
||||
self.audio_metadata = get_audio_metadata(self.import_file_path)
|
||||
|
||||
# Remove old file if so requested
|
||||
if self.file_path_to_remove and os.path.exists(self.file_path_to_remove):
|
||||
@ -637,42 +662,20 @@ class DoTrackImport(QThread):
|
||||
# Move new file to destination
|
||||
shutil.move(self.import_file_path, self.destination_track_path)
|
||||
|
||||
with db.Session() as session:
|
||||
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
|
||||
else:
|
||||
log.error(f"Unable to retrieve {self.track_id=}")
|
||||
return
|
||||
session.commit()
|
||||
# Normalise
|
||||
normalise_track(self.destination_track_path)
|
||||
|
||||
helpers.normalise_track(self.destination_track_path)
|
||||
# Update databse
|
||||
metadata = get_all_track_metadata(self.destination_track_path)
|
||||
if self.track_id == 0:
|
||||
track_dto = ds.track_create(metadata)
|
||||
else:
|
||||
track_dto = ds.track_update(self.track_id, metadata)
|
||||
|
||||
self.signals.status_message_signal.emit(
|
||||
f"{os.path.basename(self.import_file_path)} imported", 10000
|
||||
)
|
||||
self.import_finished.emit(self.import_file_path, track.id)
|
||||
self.signals.status_message_signal.emit(
|
||||
f"{os.path.basename(self.import_file_path)} imported", 10000
|
||||
)
|
||||
self.import_finished.emit(self.import_file_path, track_dto.track_id)
|
||||
|
||||
|
||||
class PickMatch(QDialog):
|
||||
@ -701,6 +704,7 @@ class PickMatch(QDialog):
|
||||
self.setWindowTitle("New or replace")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
track_sequence = TrackSequence()
|
||||
|
||||
# Add instructions
|
||||
instructions = (
|
||||
|
||||
@ -13,6 +13,7 @@ import tempfile
|
||||
from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget
|
||||
|
||||
# Third party imports
|
||||
import filetype
|
||||
from mutagen.flac import FLAC # type: ignore
|
||||
from mutagen.mp3 import MP3 # type: ignore
|
||||
from pydub import AudioSegment, effects
|
||||
@ -20,10 +21,9 @@ from pydub.utils import mediainfo
|
||||
from tinytag import TinyTag, TinyTagException # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import AudioMetadata, ApplicationError, Tags
|
||||
from classes import AudioMetadata, ApplicationError, Tags, TrackDTO
|
||||
from config import Config
|
||||
from log import log
|
||||
from models import Tracks
|
||||
|
||||
start_time_re = re.compile(r"@\d\d:\d\d")
|
||||
|
||||
@ -50,6 +50,14 @@ def ask_yes_no(
|
||||
return button == QMessageBox.StandardButton.Yes
|
||||
|
||||
|
||||
def audio_file_extension(fpath: str) -> str | None:
|
||||
"""
|
||||
Return the correct extension for this type of file.
|
||||
"""
|
||||
|
||||
return filetype.guess(fpath).extension
|
||||
|
||||
|
||||
def fade_point(
|
||||
audio_segment: AudioSegment,
|
||||
fade_threshold: float = 0.0,
|
||||
@ -72,7 +80,7 @@ def fade_point(
|
||||
fade_threshold = max_vol
|
||||
|
||||
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
|
||||
trim_ms -= chunk_size
|
||||
@ -94,6 +102,9 @@ def file_is_unreadable(path: Optional[str]) -> bool:
|
||||
|
||||
|
||||
def get_audio_segment(path: str) -> Optional[AudioSegment]:
|
||||
if not path.endswith(audio_file_extension(path)):
|
||||
return None
|
||||
|
||||
try:
|
||||
if path.endswith(".mp3"):
|
||||
return AudioSegment.from_mp3(path)
|
||||
@ -187,26 +198,32 @@ def get_relative_date(
|
||||
|
||||
# Check parameters
|
||||
if past_date > reference_date:
|
||||
return "get_relative_date() past_date is after relative_date"
|
||||
raise ApplicationError("get_relative_date() past_date is after relative_date")
|
||||
|
||||
days: int
|
||||
days_str: str
|
||||
weeks: int
|
||||
weeks_str: str
|
||||
delta = reference_date - past_date
|
||||
days = delta.days
|
||||
|
||||
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
|
||||
if weeks == days == 0:
|
||||
# Same day so return time instead
|
||||
if days == 0:
|
||||
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M")
|
||||
if weeks == 1:
|
||||
weeks_str = "week"
|
||||
else:
|
||||
weeks_str = "weeks"
|
||||
if days == 1:
|
||||
days_str = "day"
|
||||
else:
|
||||
days_str = "days"
|
||||
return f"{weeks} {weeks_str}, {days} {days_str}"
|
||||
|
||||
elif days == 1:
|
||||
return "(Yesterday)"
|
||||
|
||||
years, days_remain_years = divmod(days, 365)
|
||||
months, days_remain_months = divmod(days_remain_years, 30)
|
||||
weeks, days_final = divmod(days_remain_months, 7)
|
||||
|
||||
parts = []
|
||||
if years:
|
||||
parts.append(f"{years}y")
|
||||
if months:
|
||||
parts.append(f"{months}m")
|
||||
if weeks:
|
||||
parts.append(f"{weeks}w")
|
||||
if days_final:
|
||||
parts.append(f"{days_final}d")
|
||||
formatted = ", ".join(parts)
|
||||
return formatted
|
||||
|
||||
|
||||
def get_tags(path: str) -> Tags:
|
||||
@ -353,32 +370,6 @@ def normalise_track(path: str) -> None:
|
||||
os.remove(temp_path)
|
||||
|
||||
|
||||
def remove_substring_case_insensitive(parent_string: str, substring: str) -> str:
|
||||
"""
|
||||
Remove all instances of substring from parent string, case insensitively
|
||||
"""
|
||||
|
||||
# Convert both strings to lowercase for case-insensitive comparison
|
||||
lower_parent = parent_string.lower()
|
||||
lower_substring = substring.lower()
|
||||
|
||||
# Initialize the result string
|
||||
result = parent_string
|
||||
|
||||
# Continue removing the substring until it's no longer found
|
||||
while lower_substring in lower_parent:
|
||||
# Find the index of the substring
|
||||
index = lower_parent.find(lower_substring)
|
||||
|
||||
# Remove the substring
|
||||
result = result[:index] + result[index + len(substring) :]
|
||||
|
||||
# Update the lowercase versions
|
||||
lower_parent = result.lower()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
|
||||
# From https://docs.python.org/3/library/email.examples.html
|
||||
|
||||
@ -405,18 +396,6 @@ def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
|
||||
s.quit()
|
||||
|
||||
|
||||
def set_track_metadata(track: Tracks) -> None:
|
||||
"""Set/update track metadata in database"""
|
||||
|
||||
audio_metadata = get_audio_metadata(track.path)
|
||||
tags = get_tags(track.path)
|
||||
|
||||
for audio_key in AudioMetadata._fields:
|
||||
setattr(track, audio_key, getattr(audio_metadata, audio_key))
|
||||
for tag_key in Tags._fields:
|
||||
setattr(track, tag_key, getattr(tags, tag_key))
|
||||
|
||||
|
||||
def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
|
||||
"""Display a message to user"""
|
||||
|
||||
|
||||
42
app/log.py
42
app/log.py
@ -80,17 +80,37 @@ log = logging.getLogger(Config.LOG_NAME)
|
||||
|
||||
|
||||
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
error = str(exc_value)
|
||||
"""
|
||||
Inform user of exception
|
||||
"""
|
||||
|
||||
# Navigate to the inner stack frame
|
||||
tb = exc_traceback
|
||||
if not tb:
|
||||
log.error(f"handle_excption({exc_type=}, {exc_value=}, {exc_traceback=}")
|
||||
return
|
||||
while tb.tb_next:
|
||||
tb = tb.tb_next
|
||||
|
||||
fname = os.path.basename(tb.tb_frame.f_code.co_filename)
|
||||
lineno = tb.tb_lineno
|
||||
msg = f"ApplicationError: {exc_value}\nat {fname}:{lineno}"
|
||||
logmsg = f"ApplicationError: {exc_value} at {fname}:{lineno}"
|
||||
|
||||
if issubclass(exc_type, ApplicationError):
|
||||
log.error(error)
|
||||
log.error(logmsg)
|
||||
else:
|
||||
# Handle unexpected errors (log and display)
|
||||
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
||||
error_msg = "".join(
|
||||
traceback.format_exception(exc_type, exc_value, exc_traceback)
|
||||
)
|
||||
|
||||
print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg'))
|
||||
print(
|
||||
stackprinter.format(exc_value, suppressed_paths=["/.venv"], style="darkbg")
|
||||
)
|
||||
|
||||
msg = stackprinter.format(exc_value)
|
||||
log.error(msg)
|
||||
stack = stackprinter.format(exc_value)
|
||||
log.error(stack)
|
||||
log.error(error_msg)
|
||||
print("Critical error:", error_msg) # Consider logging instead of print
|
||||
|
||||
@ -101,11 +121,10 @@ def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
Config.ERRORS_TO,
|
||||
Config.ERRORS_FROM,
|
||||
"Exception (log_uncaught_exceptions) from musicmuster",
|
||||
msg,
|
||||
stack,
|
||||
)
|
||||
if QApplication.instance() is not None:
|
||||
fname = os.path.split(exc_traceback.tb_frame.f_code.co_filename)[1]
|
||||
msg = f"ApplicationError: {error}\nat {fname}:{exc_traceback.tb_lineno}"
|
||||
QMessageBox.critical(None, "Application Error", msg)
|
||||
|
||||
|
||||
@ -124,14 +143,15 @@ def log_call(func):
|
||||
args_repr = [truncate_large(a) for a in args]
|
||||
kwargs_repr = [f"{k}={truncate_large(v)}" for k, v in kwargs.items()]
|
||||
params_repr = ", ".join(args_repr + kwargs_repr)
|
||||
log.debug(f"call {func.__name__}({params_repr})")
|
||||
log.debug(f"call {func.__name__}({params_repr})", stacklevel=2)
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
log.debug(f"return {func.__name__}: {truncate_large(result)}")
|
||||
log.debug(f"return {func.__name__}: {truncate_large(result)}", stacklevel=2)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.debug(f"exception in {func.__name__}: {e}")
|
||||
log.debug(f"exception in {func.__name__}: {e}", stacklevel=2)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ disable_existing_loggers: True
|
||||
formatters:
|
||||
colored:
|
||||
(): colorlog.ColoredFormatter
|
||||
format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(blue)s%(message)s"
|
||||
format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(light_blue)s%(message)s"
|
||||
datefmt: "%H:%M:%S"
|
||||
syslog:
|
||||
format: "[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
|
||||
@ -25,6 +25,7 @@ filters:
|
||||
musicmuster:
|
||||
- update_clocks
|
||||
- play_next
|
||||
- show_signal
|
||||
|
||||
handlers:
|
||||
stderr:
|
||||
|
||||
873
app/models.py
873
app/models.py
@ -1,873 +0,0 @@
|
||||
# Standard library imports
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Sequence
|
||||
import datetime as dt
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
from dogpile.cache import make_region
|
||||
from dogpile.cache.api import NO_VALUE
|
||||
from sqlalchemy import (
|
||||
bindparam,
|
||||
delete,
|
||||
func,
|
||||
select,
|
||||
text,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError, ProgrammingError
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.engine.row import RowMapping
|
||||
|
||||
# App imports
|
||||
from classes import ApplicationError, Filter
|
||||
from config import Config
|
||||
from dbmanager import DatabaseManager
|
||||
import dbtables
|
||||
from log import log
|
||||
|
||||
|
||||
# Establish database connection
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||
if DATABASE_URL is None:
|
||||
raise ValueError("DATABASE_URL is undefined")
|
||||
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
|
||||
raise ValueError("Unit tests running on non-Sqlite database")
|
||||
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
|
||||
|
||||
# Configure the cache region
|
||||
cache_region = make_region().configure(
|
||||
'dogpile.cache.memory', # Use in-memory caching for now (switch to Redis if needed)
|
||||
expiration_time=600 # Cache expires after 10 minutes
|
||||
)
|
||||
|
||||
|
||||
def run_sql(session: Session, sql: str) -> Sequence[RowMapping]:
|
||||
"""
|
||||
Run a sql string and return results
|
||||
"""
|
||||
|
||||
try:
|
||||
return session.execute(text(sql)).mappings().all()
|
||||
except ProgrammingError as e:
|
||||
raise ApplicationError(e)
|
||||
|
||||
|
||||
# Database classes
|
||||
class NoteColours(dbtables.NoteColoursTable):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
substring: str,
|
||||
colour: str,
|
||||
enabled: bool = True,
|
||||
is_regex: bool = False,
|
||||
is_casesensitive: bool = False,
|
||||
order: Optional[int] = 0,
|
||||
) -> None:
|
||||
self.substring = substring
|
||||
self.colour = colour
|
||||
self.enabled = enabled
|
||||
self.is_regex = is_regex
|
||||
self.is_casesensitive = is_casesensitive
|
||||
self.order = order
|
||||
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, session: Session) -> Sequence["NoteColours"]:
|
||||
"""
|
||||
Return all records
|
||||
"""
|
||||
|
||||
cache_key = "note_colours_all"
|
||||
cached_result = cache_region.get(cache_key)
|
||||
|
||||
if cached_result is not NO_VALUE:
|
||||
return cached_result
|
||||
|
||||
# Query the database
|
||||
result = session.scalars(
|
||||
select(cls)
|
||||
.where(
|
||||
cls.enabled.is_(True),
|
||||
)
|
||||
.order_by(cls.order)
|
||||
).all()
|
||||
cache_region.set(cache_key, result)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_colour(
|
||||
session: Session, text: str, foreground: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Parse text and return background (foreground if foreground==True) colour
|
||||
string if matched, else None
|
||||
|
||||
"""
|
||||
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
match = False
|
||||
for rec in NoteColours.get_all(session):
|
||||
if rec.is_regex:
|
||||
flags = re.UNICODE
|
||||
if not rec.is_casesensitive:
|
||||
flags |= re.IGNORECASE
|
||||
p = re.compile(rec.substring, flags)
|
||||
if p.match(text):
|
||||
match = True
|
||||
else:
|
||||
if rec.is_casesensitive:
|
||||
if rec.substring in text:
|
||||
match = True
|
||||
else:
|
||||
if rec.substring.lower() in text.lower():
|
||||
match = True
|
||||
|
||||
if match:
|
||||
if foreground:
|
||||
return rec.foreground or ""
|
||||
else:
|
||||
return rec.colour
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def invalidate_cache() -> None:
|
||||
"""Invalidate dogpile cache"""
|
||||
|
||||
cache_region.delete("note_colours_all")
|
||||
|
||||
|
||||
class Playdates(dbtables.PlaydatesTable):
|
||||
def __init__(
|
||||
self, session: Session, track_id: int, when: Optional[dt.datetime] = None
|
||||
) -> None:
|
||||
"""Record that track was played"""
|
||||
|
||||
if not when:
|
||||
self.lastplayed = dt.datetime.now()
|
||||
else:
|
||||
self.lastplayed = when
|
||||
self.track_id = track_id
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def last_playdates(
|
||||
session: Session, track_id: int, limit: int = 5
|
||||
) -> Sequence["Playdates"]:
|
||||
"""
|
||||
Return a list of the last limit playdates for this track, sorted
|
||||
latest to earliest.
|
||||
"""
|
||||
|
||||
return session.scalars(
|
||||
Playdates.select()
|
||||
.where(Playdates.track_id == track_id)
|
||||
.order_by(Playdates.lastplayed.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
@staticmethod
|
||||
def last_played(session: Session, track_id: int) -> dt.datetime:
|
||||
"""Return datetime track last played or None"""
|
||||
|
||||
last_played = session.execute(
|
||||
select(Playdates.lastplayed)
|
||||
.where(Playdates.track_id == track_id)
|
||||
.order_by(Playdates.lastplayed.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
if last_played:
|
||||
return last_played[0]
|
||||
else:
|
||||
# Should never be reached as we create record with a
|
||||
# last_played value
|
||||
return Config.EPOCH # pragma: no cover
|
||||
|
||||
@staticmethod
|
||||
def last_played_tracks(session: Session, limit: int = 5) -> Sequence["Playdates"]:
|
||||
"""
|
||||
Return a list of the last limit tracks played, sorted
|
||||
earliest to latest.
|
||||
"""
|
||||
|
||||
return session.scalars(
|
||||
Playdates.select().order_by(Playdates.lastplayed.desc()).limit(limit)
|
||||
).all()
|
||||
|
||||
@staticmethod
|
||||
def played_after(session: Session, since: dt.datetime) -> Sequence["Playdates"]:
|
||||
"""Return a list of Playdates objects since passed time"""
|
||||
|
||||
return session.scalars(
|
||||
select(Playdates)
|
||||
.where(Playdates.lastplayed >= since)
|
||||
.order_by(Playdates.lastplayed)
|
||||
).all()
|
||||
|
||||
|
||||
class Playlists(dbtables.PlaylistsTable):
|
||||
def __init__(self, session: Session, name: str, template_id: int) -> None:
|
||||
"""Create playlist with passed name"""
|
||||
|
||||
self.name = name
|
||||
self.last_used = dt.datetime.now()
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
# If a template is specified, copy from it
|
||||
if template_id:
|
||||
PlaylistRows.copy_playlist(session, template_id, self.id)
|
||||
|
||||
@staticmethod
|
||||
def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
|
||||
"""
|
||||
Make all tab records NULL
|
||||
"""
|
||||
|
||||
session.execute(
|
||||
update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
|
||||
)
|
||||
|
||||
def close(self, session: Session) -> None:
|
||||
"""Mark playlist as unloaded"""
|
||||
|
||||
self.open = False
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, session: Session) -> Sequence["Playlists"]:
|
||||
"""Returns a list of all playlists ordered by last use"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls)
|
||||
.filter(cls.is_template.is_(False))
|
||||
.order_by(cls.last_used.desc())
|
||||
).all()
|
||||
|
||||
@classmethod
|
||||
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
|
||||
"""Returns a list of all templates ordered by name"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
|
||||
).all()
|
||||
|
||||
@classmethod
|
||||
def get_favourite_templates(cls, session: Session) -> Sequence["Playlists"]:
|
||||
"""Returns a list of favourite templates ordered by name"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls)
|
||||
.where(cls.is_template.is_(True), cls.favourite.is_(True))
|
||||
.order_by(cls.name)
|
||||
).all()
|
||||
|
||||
@classmethod
|
||||
def get_closed(cls, session: Session) -> Sequence["Playlists"]:
|
||||
"""Returns a list of all closed playlists ordered by last use"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls)
|
||||
.filter(
|
||||
cls.open.is_(False),
|
||||
cls.is_template.is_(False),
|
||||
)
|
||||
.order_by(cls.last_used.desc())
|
||||
).all()
|
||||
|
||||
@classmethod
|
||||
def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]:
|
||||
"""
|
||||
Return a list of loaded playlists ordered by tab.
|
||||
"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls).where(cls.open.is_(True)).order_by(cls.tab)
|
||||
).all()
|
||||
|
||||
def mark_open(self) -> None:
|
||||
"""Mark playlist as loaded and used now"""
|
||||
|
||||
self.open = True
|
||||
self.last_used = dt.datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def name_is_available(session: Session, name: str) -> bool:
|
||||
"""
|
||||
Return True if no playlist of this name exists else false.
|
||||
"""
|
||||
|
||||
return (
|
||||
session.execute(select(Playlists).where(Playlists.name == name)).first()
|
||||
is None
|
||||
)
|
||||
|
||||
def rename(self, session: Session, new_name: str) -> None:
|
||||
"""
|
||||
Rename playlist
|
||||
"""
|
||||
|
||||
self.name = new_name
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def save_as_template(
|
||||
session: Session, playlist_id: int, template_name: str
|
||||
) -> None:
|
||||
"""Save passed playlist as new template"""
|
||||
|
||||
template = Playlists(session, template_name, template_id=0)
|
||||
if not template or not template.id:
|
||||
return
|
||||
|
||||
template.is_template = True
|
||||
session.commit()
|
||||
|
||||
PlaylistRows.copy_playlist(session, playlist_id, template.id)
|
||||
|
||||
|
||||
class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
playlist_id: int,
|
||||
row_number: int,
|
||||
note: str = "",
|
||||
track_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Create PlaylistRows object"""
|
||||
|
||||
self.playlist_id = playlist_id
|
||||
self.track_id = track_id
|
||||
self.row_number = row_number
|
||||
self.note = note
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
def append_note(self, extra_note: str) -> None:
|
||||
"""Append passed note to any existing note"""
|
||||
|
||||
current_note = self.note
|
||||
if current_note:
|
||||
self.note = current_note + "\n" + extra_note
|
||||
else:
|
||||
self.note = extra_note
|
||||
|
||||
@staticmethod
|
||||
def copy_playlist(session: Session, src_id: int, dst_id: int) -> None:
|
||||
"""Copy playlist entries"""
|
||||
|
||||
src_rows = session.scalars(
|
||||
select(PlaylistRows).filter(PlaylistRows.playlist_id == src_id)
|
||||
).all()
|
||||
|
||||
for plr in src_rows:
|
||||
PlaylistRows(
|
||||
session=session,
|
||||
playlist_id=dst_id,
|
||||
row_number=plr.row_number,
|
||||
note=plr.note,
|
||||
track_id=plr.track_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def deep_row(
|
||||
cls, session: Session, playlist_id: int, row_number: int
|
||||
) -> "PlaylistRows":
|
||||
"""
|
||||
Return a playlist row that includes full track and lastplayed data for
|
||||
given playlist_id and row
|
||||
"""
|
||||
|
||||
stmt = (
|
||||
select(PlaylistRows)
|
||||
.options(joinedload(cls.track))
|
||||
.where(
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
PlaylistRows.row_number == row_number,
|
||||
)
|
||||
# .options(joinedload(Tracks.playdates))
|
||||
)
|
||||
|
||||
return session.execute(stmt).unique().scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None:
|
||||
"""
|
||||
Delete rows in given playlist that have a higher row number
|
||||
than 'maxrow'
|
||||
"""
|
||||
|
||||
session.execute(
|
||||
delete(PlaylistRows).where(
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
PlaylistRows.row_number > maxrow,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def delete_row(session: Session, playlist_id: int, row_number: int) -> None:
|
||||
"""
|
||||
Delete passed row in given playlist.
|
||||
"""
|
||||
|
||||
session.execute(
|
||||
delete(PlaylistRows).where(
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
PlaylistRows.row_number == row_number,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def fixup_rownumbers(session: Session, playlist_id: int) -> None:
|
||||
"""
|
||||
Ensure the row numbers for passed playlist have no gaps
|
||||
"""
|
||||
|
||||
plrs = session.scalars(
|
||||
select(PlaylistRows)
|
||||
.where(PlaylistRows.playlist_id == playlist_id)
|
||||
.order_by(PlaylistRows.row_number)
|
||||
).all()
|
||||
|
||||
for i, plr in enumerate(plrs):
|
||||
plr.row_number = i
|
||||
|
||||
# Ensure new row numbers are available to the caller
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
def plrids_to_plrs(
|
||||
cls, session: Session, playlist_id: int, plr_ids: list[int]
|
||||
) -> Sequence["PlaylistRows"]:
|
||||
"""
|
||||
Take a list of PlaylistRows ids and return a list of corresponding
|
||||
PlaylistRows objects
|
||||
"""
|
||||
|
||||
plrs = session.scalars(
|
||||
select(cls)
|
||||
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
|
||||
.order_by(cls.row_number)
|
||||
).all()
|
||||
|
||||
return plrs
|
||||
|
||||
@staticmethod
|
||||
def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]:
|
||||
"""Return the last used row for playlist, or None if no rows"""
|
||||
|
||||
return session.execute(
|
||||
select(func.max(PlaylistRows.row_number)).where(
|
||||
PlaylistRows.playlist_id == playlist_id
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def get_track_plr(
|
||||
session: Session, track_id: int, playlist_id: int
|
||||
) -> Optional["PlaylistRows"]:
|
||||
"""Return first matching PlaylistRows object or None"""
|
||||
|
||||
return session.scalars(
|
||||
select(PlaylistRows)
|
||||
.where(
|
||||
PlaylistRows.track_id == track_id,
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
@classmethod
|
||||
def get_played_rows(
|
||||
cls, session: Session, playlist_id: int
|
||||
) -> Sequence["PlaylistRows"]:
|
||||
"""
|
||||
For passed playlist, return a list of rows that
|
||||
have been played.
|
||||
"""
|
||||
|
||||
plrs = session.scalars(
|
||||
select(cls)
|
||||
.where(cls.playlist_id == playlist_id, cls.played.is_(True))
|
||||
.order_by(cls.row_number)
|
||||
).all()
|
||||
|
||||
return plrs
|
||||
|
||||
@classmethod
|
||||
def get_playlist_rows(
|
||||
cls, session: Session, playlist_id: int
|
||||
) -> Sequence["PlaylistRows"]:
|
||||
"""
|
||||
For passed playlist, return a list of rows.
|
||||
"""
|
||||
|
||||
stmt = (
|
||||
select(cls)
|
||||
.where(cls.playlist_id == playlist_id)
|
||||
.options(selectinload(cls.track))
|
||||
.order_by(cls.row_number)
|
||||
)
|
||||
plrs = session.execute(stmt).scalars().all()
|
||||
|
||||
return plrs
|
||||
|
||||
@classmethod
|
||||
def get_rows_with_tracks(
|
||||
cls,
|
||||
session: Session,
|
||||
playlist_id: int,
|
||||
) -> Sequence["PlaylistRows"]:
|
||||
"""
|
||||
For passed playlist, return a list of rows that
|
||||
contain tracks
|
||||
"""
|
||||
|
||||
query = select(cls).where(
|
||||
cls.playlist_id == playlist_id, cls.track_id.is_not(None)
|
||||
)
|
||||
plrs = session.scalars((query).order_by(cls.row_number)).all()
|
||||
|
||||
return plrs
|
||||
|
||||
@classmethod
|
||||
def get_unplayed_rows(
|
||||
cls, session: Session, playlist_id: int
|
||||
) -> Sequence["PlaylistRows"]:
|
||||
"""
|
||||
For passed playlist, return a list of playlist rows that
|
||||
have not been played.
|
||||
"""
|
||||
|
||||
plrs = session.scalars(
|
||||
select(cls)
|
||||
.where(
|
||||
cls.playlist_id == playlist_id,
|
||||
cls.track_id.is_not(None),
|
||||
cls.played.is_(False),
|
||||
)
|
||||
.order_by(cls.row_number)
|
||||
).all()
|
||||
|
||||
return plrs
|
||||
|
||||
@classmethod
|
||||
def insert_row(
|
||||
cls,
|
||||
session: Session,
|
||||
playlist_id: int,
|
||||
new_row_number: int,
|
||||
note: str = "",
|
||||
track_id: Optional[int] = None,
|
||||
) -> "PlaylistRows":
|
||||
cls.move_rows_down(session, playlist_id, new_row_number, 1)
|
||||
return cls(
|
||||
session,
|
||||
playlist_id=playlist_id,
|
||||
row_number=new_row_number,
|
||||
note=note,
|
||||
track_id=track_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def move_rows_down(
|
||||
session: Session, playlist_id: int, starting_row: int, move_by: int
|
||||
) -> None:
|
||||
"""
|
||||
Create space to insert move_by additional rows by incremented row
|
||||
number from starting_row to end of playlist
|
||||
"""
|
||||
|
||||
log.debug(f"(move_rows_down({playlist_id=}, {starting_row=}, {move_by=}")
|
||||
|
||||
session.execute(
|
||||
update(PlaylistRows)
|
||||
.where(
|
||||
(PlaylistRows.playlist_id == playlist_id),
|
||||
(PlaylistRows.row_number >= starting_row),
|
||||
)
|
||||
.values(row_number=PlaylistRows.row_number + move_by)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_plr_row_numbers(
|
||||
session: Session,
|
||||
playlist_id: int,
|
||||
sqla_map: list[dict[str, int]],
|
||||
) -> None:
|
||||
"""
|
||||
Take a {plrid: row_number} dictionary and update the row numbers accordingly
|
||||
"""
|
||||
|
||||
# Update database. Ref:
|
||||
# https://docs.sqlalchemy.org/en/20/tutorial/data_update.html#the-update-sql-expression-construct
|
||||
stmt = (
|
||||
update(PlaylistRows)
|
||||
.where(
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
PlaylistRows.id == bindparam("playlistrow_id"),
|
||||
)
|
||||
.values(row_number=bindparam("row_number"))
|
||||
)
|
||||
session.connection().execute(stmt, sqla_map)
|
||||
|
||||
|
||||
class Queries(dbtables.QueriesTable):
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
name: str,
|
||||
filter: dbtables.Filter,
|
||||
favourite: bool = False,
|
||||
) -> None:
|
||||
"""Create new query"""
|
||||
|
||||
self.name = name
|
||||
self.filter = filter
|
||||
self.favourite = favourite
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, session: Session) -> Sequence["Queries"]:
|
||||
"""Returns a list of all queries ordered by name"""
|
||||
|
||||
return session.scalars(select(cls).order_by(cls.name)).all()
|
||||
|
||||
@classmethod
|
||||
def get_favourites(cls, session: Session) -> Sequence["Queries"]:
|
||||
"""Returns a list of favourite queries ordered by name"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls).where(cls.favourite.is_(True)).order_by(cls.name)
|
||||
).all()
|
||||
|
||||
|
||||
class Settings(dbtables.SettingsTable):
|
||||
def __init__(self, session: Session, name: str) -> None:
|
||||
self.name = name
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_setting(cls, session: Session, name: str) -> "Settings":
|
||||
"""Get existing setting or return new setting record"""
|
||||
|
||||
try:
|
||||
return session.execute(select(cls).where(cls.name == name)).scalar_one()
|
||||
|
||||
except NoResultFound:
|
||||
return Settings(session, name)
|
||||
|
||||
|
||||
class Tracks(dbtables.TracksTable):
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
path: str,
|
||||
title: str,
|
||||
artist: str,
|
||||
duration: int,
|
||||
start_gap: int,
|
||||
fade_at: int,
|
||||
silence_at: int,
|
||||
bitrate: int,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
self.bitrate = bitrate
|
||||
self.duration = duration
|
||||
self.start_gap = start_gap
|
||||
self.fade_at = fade_at
|
||||
self.silence_at = silence_at
|
||||
|
||||
try:
|
||||
session.add(self)
|
||||
session.commit()
|
||||
except IntegrityError as error:
|
||||
session.rollback()
|
||||
log.error(f"Error ({error=}) importing track ({path=})")
|
||||
raise ValueError(error)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, session: Session) -> Sequence["Tracks"]:
|
||||
"""Return a list of all tracks"""
|
||||
|
||||
return session.scalars(select(cls)).unique().all()
|
||||
|
||||
@classmethod
|
||||
def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
|
||||
"""
|
||||
Return a dictionary of all tracks, keyed by title
|
||||
"""
|
||||
|
||||
result: dict[int, Tracks] = {}
|
||||
|
||||
for track in cls.get_all(session):
|
||||
result[track.id] = track
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def exact_title_and_artist(
|
||||
cls, session: Session, title: str, artist: str
|
||||
) -> Sequence["Tracks"]:
|
||||
"""
|
||||
Search for exact but case-insensitive match of title and artist
|
||||
"""
|
||||
|
||||
return (
|
||||
session.scalars(
|
||||
select(cls)
|
||||
.where(cls.title.ilike(title), cls.artist.ilike(artist))
|
||||
.order_by(cls.title)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_filtered_tracks(
|
||||
cls, session: Session, filter: Filter
|
||||
) -> Sequence["Tracks"]:
|
||||
"""
|
||||
Return tracks matching filter
|
||||
"""
|
||||
|
||||
query = select(cls)
|
||||
|
||||
# Path specification
|
||||
if filter.path:
|
||||
if filter.path_type == "contains":
|
||||
query = query.where(cls.path.ilike(f"%{filter.path}%"))
|
||||
elif filter.path_type == "excluding":
|
||||
query = query.where(cls.path.notilike(f"%{filter.path}%"))
|
||||
else:
|
||||
raise ApplicationError(f"Can't process filter path ({filter=})")
|
||||
|
||||
# Duration specification
|
||||
seconds_duration = filter.duration_number
|
||||
if filter.duration_unit == Config.FILTER_DURATION_MINUTES:
|
||||
seconds_duration *= 60
|
||||
elif filter.duration_unit != Config.FILTER_DURATION_SECONDS:
|
||||
raise ApplicationError(f"Can't process filter duration ({filter=})")
|
||||
|
||||
if filter.duration_type == Config.FILTER_DURATION_LONGER:
|
||||
query = query.where(cls.duration >= seconds_duration)
|
||||
elif filter.duration_unit == Config.FILTER_DURATION_SHORTER:
|
||||
query = query.where(cls.duration <= seconds_duration)
|
||||
else:
|
||||
raise ApplicationError(f"Can't process filter duration type ({filter=})")
|
||||
|
||||
# Process comparator
|
||||
if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER:
|
||||
# Select tracks that have never been played
|
||||
query = query.outerjoin(Playdates, cls.id == Playdates.track_id).where(
|
||||
Playdates.id.is_(None)
|
||||
)
|
||||
else:
|
||||
# Last played specification
|
||||
now = dt.datetime.now()
|
||||
# Set sensible default, and correct for Config.FILTER_PLAYED_COMPARATOR_ANYTIME
|
||||
before = now
|
||||
# If not ANYTIME, set 'before' appropriates
|
||||
if filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME:
|
||||
if filter.last_played_unit == Config.FILTER_PLAYED_DAYS:
|
||||
before = now - dt.timedelta(days=filter.last_played_number)
|
||||
elif filter.last_played_unit == Config.FILTER_PLAYED_WEEKS:
|
||||
before = now - dt.timedelta(days=7 * filter.last_played_number)
|
||||
elif filter.last_played_unit == Config.FILTER_PLAYED_MONTHS:
|
||||
before = now - dt.timedelta(days=30 * filter.last_played_number)
|
||||
elif filter.last_played_unit == Config.FILTER_PLAYED_YEARS:
|
||||
before = now - dt.timedelta(days=365 * filter.last_played_number)
|
||||
|
||||
subquery = (
|
||||
select(
|
||||
Playdates.track_id,
|
||||
func.max(Playdates.lastplayed).label("max_last_played"),
|
||||
)
|
||||
.group_by(Playdates.track_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.join(subquery, Tracks.id == subquery.c.track_id).where(
|
||||
subquery.c.max_last_played < before
|
||||
)
|
||||
|
||||
records = session.scalars(query).unique().all()
|
||||
|
||||
return records
|
||||
|
||||
@classmethod
|
||||
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
|
||||
"""
|
||||
Return track with passed path, or None.
|
||||
"""
|
||||
|
||||
try:
|
||||
return (
|
||||
session.execute(select(Tracks).where(Tracks.path == path))
|
||||
.unique()
|
||||
.scalar_one()
|
||||
)
|
||||
except NoResultFound:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def search_artists(cls, session: Session, text: str) -> Sequence["Tracks"]:
|
||||
"""
|
||||
Search case-insenstively for artists containing str
|
||||
|
||||
The query performs an outer join with 'joinedload' to populate the results
|
||||
from the Playdates table at the same time. unique() needed; see
|
||||
https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading
|
||||
"""
|
||||
|
||||
return (
|
||||
session.scalars(
|
||||
select(cls)
|
||||
.options(joinedload(Tracks.playdates))
|
||||
.where(cls.artist.ilike(f"%{text}%"))
|
||||
.order_by(cls.title)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def search_titles(cls, session: Session, text: str) -> Sequence["Tracks"]:
|
||||
"""
|
||||
Search case-insenstively for titles containing str
|
||||
|
||||
The query performs an outer join with 'joinedload' to populate the results
|
||||
from the Playdates table at the same time. unique() needed; see
|
||||
https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading
|
||||
"""
|
||||
return (
|
||||
session.scalars(
|
||||
select(cls)
|
||||
.options(joinedload(Tracks.playdates))
|
||||
.where(cls.title.like(f"{text}%"))
|
||||
.order_by(cls.title)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
@ -2,165 +2,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import threading
|
||||
from time import sleep
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Third party imports
|
||||
# import line_profiler
|
||||
import numpy as np
|
||||
import pyqtgraph as pg # type: ignore
|
||||
from sqlalchemy.orm.session import Session
|
||||
import vlc # type: ignore
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QObject,
|
||||
QThread,
|
||||
)
|
||||
from pyqtgraph import PlotWidget
|
||||
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
|
||||
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import ApplicationError, MusicMusterSignals
|
||||
from classes import MusicMusterSignals, singleton
|
||||
from config import Config
|
||||
import helpers
|
||||
from log import log
|
||||
from models import PlaylistRows
|
||||
from vlcmanager import VLCManager
|
||||
|
||||
# Define the VLC callback function type
|
||||
# import ctypes
|
||||
# import platform
|
||||
# VLC logging is very noisy so comment out unless needed
|
||||
# VLC_LOG_CB = ctypes.CFUNCTYPE(
|
||||
# None,
|
||||
# ctypes.c_void_p,
|
||||
# ctypes.c_int,
|
||||
# ctypes.c_void_p,
|
||||
# ctypes.c_char_p,
|
||||
# ctypes.c_void_p,
|
||||
# )
|
||||
|
||||
# # Determine the correct C library for vsnprintf based on the platform
|
||||
# if platform.system() == "Windows":
|
||||
# libc = ctypes.CDLL("msvcrt")
|
||||
# elif platform.system() == "Linux":
|
||||
# libc = ctypes.CDLL("libc.so.6")
|
||||
# elif platform.system() == "Darwin": # macOS
|
||||
# libc = ctypes.CDLL("libc.dylib")
|
||||
# else:
|
||||
# raise OSError("Unsupported operating system")
|
||||
|
||||
# # Define the vsnprintf function
|
||||
# libc.vsnprintf.argtypes = [
|
||||
# ctypes.c_char_p,
|
||||
# ctypes.c_size_t,
|
||||
# ctypes.c_char_p,
|
||||
# ctypes.c_void_p,
|
||||
# ]
|
||||
# libc.vsnprintf.restype = ctypes.c_int
|
||||
|
||||
|
||||
class _AddFadeCurve(QObject):
|
||||
"""
|
||||
Initialising a fade curve introduces a noticeable delay so carry out in
|
||||
a thread.
|
||||
"""
|
||||
|
||||
finished = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rat: RowAndTrack,
|
||||
track_path: str,
|
||||
track_fade_at: int,
|
||||
track_silence_at: int,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.rat = rat
|
||||
self.track_path = track_path
|
||||
self.track_fade_at = track_fade_at
|
||||
self.track_silence_at = track_silence_at
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Create fade curve and add to PlaylistTrack object
|
||||
"""
|
||||
|
||||
fc = _FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
|
||||
if not fc:
|
||||
log.error(f"Failed to create FadeCurve for {self.track_path=}")
|
||||
else:
|
||||
self.rat.fade_graph = fc
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class _FadeCurve:
|
||||
GraphWidget: Optional[PlotWidget] = None
|
||||
|
||||
def __init__(
|
||||
self, track_path: str, track_fade_at: int, track_silence_at: int
|
||||
) -> None:
|
||||
"""
|
||||
Set up fade graph array
|
||||
"""
|
||||
|
||||
audio = helpers.get_audio_segment(track_path)
|
||||
if not audio:
|
||||
log.error(f"FadeCurve: could not get audio for {track_path=}")
|
||||
return None
|
||||
|
||||
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
|
||||
# milliseconds before fade starts to silence
|
||||
self.start_ms: int = max(
|
||||
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
|
||||
)
|
||||
self.end_ms: int = track_silence_at
|
||||
audio_segment = audio[self.start_ms : self.end_ms]
|
||||
self.graph_array = np.array(audio_segment.get_array_of_samples())
|
||||
|
||||
# Calculate the factor to map milliseconds of track to array
|
||||
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
|
||||
|
||||
self.curve: Optional[PlotDataItem] = None
|
||||
self.region: Optional[LinearRegionItem] = None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the current graph"""
|
||||
|
||||
if self.GraphWidget:
|
||||
self.GraphWidget.clear()
|
||||
|
||||
def plot(self) -> None:
|
||||
if self.GraphWidget:
|
||||
self.curve = self.GraphWidget.plot(self.graph_array)
|
||||
if self.curve:
|
||||
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
|
||||
else:
|
||||
log.debug("_FadeCurve.plot: no curve")
|
||||
else:
|
||||
log.debug("_FadeCurve.plot: no GraphWidget")
|
||||
|
||||
def tick(self, play_time: int) -> None:
|
||||
"""Update volume fade curve"""
|
||||
|
||||
if not self.GraphWidget:
|
||||
return
|
||||
|
||||
ms_of_graph = play_time - self.start_ms
|
||||
if ms_of_graph < 0:
|
||||
return
|
||||
|
||||
if self.region is None:
|
||||
# Create the region now that we're into fade
|
||||
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
|
||||
self.GraphWidget.addItem(self.region)
|
||||
|
||||
# Update region position
|
||||
if self.region:
|
||||
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
|
||||
|
||||
|
||||
class _FadeTrack(QThread):
|
||||
@ -193,71 +53,39 @@ class _FadeTrack(QThread):
|
||||
)
|
||||
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
|
||||
|
||||
self.player.stop()
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
# TODO can we move this into the _Music class?
|
||||
vlc_instance = VLCManager().vlc_instance
|
||||
@singleton
|
||||
class VLCManager:
|
||||
"""
|
||||
Singleton class to ensure we only ever have one vlc Instance
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.vlc_instance = vlc.Instance()
|
||||
|
||||
def get_instance(self) -> vlc.Instance:
|
||||
return self.vlc_instance
|
||||
|
||||
|
||||
class _Music:
|
||||
class Music:
|
||||
"""
|
||||
Manage the playing of music tracks
|
||||
"""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
vlc_instance.set_user_agent(name, name)
|
||||
self.player: Optional[vlc.MediaPlayer] = None
|
||||
self.name = name
|
||||
vlc_manager = VLCManager()
|
||||
self.vlc_instance = vlc_manager.get_instance()
|
||||
self.vlc_instance.set_user_agent(name, name)
|
||||
self.player: vlc.MediaPlayer | None = None
|
||||
self.vlc_event_manager: vlc.EventManager | None = None
|
||||
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
|
||||
self.start_dt: Optional[dt.datetime] = None
|
||||
|
||||
# Set up logging
|
||||
# self._set_vlc_log()
|
||||
|
||||
# VLC logging very noisy so comment out unless needed
|
||||
# @VLC_LOG_CB
|
||||
# def log_callback(data, level, ctx, fmt, args):
|
||||
# try:
|
||||
# # Create a ctypes string buffer to hold the formatted message
|
||||
# buf = ctypes.create_string_buffer(1024)
|
||||
|
||||
# # Use vsnprintf to format the string with the va_list
|
||||
# libc.vsnprintf(buf, len(buf), fmt, args)
|
||||
|
||||
# # Decode the formatted message
|
||||
# message = buf.value.decode("utf-8", errors="replace")
|
||||
# log.debug("VLC: " + message)
|
||||
# except Exception as e:
|
||||
# log.error(f"Error in VLC log callback: {e}")
|
||||
|
||||
# def _set_vlc_log(self):
|
||||
# try:
|
||||
# vlc.libvlc_log_set(vlc_instance, self.log_callback, None)
|
||||
# log.debug("VLC logging set up successfully")
|
||||
# except Exception as e:
|
||||
# log.error(f"Failed to set up VLC logging: {e}")
|
||||
|
||||
def adjust_by_ms(self, ms: int) -> None:
|
||||
"""Move player position by ms milliseconds"""
|
||||
|
||||
if not self.player:
|
||||
return
|
||||
|
||||
elapsed_ms = self.get_playtime()
|
||||
position = self.get_position()
|
||||
if not position:
|
||||
position = 0.0
|
||||
new_position = max(0.0, position + ((position * ms) / elapsed_ms))
|
||||
self.set_position(new_position)
|
||||
# Adjus start time so elapsed time calculations are correct
|
||||
if new_position == 0:
|
||||
self.start_dt = dt.datetime.now()
|
||||
else:
|
||||
if self.start_dt:
|
||||
self.start_dt -= dt.timedelta(milliseconds=ms)
|
||||
else:
|
||||
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
|
||||
self.start_dt: dt.datetime | None = None
|
||||
self.signals = MusicMusterSignals()
|
||||
self.end_of_track_signalled = False
|
||||
|
||||
def fade(self, fade_seconds: int) -> None:
|
||||
"""
|
||||
@ -273,6 +101,8 @@ class _Music:
|
||||
if not self.player.get_position() > 0 and self.player.is_playing():
|
||||
return
|
||||
|
||||
self.emit_signal_track_ended()
|
||||
|
||||
self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds)
|
||||
self.fader_worker.finished.connect(self.player.release)
|
||||
self.fader_worker.start()
|
||||
@ -292,11 +122,11 @@ class _Music:
|
||||
elapsed_seconds = (now - self.start_dt).total_seconds()
|
||||
return int(elapsed_seconds * 1000)
|
||||
|
||||
def get_position(self) -> Optional[float]:
|
||||
def get_position(self) -> float:
|
||||
"""Return current position"""
|
||||
|
||||
if not self.player:
|
||||
return None
|
||||
return 0.0
|
||||
return self.player.get_position()
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
@ -317,11 +147,13 @@ class _Music:
|
||||
< dt.timedelta(microseconds=Config.PLAY_SETTLE)
|
||||
)
|
||||
|
||||
# @log_call
|
||||
def play(
|
||||
self,
|
||||
path: str,
|
||||
start_time: dt.datetime,
|
||||
position: Optional[float] = None,
|
||||
playlist_id: int,
|
||||
position: float | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Start playing the track at path.
|
||||
@ -332,13 +164,13 @@ class _Music:
|
||||
the start time is the same
|
||||
"""
|
||||
|
||||
log.debug(f"Music[{self.name}].play({path=}, {position=}")
|
||||
self.playlist_id = playlist_id
|
||||
|
||||
if helpers.file_is_unreadable(path):
|
||||
log.error(f"play({path}): path not readable")
|
||||
return None
|
||||
return
|
||||
|
||||
self.player = vlc.MediaPlayer(vlc_instance, path)
|
||||
self.player = vlc.MediaPlayer(self.vlc_instance, path)
|
||||
if self.player is None:
|
||||
log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
|
||||
helpers.show_warning(
|
||||
@ -346,6 +178,14 @@ class _Music:
|
||||
)
|
||||
return
|
||||
|
||||
self.events = self.player.event_manager()
|
||||
self.events.event_attach(
|
||||
vlc.EventType.MediaPlayerEndReached, self.track_end_event_handler
|
||||
)
|
||||
self.events.event_attach(
|
||||
vlc.EventType.MediaPlayerStopped, self.track_end_event_handler
|
||||
)
|
||||
|
||||
_ = self.player.play()
|
||||
self.set_volume(self.max_volume)
|
||||
|
||||
@ -353,21 +193,6 @@ class _Music:
|
||||
self.player.set_position(position)
|
||||
self.start_dt = start_time
|
||||
|
||||
# For as-yet unknown reasons. sometimes the volume gets
|
||||
# reset to zero within 200mS or so of starting play. This
|
||||
# only happened since moving to Debian 12, which uses
|
||||
# Pipewire for sound (which may be irrelevant).
|
||||
# It has been known for the volume to need correcting more
|
||||
# than once in the first 200mS.
|
||||
# Update August 2024: This no longer seems to be an issue
|
||||
# for _ in range(3):
|
||||
# if self.player:
|
||||
# volume = self.player.audio_get_volume()
|
||||
# if volume < Config.VLC_VOLUME_DEFAULT:
|
||||
# self.set_volume(Config.VLC_VOLUME_DEFAULT)
|
||||
# log.error(f"Reset from {volume=}")
|
||||
# sleep(0.1)
|
||||
|
||||
def set_position(self, position: float) -> None:
|
||||
"""
|
||||
Set player position
|
||||
@ -376,9 +201,7 @@ class _Music:
|
||||
if self.player:
|
||||
self.player.set_position(position)
|
||||
|
||||
def set_volume(
|
||||
self, volume: Optional[int] = None, set_default: bool = True
|
||||
) -> None:
|
||||
def set_volume(self, volume: int | None = None, set_default: bool = True) -> None:
|
||||
"""Set maximum volume used for player"""
|
||||
|
||||
if not self.player:
|
||||
@ -396,13 +219,29 @@ class _Music:
|
||||
# reset to zero within 200mS or so of starting play. This
|
||||
# only happened since moving to Debian 12, which uses
|
||||
# Pipewire for sound (which may be irrelevant).
|
||||
# Update 19 April 2025: this may no longer be occuring
|
||||
for _ in range(3):
|
||||
current_volume = self.player.audio_get_volume()
|
||||
if current_volume < volume:
|
||||
self.player.audio_set_volume(volume)
|
||||
log.debug(f"Reset from {volume=}")
|
||||
log.debug(f"Volume reset from {volume=}")
|
||||
sleep(0.1)
|
||||
|
||||
def emit_signal_track_ended(self) -> None:
|
||||
"""
|
||||
Multiple parts of the Music class can signal that the track has
|
||||
ended. Handle them all here to ensure that only one such signal
|
||||
is raised. Make this thead safe.
|
||||
"""
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
with lock:
|
||||
if self.end_of_track_signalled:
|
||||
return
|
||||
self.signals.signal_track_ended.emit(self.playlist_id)
|
||||
self.end_of_track_signalled = True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Immediately stop playing"""
|
||||
|
||||
@ -417,333 +256,12 @@ class _Music:
|
||||
self.player.stop()
|
||||
self.player.release()
|
||||
self.player = None
|
||||
self.emit_signal_track_ended()
|
||||
|
||||
|
||||
class RowAndTrack:
|
||||
"""
|
||||
Object to manage playlist rows and tracks.
|
||||
"""
|
||||
|
||||
def __init__(self, playlist_row: PlaylistRows) -> None:
|
||||
def track_end_event_handler(self, event: vlc.Event) -> None:
|
||||
"""
|
||||
Initialises data structure.
|
||||
|
||||
The passed PlaylistRows object will include a Tracks object if this
|
||||
row has a track.
|
||||
Handler for MediaPlayerEndReached
|
||||
"""
|
||||
|
||||
# Collect playlistrow data
|
||||
self.note = playlist_row.note
|
||||
self.played = playlist_row.played
|
||||
self.playlist_id = playlist_row.playlist_id
|
||||
self.playlistrow_id = playlist_row.id
|
||||
self.row_number = playlist_row.row_number
|
||||
self.track_id = playlist_row.track_id
|
||||
|
||||
# Playlist display data
|
||||
self.row_fg: Optional[str] = None
|
||||
self.row_bg: Optional[str] = None
|
||||
self.note_fg: Optional[str] = None
|
||||
self.note_bg: Optional[str] = None
|
||||
|
||||
# Collect track data if there's a track
|
||||
if playlist_row.track_id:
|
||||
self.artist = playlist_row.track.artist
|
||||
self.bitrate = playlist_row.track.bitrate
|
||||
self.duration = playlist_row.track.duration
|
||||
self.fade_at = playlist_row.track.fade_at
|
||||
self.intro = playlist_row.track.intro
|
||||
if playlist_row.track.playdates:
|
||||
self.lastplayed = max(
|
||||
[a.lastplayed for a in playlist_row.track.playdates]
|
||||
)
|
||||
else:
|
||||
self.lastplayed = Config.EPOCH
|
||||
self.path = playlist_row.track.path
|
||||
self.silence_at = playlist_row.track.silence_at
|
||||
self.start_gap = playlist_row.track.start_gap
|
||||
self.title = playlist_row.track.title
|
||||
else:
|
||||
self.artist = ""
|
||||
self.bitrate = 0
|
||||
self.duration = 0
|
||||
self.fade_at = 0
|
||||
self.intro = None
|
||||
self.lastplayed = Config.EPOCH
|
||||
self.path = ""
|
||||
self.silence_at = 0
|
||||
self.start_gap = 0
|
||||
self.title = ""
|
||||
|
||||
# Track playing data
|
||||
self.end_of_track_signalled: bool = False
|
||||
self.end_time: Optional[dt.datetime] = None
|
||||
self.fade_graph: Optional[_FadeCurve] = None
|
||||
self.fade_graph_start_updates: Optional[dt.datetime] = None
|
||||
self.resume_marker: Optional[float] = 0.0
|
||||
self.forecast_end_time: Optional[dt.datetime] = None
|
||||
self.forecast_start_time: Optional[dt.datetime] = None
|
||||
self.start_time: Optional[dt.datetime] = None
|
||||
|
||||
# Other object initialisation
|
||||
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
|
||||
self.signals = MusicMusterSignals()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<RowAndTrack(playlist_id={self.playlist_id}, "
|
||||
f"row_number={self.row_number}, "
|
||||
f"playlistrow_id={self.playlistrow_id}, "
|
||||
f"note={self.note}, track_id={self.track_id}>"
|
||||
)
|
||||
|
||||
def check_for_end_of_track(self) -> None:
|
||||
"""
|
||||
Check whether track has ended. If so, emit track_ended_signal
|
||||
"""
|
||||
|
||||
if self.start_time is None:
|
||||
return
|
||||
|
||||
if self.end_of_track_signalled:
|
||||
return
|
||||
|
||||
if self.music.is_playing():
|
||||
return
|
||||
|
||||
self.start_time = None
|
||||
if self.fade_graph:
|
||||
self.fade_graph.clear()
|
||||
# Ensure that player is released
|
||||
self.music.fade(0)
|
||||
self.signals.track_ended_signal.emit()
|
||||
self.end_of_track_signalled = True
|
||||
|
||||
def create_fade_graph(self) -> None:
|
||||
"""
|
||||
Initialise and add FadeCurve in a thread as it's slow
|
||||
"""
|
||||
|
||||
self.fadecurve_thread = QThread()
|
||||
self.worker = _AddFadeCurve(
|
||||
self,
|
||||
track_path=self.path,
|
||||
track_fade_at=self.fade_at,
|
||||
track_silence_at=self.silence_at,
|
||||
)
|
||||
self.worker.moveToThread(self.fadecurve_thread)
|
||||
self.fadecurve_thread.started.connect(self.worker.run)
|
||||
self.worker.finished.connect(self.fadecurve_thread.quit)
|
||||
self.worker.finished.connect(self.worker.deleteLater)
|
||||
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
|
||||
self.fadecurve_thread.start()
|
||||
|
||||
def drop3db(self, enable: bool) -> None:
|
||||
"""
|
||||
If enable is true, drop output by 3db else restore to full volume
|
||||
"""
|
||||
|
||||
if enable:
|
||||
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
|
||||
else:
|
||||
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
|
||||
|
||||
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
|
||||
"""Fade music"""
|
||||
|
||||
self.resume_marker = self.music.get_position()
|
||||
self.music.fade(fade_seconds)
|
||||
self.signals.track_ended_signal.emit()
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
"""
|
||||
Return True if we're currently playing else False
|
||||
"""
|
||||
|
||||
if self.start_time is None:
|
||||
return False
|
||||
|
||||
return self.music.is_playing()
|
||||
|
||||
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
|
||||
"""
|
||||
Rewind player by ms milliseconds
|
||||
"""
|
||||
|
||||
self.music.adjust_by_ms(ms * -1)
|
||||
|
||||
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
|
||||
"""
|
||||
Rewind player by ms milliseconds
|
||||
"""
|
||||
|
||||
self.music.adjust_by_ms(ms)
|
||||
|
||||
def play(self, position: Optional[float] = None) -> None:
|
||||
"""Play track"""
|
||||
|
||||
now = dt.datetime.now()
|
||||
self.start_time = now
|
||||
|
||||
# Initialise player
|
||||
self.music.play(self.path, start_time=now, position=position)
|
||||
|
||||
self.end_time = now + dt.timedelta(milliseconds=self.duration)
|
||||
|
||||
# Calculate time fade_graph should start updating
|
||||
if self.fade_at:
|
||||
update_graph_at_ms = max(
|
||||
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
|
||||
)
|
||||
self.fade_graph_start_updates = now + dt.timedelta(
|
||||
milliseconds=update_graph_at_ms
|
||||
)
|
||||
|
||||
def restart(self) -> None:
|
||||
"""
|
||||
Restart player
|
||||
"""
|
||||
|
||||
self.music.adjust_by_ms(self.time_playing() * -1)
|
||||
|
||||
def set_forecast_start_time(
|
||||
self, modified_rows: list[int], start: Optional[dt.datetime]
|
||||
) -> Optional[dt.datetime]:
|
||||
"""
|
||||
Set forecast start time for this row
|
||||
|
||||
Update passed modified rows list if we changed the row.
|
||||
|
||||
Return new start time
|
||||
"""
|
||||
|
||||
changed = False
|
||||
|
||||
if self.forecast_start_time != start:
|
||||
self.forecast_start_time = start
|
||||
changed = True
|
||||
if start is None:
|
||||
if self.forecast_end_time is not None:
|
||||
self.forecast_end_time = None
|
||||
changed = True
|
||||
new_start_time = None
|
||||
else:
|
||||
end_time = start + dt.timedelta(milliseconds=self.duration)
|
||||
new_start_time = end_time
|
||||
if self.forecast_end_time != end_time:
|
||||
self.forecast_end_time = end_time
|
||||
changed = True
|
||||
|
||||
if changed and self.row_number not in modified_rows:
|
||||
modified_rows.append(self.row_number)
|
||||
|
||||
return new_start_time
|
||||
|
||||
def stop(self, fade_seconds: int = 0) -> None:
|
||||
"""
|
||||
Stop this track playing
|
||||
"""
|
||||
|
||||
self.resume_marker = self.music.get_position()
|
||||
self.fade(fade_seconds)
|
||||
|
||||
# Reset fade graph
|
||||
if self.fade_graph:
|
||||
self.fade_graph.clear()
|
||||
|
||||
def time_playing(self) -> int:
|
||||
"""
|
||||
Return time track has been playing in milliseconds, zero if not playing
|
||||
"""
|
||||
|
||||
if self.start_time is None:
|
||||
return 0
|
||||
|
||||
return self.music.get_playtime()
|
||||
|
||||
def time_remaining_intro(self) -> int:
|
||||
"""
|
||||
Return milliseconds of intro remaining. Return 0 if no intro time in track
|
||||
record or if intro has finished.
|
||||
"""
|
||||
|
||||
if not self.intro:
|
||||
return 0
|
||||
|
||||
return max(0, self.intro - self.time_playing())
|
||||
|
||||
def time_to_fade(self) -> int:
|
||||
"""
|
||||
Return milliseconds until fade time. Return zero if we're not playing.
|
||||
"""
|
||||
|
||||
if self.start_time is None:
|
||||
return 0
|
||||
|
||||
return self.fade_at - self.time_playing()
|
||||
|
||||
def time_to_silence(self) -> int:
|
||||
"""
|
||||
Return milliseconds until silent. Return zero if we're not playing.
|
||||
"""
|
||||
|
||||
if self.start_time is None:
|
||||
return 0
|
||||
|
||||
return self.silence_at - self.time_playing()
|
||||
|
||||
def update_fade_graph(self) -> None:
|
||||
"""
|
||||
Update fade graph
|
||||
"""
|
||||
|
||||
if (
|
||||
not self.is_playing()
|
||||
or not self.fade_graph_start_updates
|
||||
or not self.fade_graph
|
||||
):
|
||||
return
|
||||
|
||||
now = dt.datetime.now()
|
||||
|
||||
if self.fade_graph_start_updates > now:
|
||||
return
|
||||
|
||||
self.fade_graph.tick(self.time_playing())
|
||||
|
||||
def update_playlist_and_row(self, session: Session) -> None:
|
||||
"""
|
||||
Update local playlist_id and row_number from playlistrow_id
|
||||
"""
|
||||
|
||||
plr = session.get(PlaylistRows, self.playlistrow_id)
|
||||
if not plr:
|
||||
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
|
||||
self.playlist_id = plr.playlist_id
|
||||
self.row_number = plr.row_number
|
||||
|
||||
|
||||
class TrackSequence:
|
||||
next: Optional[RowAndTrack] = None
|
||||
current: Optional[RowAndTrack] = None
|
||||
previous: Optional[RowAndTrack] = None
|
||||
|
||||
def set_next(self, rat: Optional[RowAndTrack]) -> None:
|
||||
"""
|
||||
Set the 'next' track to be passed rat. Clear
|
||||
any previous next track. If passed rat is None
|
||||
just clear existing next track.
|
||||
"""
|
||||
|
||||
# Clear any existing fade graph
|
||||
if self.next and self.next.fade_graph:
|
||||
self.next.fade_graph.clear()
|
||||
|
||||
if rat is None:
|
||||
self.next = None
|
||||
else:
|
||||
self.next = rat
|
||||
self.next.create_fade_graph()
|
||||
|
||||
|
||||
track_sequence = TrackSequence()
|
||||
log.debug("track_end_event_handler() called")
|
||||
self.emit_signal_track_ended()
|
||||
|
||||
1490
app/musicmuster.py
1490
app/musicmuster.py
File diff suppressed because it is too large
Load Diff
1334
app/playlistmodel.py
1334
app/playlistmodel.py
File diff suppressed because it is too large
Load Diff
546
app/playlistrow.py
Normal file
546
app/playlistrow.py
Normal file
@ -0,0 +1,546 @@
|
||||
# Standard library imports
|
||||
from collections import deque
|
||||
import datetime as dt
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QObject,
|
||||
QThread,
|
||||
)
|
||||
|
||||
# Third party imports
|
||||
from pyqtgraph import PlotWidget # type: ignore
|
||||
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
|
||||
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
|
||||
import numpy as np
|
||||
import pyqtgraph as pg # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import ApplicationError, MusicMusterSignals, PlaylistRowDTO, singleton
|
||||
from config import Config
|
||||
from log import log
|
||||
from music_manager import Music
|
||||
import ds
|
||||
import helpers
|
||||
|
||||
|
||||
class FadeGraphGenerator(QObject):
|
||||
finished = pyqtSignal(object, object)
|
||||
task_completed = pyqtSignal()
|
||||
|
||||
def generate_graph(self, plr: "PlaylistRow") -> None:
|
||||
fade_graph = FadeCurve(plr.path, plr.fade_at, plr.silence_at)
|
||||
if not fade_graph:
|
||||
log.error(f"Failed to create FadeCurve for {plr=}")
|
||||
return
|
||||
|
||||
self.finished.emit(plr, fade_graph)
|
||||
self.task_completed.emit()
|
||||
|
||||
|
||||
@singleton
|
||||
class FadegraphThreadController(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._thread = None
|
||||
self._generator = None
|
||||
self._request_queue = deque()
|
||||
|
||||
def generate_fade_graph(self, playlist_row):
|
||||
self._request_queue.append(playlist_row) # Use append for enqueue with deque
|
||||
if self._thread is None or not self._thread.isRunning():
|
||||
self._start_next_generation()
|
||||
|
||||
def _start_next_generation(self):
|
||||
if not self._request_queue: # Check if deque is empty
|
||||
return
|
||||
playlist_row = self._request_queue.popleft() # Use popleft for dequeue with deque
|
||||
self._start_thread(playlist_row)
|
||||
|
||||
def _start_thread(self, playlist_row):
|
||||
self._thread = QThread()
|
||||
self._generator = FadeGraphGenerator()
|
||||
self._generator.moveToThread(self._thread)
|
||||
self._generator.finished.connect(lambda row, graph: row.attach_fade_graph(graph))
|
||||
self._generator.task_completed.connect(self._cleanup_thread)
|
||||
self._thread.started.connect(lambda: self._generator.generate_graph(playlist_row))
|
||||
self._thread.start()
|
||||
|
||||
def _cleanup_thread(self):
|
||||
if self._thread:
|
||||
self._thread.quit()
|
||||
self._thread.wait()
|
||||
self._thread.deleteLater()
|
||||
self._thread = None
|
||||
self._generator.deleteLater()
|
||||
self._generator = None
|
||||
# Start the next request if any
|
||||
self._start_next_generation()
|
||||
|
||||
|
||||
class PlaylistRow:
|
||||
"""
|
||||
Object to manage playlist row and track.
|
||||
"""
|
||||
|
||||
def __init__(self, dto: PlaylistRowDTO) -> None:
|
||||
"""
|
||||
The dto object will include row information plus a Tracks object
|
||||
if this row has a track.
|
||||
"""
|
||||
|
||||
self.dto = dto
|
||||
self.music = Music(name=Config.VLC_MAIN_PLAYER_NAME)
|
||||
self.signals = MusicMusterSignals()
|
||||
self.end_of_track_signalled: bool = False
|
||||
self.end_time: dt.datetime | None = None
|
||||
self.fade_graph: FadeCurve | None = None
|
||||
self.fade_graph_start_updates: dt.datetime | None = None
|
||||
self.forecast_end_time: dt.datetime | None = None
|
||||
self.forecast_start_time: dt.datetime | None = None
|
||||
self.note_bg: str | None = None
|
||||
self.note_fg: str | None = None
|
||||
self.resume_marker: float = 0.0
|
||||
self.row_bg: str | None = None
|
||||
self.row_fg: str | None = None
|
||||
self.start_time: dt.datetime | None = None
|
||||
self.fadegraph_thread_controller = FadegraphThreadController()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
track_id = None
|
||||
if self.dto.track:
|
||||
track_id = self.dto.track.track_id
|
||||
return (
|
||||
f"<PlaylistRow(playlist_id={self.dto.playlist_id}, "
|
||||
f"row_number={self.dto.row_number}, "
|
||||
f"playlistrow_id={self.dto.playlistrow_id}, "
|
||||
f"note={self.dto.note}, track_id={track_id}>"
|
||||
)
|
||||
|
||||
# Expose TrackDTO fields as properties
|
||||
@property
|
||||
def artist(self) -> str:
|
||||
if self.dto.track:
|
||||
return self.dto.track.artist
|
||||
else:
|
||||
return ""
|
||||
|
||||
@artist.setter
|
||||
def artist(self, artist: str) -> None:
|
||||
if not self.dto.track:
|
||||
raise ApplicationError(f"No track_id when trying to set artist ({self})")
|
||||
|
||||
self.dto.track.artist = artist
|
||||
ds.track_update(self.track_id, dict(artist=str(artist)))
|
||||
|
||||
@property
|
||||
def bitrate(self) -> int:
|
||||
if self.dto.track:
|
||||
return self.dto.track.bitrate
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def duration(self) -> int:
|
||||
if self.dto.track:
|
||||
return self.dto.track.duration
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def fade_at(self) -> int:
|
||||
if self.dto.track:
|
||||
return self.dto.track.fade_at
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def intro(self) -> int:
|
||||
if self.dto.track:
|
||||
return self.dto.track.intro or 0
|
||||
else:
|
||||
return 0
|
||||
|
||||
@intro.setter
|
||||
def intro(self, intro: int) -> None:
|
||||
if not self.dto.track:
|
||||
raise ApplicationError(f"No track_id when trying to set intro ({self})")
|
||||
|
||||
self.dto.track.intro = intro
|
||||
ds.track_update(self.track_id, dict(intro=str(intro)))
|
||||
|
||||
@property
|
||||
def lastplayed(self) -> dt.datetime | None:
|
||||
if self.dto.track:
|
||||
return self.dto.track.lastplayed
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
if self.dto.track:
|
||||
return self.dto.track.path
|
||||
else:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def silence_at(self) -> int:
|
||||
if self.dto.track:
|
||||
return self.dto.track.silence_at
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def start_gap(self) -> int:
|
||||
if self.dto.track:
|
||||
return self.dto.track.start_gap
|
||||
else:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
if self.dto.track:
|
||||
return self.dto.track.title
|
||||
else:
|
||||
return ""
|
||||
|
||||
@title.setter
|
||||
def title(self, title: str) -> None:
|
||||
if not self.dto.track:
|
||||
raise ApplicationError(f"No track_id when trying to set title ({self})")
|
||||
|
||||
self.dto.track.title = title
|
||||
ds.track_update(self.track_id, dict(title=str(title)))
|
||||
|
||||
@property
|
||||
def track_id(self) -> int:
|
||||
if self.dto.track:
|
||||
return self.dto.track.track_id
|
||||
else:
|
||||
return 0
|
||||
|
||||
@track_id.setter
|
||||
def track_id(self, track_id: int) -> None:
|
||||
"""
|
||||
Adding a track_id should only happen to a header row.
|
||||
"""
|
||||
|
||||
if self.track_id > 0:
|
||||
raise ApplicationError(
|
||||
"Attempting to add track to row with existing track ({self=}"
|
||||
)
|
||||
|
||||
ds.track_add_to_header(playlistrow_id=self.playlistrow_id, track_id=track_id)
|
||||
|
||||
# Need to update with track information
|
||||
track = ds.track_by_id(track_id)
|
||||
if track:
|
||||
for attr, value in track.__dataclass_fields__.items():
|
||||
setattr(self, attr, value)
|
||||
|
||||
# Expose PlaylistRowDTO fields as properties
|
||||
@property
|
||||
def note(self) -> str:
|
||||
return self.dto.note
|
||||
|
||||
@note.setter
|
||||
def note(self, note: str) -> None:
|
||||
self.dto.note = note
|
||||
ds.playlistrow_update_note(self.playlistrow_id, str(note))
|
||||
|
||||
@property
|
||||
def played(self) -> bool:
|
||||
return self.dto.played
|
||||
|
||||
@played.setter
|
||||
def played(self, value: bool) -> None:
|
||||
self.dto.played = True
|
||||
ds.playlistrow_played(self.playlistrow_id, value)
|
||||
|
||||
@property
|
||||
def playlist_id(self) -> int:
|
||||
return self.dto.playlist_id
|
||||
|
||||
@property
|
||||
def playlistrow_id(self) -> int:
|
||||
return self.dto.playlistrow_id
|
||||
|
||||
@property
|
||||
def row_number(self) -> int:
|
||||
return self.dto.row_number
|
||||
|
||||
@row_number.setter
|
||||
def row_number(self, value: int) -> None:
|
||||
# This does not update the database. The only times the row
|
||||
# number changes are 1) in ds._playlist_check_playlist and
|
||||
# ds.playlist_move_rows, and in both those places ds saves
|
||||
# the change to the database.
|
||||
self.dto.row_number = value
|
||||
|
||||
def attach_fade_graph(self, fade_graph):
|
||||
self.fade_graph = fade_graph
|
||||
|
||||
def drop3db(self, enable: bool) -> None:
|
||||
"""
|
||||
If enable is true, drop output by 3db else restore to full volume
|
||||
"""
|
||||
|
||||
if enable:
|
||||
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
|
||||
else:
|
||||
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
|
||||
|
||||
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
|
||||
"""Fade music"""
|
||||
|
||||
self.resume_marker = self.music.get_position()
|
||||
self.music.fade(fade_seconds)
|
||||
|
||||
def play(self, position: float | None = None) -> None:
|
||||
"""Play track"""
|
||||
|
||||
now = dt.datetime.now()
|
||||
self.start_time = now
|
||||
|
||||
# Initialise player
|
||||
self.music.play(
|
||||
path=self.path,
|
||||
start_time=now,
|
||||
playlist_id=self.playlist_id,
|
||||
position=position,
|
||||
)
|
||||
|
||||
self.end_time = now + dt.timedelta(milliseconds=self.duration)
|
||||
|
||||
# Calculate time fade_graph should start updating
|
||||
if self.fade_at:
|
||||
update_graph_at_ms = max(
|
||||
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
|
||||
)
|
||||
self.fade_graph_start_updates = now + dt.timedelta(
|
||||
milliseconds=update_graph_at_ms
|
||||
)
|
||||
|
||||
def stop(self, fade_seconds: int = 0) -> None:
|
||||
"""
|
||||
Stop this track playing
|
||||
"""
|
||||
|
||||
self.resume_marker = self.music.get_position()
|
||||
self.fade(fade_seconds)
|
||||
|
||||
# Reset fade graph
|
||||
if self.fade_graph:
|
||||
self.fade_graph.clear()
|
||||
|
||||
def time_playing(self) -> int:
|
||||
"""
|
||||
Return time track has been playing in milliseconds, zero if not playing
|
||||
"""
|
||||
|
||||
if self.start_time is None:
|
||||
return 0
|
||||
|
||||
return self.music.get_playtime()
|
||||
|
||||
def time_remaining_intro(self) -> int:
|
||||
"""
|
||||
Return milliseconds of intro remaining. Return 0 if no intro time in track
|
||||
record or if intro has finished.
|
||||
"""
|
||||
|
||||
if not self.intro:
|
||||
return 0
|
||||
|
||||
return max(0, self.intro - self.time_playing())
|
||||
|
||||
def time_to_fade(self) -> int:
|
||||
"""
|
||||
Return milliseconds until fade time. Return zero if we're not playing.
|
||||
"""
|
||||
|
||||
if self.start_time is None:
|
||||
return 0
|
||||
|
||||
return self.fade_at - self.time_playing()
|
||||
|
||||
def time_to_silence(self) -> int:
|
||||
"""
|
||||
Return milliseconds until silent. Return zero if we're not playing.
|
||||
"""
|
||||
|
||||
if self.start_time is None:
|
||||
return 0
|
||||
|
||||
return self.silence_at - self.time_playing()
|
||||
|
||||
def update_fade_graph(self) -> None:
|
||||
"""
|
||||
Update fade graph
|
||||
"""
|
||||
|
||||
if (
|
||||
not self.music.is_playing()
|
||||
or not self.fade_graph_start_updates
|
||||
or not self.fade_graph
|
||||
):
|
||||
return
|
||||
|
||||
now = dt.datetime.now()
|
||||
|
||||
if self.fade_graph_start_updates > now:
|
||||
return
|
||||
|
||||
self.fade_graph.tick(self.time_playing())
|
||||
|
||||
|
||||
class FadeCurve:
|
||||
GraphWidget: PlotWidget | None = None
|
||||
|
||||
def __init__(
|
||||
self, track_path: str, track_fade_at: int, track_silence_at: int
|
||||
) -> None:
|
||||
"""
|
||||
Set up fade graph array
|
||||
"""
|
||||
|
||||
audio = helpers.get_audio_segment(track_path)
|
||||
if not audio:
|
||||
log.error(f"FadeCurve: could not get audio for {track_path=}")
|
||||
return None
|
||||
|
||||
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
|
||||
# milliseconds before fade starts to silence
|
||||
self.start_ms = max(
|
||||
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
|
||||
)
|
||||
self.end_ms = track_silence_at
|
||||
audio_segment = audio[self.start_ms : self.end_ms]
|
||||
self.graph_array = np.array(audio_segment.get_array_of_samples())
|
||||
|
||||
# Calculate the factor to map milliseconds of track to array
|
||||
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
|
||||
|
||||
self.curve: PlotDataItem | None = None
|
||||
self.region: LinearRegionItem | None = None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the current graph"""
|
||||
|
||||
if self.GraphWidget:
|
||||
self.GraphWidget.clear()
|
||||
|
||||
def plot(self) -> None:
|
||||
if self.GraphWidget:
|
||||
self.curve = self.GraphWidget.plot(self.graph_array)
|
||||
if self.curve:
|
||||
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
|
||||
else:
|
||||
log.debug("_FadeCurve.plot: no curve")
|
||||
else:
|
||||
log.debug("_FadeCurve.plot: no GraphWidget")
|
||||
|
||||
def tick(self, play_time: int) -> None:
|
||||
"""Update volume fade curve"""
|
||||
|
||||
if not self.GraphWidget:
|
||||
return
|
||||
|
||||
ms_of_graph = play_time - self.start_ms
|
||||
if ms_of_graph < 0:
|
||||
return
|
||||
|
||||
if self.region is None:
|
||||
# Create the region now that we're into fade
|
||||
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
|
||||
self.GraphWidget.addItem(self.region)
|
||||
|
||||
# Update region position
|
||||
if self.region:
|
||||
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
|
||||
|
||||
|
||||
@singleton
|
||||
class TrackSequence:
|
||||
"""
|
||||
Maintain a list of which track (if any) is next, current and
|
||||
previous. A track can only be previous after being current, and can
|
||||
only be current after being next. If one of the tracks listed here
|
||||
moves, the row_number and/or playlist_id will change.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Set up storage for the three monitored tracks
|
||||
"""
|
||||
|
||||
self.next: PlaylistRow | None = None
|
||||
self.current: PlaylistRow | None = None
|
||||
self.previous: PlaylistRow | None = None
|
||||
|
||||
def set_next(self, plr: PlaylistRow | None) -> None:
|
||||
"""
|
||||
Set the 'next' track to be passed PlaylistRow. Clear any previous
|
||||
next track. If passed PlaylistRow is None just clear existing
|
||||
next track.
|
||||
"""
|
||||
|
||||
# Clear any existing fade graph
|
||||
if self.next and self.next.fade_graph:
|
||||
self.next.fade_graph.clear()
|
||||
|
||||
if plr is None:
|
||||
self.next = None
|
||||
else:
|
||||
self.next = plr
|
||||
plr.fadegraph_thread_controller.generate_fade_graph(plr)
|
||||
|
||||
def move_next_to_current(self) -> None:
|
||||
"""
|
||||
Make the next track the current track
|
||||
"""
|
||||
|
||||
self.current = self.next
|
||||
self.next = None
|
||||
|
||||
def move_current_to_previous(self) -> None:
|
||||
"""
|
||||
Make the current track the previous track
|
||||
"""
|
||||
|
||||
if self.current is None:
|
||||
raise ApplicationError(
|
||||
"Tried to move non-existent track from current to previous"
|
||||
)
|
||||
|
||||
# Dereference the fade curve so it can be garbage collected
|
||||
if self.current.fade_graph:
|
||||
self.current.fade_graph.clear()
|
||||
self.current.fade_graph = None
|
||||
self.previous = self.current
|
||||
self.current = None
|
||||
self.start_time = None
|
||||
|
||||
def move_previous_to_next(self) -> None:
|
||||
"""
|
||||
Make the previous track the next track
|
||||
"""
|
||||
|
||||
self.next = self.previous
|
||||
self.previous = None
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
If a PlaylistRow is edited (moved, title changed, etc), the
|
||||
playlistrow_id won't change. We can retrieve the PlaylistRow
|
||||
using the playlistrow_id and update the stored PlaylistRow.
|
||||
"""
|
||||
|
||||
for ts in [self.next, self.current, self.previous]:
|
||||
if not ts:
|
||||
continue
|
||||
playlist_row_dto = ds.playlistrow_by_id(ts.playlistrow_id)
|
||||
if not playlist_row_dto:
|
||||
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
|
||||
ts = PlaylistRow(playlist_row_dto)
|
||||
197
app/playlists.py
197
app/playlists.py
@ -34,10 +34,16 @@ from PyQt6.QtWidgets import (
|
||||
# import line_profiler
|
||||
|
||||
# App imports
|
||||
from audacity_controller import AudacityController
|
||||
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
|
||||
from classes import (
|
||||
ApplicationError,
|
||||
Col,
|
||||
MusicMusterSignals,
|
||||
PlaylistStyle,
|
||||
SelectedRows,
|
||||
TrackInfo,
|
||||
)
|
||||
from config import Config
|
||||
from dialogs import TrackSelectDialog
|
||||
from dialogs import TrackInsertDialog
|
||||
from helpers import (
|
||||
ask_yes_no,
|
||||
ms_to_mmss,
|
||||
@ -45,9 +51,9 @@ from helpers import (
|
||||
show_warning,
|
||||
)
|
||||
from log import log, log_call
|
||||
from models import db, Settings
|
||||
from music_manager import track_sequence
|
||||
from playlistrow import TrackSequence
|
||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||
import ds
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from musicmuster import Window
|
||||
@ -183,9 +189,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
||||
# Close editor if no changes have been made
|
||||
data_modified = False
|
||||
if isinstance(editor, QTextEdit):
|
||||
data_modified = (
|
||||
self.original_model_data != editor.toPlainText()
|
||||
)
|
||||
data_modified = self.original_model_data != editor.toPlainText()
|
||||
elif isinstance(editor, QDoubleSpinBox):
|
||||
data_modified = (
|
||||
self.original_model_data != int(editor.value()) * 1000
|
||||
@ -278,6 +282,7 @@ class PlaylistTab(QTableView):
|
||||
self.musicmuster = musicmuster
|
||||
|
||||
self.playlist_id = model.sourceModel().playlist_id
|
||||
self.track_sequence = TrackSequence()
|
||||
|
||||
# Set up widget
|
||||
self.setItemDelegate(PlaylistDelegate(self, model.sourceModel()))
|
||||
@ -301,20 +306,14 @@ class PlaylistTab(QTableView):
|
||||
|
||||
# Connect signals
|
||||
self.signals = MusicMusterSignals()
|
||||
self.signals.resize_rows_signal.connect(self.resize_rows)
|
||||
self.signals.span_cells_signal.connect(self._span_cells)
|
||||
self.signals.resize_rows_signal.connect(self.resize_rows_handler)
|
||||
self.signals.span_cells_signal.connect(self._span_cells_handler)
|
||||
self.signals.signal_track_started.connect(self.track_started_handler)
|
||||
|
||||
# Selection model
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
|
||||
# Set up for Audacity
|
||||
try:
|
||||
self.ac: Optional[AudacityController] = AudacityController()
|
||||
except ApplicationError as e:
|
||||
self.ac = None
|
||||
show_warning(self.musicmuster, "Audacity error", str(e))
|
||||
|
||||
# Load model, set column widths
|
||||
self.setModel(model)
|
||||
self._set_column_widths()
|
||||
@ -333,7 +332,7 @@ class PlaylistTab(QTableView):
|
||||
v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
|
||||
|
||||
# Setting ResizeToContents causes screen flash on load
|
||||
self.resize_rows()
|
||||
self.resize_rows_handler()
|
||||
|
||||
# ########## Overridden class functions ##########
|
||||
|
||||
@ -344,12 +343,12 @@ class PlaylistTab(QTableView):
|
||||
Override closeEditor to enable play controls and update display.
|
||||
"""
|
||||
|
||||
self.musicmuster.enable_escape(True)
|
||||
self.signals.enable_escape_signal.emit(True)
|
||||
|
||||
super(PlaylistTab, self).closeEditor(editor, hint)
|
||||
|
||||
# Optimise row heights after increasing row height for editing
|
||||
self.resize_rows()
|
||||
self.resize_rows_handler()
|
||||
|
||||
# Update start times in case a start time in a note has been
|
||||
# edited
|
||||
@ -358,7 +357,8 @@ class PlaylistTab(QTableView):
|
||||
# Deselect edited line
|
||||
self.clear_selection()
|
||||
|
||||
def dropEvent(self, event: Optional[QDropEvent], dummy: int | None = None) -> None:
|
||||
# @log_call
|
||||
def dropEvent(self, event: Optional[QDropEvent]) -> None:
|
||||
"""
|
||||
Move dropped rows
|
||||
"""
|
||||
@ -394,9 +394,6 @@ class PlaylistTab(QTableView):
|
||||
destination_index = to_index
|
||||
|
||||
to_model_row = self.model().mapToSource(destination_index).row()
|
||||
log.debug(
|
||||
f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_row=}"
|
||||
)
|
||||
|
||||
# Sanity check
|
||||
base_model_row_count = self.get_base_model().rowCount()
|
||||
@ -408,8 +405,8 @@ class PlaylistTab(QTableView):
|
||||
# that moved row the next track
|
||||
set_next_row: Optional[int] = None
|
||||
if (
|
||||
track_sequence.current
|
||||
and to_model_row == track_sequence.current.row_number + 1
|
||||
self.track_sequence.current
|
||||
and to_model_row == self.track_sequence.current.row_number + 1
|
||||
):
|
||||
set_next_row = to_model_row
|
||||
|
||||
@ -422,11 +419,11 @@ class PlaylistTab(QTableView):
|
||||
self.clear_selection()
|
||||
|
||||
# Resize rows
|
||||
self.resize_rows()
|
||||
self.resize_rows_handler()
|
||||
|
||||
# Set next row if we are immediately under current row
|
||||
if set_next_row:
|
||||
self.get_base_model().set_next_row(set_next_row)
|
||||
self.get_base_model().set_next_row_handler(set_next_row)
|
||||
|
||||
event.accept()
|
||||
|
||||
@ -456,14 +453,21 @@ class PlaylistTab(QTableView):
|
||||
self, selected: QItemSelection, deselected: QItemSelection
|
||||
) -> None:
|
||||
"""
|
||||
Tell model which rows are selected.
|
||||
|
||||
Toggle drag behaviour according to whether rows are selected
|
||||
"""
|
||||
|
||||
selected_rows = self.get_selected_rows()
|
||||
self.musicmuster.current.selected_rows = selected_rows
|
||||
selected_row_numbers = self.get_selected_rows()
|
||||
|
||||
# Signal selected rows to model
|
||||
self.signals.signal_playlist_selected_rows.emit(
|
||||
SelectedRows(self.playlist_id, selected_row_numbers)
|
||||
)
|
||||
|
||||
# Put sum of selected tracks' duration in status bar
|
||||
# If no rows are selected, we have nothing to do
|
||||
if len(selected_rows) == 0:
|
||||
if len(selected_row_numbers) == 0:
|
||||
self.musicmuster.lblSumPlaytime.setText("")
|
||||
else:
|
||||
if not self.musicmuster.disable_selection_timing:
|
||||
@ -507,22 +511,16 @@ class PlaylistTab(QTableView):
|
||||
return menu_item
|
||||
|
||||
def _add_track(self) -> None:
|
||||
"""Add a track to a section header making it a normal track row"""
|
||||
"""
|
||||
Add a track to a section header making it a normal track row.
|
||||
"""
|
||||
|
||||
model_row_number = self.source_model_selected_row_number()
|
||||
if model_row_number is None:
|
||||
return
|
||||
|
||||
with db.Session() as session:
|
||||
dlg = TrackSelectDialog(
|
||||
parent=self.musicmuster,
|
||||
session=session,
|
||||
new_row_number=model_row_number,
|
||||
base_model=self.get_base_model(),
|
||||
add_to_header=True,
|
||||
)
|
||||
dlg.exec()
|
||||
session.commit()
|
||||
dlg = TrackInsertDialog(
|
||||
parent=self.musicmuster,
|
||||
playlist_id=self.playlist_id,
|
||||
add_to_header=True,
|
||||
)
|
||||
dlg.exec()
|
||||
|
||||
def _build_context_menu(self, item: QTableWidgetItem) -> None:
|
||||
"""Used to process context (right-click) menu, which is defined here"""
|
||||
@ -535,19 +533,21 @@ class PlaylistTab(QTableView):
|
||||
|
||||
header_row = self.get_base_model().is_header_row(model_row_number)
|
||||
track_row = not header_row
|
||||
if track_sequence.current:
|
||||
this_is_current_row = model_row_number == track_sequence.current.row_number
|
||||
if self.track_sequence.current:
|
||||
this_is_current_row = (
|
||||
model_row_number == self.track_sequence.current.row_number
|
||||
)
|
||||
else:
|
||||
this_is_current_row = False
|
||||
if track_sequence.next:
|
||||
this_is_next_row = model_row_number == track_sequence.next.row_number
|
||||
if self.track_sequence.next:
|
||||
this_is_next_row = model_row_number == self.track_sequence.next.row_number
|
||||
else:
|
||||
this_is_next_row = False
|
||||
track_path = base_model.get_row_info(model_row_number).path
|
||||
|
||||
# Open/import in/from Audacity
|
||||
if track_row and not this_is_current_row:
|
||||
if self.ac and track_path == self.ac.path:
|
||||
if track_row and not this_is_current_row and self.musicmuster.ac:
|
||||
if track_path == self.musicmuster.ac.path:
|
||||
# This track was opened in Audacity
|
||||
self._add_context_menu(
|
||||
"Update from Audacity",
|
||||
@ -568,7 +568,7 @@ class PlaylistTab(QTableView):
|
||||
"Rescan track", lambda: self._rescan(model_row_number)
|
||||
)
|
||||
self._add_context_menu("Mark for moving", lambda: self._mark_for_moving())
|
||||
if self.musicmuster.move_source_rows:
|
||||
if self.musicmuster.move_source:
|
||||
self._add_context_menu(
|
||||
"Move selected rows here", lambda: self._move_selected_rows()
|
||||
)
|
||||
@ -657,8 +657,8 @@ class PlaylistTab(QTableView):
|
||||
that we have an edit open.
|
||||
"""
|
||||
|
||||
if self.ac:
|
||||
self.ac.path = None
|
||||
if self.musicmuster.ac:
|
||||
self.musicmuster.ac.path = None
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
"""Unselect all tracks and reset drag mode"""
|
||||
@ -676,8 +676,6 @@ class PlaylistTab(QTableView):
|
||||
Called when column width changes. Save new width to database.
|
||||
"""
|
||||
|
||||
log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}")
|
||||
|
||||
header = self.horizontalHeader()
|
||||
if not header:
|
||||
return
|
||||
@ -685,11 +683,10 @@ class PlaylistTab(QTableView):
|
||||
# Resize rows if necessary
|
||||
self.resizeRowsToContents()
|
||||
|
||||
with db.Session() as session:
|
||||
attr_name = f"playlist_col_{column_number}_width"
|
||||
record = Settings.get_setting(session, attr_name)
|
||||
record.f_int = self.columnWidth(column_number)
|
||||
session.commit()
|
||||
# Save settings
|
||||
ds.setting_set(
|
||||
f"playlist_col_{column_number}_width", self.columnWidth(column_number)
|
||||
)
|
||||
|
||||
def _context_menu(self, pos):
|
||||
"""Display right-click menu"""
|
||||
@ -722,12 +719,19 @@ class PlaylistTab(QTableView):
|
||||
cb.clear(mode=cb.Mode.Clipboard)
|
||||
cb.setText(track_path, mode=cb.Mode.Clipboard)
|
||||
|
||||
def current_track_started(self) -> None:
|
||||
# @log_call
|
||||
def track_started_handler(self) -> None:
|
||||
"""
|
||||
Called when track starts playing
|
||||
"""
|
||||
|
||||
self.get_base_model().current_track_started()
|
||||
if self.track_sequence.current is None:
|
||||
return
|
||||
|
||||
if self.track_sequence.current.playlist_id != self.playlist_id:
|
||||
# Not for us
|
||||
return
|
||||
|
||||
# Scroll to current section if hide mode is by section
|
||||
if (
|
||||
self.musicmuster.hide_played_tracks
|
||||
@ -757,8 +761,8 @@ class PlaylistTab(QTableView):
|
||||
# Don't delete current or next tracks
|
||||
selected_row_numbers = self.selected_model_row_numbers()
|
||||
for ts in [
|
||||
track_sequence.next,
|
||||
track_sequence.current,
|
||||
self.track_sequence.next,
|
||||
self.track_sequence.current,
|
||||
]:
|
||||
if ts:
|
||||
if (
|
||||
@ -809,6 +813,7 @@ class PlaylistTab(QTableView):
|
||||
else:
|
||||
return TrackInfo(track_id, selected_row)
|
||||
|
||||
# @log_call
|
||||
def get_selected_row(self) -> Optional[int]:
|
||||
"""
|
||||
Return selected row number. If no rows or multiple rows selected, return None
|
||||
@ -820,6 +825,7 @@ class PlaylistTab(QTableView):
|
||||
else:
|
||||
return None
|
||||
|
||||
# @log_call
|
||||
def get_selected_rows(self) -> list[int]:
|
||||
"""Return a list of model-selected row numbers sorted by row"""
|
||||
|
||||
@ -830,8 +836,11 @@ class PlaylistTab(QTableView):
|
||||
if not selected_indexes:
|
||||
return []
|
||||
|
||||
return sorted(list(set([self.model().mapToSource(a).row() for a in selected_indexes])))
|
||||
return sorted(
|
||||
list(set([self.model().mapToSource(a).row() for a in selected_indexes]))
|
||||
)
|
||||
|
||||
# @log_call
|
||||
def get_top_visible_row(self) -> int:
|
||||
"""
|
||||
Get the viewport of the table view
|
||||
@ -860,10 +869,10 @@ class PlaylistTab(QTableView):
|
||||
Import current Audacity track to passed row
|
||||
"""
|
||||
|
||||
if not self.ac:
|
||||
if not self.musicmuster.ac:
|
||||
return
|
||||
try:
|
||||
self.ac.export()
|
||||
self.ac.musicmuster.export()
|
||||
self._rescan(row_number)
|
||||
except ApplicationError as e:
|
||||
show_warning(self.musicmuster, "Audacity error", str(e))
|
||||
@ -920,15 +929,16 @@ class PlaylistTab(QTableView):
|
||||
Open track in passed row in Audacity
|
||||
"""
|
||||
|
||||
if not self.musicmuster.ac:
|
||||
return
|
||||
|
||||
path = self.get_base_model().get_row_track_path(row_number)
|
||||
if not path:
|
||||
log.error(f"_open_in_audacity: can't get path for {row_number=}")
|
||||
return
|
||||
|
||||
try:
|
||||
if not self.ac:
|
||||
self.ac = AudacityController()
|
||||
self.ac.open(path)
|
||||
self.musicmuster.ac.open(path)
|
||||
except ApplicationError as e:
|
||||
show_warning(self.musicmuster, "Audacity error", str(e))
|
||||
|
||||
@ -949,13 +959,11 @@ class PlaylistTab(QTableView):
|
||||
self.get_base_model().rescan_track(row_number)
|
||||
self.clear_selection()
|
||||
|
||||
def resize_rows(self, playlist_id: Optional[int] = None) -> None:
|
||||
def resize_rows_handler(self, playlist_id: Optional[int] = None) -> None:
|
||||
"""
|
||||
If playlist_id is us, resize rows
|
||||
"""
|
||||
|
||||
log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}")
|
||||
|
||||
if playlist_id and playlist_id != self.playlist_id:
|
||||
return
|
||||
|
||||
@ -1002,6 +1010,7 @@ class PlaylistTab(QTableView):
|
||||
# Reset selection mode
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
|
||||
# @log_call
|
||||
def source_model_selected_row_number(self) -> Optional[int]:
|
||||
"""
|
||||
Return the model row number corresponding to the selected row or None
|
||||
@ -1012,6 +1021,7 @@ class PlaylistTab(QTableView):
|
||||
return None
|
||||
return self.model().mapToSource(selected_index).row()
|
||||
|
||||
# @log_call
|
||||
def selected_model_row_numbers(self) -> list[int]:
|
||||
"""
|
||||
Return a list of model row numbers corresponding to the selected rows or
|
||||
@ -1054,21 +1064,18 @@ class PlaylistTab(QTableView):
|
||||
def _set_column_widths(self) -> None:
|
||||
"""Column widths from settings"""
|
||||
|
||||
log.debug("_set_column_widths()")
|
||||
|
||||
header = self.horizontalHeader()
|
||||
if not header:
|
||||
return
|
||||
|
||||
# Last column is set to stretch so ignore it here
|
||||
with db.Session() as session:
|
||||
for column_number in range(header.count() - 1):
|
||||
attr_name = f"playlist_col_{column_number}_width"
|
||||
record = Settings.get_setting(session, attr_name)
|
||||
if record.f_int is not None:
|
||||
self.setColumnWidth(column_number, record.f_int)
|
||||
else:
|
||||
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
|
||||
for column_number in range(header.count() - 1):
|
||||
attr_name = f"playlist_col_{column_number}_width"
|
||||
value = ds.setting_get(attr_name)
|
||||
if value is not None:
|
||||
self.setColumnWidth(column_number, value)
|
||||
else:
|
||||
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
|
||||
|
||||
def set_row_as_next_track(self) -> None:
|
||||
"""
|
||||
@ -1079,10 +1086,10 @@ class PlaylistTab(QTableView):
|
||||
log.debug(f"set_row_as_next_track() {model_row_number=}")
|
||||
if model_row_number is None:
|
||||
return
|
||||
self.get_base_model().set_next_row(model_row_number)
|
||||
self.get_base_model().set_next_row_handler(model_row_number)
|
||||
self.clearSelection()
|
||||
|
||||
def _span_cells(
|
||||
def _span_cells_handler(
|
||||
self, playlist_id: int, row: int, column: int, rowSpan: int, columnSpan: int
|
||||
) -> None:
|
||||
"""
|
||||
@ -1118,16 +1125,18 @@ class PlaylistTab(QTableView):
|
||||
"""
|
||||
|
||||
# Update musicmuster
|
||||
self.musicmuster.current.playlist_id = self.playlist_id
|
||||
self.musicmuster.current.selected_rows = self.get_selected_rows()
|
||||
self.musicmuster.current.base_model = self.get_base_model()
|
||||
self.musicmuster.current.proxy_model = self.model()
|
||||
self.musicmuster.update_current(
|
||||
base_model=self.get_base_model(),
|
||||
proxy_model=self.model(),
|
||||
playlist_id=self.playlist_id,
|
||||
selected_row_numbers=self.get_selected_rows(),
|
||||
)
|
||||
|
||||
self.resize_rows()
|
||||
self.resize_rows_handler()
|
||||
|
||||
def _unmark_as_next(self) -> None:
|
||||
"""Rescan track"""
|
||||
|
||||
track_sequence.set_next(None)
|
||||
self.track_sequence.set_next(None)
|
||||
self.clear_selection()
|
||||
self.signals.next_track_changed_signal.emit()
|
||||
self.signals.signal_set_next_track.emit(None)
|
||||
|
||||
@ -21,7 +21,6 @@ from PyQt6.QtGui import (
|
||||
)
|
||||
|
||||
# Third party imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# import snoop # type: ignore
|
||||
|
||||
@ -38,9 +37,9 @@ from helpers import (
|
||||
ms_to_mmss,
|
||||
show_warning,
|
||||
)
|
||||
from log import log
|
||||
from models import db, Playdates, Tracks
|
||||
from music_manager import RowAndTrack
|
||||
from log import log, log_call
|
||||
from playlistrow import PlaylistRow
|
||||
import ds
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -64,7 +63,7 @@ class QuerylistModel(QAbstractTableModel):
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, session: Session, filter: Filter) -> None:
|
||||
def __init__(self, filter: Filter) -> None:
|
||||
"""
|
||||
Load query
|
||||
"""
|
||||
@ -72,7 +71,6 @@ class QuerylistModel(QAbstractTableModel):
|
||||
log.debug(f"QuerylistModel.__init__({filter=})")
|
||||
|
||||
super().__init__()
|
||||
self.session = session
|
||||
self.filter = filter
|
||||
|
||||
self.querylist_rows: dict[int, QueryRow] = {}
|
||||
@ -136,7 +134,7 @@ class QuerylistModel(QAbstractTableModel):
|
||||
|
||||
row = index.row()
|
||||
column = index.column()
|
||||
# rat for playlist row data as it's used a lot
|
||||
# plr for playlist row data as it's used a lot
|
||||
qrow = self.querylist_rows[row]
|
||||
|
||||
# Dispatch to role-specific functions
|
||||
@ -230,21 +228,16 @@ class QuerylistModel(QAbstractTableModel):
|
||||
row = 0
|
||||
|
||||
try:
|
||||
results = Tracks.get_filtered_tracks(self.session, self.filter)
|
||||
results = ds.tracks_filtered(self.filter)
|
||||
for result in results:
|
||||
lastplayed = None
|
||||
if hasattr(result, "playdates"):
|
||||
pds = result.playdates
|
||||
if pds:
|
||||
lastplayed = max([a.lastplayed for a in pds])
|
||||
queryrow = QueryRow(
|
||||
artist=result.artist,
|
||||
bitrate=result.bitrate or 0,
|
||||
duration=result.duration,
|
||||
lastplayed=lastplayed,
|
||||
lastplayed=result.lastplayed,
|
||||
path=result.path,
|
||||
title=result.title,
|
||||
track_id=result.id,
|
||||
track_id=result.track_id,
|
||||
)
|
||||
|
||||
self.querylist_rows[row] = queryrow
|
||||
@ -268,23 +261,14 @@ class QuerylistModel(QAbstractTableModel):
|
||||
bottom_right = self.index(row, self.columnCount() - 1)
|
||||
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
|
||||
|
||||
def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str | QVariant:
|
||||
def _tooltip_role(self, row: int, column: int, plr: PlaylistRow) -> str | QVariant:
|
||||
"""
|
||||
Return tooltip. Currently only used for last_played column.
|
||||
"""
|
||||
|
||||
if column != QueryCol.LAST_PLAYED.value:
|
||||
return QVariant()
|
||||
with db.Session() as session:
|
||||
track_id = self.querylist_rows[row].track_id
|
||||
if not track_id:
|
||||
return QVariant()
|
||||
playdates = Playdates.last_playdates(session, track_id)
|
||||
return (
|
||||
"<br>".join(
|
||||
[
|
||||
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
|
||||
for a in reversed(playdates)
|
||||
]
|
||||
)
|
||||
)
|
||||
track_id = self.querylist_rows[row].track_id
|
||||
if not track_id:
|
||||
return QVariant()
|
||||
return ds.playdates_get_last(track_id)
|
||||
|
||||
@ -1,131 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Dialog</class>
|
||||
<widget class="QDialog" name="Dialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>584</width>
|
||||
<height>377</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Dialog</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Title:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="searchString"/>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QListWidget" name="matchList"/>
|
||||
</item>
|
||||
<item row="2" column="0" colspan="2">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="lblNote">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>46</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>&Note:</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>txtNote</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="txtNote"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="2">
|
||||
<widget class="QLabel" name="dbPath">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0" colspan="2">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioTitle">
|
||||
<property name="text">
|
||||
<string>&Title</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioArtist">
|
||||
<property name="text">
|
||||
<string>&Artist</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="btnAdd">
|
||||
<property name="text">
|
||||
<string>&Add</string>
|
||||
</property>
|
||||
<property name="default">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="btnAddClose">
|
||||
<property name="text">
|
||||
<string>A&dd and close</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="btnClose">
|
||||
<property name="text">
|
||||
<string>&Close</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@ -1,83 +0,0 @@
|
||||
# Form implementation generated from reading ui file 'dlg_TrackSelect.ui'
|
||||
#
|
||||
# Created by: PyQt6 UI code generator 6.5.3
|
||||
#
|
||||
# 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.
|
||||
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
|
||||
class Ui_Dialog(object):
|
||||
def setupUi(self, Dialog):
|
||||
Dialog.setObjectName("Dialog")
|
||||
Dialog.resize(584, 377)
|
||||
self.gridLayout = QtWidgets.QGridLayout(Dialog)
|
||||
self.gridLayout.setObjectName("gridLayout")
|
||||
self.label = QtWidgets.QLabel(parent=Dialog)
|
||||
self.label.setObjectName("label")
|
||||
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
|
||||
self.searchString = QtWidgets.QLineEdit(parent=Dialog)
|
||||
self.searchString.setObjectName("searchString")
|
||||
self.gridLayout.addWidget(self.searchString, 0, 1, 1, 1)
|
||||
self.matchList = QtWidgets.QListWidget(parent=Dialog)
|
||||
self.matchList.setObjectName("matchList")
|
||||
self.gridLayout.addWidget(self.matchList, 1, 0, 1, 2)
|
||||
self.horizontalLayout = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||
self.lblNote = QtWidgets.QLabel(parent=Dialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.lblNote.sizePolicy().hasHeightForWidth())
|
||||
self.lblNote.setSizePolicy(sizePolicy)
|
||||
self.lblNote.setMaximumSize(QtCore.QSize(46, 16777215))
|
||||
self.lblNote.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
|
||||
self.lblNote.setObjectName("lblNote")
|
||||
self.horizontalLayout.addWidget(self.lblNote)
|
||||
self.txtNote = QtWidgets.QLineEdit(parent=Dialog)
|
||||
self.txtNote.setObjectName("txtNote")
|
||||
self.horizontalLayout.addWidget(self.txtNote)
|
||||
self.gridLayout.addLayout(self.horizontalLayout, 2, 0, 1, 2)
|
||||
self.dbPath = QtWidgets.QLabel(parent=Dialog)
|
||||
self.dbPath.setText("")
|
||||
self.dbPath.setObjectName("dbPath")
|
||||
self.gridLayout.addWidget(self.dbPath, 3, 0, 1, 2)
|
||||
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
||||
self.radioTitle = QtWidgets.QRadioButton(parent=Dialog)
|
||||
self.radioTitle.setChecked(True)
|
||||
self.radioTitle.setObjectName("radioTitle")
|
||||
self.horizontalLayout_2.addWidget(self.radioTitle)
|
||||
self.radioArtist = QtWidgets.QRadioButton(parent=Dialog)
|
||||
self.radioArtist.setObjectName("radioArtist")
|
||||
self.horizontalLayout_2.addWidget(self.radioArtist)
|
||||
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
|
||||
self.horizontalLayout_2.addItem(spacerItem)
|
||||
self.btnAdd = QtWidgets.QPushButton(parent=Dialog)
|
||||
self.btnAdd.setDefault(True)
|
||||
self.btnAdd.setObjectName("btnAdd")
|
||||
self.horizontalLayout_2.addWidget(self.btnAdd)
|
||||
self.btnAddClose = QtWidgets.QPushButton(parent=Dialog)
|
||||
self.btnAddClose.setObjectName("btnAddClose")
|
||||
self.horizontalLayout_2.addWidget(self.btnAddClose)
|
||||
self.btnClose = QtWidgets.QPushButton(parent=Dialog)
|
||||
self.btnClose.setObjectName("btnClose")
|
||||
self.horizontalLayout_2.addWidget(self.btnClose)
|
||||
self.gridLayout.addLayout(self.horizontalLayout_2, 4, 0, 1, 2)
|
||||
self.lblNote.setBuddy(self.txtNote)
|
||||
|
||||
self.retranslateUi(Dialog)
|
||||
QtCore.QMetaObject.connectSlotsByName(Dialog)
|
||||
|
||||
def retranslateUi(self, Dialog):
|
||||
_translate = QtCore.QCoreApplication.translate
|
||||
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
|
||||
self.label.setText(_translate("Dialog", "Title:"))
|
||||
self.lblNote.setText(_translate("Dialog", "&Note:"))
|
||||
self.radioTitle.setText(_translate("Dialog", "&Title"))
|
||||
self.radioArtist.setText(_translate("Dialog", "&Artist"))
|
||||
self.btnAdd.setText(_translate("Dialog", "&Add"))
|
||||
self.btnAddClose.setText(_translate("Dialog", "A&dd and close"))
|
||||
self.btnClose.setText(_translate("Dialog", "&Close"))
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@ import os
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
from config import Config
|
||||
@ -13,10 +12,10 @@ from helpers import (
|
||||
get_tags,
|
||||
)
|
||||
from log import log
|
||||
from models import Tracks
|
||||
import ds
|
||||
|
||||
|
||||
def check_db(session: Session) -> None:
|
||||
def check_db() -> None:
|
||||
"""
|
||||
Database consistency check.
|
||||
|
||||
@ -27,7 +26,7 @@ def check_db(session: Session) -> None:
|
||||
Check all paths in database exist
|
||||
"""
|
||||
|
||||
db_paths = set([a.path for a in Tracks.get_all(session)])
|
||||
db_paths = set([a.path for a in ds.tracks_all()])
|
||||
|
||||
os_paths_list = []
|
||||
for root, _dirs, files in os.walk(Config.ROOT):
|
||||
@ -52,7 +51,7 @@ def check_db(session: Session) -> None:
|
||||
|
||||
missing_file_count += 1
|
||||
|
||||
track = Tracks.get_by_path(session, path)
|
||||
track = ds.track_by_path(path)
|
||||
if not track:
|
||||
# This shouldn't happen as we're looking for paths in
|
||||
# database that aren't in filesystem, but just in case...
|
||||
@ -74,7 +73,7 @@ def check_db(session: Session) -> None:
|
||||
for t in paths_not_found:
|
||||
print(
|
||||
f"""
|
||||
Track ID: {t.id}
|
||||
Track ID: {t.track_id}
|
||||
Path: {t.path}
|
||||
Title: {t.title}
|
||||
Artist: {t.artist}
|
||||
@ -84,14 +83,14 @@ def check_db(session: Session) -> None:
|
||||
print("There were more paths than listed that were not found")
|
||||
|
||||
|
||||
def update_bitrates(session: Session) -> None:
|
||||
def update_bitrates() -> None:
|
||||
"""
|
||||
Update bitrates on all tracks in database
|
||||
"""
|
||||
|
||||
for track in Tracks.get_all(session):
|
||||
for track in ds.tracks_all():
|
||||
try:
|
||||
t = get_tags(track.path)
|
||||
track.bitrate = t.bitrate
|
||||
ds.track_update(track.track_id, t._asdict())
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
# Standard library imports
|
||||
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
import vlc # type: ignore
|
||||
|
||||
# App imports
|
||||
|
||||
|
||||
class VLCManager:
|
||||
"""
|
||||
Singleton class to ensure we only ever have one vlc Instance
|
||||
"""
|
||||
|
||||
__instance = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
if VLCManager.__instance is None:
|
||||
self.vlc_instance = vlc.Instance()
|
||||
VLCManager.__instance = self
|
||||
else:
|
||||
raise Exception("Attempted to create a second VLCManager instance")
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> vlc.Instance:
|
||||
if VLCManager.__instance is None:
|
||||
VLCManager()
|
||||
return VLCManager.__instance
|
||||
@ -2,25 +2,26 @@ from importlib import import_module
|
||||
from alembic import context
|
||||
from alchemical.alembic.env import run_migrations
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
# Load Alembic configuration
|
||||
config = context.config
|
||||
|
||||
# import the application's Alchemical instance
|
||||
try:
|
||||
import_mod, db_name = config.get_main_option('alchemical_db', '').split(
|
||||
':')
|
||||
# Import the Alchemical database instance as specified in alembic.ini
|
||||
import_mod, db_name = config.get_main_option('alchemical_db', '').split(':')
|
||||
db = getattr(import_module(import_mod), db_name)
|
||||
except (ModuleNotFoundError, AttributeError):
|
||||
raise ValueError(
|
||||
'Could not import the Alchemical database instance. '
|
||||
'Ensure that the alchemical_db setting in alembic.ini is correct.'
|
||||
)
|
||||
print(f"Successfully loaded Alchemical database instance: {db}")
|
||||
|
||||
# run the migration engine
|
||||
# The dictionary provided as second argument includes options to pass to the
|
||||
# Alembic context. For details on what other options are available, see
|
||||
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html
|
||||
# Use the metadata associated with the Alchemical instance
|
||||
metadata = db.Model.metadata
|
||||
print(f"Metadata tables detected: {metadata.tables.keys()}") # Debug output
|
||||
except (ModuleNotFoundError, AttributeError) as e:
|
||||
raise ValueError(
|
||||
'Could not import the Alchemical database instance or access metadata. '
|
||||
'Ensure that the alchemical_db setting in alembic.ini is correct and '
|
||||
'that the Alchemical instance is correctly configured.'
|
||||
) from e
|
||||
|
||||
# Run migrations with metadata
|
||||
run_migrations(db, {
|
||||
'render_as_batch': True,
|
||||
'compare_type': True,
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
"""notes substrings, indexing, playlist faviourites, bitrate not null
|
||||
|
||||
Revision ID: 6d36cde8dea0
|
||||
Revises: 4fc2a9a82ab0
|
||||
Create Date: 2025-04-22 17:03:00.497945
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6d36cde8dea0'
|
||||
down_revision = '4fc2a9a82ab0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade(engine_name: str) -> None:
|
||||
globals()["upgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
def downgrade(engine_name: str) -> None:
|
||||
globals()["downgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def upgrade_() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('notecolours', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('strip_substring', sa.Boolean(), nullable=False))
|
||||
batch_op.create_index(batch_op.f('ix_notecolours_substring'), ['substring'], unique=True)
|
||||
|
||||
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_playlist_rows_playlist_id'), ['playlist_id'], unique=False)
|
||||
|
||||
with op.batch_alter_table('playlists', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('favourite', sa.Boolean(), nullable=False))
|
||||
|
||||
with op.batch_alter_table('tracks', schema=None) as batch_op:
|
||||
batch_op.alter_column('bitrate',
|
||||
existing_type=mysql.INTEGER(display_width=11),
|
||||
nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade_() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('tracks', schema=None) as batch_op:
|
||||
batch_op.alter_column('bitrate',
|
||||
existing_type=mysql.INTEGER(display_width=11),
|
||||
nullable=True)
|
||||
|
||||
with op.batch_alter_table('playlists', schema=None) as batch_op:
|
||||
batch_op.drop_column('favourite')
|
||||
|
||||
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_playlist_rows_playlist_id'))
|
||||
|
||||
with op.batch_alter_table('notecolours', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_notecolours_substring'))
|
||||
batch_op.drop_column('strip_substring')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
"""Have id field reflect table name
|
||||
|
||||
Revision ID: 8e06d465923a
|
||||
Revises: 6d36cde8dea0
|
||||
Create Date: 2025-04-22 13:23:18.813024
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableInfo:
|
||||
table: str
|
||||
old: str
|
||||
new: str
|
||||
|
||||
|
||||
data = [
|
||||
TableInfo("notecolours", "id", "notecolour_id"),
|
||||
TableInfo("playdates", "id", "playdate_id"),
|
||||
TableInfo("playlists", "id", "playlist_id"),
|
||||
TableInfo("playlist_rows", "id", "playlistrow_id"),
|
||||
TableInfo("queries", "id", "query_id"),
|
||||
TableInfo("settings", "id", "setting_id"),
|
||||
TableInfo("tracks", "id", "track_id"),
|
||||
]
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8e06d465923a'
|
||||
down_revision = '6d36cde8dea0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade(engine_name: str) -> None:
|
||||
globals()["upgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
def downgrade(engine_name: str) -> None:
|
||||
globals()["downgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
def upgrade_() -> None:
|
||||
# Drop foreign key constraints
|
||||
op.drop_constraint('fk_playdates_track_id_tracks', 'playdates', type_='foreignkey')
|
||||
op.drop_constraint('fk_playlist_rows_track_id_tracks', 'playlist_rows', type_='foreignkey')
|
||||
|
||||
for record in data:
|
||||
op.alter_column(
|
||||
record.table,
|
||||
record.old,
|
||||
new_column_name=record.new,
|
||||
existing_type=sa.Integer(), # Specify the existing column type
|
||||
existing_nullable=False # If the column is NOT NULL, specify that too
|
||||
)
|
||||
|
||||
|
||||
# Recreate the foreign key constraints
|
||||
op.create_foreign_key('fk_playdates_track_id_tracks', 'playdates', 'tracks', ['track_id'], ['track_id'])
|
||||
op.create_foreign_key('fk_playlist_rows_track_id_tracks', 'playlist_rows', 'tracks', ['track_id'], ['track_id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade_() -> None:
|
||||
# Drop foreign key constraints
|
||||
op.drop_constraint('fk_playdates_track_id_tracks', 'playdates', type_='foreignkey')
|
||||
op.drop_constraint('fk_playlist_rows_track_id_tracks', 'playlist_rows', type_='foreignkey')
|
||||
|
||||
for record in data:
|
||||
op.alter_column(
|
||||
record.table,
|
||||
record.new,
|
||||
new_column_name=record.old,
|
||||
existing_type=sa.Integer(), # Specify the existing column type
|
||||
existing_nullable=False # If the column is NOT NULL, specify that too
|
||||
)
|
||||
|
||||
# Recreate the foreign key constraints
|
||||
op.create_foreign_key('fk_playdates_track_id_tracks', 'playdates', 'tracks', ['track_id'], ['track_id'])
|
||||
op.create_foreign_key('fk_playlist_rows_track_id_tracks', 'playlist_rows', 'tracks', ['track_id'], ['track_id'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@ -33,6 +33,8 @@ dependencies = [
|
||||
"types-pyyaml>=6.0.12.20241230",
|
||||
"dogpile-cache>=1.3.4",
|
||||
"pdbpp>=0.10.3",
|
||||
"filetype>=1.2.0",
|
||||
"black>=25.1.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@ -63,6 +65,9 @@ python_version = 3.11
|
||||
warn_unused_configs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
[tool.pylsp.plugins.pycodestyle]
|
||||
maxLineLength = 88
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--exitfirst --showlocals --capture=no"
|
||||
pythonpath = [".", "app"]
|
||||
|
||||
40
tests/template_test_harness.py
Normal file
40
tests/template_test_harness.py
Normal file
@ -0,0 +1,40 @@
|
||||
# Standard library imports
|
||||
import unittest
|
||||
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
|
||||
# App imports
|
||||
from app.models import (
|
||||
db,
|
||||
)
|
||||
|
||||
|
||||
class MyTestCase(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Runs once before any test in this class"""
|
||||
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Runs once after all tests"""
|
||||
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
"""Runs before each test"""
|
||||
|
||||
db.create_all()
|
||||
|
||||
def tearDown(self):
|
||||
"""Runs after each test"""
|
||||
|
||||
db.drop_all()
|
||||
|
||||
def test_xxx(self):
|
||||
"""Comment"""
|
||||
|
||||
pass
|
||||
297
tests/test_ds.py
Normal file
297
tests/test_ds.py
Normal file
@ -0,0 +1,297 @@
|
||||
# Standard library imports
|
||||
import unittest
|
||||
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
|
||||
# App imports
|
||||
from app import playlistmodel
|
||||
from app import ds
|
||||
from classes import PlaylistDTO
|
||||
from helpers import get_all_track_metadata
|
||||
from playlistmodel import PlaylistModel
|
||||
|
||||
|
||||
class MyTestCase(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Runs once before any test in this class"""
|
||||
|
||||
cls.isa_path = "testdata/isa.mp3"
|
||||
cls.isa_title = "I'm So Afraid"
|
||||
cls.isa_artist = "Fleetwood Mac"
|
||||
cls.mom_path = "testdata/mom.mp3"
|
||||
cls.mom_title = "Man of Mystery"
|
||||
cls.mom_artist = "The Shadows"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Runs once after all tests"""
|
||||
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
"""Runs before each test"""
|
||||
|
||||
ds.db.create_all()
|
||||
|
||||
def playlist_create_and_model(
|
||||
self, playlist_name: str
|
||||
) -> (PlaylistDTO, PlaylistModel):
|
||||
# Create a playlist and model
|
||||
playlist = ds.playlist_create(name=playlist_name, template_id=0)
|
||||
assert playlist
|
||||
model = playlistmodel.PlaylistModel(playlist.playlist_id, is_template=False)
|
||||
assert model
|
||||
|
||||
return (playlist, model)
|
||||
|
||||
def playlist_create_model_tracks(self, playlist_name: str):
|
||||
(playlist, model) = self.playlist_create_and_model(playlist_name)
|
||||
# Create tracks
|
||||
metadata1 = get_all_track_metadata(self.isa_path)
|
||||
self.track1 = ds.track_create(metadata1)
|
||||
|
||||
metadata2 = get_all_track_metadata(self.mom_path)
|
||||
self.track2 = ds.track_create(metadata2)
|
||||
|
||||
# Add tracks and header to playlist
|
||||
self.row0 = ds.playlist_insert_row(
|
||||
playlist.playlist_id,
|
||||
row_number=0,
|
||||
track_id=self.track1.track_id,
|
||||
note="track 1",
|
||||
)
|
||||
self.row1 = ds.playlist_insert_row(
|
||||
playlist.playlist_id,
|
||||
row_number=1,
|
||||
track_id=0,
|
||||
note="Header row",
|
||||
)
|
||||
self.row2 = ds.playlist_insert_row(
|
||||
playlist.playlist_id,
|
||||
row_number=2,
|
||||
track_id=self.track2.track_id,
|
||||
note="track 2",
|
||||
)
|
||||
|
||||
def create_rows(
|
||||
self, playlist_name: str, number_of_rows: int
|
||||
) -> (PlaylistDTO, PlaylistModel):
|
||||
(playlist, model) = self.playlist_create_and_model(playlist_name)
|
||||
for row_number in range(number_of_rows):
|
||||
ds.playlist_insert_row(
|
||||
playlist.playlist_id, row_number, None, str(row_number)
|
||||
)
|
||||
|
||||
return (playlist, model)
|
||||
|
||||
def tearDown(self):
|
||||
"""Runs after each test"""
|
||||
|
||||
ds.db.drop_all()
|
||||
|
||||
def test_add_track_to_header(self):
|
||||
"""Add a track to a header row"""
|
||||
|
||||
self.playlist_create_model_tracks("my playlist")
|
||||
ds.track_add_to_header(self.row1.playlistrow_id, self.track2.track_id)
|
||||
result = ds.playlistrow_by_id(self.row1.playlistrow_id)
|
||||
assert result.track.track_id == self.track2.track_id
|
||||
|
||||
def test_track_create(self):
|
||||
metadata = get_all_track_metadata(self.isa_path)
|
||||
ds.track_create(metadata)
|
||||
results = ds.tracks_all()
|
||||
assert len(results) == 1
|
||||
assert results[0].path == self.isa_path
|
||||
|
||||
def test_get_track_by_id(self):
|
||||
metadata = get_all_track_metadata(self.isa_path)
|
||||
dto = ds.track_create(metadata)
|
||||
result = ds.track_by_id(dto.track_id)
|
||||
assert result.path == self.isa_path
|
||||
|
||||
def test_get_track_by_artist(self):
|
||||
metadata = get_all_track_metadata(self.isa_path)
|
||||
_ = ds.track_create(metadata)
|
||||
metadata = get_all_track_metadata(self.mom_path)
|
||||
_ = ds.track_create(metadata)
|
||||
result_isa = ds.tracks_by_artist(self.isa_artist)
|
||||
assert len(result_isa) == 1
|
||||
assert result_isa[0].artist == self.isa_artist
|
||||
result_mom = ds.tracks_by_artist(self.mom_artist)
|
||||
assert len(result_mom) == 1
|
||||
assert result_mom[0].artist == self.mom_artist
|
||||
|
||||
def test_get_track_by_title(self):
|
||||
metadata_isa = get_all_track_metadata(self.isa_path)
|
||||
_ = ds.track_create(metadata_isa)
|
||||
metadata_mom = get_all_track_metadata(self.mom_path)
|
||||
_ = ds.track_create(metadata_mom)
|
||||
result_isa = ds.tracks_by_title(self.isa_title)
|
||||
assert len(result_isa) == 1
|
||||
assert result_isa[0].title == self.isa_title
|
||||
result_mom = ds.tracks_by_title(self.mom_title)
|
||||
assert len(result_mom) == 1
|
||||
assert result_mom[0].title == self.mom_title
|
||||
|
||||
def test_tracks_get_all_tracks(self):
|
||||
self.playlist_create_model_tracks(playlist_name="test_track_get_all_tracks")
|
||||
all_tracks = ds.tracks_all()
|
||||
assert len(all_tracks) == 2
|
||||
|
||||
def test_tracks_by_path(self):
|
||||
metadata_isa = get_all_track_metadata(self.isa_path)
|
||||
_ = ds.track_create(metadata_isa)
|
||||
metadata_mom = get_all_track_metadata(self.mom_path)
|
||||
_ = ds.track_create(metadata_mom)
|
||||
result_isa = ds.track_by_path(self.isa_path)
|
||||
assert result_isa.title == self.isa_title
|
||||
result_mom = ds.track_by_path(self.mom_path)
|
||||
assert result_mom.title == self.mom_title
|
||||
|
||||
def test_move_rows_test1(self):
|
||||
# move row 3 to row 5
|
||||
|
||||
number_of_rows = 10
|
||||
(playlist, model) = self.create_rows("test_move_rows_test1", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows([3], playlist.playlist_id, 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in ds.playlistrows_by_playlist(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 4, 3, 5, 6, 7, 8, 9]
|
||||
|
||||
def test_move_rows_test2(self):
|
||||
# move row 4 to row 3
|
||||
|
||||
number_of_rows = 10
|
||||
(playlist, model) = self.create_rows("test_move_rows_test2", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows([4], playlist.playlist_id, 3)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in ds.playlistrows_by_playlist(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 4, 3, 5, 6, 7, 8, 9]
|
||||
|
||||
def test_move_rows_test3(self):
|
||||
# move row 4 to row 2
|
||||
|
||||
number_of_rows = 10
|
||||
(playlist, model) = self.create_rows("test_move_rows_test3", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows([4], playlist.playlist_id, 2)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in ds.playlistrows_by_playlist(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 4, 2, 3, 5, 6, 7, 8, 9]
|
||||
|
||||
def test_move_rows_test4(self):
|
||||
# move rows [1, 4, 5, 10] → 8
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test4", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows([1, 4, 5, 10], playlist.playlist_id, 8)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in ds.playlistrows_by_playlist(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9]
|
||||
|
||||
def test_move_rows_test5(self):
|
||||
# move rows [3, 6] → 5
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test5", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows([3, 6], playlist.playlist_id, 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in ds.playlistrows_by_playlist(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10]
|
||||
|
||||
def test_move_rows_test6(self):
|
||||
# move rows [3, 5, 6] → 8
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test6", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows([3, 5, 6], playlist.playlist_id, 8)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in ds.playlistrows_by_playlist(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10]
|
||||
|
||||
def test_move_rows_test7(self):
|
||||
# move rows [7, 8, 10] → 5
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test7", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows([7, 8, 10], playlist.playlist_id, 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in ds.playlistrows_by_playlist(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
|
||||
|
||||
def test_move_rows_test8(self):
|
||||
# move rows [1, 2, 3] → 0
|
||||
# Replicate issue 244
|
||||
|
||||
number_of_rows = 11
|
||||
(playlist, model) = self.create_rows("test_move_rows_test8", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows([1, 2, 3], playlist.playlist_id, 0)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in ds.playlistrows_by_playlist(playlist.playlist_id):
|
||||
new_order.append(int(row.note))
|
||||
assert new_order == [1, 2, 3, 0, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
def test_move_rows_to_playlist(self):
|
||||
number_of_rows = 11
|
||||
rows_to_move = [2, 4, 6]
|
||||
to_row = 5
|
||||
|
||||
(playlist_src, model_src) = self.create_rows("src playlist", number_of_rows)
|
||||
(playlist_dst, model_dst) = self.create_rows("dst playlist", number_of_rows)
|
||||
|
||||
ds.playlist_move_rows(
|
||||
rows_to_move, playlist_src.playlist_id, to_row, playlist_dst.playlist_id
|
||||
)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order_src = []
|
||||
for row in ds.playlistrows_by_playlist(playlist_src.playlist_id):
|
||||
new_order_src.append(int(row.note))
|
||||
assert new_order_src == [0, 1, 3, 5, 7, 8, 9, 10]
|
||||
new_order_dst = []
|
||||
for row in ds.playlistrows_by_playlist(playlist_dst.playlist_id):
|
||||
new_order_dst.append(int(row.note))
|
||||
assert new_order_dst == [0, 1, 2, 3, 4, 2, 4, 6, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
def test_remove_rows(self):
|
||||
pass
|
||||
|
||||
def test_get_playlist_by_id(self):
|
||||
pass
|
||||
|
||||
def test_settings(self):
|
||||
pass
|
||||
@ -20,12 +20,7 @@ import pytest
|
||||
from pytestqt.plugin import QtBot # type: ignore
|
||||
|
||||
# App imports
|
||||
from app import musicmuster
|
||||
from app.models import (
|
||||
db,
|
||||
Playlists,
|
||||
Tracks,
|
||||
)
|
||||
from app import ds, musicmuster
|
||||
from config import Config
|
||||
from file_importer import FileImporter
|
||||
|
||||
@ -50,15 +45,14 @@ class MyTestCase(unittest.TestCase):
|
||||
def setUpClass(cls):
|
||||
"""Runs once before any test in this class"""
|
||||
|
||||
db.create_all()
|
||||
ds.db.create_all()
|
||||
|
||||
cls.widget = musicmuster.Window()
|
||||
|
||||
# Create a playlist for all tests
|
||||
playlist_name = "file importer playlist"
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session=session, name=playlist_name, template_id=0)
|
||||
cls.widget._open_playlist(playlist)
|
||||
playlist = ds.playlist_create(name=playlist_name, template_id=0)
|
||||
cls.widget._open_playlist(playlist)
|
||||
|
||||
# Create our musicstore
|
||||
cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp")
|
||||
@ -70,7 +64,7 @@ class MyTestCase(unittest.TestCase):
|
||||
def tearDownClass(cls):
|
||||
"""Runs once after all tests"""
|
||||
|
||||
db.drop_all()
|
||||
ds.db.drop_all()
|
||||
shutil.rmtree(cls.musicstore)
|
||||
shutil.rmtree(cls.import_source)
|
||||
|
||||
@ -84,7 +78,8 @@ class MyTestCase(unittest.TestCase):
|
||||
"""Runs after each test"""
|
||||
self.widget.close() # Close UI to prevent side effects
|
||||
|
||||
def wait_for_workers(self, timeout: int = 10000):
|
||||
# def wait_for_workers(self, timeout: int = 10000):
|
||||
def wait_for_workers(self, timeout: int = 1000000):
|
||||
"""
|
||||
Let import threads workers run to completion
|
||||
"""
|
||||
@ -176,18 +171,15 @@ class MyTestCase(unittest.TestCase):
|
||||
self.wait_for_workers()
|
||||
|
||||
# Check track was imported
|
||||
with db.Session() as session:
|
||||
tracks = Tracks.get_all(session)
|
||||
assert len(tracks) == 1
|
||||
track = tracks[0]
|
||||
assert track.title == "I'm So Afraid"
|
||||
assert track.artist == "Fleetwood Mac"
|
||||
track_file = os.path.join(
|
||||
self.musicstore, os.path.basename(test_track_path)
|
||||
)
|
||||
assert track.path == track_file
|
||||
assert os.path.exists(track_file)
|
||||
assert os.listdir(self.import_source) == []
|
||||
tracks = ds.tracks_all()
|
||||
assert len(tracks) == 1
|
||||
track = tracks[0]
|
||||
assert track.title == "I'm So Afraid"
|
||||
assert track.artist == "Fleetwood Mac"
|
||||
track_file = os.path.join(self.musicstore, os.path.basename(test_track_path))
|
||||
assert track.path == track_file
|
||||
assert os.path.exists(track_file)
|
||||
assert os.listdir(self.import_source) == []
|
||||
|
||||
def test_004_import_second_file(self):
|
||||
"""Import a second file"""
|
||||
@ -222,18 +214,15 @@ class MyTestCase(unittest.TestCase):
|
||||
self.wait_for_workers()
|
||||
|
||||
# Check track was imported
|
||||
with db.Session() as session:
|
||||
tracks = Tracks.get_all(session)
|
||||
assert len(tracks) == 2
|
||||
track = tracks[1]
|
||||
assert track.title == "The Lovecats"
|
||||
assert track.artist == "The Cure"
|
||||
track_file = os.path.join(
|
||||
self.musicstore, os.path.basename(test_track_path)
|
||||
)
|
||||
assert track.path == track_file
|
||||
assert os.path.exists(track_file)
|
||||
assert os.listdir(self.import_source) == []
|
||||
tracks = ds.tracks_all()
|
||||
assert len(tracks) == 2
|
||||
track = tracks[1]
|
||||
assert track.title == "The Lovecats"
|
||||
assert track.artist == "The Cure"
|
||||
track_file = os.path.join(self.musicstore, os.path.basename(test_track_path))
|
||||
assert track.path == track_file
|
||||
assert os.path.exists(track_file)
|
||||
assert os.listdir(self.import_source) == []
|
||||
|
||||
def test_005_replace_file(self):
|
||||
"""Import the same file again and update existing track"""
|
||||
@ -275,19 +264,16 @@ class MyTestCase(unittest.TestCase):
|
||||
self.wait_for_workers()
|
||||
|
||||
# Check track was imported
|
||||
with db.Session() as session:
|
||||
tracks = Tracks.get_all(session)
|
||||
assert len(tracks) == 2
|
||||
track = tracks[1]
|
||||
assert track.title == "The Lovecats"
|
||||
assert track.artist == "The Cure"
|
||||
assert track.id == 2
|
||||
track_file = os.path.join(
|
||||
self.musicstore, os.path.basename(test_track_path)
|
||||
)
|
||||
assert track.path == track_file
|
||||
assert os.path.exists(track_file)
|
||||
assert os.listdir(self.import_source) == []
|
||||
tracks = ds.tracks_all()
|
||||
assert len(tracks) == 2
|
||||
track = tracks[1]
|
||||
assert track.title == "The Lovecats"
|
||||
assert track.artist == "The Cure"
|
||||
assert track.track_id == 2
|
||||
track_file = os.path.join(self.musicstore, os.path.basename(test_track_path))
|
||||
assert track.path == track_file
|
||||
assert os.path.exists(track_file)
|
||||
assert os.listdir(self.import_source) == []
|
||||
|
||||
def test_006_import_file_no_tags(self) -> None:
|
||||
"""Try to import untagged file"""
|
||||
@ -405,25 +391,22 @@ class MyTestCase(unittest.TestCase):
|
||||
assert result[0] == new_destination # Validate return value
|
||||
|
||||
# Check track was imported
|
||||
with db.Session() as session:
|
||||
tracks = Tracks.get_all(session)
|
||||
assert len(tracks) == 3
|
||||
track = tracks[2]
|
||||
assert track.title == "The Lovecats"
|
||||
assert track.artist == "The Cure"
|
||||
assert track.id == 3
|
||||
assert track.path == new_destination
|
||||
assert os.path.exists(new_destination)
|
||||
assert os.listdir(self.import_source) == []
|
||||
tracks = ds.tracks_all()
|
||||
track = tracks[2]
|
||||
assert track.title == "The Lovecats"
|
||||
assert track.artist == "The Cure"
|
||||
assert track.track_id == 3
|
||||
assert track.path == new_destination
|
||||
assert os.path.exists(new_destination)
|
||||
assert os.listdir(self.import_source) == []
|
||||
|
||||
# Remove file so as not to interfere with later tests
|
||||
session.delete(track)
|
||||
tracks = Tracks.get_all(session)
|
||||
assert len(tracks) == 2
|
||||
session.commit()
|
||||
# Remove file so as not to interfere with later tests
|
||||
ds.track_delete(track.track_id)
|
||||
tracks = ds.tracks_all()
|
||||
assert len(tracks) == 2
|
||||
|
||||
os.unlink(new_destination)
|
||||
assert not os.path.exists(new_destination)
|
||||
os.unlink(new_destination)
|
||||
assert not os.path.exists(new_destination)
|
||||
|
||||
def test_009_import_similar_file(self) -> None:
|
||||
"""Import file with similar, but different, title"""
|
||||
@ -474,16 +457,13 @@ class MyTestCase(unittest.TestCase):
|
||||
self.wait_for_workers()
|
||||
|
||||
# Check track was imported
|
||||
with db.Session() as session:
|
||||
tracks = Tracks.get_all(session)
|
||||
assert len(tracks) == 2
|
||||
track = tracks[1]
|
||||
assert track.title == "The Lovecats xyz"
|
||||
assert track.artist == "The Cure"
|
||||
assert track.id == 2
|
||||
track_file = os.path.join(
|
||||
self.musicstore, os.path.basename(test_track_path)
|
||||
)
|
||||
assert track.path == track_file
|
||||
assert os.path.exists(track_file)
|
||||
assert os.listdir(self.import_source) == []
|
||||
tracks = ds.tracks_all()
|
||||
assert len(tracks) == 2
|
||||
track = tracks[1]
|
||||
assert track.title == "The Lovecats xyz"
|
||||
assert track.artist == "The Cure"
|
||||
assert track.track_id == 2
|
||||
track_file = os.path.join(self.musicstore, os.path.basename(test_track_path))
|
||||
assert track.path == track_file
|
||||
assert os.path.exists(track_file)
|
||||
assert os.listdir(self.import_source) == []
|
||||
|
||||
@ -64,9 +64,9 @@ class TestMMHelpers(unittest.TestCase):
|
||||
today_at_11 = dt.datetime.now().replace(hour=11, minute=0)
|
||||
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
|
||||
eight_days_ago = today_at_10 - dt.timedelta(days=8)
|
||||
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day"
|
||||
assert get_relative_date(eight_days_ago, today_at_11) == "1w, 1d"
|
||||
sixteen_days_ago = today_at_10 - dt.timedelta(days=16)
|
||||
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days"
|
||||
assert get_relative_date(sixteen_days_ago, today_at_11) == "2w, 2d"
|
||||
|
||||
def test_leading_silence(self):
|
||||
test_track_path = "testdata/isa.mp3"
|
||||
|
||||
@ -7,15 +7,15 @@ import unittest
|
||||
import pytest
|
||||
|
||||
# App imports
|
||||
from app.models import db, Settings
|
||||
import ds
|
||||
|
||||
|
||||
class TestMMMisc(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.create_all()
|
||||
ds.db.create_all()
|
||||
|
||||
def tearDown(self):
|
||||
db.drop_all()
|
||||
ds.db.drop_all()
|
||||
|
||||
def test_log_exception(self):
|
||||
"""Test deliberate exception"""
|
||||
@ -25,16 +25,11 @@ class TestMMMisc(unittest.TestCase):
|
||||
|
||||
def test_create_settings(self):
|
||||
SETTING_NAME = "wombat"
|
||||
NO_SUCH_SETTING = "abc"
|
||||
VALUE = 3
|
||||
|
||||
with db.Session() as session:
|
||||
setting = Settings(session, SETTING_NAME)
|
||||
# test repr
|
||||
_ = str(setting)
|
||||
setting.f_int = VALUE
|
||||
test = Settings.get_setting(session, SETTING_NAME)
|
||||
assert test.name == SETTING_NAME
|
||||
assert test.f_int == VALUE
|
||||
test_new = Settings.get_setting(session, NO_SUCH_SETTING)
|
||||
assert test_new.name == NO_SUCH_SETTING
|
||||
test_non_existant = ds.setting_get(SETTING_NAME)
|
||||
assert test_non_existant is None
|
||||
|
||||
ds.setting_set(SETTING_NAME, VALUE)
|
||||
test_ok = ds.setting_get(SETTING_NAME)
|
||||
assert test_ok == VALUE
|
||||
|
||||
@ -8,11 +8,10 @@ from PyQt6.QtCore import Qt, QModelIndex
|
||||
|
||||
# App imports
|
||||
from app.helpers import get_all_track_metadata
|
||||
from app import playlistmodel
|
||||
from app.models import (
|
||||
db,
|
||||
Playlists,
|
||||
Tracks,
|
||||
from app import ds, playlistmodel
|
||||
from classes import (
|
||||
InsertTrack,
|
||||
TrackAndPlaylist,
|
||||
)
|
||||
|
||||
|
||||
@ -30,24 +29,28 @@ class TestMMMiscTracks(unittest.TestCase):
|
||||
"testdata/wrb.flac",
|
||||
]
|
||||
|
||||
db.create_all()
|
||||
ds.db.create_all()
|
||||
|
||||
# Create a playlist and model
|
||||
with db.Session() as session:
|
||||
self.playlist = Playlists(session, PLAYLIST_NAME, template_id=0)
|
||||
self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False)
|
||||
self.playlist = ds.playlist_create(PLAYLIST_NAME, template_id=0)
|
||||
self.model = playlistmodel.PlaylistModel(
|
||||
self.playlist.playlist_id, is_template=False
|
||||
)
|
||||
|
||||
for row in range(len(self.test_tracks)):
|
||||
track_path = self.test_tracks[row % len(self.test_tracks)]
|
||||
track = Tracks(session, **get_all_track_metadata(track_path))
|
||||
self.model.insert_row(
|
||||
proposed_row_number=row, track_id=track.id, note=f"{row=}"
|
||||
for row in range(len(self.test_tracks)):
|
||||
track_path = self.test_tracks[row % len(self.test_tracks)]
|
||||
metadata = get_all_track_metadata(track_path)
|
||||
track = ds.track_create(metadata)
|
||||
self.model.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=self.playlist.playlist_id,
|
||||
track_id=track.track_id,
|
||||
note=f"{row=}",
|
||||
)
|
||||
|
||||
session.commit()
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
db.drop_all()
|
||||
ds.db.drop_all()
|
||||
|
||||
def test_8_row_playlist(self):
|
||||
# Test auto-created playlist
|
||||
@ -62,8 +65,17 @@ class TestMMMiscTracks(unittest.TestCase):
|
||||
START_ROW = 0
|
||||
END_ROW = 2
|
||||
|
||||
self.model.insert_row(proposed_row_number=START_ROW, note="start+")
|
||||
self.model.insert_row(proposed_row_number=END_ROW, note="-")
|
||||
# Fake selected row in model
|
||||
self.model.selected_rows = [self.model.playlist_rows[START_ROW]]
|
||||
self.model.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=self.playlist.playlist_id, track_id=None, note="start+"
|
||||
)
|
||||
)
|
||||
self.model.selected_rows = [self.model.playlist_rows[END_ROW]]
|
||||
self.model.insert_row_signal_handler(
|
||||
InsertTrack(playlist_id=self.playlist.playlist_id, track_id=None, note="-+")
|
||||
)
|
||||
|
||||
prd = self.model.playlist_rows[START_ROW]
|
||||
qv_value = self.model._display_role(
|
||||
@ -85,35 +97,38 @@ class TestMMMiscNoPlaylist(unittest.TestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
db.create_all()
|
||||
ds.db.create_all()
|
||||
|
||||
def tearDown(self):
|
||||
db.drop_all()
|
||||
ds.db.drop_all()
|
||||
|
||||
def test_insert_track_new_playlist(self):
|
||||
# insert a track into a new playlist
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0)
|
||||
# Create a model
|
||||
model = playlistmodel.PlaylistModel(playlist.id, is_template=False)
|
||||
# test repr
|
||||
_ = str(model)
|
||||
playlist = ds.playlist_create(self.PLAYLIST_NAME, template_id=0)
|
||||
# Create a model
|
||||
model = playlistmodel.PlaylistModel(playlist.playlist_id, is_template=False)
|
||||
# test repr
|
||||
_ = str(model)
|
||||
|
||||
track_path = self.test_tracks[0]
|
||||
metadata = get_all_track_metadata(track_path)
|
||||
track = Tracks(session, **metadata)
|
||||
model.insert_row(proposed_row_number=0, track_id=track.id)
|
||||
|
||||
prd = model.playlist_rows[model.rowCount() - 1]
|
||||
# test repr
|
||||
_ = str(prd)
|
||||
|
||||
assert (
|
||||
model._edit_role(
|
||||
model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd
|
||||
)
|
||||
== metadata["title"]
|
||||
track_path = self.test_tracks[0]
|
||||
metadata = get_all_track_metadata(track_path)
|
||||
track = ds.track_create(metadata)
|
||||
model.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=playlist.playlist_id,
|
||||
track_id=track.track_id,
|
||||
note="",
|
||||
)
|
||||
)
|
||||
|
||||
prd = model.playlist_rows[model.rowCount() - 1]
|
||||
# test repr
|
||||
_ = str(prd)
|
||||
|
||||
assert (
|
||||
model._edit_role(model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd)
|
||||
== metadata["title"]
|
||||
)
|
||||
|
||||
|
||||
class TestMMMiscRowMove(unittest.TestCase):
|
||||
@ -121,134 +136,23 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
ROWS_TO_CREATE = 11
|
||||
|
||||
def setUp(self):
|
||||
db.create_all()
|
||||
ds.db.create_all()
|
||||
|
||||
with db.Session() as session:
|
||||
self.playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0)
|
||||
self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
self.model.insert_row(proposed_row_number=row, note=str(row))
|
||||
|
||||
session.commit()
|
||||
self.playlist = ds.playlist_create(self.PLAYLIST_NAME, template_id=0)
|
||||
self.model = playlistmodel.PlaylistModel(
|
||||
self.playlist.playlist_id, is_template=False
|
||||
)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
self.model.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=self.playlist.playlist_id,
|
||||
track_id=None,
|
||||
note=str(row),
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
db.drop_all()
|
||||
|
||||
def test_move_rows_test2(self):
|
||||
# move row 3 to row 5
|
||||
self.model.move_rows([3], 5)
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [3, 4, 5]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 3:
|
||||
assert self.model.playlist_rows[row].note == str(4)
|
||||
elif row == 4:
|
||||
assert self.model.playlist_rows[row].note == str(3)
|
||||
elif row == 5:
|
||||
assert self.model.playlist_rows[row].note == str(5)
|
||||
|
||||
def test_move_rows_test3(self):
|
||||
# move row 4 to row 3
|
||||
|
||||
self.model.move_rows([4], 3)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [3, 4]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 3:
|
||||
assert self.model.playlist_rows[row].note == str(4)
|
||||
elif row == 4:
|
||||
assert self.model.playlist_rows[row].note == str(3)
|
||||
|
||||
def test_move_rows_test4(self):
|
||||
# move row 4 to row 2
|
||||
|
||||
self.model.move_rows([4], 2)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [2, 3, 4]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 2:
|
||||
assert self.model.playlist_rows[row].note == str(4)
|
||||
elif row == 3:
|
||||
assert self.model.playlist_rows[row].note == str(2)
|
||||
elif row == 4:
|
||||
assert self.model.playlist_rows[row].note == str(3)
|
||||
|
||||
def test_move_rows_test5(self):
|
||||
# move rows [1, 4, 5, 10] → 8
|
||||
|
||||
self.model.move_rows([1, 4, 5, 10], 8)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9]
|
||||
|
||||
def test_move_rows_test6(self):
|
||||
# move rows [3, 6] → 5
|
||||
|
||||
self.model.move_rows([3, 6], 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10]
|
||||
|
||||
def test_move_rows_test7(self):
|
||||
# move rows [3, 5, 6] → 8
|
||||
|
||||
self.model.move_rows([3, 5, 6], 8)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10]
|
||||
|
||||
def test_move_rows_test8(self):
|
||||
# move rows [7, 8, 10] → 5
|
||||
|
||||
self.model.move_rows([7, 8, 10], 5)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
|
||||
|
||||
def test_move_rows_test9(self):
|
||||
# move rows [1, 2, 3] → 0
|
||||
# Replicate issue 244
|
||||
|
||||
self.model.move_rows([0, 1, 2, 3], 0)
|
||||
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
ds.db.drop_all()
|
||||
|
||||
def test_insert_header_row_end(self):
|
||||
# insert header row at end of playlist
|
||||
@ -256,7 +160,11 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
note_text = "test text"
|
||||
|
||||
assert self.model.rowCount() == self.ROWS_TO_CREATE
|
||||
self.model.insert_row(proposed_row_number=None, note=note_text)
|
||||
self.model.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=self.playlist.playlist_id, track_id=None, note=note_text
|
||||
)
|
||||
)
|
||||
assert self.model.rowCount() == self.ROWS_TO_CREATE + 1
|
||||
prd = self.model.playlist_rows[self.model.rowCount() - 1]
|
||||
# Test against edit_role because display_role for headers is
|
||||
@ -274,7 +182,14 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
note_text = "test text"
|
||||
insert_row = 6
|
||||
|
||||
self.model.insert_row(proposed_row_number=insert_row, note=note_text)
|
||||
# Fake selected row in model
|
||||
self.model.selected_rows = [self.model.playlist_rows[insert_row]]
|
||||
|
||||
self.model.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=self.playlist.playlist_id, track_id=None, note=note_text
|
||||
)
|
||||
)
|
||||
assert self.model.rowCount() == self.ROWS_TO_CREATE + 1
|
||||
prd = self.model.playlist_rows[insert_row]
|
||||
# Test against edit_role because display_role for headers is
|
||||
@ -290,11 +205,20 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
note_text = "test text"
|
||||
insert_row = 6
|
||||
|
||||
self.model.insert_row(proposed_row_number=insert_row, note=note_text)
|
||||
self.model.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=self.playlist.playlist_id, track_id=None, note=note_text
|
||||
)
|
||||
)
|
||||
assert self.model.rowCount() == self.ROWS_TO_CREATE + 1
|
||||
|
||||
# Fake selected row in model
|
||||
self.model.selected_rows = [self.model.playlist_rows[insert_row]]
|
||||
|
||||
prd = self.model.playlist_rows[1]
|
||||
self.model.add_track_to_header(insert_row, prd.track_id)
|
||||
self.model.signal_add_track_to_header_handler(
|
||||
TrackAndPlaylist(playlist_id=self.model.playlist_id, track_id=prd.track_id)
|
||||
)
|
||||
|
||||
def test_reverse_row_groups_one_row(self):
|
||||
rows_to_move = [3]
|
||||
@ -314,20 +238,26 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
def test_move_one_row_between_playlists_to_end(self):
|
||||
from_rows = [3]
|
||||
to_row = self.ROWS_TO_CREATE
|
||||
destination_playlist = "destination"
|
||||
destination_playlist_name = "destination"
|
||||
|
||||
model_src = self.model
|
||||
with db.Session() as session:
|
||||
playlist_dst = Playlists(session, destination_playlist, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row(proposed_row_number=row, note=str(row))
|
||||
playlist_dst = ds.playlist_create(destination_playlist_name, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(
|
||||
playlist_dst.playlist_id, is_template=False
|
||||
)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=playlist_dst.playlist_id, track_id=None, note=str(row)
|
||||
)
|
||||
)
|
||||
|
||||
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
|
||||
model_dst.refresh_data(session)
|
||||
model_src.move_rows_between_playlists(
|
||||
from_rows, to_row, playlist_dst.playlist_id
|
||||
)
|
||||
|
||||
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
|
||||
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
|
||||
assert model_src.rowCount() == self.ROWS_TO_CREATE - len(from_rows)
|
||||
assert model_dst.rowCount() == self.ROWS_TO_CREATE + len(from_rows)
|
||||
assert sorted([a.row_number for a in model_src.playlist_rows.values()]) == list(
|
||||
range(len(model_src.playlist_rows))
|
||||
)
|
||||
@ -335,17 +265,23 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
def test_move_one_row_between_playlists_to_middle(self):
|
||||
from_rows = [3]
|
||||
to_row = 2
|
||||
destination_playlist = "destination"
|
||||
destination_playlist_name = "destination"
|
||||
|
||||
model_src = self.model
|
||||
with db.Session() as session:
|
||||
playlist_dst = Playlists(session, destination_playlist, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row(proposed_row_number=row, note=str(row))
|
||||
playlist_dst = ds.playlist_create(destination_playlist_name, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(
|
||||
playlist_dst.playlist_id, is_template=False
|
||||
)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=playlist_dst.playlist_id, track_id=None, note=str(row)
|
||||
)
|
||||
)
|
||||
|
||||
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
|
||||
model_dst.refresh_data(session)
|
||||
model_src.move_rows_between_playlists(
|
||||
from_rows, to_row, playlist_dst.playlist_id
|
||||
)
|
||||
|
||||
# Check the rows of the destination model
|
||||
row_notes = []
|
||||
@ -355,24 +291,31 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
)
|
||||
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole))
|
||||
|
||||
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
|
||||
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
|
||||
assert model_src.rowCount() == self.ROWS_TO_CREATE - len(from_rows)
|
||||
assert model_dst.rowCount() == self.ROWS_TO_CREATE + len(from_rows)
|
||||
assert [int(a) for a in row_notes] == [0, 1, 3, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
def test_move_multiple_rows_between_playlists_to_end(self):
|
||||
from_rows = [1, 3, 4]
|
||||
to_row = 2
|
||||
destination_playlist = "destination"
|
||||
destination_playlist_name = "destination"
|
||||
|
||||
model_src = self.model
|
||||
with db.Session() as session:
|
||||
playlist_dst = Playlists(session, destination_playlist, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row(proposed_row_number=row, note=str(row))
|
||||
|
||||
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
|
||||
model_dst.refresh_data(session)
|
||||
playlist_dst = ds.playlist_create(destination_playlist_name, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(
|
||||
playlist_dst.playlist_id, is_template=False
|
||||
)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=playlist_dst.playlist_id, track_id=None, note=str(row)
|
||||
)
|
||||
)
|
||||
|
||||
model_src.move_rows_between_playlists(
|
||||
from_rows, to_row, playlist_dst.playlist_id
|
||||
)
|
||||
|
||||
# Check the rows of the destination model
|
||||
row_notes = []
|
||||
@ -382,8 +325,8 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
)
|
||||
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole))
|
||||
|
||||
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
|
||||
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
|
||||
assert model_src.rowCount() == self.ROWS_TO_CREATE - len(from_rows)
|
||||
assert model_dst.rowCount() == self.ROWS_TO_CREATE + len(from_rows)
|
||||
assert [int(a) for a in row_notes] == [
|
||||
0,
|
||||
1,
|
||||
@ -400,22 +343,3 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
9,
|
||||
10,
|
||||
]
|
||||
|
||||
|
||||
# # def test_edit_header(monkeypatch, session): # edit header row in middle of playlist
|
||||
|
||||
# # monkeypatch.setattr(playlistmodel, "Session", session)
|
||||
# # note_text = "test text"
|
||||
# # initial_row_count = 11
|
||||
# # insert_row = 6
|
||||
|
||||
# # model = create_model_with_playlist_rows(session, initial_row_count)
|
||||
# # model.insert_header_row(insert_row, note_text)
|
||||
# # assert model.rowCount() == initial_row_count + 1
|
||||
# # prd = model.playlist_rows[insert_row]
|
||||
# # # Test against edit_role because display_role for headers is
|
||||
# # # handled differently (sets up row span)
|
||||
# # assert (
|
||||
# # model.edit_role(model.rowCount(), playlistmodel.Col.NOTE.value, prd)
|
||||
# # == note_text
|
||||
# # )
|
||||
|
||||
@ -8,14 +8,10 @@ import unittest
|
||||
# Third party imports
|
||||
|
||||
# App imports
|
||||
from app.models import (
|
||||
db,
|
||||
Playdates,
|
||||
Tracks,
|
||||
)
|
||||
from classes import (
|
||||
Filter,
|
||||
)
|
||||
import ds
|
||||
|
||||
|
||||
class MyTestCase(unittest.TestCase):
|
||||
@ -23,43 +19,42 @@ class MyTestCase(unittest.TestCase):
|
||||
def setUpClass(cls):
|
||||
"""Runs once before any test in this class"""
|
||||
|
||||
db.create_all()
|
||||
ds.db.create_all()
|
||||
|
||||
with db.Session() as session:
|
||||
# Create some track entries
|
||||
_ = Tracks(**dict(
|
||||
session=session,
|
||||
artist="a",
|
||||
bitrate=0,
|
||||
duration=100,
|
||||
fade_at=0,
|
||||
path="/alpha/bravo/charlie",
|
||||
silence_at=0,
|
||||
start_gap=0,
|
||||
title="abc"
|
||||
))
|
||||
track2 = Tracks(**dict(
|
||||
session=session,
|
||||
artist="a",
|
||||
bitrate=0,
|
||||
duration=100,
|
||||
fade_at=0,
|
||||
path="/xray/yankee/zulu",
|
||||
silence_at=0,
|
||||
start_gap=0,
|
||||
title="xyz"
|
||||
))
|
||||
track2_id = track2.id
|
||||
# Add playdates
|
||||
# Track 2 played just over a year ago
|
||||
just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367)
|
||||
_ = Playdates(session, track2_id, when=just_over_a_year_ago)
|
||||
# Create some track entries
|
||||
track1_meta = dict(
|
||||
artist="a",
|
||||
bitrate=0,
|
||||
duration=100,
|
||||
fade_at=0,
|
||||
path="/alpha/bravo/charlie",
|
||||
silence_at=0,
|
||||
start_gap=0,
|
||||
title="abc",
|
||||
)
|
||||
_ = ds.track_create(track1_meta)
|
||||
track2_meta = dict(
|
||||
artist="a",
|
||||
bitrate=0,
|
||||
duration=100,
|
||||
fade_at=0,
|
||||
path="/xray/yankee/zulu",
|
||||
silence_at=0,
|
||||
start_gap=0,
|
||||
title="xyz",
|
||||
)
|
||||
track2 = ds.track_create(track2_meta)
|
||||
|
||||
# Add playdates
|
||||
# Track 2 played just over a year ago
|
||||
just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367)
|
||||
ds.playdates_update(track2.track_id, when=just_over_a_year_ago)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Runs once after all tests"""
|
||||
|
||||
db.drop_all()
|
||||
ds.db.drop_all()
|
||||
|
||||
def setUp(self):
|
||||
"""Runs before each test"""
|
||||
@ -76,55 +71,49 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
filter = Filter(path="alpha", last_played_comparator="never")
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 1
|
||||
assert 'alpha' in results[0].path
|
||||
results = ds.tracks_filtered(filter)
|
||||
assert len(results) == 1
|
||||
assert "alpha" in results[0].path
|
||||
|
||||
def test_search_path_2(self):
|
||||
"""Search for unplayed track that doesn't exist"""
|
||||
|
||||
filter = Filter(path="xray", last_played_comparator="never")
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 0
|
||||
results = ds.tracks_filtered(filter)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_played_over_a_year_ago(self):
|
||||
"""Search for tracks played over a year ago"""
|
||||
|
||||
filter = Filter(last_played_unit="years", last_played_number=1)
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 1
|
||||
assert 'zulu' in results[0].path
|
||||
results = ds.tracks_filtered(filter)
|
||||
assert len(results) == 1
|
||||
assert "zulu" in results[0].path
|
||||
|
||||
def test_played_over_two_years_ago(self):
|
||||
"""Search for tracks played over 2 years ago"""
|
||||
|
||||
filter = Filter(last_played_unit="years", last_played_number=2)
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 0
|
||||
results = ds.tracks_filtered(filter)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_never_played(self):
|
||||
"""Search for tracks never played"""
|
||||
|
||||
filter = Filter(last_played_comparator="never")
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 1
|
||||
assert 'alpha' in results[0].path
|
||||
results = ds.tracks_filtered(filter)
|
||||
assert len(results) == 1
|
||||
assert "alpha" in results[0].path
|
||||
|
||||
def test_played_anytime(self):
|
||||
"""Search for tracks played over a year ago"""
|
||||
|
||||
filter = Filter(last_played_comparator="Any time")
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 1
|
||||
assert 'zulu' in results[0].path
|
||||
results = ds.tracks_filtered(filter)
|
||||
assert len(results) == 1
|
||||
assert "zulu" in results[0].path
|
||||
|
||||
296
tests/test_ui.py
296
tests/test_ui.py
@ -10,12 +10,8 @@ from pytestqt.plugin import QtBot # type: ignore
|
||||
|
||||
# App imports
|
||||
from app import playlistmodel, utilities
|
||||
from app.models import (
|
||||
db,
|
||||
Playlists,
|
||||
Tracks,
|
||||
)
|
||||
from app import musicmuster
|
||||
from app import ds, musicmuster
|
||||
from classes import InsertTrack
|
||||
|
||||
|
||||
# Custom fixture to adapt qtbot for use with unittest.TestCase
|
||||
@ -44,13 +40,13 @@ def with_updown(function):
|
||||
@pytest.mark.usefixtures("qtbot_adapter")
|
||||
class MyTestCase(unittest.TestCase):
|
||||
def up(self):
|
||||
db.create_all()
|
||||
ds.db.create_all()
|
||||
self.widget = musicmuster.Window()
|
||||
# self.widget.show()
|
||||
|
||||
# Add two tracks to database
|
||||
self.tracks = {
|
||||
1: {
|
||||
self.track1 = ds.track_create(
|
||||
{
|
||||
"path": "testdata/isa.mp3",
|
||||
"title": "I'm so afraid",
|
||||
"artist": "Fleetwood Mac",
|
||||
@ -59,8 +55,10 @@ class MyTestCase(unittest.TestCase):
|
||||
"start_gap": 60,
|
||||
"fade_at": 236263,
|
||||
"silence_at": 260343,
|
||||
},
|
||||
2: {
|
||||
}
|
||||
)
|
||||
self.track2 = ds.track_create(
|
||||
{
|
||||
"path": "testdata/mom.mp3",
|
||||
"title": "Man of Mystery",
|
||||
"artist": "The Shadows",
|
||||
@ -69,19 +67,11 @@ class MyTestCase(unittest.TestCase):
|
||||
"start_gap": 70,
|
||||
"fade_at": 115000,
|
||||
"silence_at": 118000,
|
||||
},
|
||||
}
|
||||
|
||||
with db.Session() as session:
|
||||
for track in self.tracks.values():
|
||||
db_track = Tracks(session=session, **track)
|
||||
session.add(db_track)
|
||||
track["id"] = db_track.id
|
||||
|
||||
session.commit()
|
||||
}
|
||||
)
|
||||
|
||||
def down(self):
|
||||
db.drop_all()
|
||||
ds.db.drop_all()
|
||||
|
||||
@with_updown
|
||||
def test_init(self):
|
||||
@ -89,11 +79,10 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
playlist_name = "test_init playlist"
|
||||
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session, playlist_name, template_id=0)
|
||||
self.widget._open_playlist(playlist, is_template=False)
|
||||
with self.qtbot.waitExposed(self.widget):
|
||||
self.widget.show()
|
||||
playlist = ds.playlist_create(playlist_name, template_id=0)
|
||||
self.widget._open_playlist(playlist, is_template=False)
|
||||
with self.qtbot.waitExposed(self.widget):
|
||||
self.widget.show()
|
||||
|
||||
@with_updown
|
||||
def test_save_and_restore(self):
|
||||
@ -102,27 +91,28 @@ class MyTestCase(unittest.TestCase):
|
||||
note_text = "my note"
|
||||
playlist_name = "test_save_and_restore playlist"
|
||||
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session, playlist_name, template_id=0)
|
||||
model = playlistmodel.PlaylistModel(playlist.id, is_template=False)
|
||||
playlist = ds.playlist_create(playlist_name, template_id=0)
|
||||
model = playlistmodel.PlaylistModel(playlist.playlist_id, is_template=False)
|
||||
|
||||
# Add a track with a note
|
||||
model.insert_row(
|
||||
proposed_row_number=0, track_id=self.tracks[1]["id"], note=note_text
|
||||
# Add a track with a note
|
||||
model.insert_row_signal_handler(
|
||||
InsertTrack(
|
||||
playlist_id=playlist.playlist_id,
|
||||
track_id=self.track1.track_id,
|
||||
note=note_text,
|
||||
)
|
||||
)
|
||||
|
||||
# We need to commit the session before re-querying
|
||||
session.commit()
|
||||
|
||||
# Retrieve playlist
|
||||
all_playlists = Playlists.get_all(session)
|
||||
assert len(all_playlists) == 1
|
||||
retrieved_playlist = all_playlists[0]
|
||||
assert len(retrieved_playlist.rows) == 1
|
||||
paths = [a.track.path for a in retrieved_playlist.rows]
|
||||
assert self.tracks[1]["path"] in paths
|
||||
notes = [a.note for a in retrieved_playlist.rows]
|
||||
assert note_text in notes
|
||||
# Retrieve playlist
|
||||
all_playlists = ds.playlists_all()
|
||||
assert len(all_playlists) == 1
|
||||
retrieved_playlist = all_playlists[0]
|
||||
playlist_rows = ds.playlistrows_by_playlist(retrieved_playlist.playlist_id)
|
||||
assert len(playlist_rows) == 1
|
||||
paths = [a.track.path for a in playlist_rows]
|
||||
assert self.track1.path in paths
|
||||
notes = [a.note for a in playlist_rows]
|
||||
assert note_text in notes
|
||||
|
||||
@with_updown
|
||||
def test_utilities(self):
|
||||
@ -132,217 +122,5 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
Config.ROOT = os.path.join(os.path.dirname(__file__), "testdata")
|
||||
|
||||
with db.Session() as session:
|
||||
utilities.check_db(session)
|
||||
utilities.update_bitrates(session)
|
||||
|
||||
|
||||
# def test_meta_all_clear(qtbot, session):
|
||||
# # Create playlist
|
||||
# playlist = models.Playlists(session, "my playlist", template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
|
||||
|
||||
# # Add some tracks
|
||||
# # Need to commit session after each one so that new row is found
|
||||
# # for subsequent inserts
|
||||
# track1_path = "/a/b/c"
|
||||
# track1 = models.Tracks(session, track1_path)
|
||||
# playlist_tab.insert_track(session, track1)
|
||||
# session.commit()
|
||||
# track2_path = "/d/e/f"
|
||||
# track2 = models.Tracks(session, track2_path)
|
||||
# playlist_tab.insert_track(session, track2)
|
||||
# session.commit()
|
||||
# track3_path = "/h/i/j"
|
||||
# track3 = models.Tracks(session, track3_path)
|
||||
# playlist_tab.insert_track(session, track3)
|
||||
# session.commit()
|
||||
|
||||
# assert playlist_tab._get_current_track_row() is None
|
||||
# assert playlist_tab._get_next_track_row() is None
|
||||
# assert playlist_tab._get_notes_rows() == []
|
||||
# assert playlist_tab._get_played_track_rows() == []
|
||||
# assert len(playlist_tab._get_unreadable_track_rows()) == 3
|
||||
|
||||
|
||||
# def test_meta(qtbot, session):
|
||||
# # Create playlist
|
||||
# playlist = playlists.Playlists(session, "my playlist",
|
||||
# template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
|
||||
|
||||
# # Add some tracks
|
||||
# track1_path = "/a/b/c"
|
||||
# track1 = models.Tracks(session, track1_path)
|
||||
# playlist_tab.insert_track(session, track1)
|
||||
# session.commit()
|
||||
# track2_path = "/d/e/f"
|
||||
# track2 = models.Tracks(session, track2_path)
|
||||
# playlist_tab.insert_track(session, track2)
|
||||
# session.commit()
|
||||
# track3_path = "/h/i/j"
|
||||
# track3 = models.Tracks(session, track3_path)
|
||||
# playlist_tab.insert_track(session, track3)
|
||||
# session.commit()
|
||||
|
||||
# assert len(playlist_tab._get_unreadable_track_rows()) == 3
|
||||
|
||||
# assert playlist_tab._get_played_track_rows() == []
|
||||
# assert playlist_tab._get_current_track_row() is None
|
||||
# assert playlist_tab._get_next_track_row() is None
|
||||
# assert playlist_tab._get_notes_rows() == []
|
||||
|
||||
# playlist_tab._set_played_row(0)
|
||||
# assert playlist_tab._get_played_track_rows() == [0]
|
||||
# assert playlist_tab._get_current_track_row() is None
|
||||
# assert playlist_tab._get_next_track_row() is None
|
||||
# assert playlist_tab._get_notes_rows() == []
|
||||
|
||||
# # Add a note
|
||||
# note_text = "my note"
|
||||
# note_row = 7 # will be added as row 3
|
||||
# note = models.Notes(session, playlist.id, note_row, note_text)
|
||||
# playlist_tab._insert_note(session, note)
|
||||
|
||||
# assert playlist_tab._get_played_track_rows() == [0]
|
||||
# assert playlist_tab._get_current_track_row() is None
|
||||
# assert playlist_tab._get_next_track_row() is None
|
||||
# assert playlist_tab._get_notes_rows() == [3]
|
||||
|
||||
# playlist_tab._set_next_track_row(1)
|
||||
# assert playlist_tab._get_played_track_rows() == [0]
|
||||
# assert playlist_tab._get_current_track_row() is None
|
||||
# assert playlist_tab._get_next_track_row() == 1
|
||||
# assert playlist_tab._get_notes_rows() == [3]
|
||||
|
||||
# playlist_tab._set_current_track_row(2)
|
||||
# assert playlist_tab._get_played_track_rows() == [0]
|
||||
# assert playlist_tab._get_current_track_row() == 2
|
||||
# assert playlist_tab._get_next_track_row() == 1
|
||||
# assert playlist_tab._get_notes_rows() == [3]
|
||||
|
||||
# playlist_tab._clear_played_row_status(0)
|
||||
# assert playlist_tab._get_played_track_rows() == []
|
||||
# assert playlist_tab._get_current_track_row() == 2
|
||||
# assert playlist_tab._get_next_track_row() == 1
|
||||
# assert playlist_tab._get_notes_rows() == [3]
|
||||
|
||||
# playlist_tab._meta_clear_next()
|
||||
# assert playlist_tab._get_played_track_rows() == []
|
||||
# assert playlist_tab._get_current_track_row() == 2
|
||||
# assert playlist_tab._get_next_track_row() is None
|
||||
# assert playlist_tab._get_notes_rows() == [3]
|
||||
|
||||
# playlist_tab._clear_current_track_row()
|
||||
# assert playlist_tab._get_played_track_rows() == []
|
||||
# assert playlist_tab._get_current_track_row() is None
|
||||
# assert playlist_tab._get_next_track_row() is None
|
||||
# assert playlist_tab._get_notes_rows() == [3]
|
||||
|
||||
# # Test clearing again has no effect
|
||||
# playlist_tab._clear_current_track_row()
|
||||
# assert playlist_tab._get_played_track_rows() == []
|
||||
# assert playlist_tab._get_current_track_row() is None
|
||||
# assert playlist_tab._get_next_track_row() is None
|
||||
# assert playlist_tab._get_notes_rows() == [3]
|
||||
|
||||
|
||||
# def test_clear_next(qtbot, session):
|
||||
# # Create playlist
|
||||
# playlist = models.Playlists(session, "my playlist", template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
|
||||
|
||||
# # Add some tracks
|
||||
# track1_path = "/a/b/c"
|
||||
# track1 = models.Tracks(session, track1_path)
|
||||
# playlist_tab.insert_track(session, track1)
|
||||
# session.commit()
|
||||
# track2_path = "/d/e/f"
|
||||
# track2 = models.Tracks(session, track2_path)
|
||||
# playlist_tab.insert_track(session, track2)
|
||||
# session.commit()
|
||||
|
||||
# playlist_tab._set_next_track_row(1)
|
||||
# assert playlist_tab._get_next_track_row() == 1
|
||||
|
||||
# playlist_tab.clear_next(session)
|
||||
# assert playlist_tab._get_next_track_row() is None
|
||||
|
||||
|
||||
# def test_get_selected_row(qtbot, monkeypatch, session):
|
||||
# monkeypatch.setattr(musicmuster, "Session", session)
|
||||
# monkeypatch.setattr(playlists, "Session", session)
|
||||
|
||||
# # Create playlist and playlist_tab
|
||||
# window = musicmuster.Window()
|
||||
# playlist = models.Playlists(session, "test playlist", template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
|
||||
|
||||
# # Add some tracks
|
||||
# track1_path = "/a/b/c"
|
||||
# track1 = models.Tracks(session, track1_path)
|
||||
# playlist_tab.insert_track(session, track1)
|
||||
# session.commit()
|
||||
# track2_path = "/d/e/f"
|
||||
# track2 = models.Tracks(session, track2_path)
|
||||
# playlist_tab.insert_track(session, track2)
|
||||
# session.commit()
|
||||
|
||||
# qtbot.addWidget(playlist_tab)
|
||||
# with qtbot.waitExposed(window):
|
||||
# window.show()
|
||||
# row0_item0 = playlist_tab.item(0, 0)
|
||||
# assert row0_item0 is not None
|
||||
# rect = playlist_tab.visualItemRect(row0_item0)
|
||||
# qtbot.mouseClick(playlist_tab.viewport(), Qt.LeftButton, pos=rect.center())
|
||||
# row_number = playlist_tab.get_selected_row()
|
||||
# assert row_number == 0
|
||||
|
||||
|
||||
# def test_set_next(qtbot, monkeypatch, session):
|
||||
# monkeypatch.setattr(musicmuster, "Session", session)
|
||||
# monkeypatch.setattr(playlists, "Session", session)
|
||||
# seed2tracks(session)
|
||||
|
||||
# playlist_name = "test playlist"
|
||||
# # Create testing playlist
|
||||
# window = musicmuster.Window()
|
||||
# playlist = models.Playlists(session, playlist_name, template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
|
||||
# idx = window.tabPlaylist.addTab(playlist_tab, playlist_name)
|
||||
# window.tabPlaylist.setCurrentIndex(idx)
|
||||
# qtbot.addWidget(playlist_tab)
|
||||
|
||||
# # Add some tracks
|
||||
# track1 = models.Tracks.get_by_filename(session, "isa.mp3")
|
||||
# track1_title = track1.title
|
||||
# assert track1_title
|
||||
|
||||
# playlist_tab.insert_track(session, track1)
|
||||
# session.commit()
|
||||
# track2 = models.Tracks.get_by_filename(session, "mom.mp3")
|
||||
# playlist_tab.insert_track(session, track2)
|
||||
|
||||
# with qtbot.waitExposed(window):
|
||||
# window.show()
|
||||
|
||||
# row0_item2 = playlist_tab.item(0, 2)
|
||||
# assert row0_item2 is not None
|
||||
# rect = playlist_tab.visualItemRect(row0_item2)
|
||||
# qtbot.mouseClick(playlist_tab.viewport(), Qt.LeftButton, pos=rect.center())
|
||||
# selected_title = playlist_tab.get_selected_title()
|
||||
# assert selected_title == track1_title
|
||||
|
||||
# qtbot.keyPress(playlist_tab.viewport(), "N", modifier=Qt.ControlModifier)
|
||||
# qtbot.wait(1000)
|
||||
|
||||
|
||||
# def test_kae(monkeypatch, session):
|
||||
# # monkeypatch.setattr(dbconfig, "Session", session)
|
||||
# monkeypatch.setattr(musicmuster, "Session", session)
|
||||
|
||||
# musicmuster.Window.kae()
|
||||
# # monkeypatch.setattr(musicmuster, "Session", session)
|
||||
# # monkeypatch.setattr(dbconfig, "Session", session)
|
||||
# # monkeypatch.setattr(models, "Session", session)
|
||||
# # monkeypatch.setattr(playlists, "Session", session)
|
||||
utilities.check_db()
|
||||
utilities.update_bitrates()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user