Merge v4 branch

This commit is contained in:
Keith Edmunds 2024-04-27 20:40:17 +01:00
commit b8c19c6046
35 changed files with 2462 additions and 2014 deletions

6
.envrc
View File

@ -9,12 +9,12 @@ branch=$(git branch --show-current)
# Always treat running from /home/kae/mm as production
if [ $(pwd) == /home/kae/mm ]; then
export MM_ENV="PRODUCTION"
export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
export ALCHEMICAL_DATABASE_URI="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
elif on_git_branch master; then
export MM_ENV="PRODUCTION"
export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
export ALCHEMICAL_DATABASE_URI="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
else
export MM_ENV="DEVELOPMENT"
export MM_DB="mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster"
export ALCHEMICAL_DATABASE_URI="mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster"
export PYTHONBREAKPOINT="pudb.set_trace"
fi

View File

@ -53,7 +53,6 @@ class MyTableWidget(QTableView):
class MyModel(QAbstractTableModel):
def columnCount(self, index):
return 2
@ -71,7 +70,11 @@ class MyModel(QAbstractTableModel):
return QVariant()
def flags(self, index):
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable
return (
Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
| Qt.ItemFlag.ItemIsEditable
)
class MainWindow(QMainWindow):

View File

@ -1,286 +0,0 @@
from PyQt6.QtCore import Qt
from app import playlists
from app import models
from app import musicmuster
def seed2tracks(session):
tracks = [
{
"path": "testdata/isa.mp3",
"title": "I'm so afraid",
"artist": "Fleetwood Mac",
"duration": 263000,
"start_gap": 60,
"fade_at": 236263,
"silence_at": 260343,
"mtime": 371900000,
},
{
"path": "testdata/mom.mp3",
"title": "Man of Mystery",
"artist": "The Shadows",
"duration": 120000,
"start_gap": 70,
"fade_at": 115000,
"silence_at": 118000,
"mtime": 1642760000,
},
]
for track in tracks:
db_track = models.Tracks(session=session, **track)
session.add(db_track)
session.commit()
def test_init(qtbot, session):
"""Just check we can create a playlist_tab"""
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
assert playlist_tab
def test_save_and_restore(qtbot, session):
"""Playlist with one track, one note, save and restore"""
# Create playlist
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Insert a note
note_text = "my note"
note_row = 7
note = models.Notes(session, playlist.id, note_row, note_text)
playlist_tab._insert_note(session, note)
# Add a track
track_path = "/a/b/c"
track = models.Tracks(session, track_path)
# Inserting the track will also save the playlist
playlist_tab.insert_track(session, track)
# We need to commit the session before re-querying
session.commit()
# Retrieve playlist
all_playlists = playlists.Playlists.get_open(session)
assert len(all_playlists) == 1
retrieved_playlist = all_playlists[0]
paths = [a.path for a in retrieved_playlist.tracks.values()]
assert track_path in paths
notes = [a.note for a in retrieved_playlist.notes]
assert note_text in notes
def test_meta_all_clear(qtbot, session):
# Create playlist
playlist = models.Playlists(session, "my playlist")
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")
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")
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")
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)
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)

View File

@ -1,13 +1,18 @@
# Standard library imports
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
import datetime as dt
# PyQt imports
from PyQt6.QtCore import pyqtSignal, QObject, QThread
# Third party imports
import numpy as np
import pyqtgraph as pg # type: ignore
from sqlalchemy.orm import scoped_session
# App imports
from config import Config
from dbconfig import scoped_session
from models import PlaylistRows
import helpers
@ -108,9 +113,10 @@ class PlaylistTrack:
self.artist: Optional[str] = None
self.duration: Optional[int] = None
self.end_time: Optional[datetime] = None
self.end_time: Optional[dt.datetime] = None
self.fade_at: Optional[int] = None
self.fade_graph: Optional[FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None
self.fade_length: Optional[int] = None
self.path: Optional[str] = None
self.playlist_id: Optional[int] = None
@ -119,7 +125,7 @@ class PlaylistTrack:
self.resume_marker: Optional[float] = None
self.silence_at: Optional[int] = None
self.start_gap: Optional[int] = None
self.start_time: Optional[datetime] = None
self.start_time: Optional[dt.datetime] = None
self.title: Optional[str] = None
self.track_id: Optional[int] = None
@ -177,9 +183,19 @@ class PlaylistTrack:
Called when track starts playing
"""
self.start_time = datetime.now()
now = dt.datetime.now()
self.start_time = now
if self.duration:
self.end_time = self.start_time + timedelta(milliseconds=self.duration)
self.end_time = self.start_time + 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
)
class AddFadeCurve(QObject):

View File

@ -1,4 +1,4 @@
import datetime
import datetime as dt
import logging
import os
from typing import List, Optional
@ -35,10 +35,10 @@ class Config(object):
COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ["dbconfig"]
DEBUG_MODULES: List[Optional[str]] = []
DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False
EPOCH = datetime.datetime(1970, 1, 1)
EPOCH = dt.datetime(1970, 1, 1)
ERRORS_FROM = ["noreply@midnighthax.com"]
ERRORS_TO = ["kae@midnighthax.com"]
FADE_CURVE_BACKGROUND = "lightyellow"

View File

@ -1,38 +0,0 @@
import inspect
import os
from config import Config
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from typing import Generator
from log import log
MYSQL_CONNECT = os.environ.get("MM_DB")
if MYSQL_CONNECT is None:
raise ValueError("MYSQL_CONNECT is undefined")
else:
dbname = MYSQL_CONNECT.split("/")[-1]
log.debug(f"Database: {dbname}")
engine = create_engine(
MYSQL_CONNECT,
echo=Config.DISPLAY_SQL,
pool_pre_ping=True,
future=True,
connect_args={"charset": "utf8mb4"},
)
@contextmanager
def Session() -> Generator[scoped_session, None, None]:
frame = inspect.stack()[2]
file = frame.filename
function = frame.function
lineno = frame.lineno
Session = scoped_session(sessionmaker(bind=engine))
log.debug(f"Session acquired: {file}:{function}:{lineno} " f"[{hex(id(Session))}]")
yield Session
log.debug(f" Session released [{hex(id(Session))}]")
Session.commit()
Session.close()

178
app/dbtables.py Normal file
View File

@ -0,0 +1,178 @@
# Standard library imports
from typing import List, Optional
import datetime as dt
# PyQt imports
# Third party imports
from alchemical import Model # type: ignore
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
String,
)
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
# App imports
# Database classes
class CartsTable(Model):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
cart_number: Mapped[int] = mapped_column(unique=True)
name: Mapped[str] = mapped_column(String(256), index=True)
duration: Mapped[Optional[int]] = mapped_column(index=True)
path: Mapped[Optional[str]] = mapped_column(String(2048), index=False)
enabled: Mapped[Optional[bool]] = mapped_column(default=False)
def __repr__(self) -> str:
return (
f"<Carts(id={self.id}, cart={self.cart_number}, "
f"name={self.name}, path={self.path}>"
)
class NoteColoursTable(Model):
__tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=False)
colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True)
is_regex: Mapped[bool] = mapped_column(default=False, index=False)
is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
order: Mapped[Optional[int]] = mapped_column(index=True)
def __repr__(self) -> str:
return (
f"<NoteColours(id={self.id}, substring={self.substring}, "
f"colour={self.colour}>"
)
class PlaydatesTable(Model):
__tablename__ = "playdates"
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"))
track: Mapped["TracksTable"] = relationship(
"TracksTable", back_populates="playdates"
)
def __repr__(self) -> str:
return (
f"<Playdates(id={self.id}, track_id={self.track_id} "
f"lastplayed={self.lastplayed}>"
)
class PlaylistsTable(Model):
"""
Manage playlists
"""
__tablename__ = "playlists"
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)
deleted: Mapped[bool] = mapped_column(default=False)
rows: Mapped[List["PlaylistRowsTable"]] = relationship(
"PlaylistRowsTable",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistRowsTable.plr_rownum",
)
def __repr__(self) -> str:
return (
f"<Playlists(id={self.id}, name={self.name}, "
f"is_templatee={self.is_template}, open={self.open}>"
)
class PlaylistRowsTable(Model):
__tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
plr_rownum: Mapped[int]
note: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False
)
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"))
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id"))
track: Mapped["TracksTable"] = relationship(
"TracksTable",
back_populates="playlistrows",
)
played: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
return (
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, "
f"note={self.note}, plr_rownum={self.plr_rownum}>"
)
class SettingsTable(Model):
"""Manage settings"""
__tablename__ = "settings"
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)
f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str:
return (
f"<Settings(id={self.id}, name={self.name}, "
f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>"
)
class TracksTable(Model):
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(256), index=True)
artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[Optional[int]] = mapped_column(default=None)
duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False)
mtime: Mapped[float] = mapped_column(index=True)
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False)
playlistrows: Mapped[List[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable", back_populates="track"
)
playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[List[PlaydatesTable]] = relationship(
"PlaydatesTable",
back_populates="track",
lazy="joined",
)
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)

View File

@ -1,10 +1,15 @@
# Standard library imports
from typing import Optional
# PyQt imports
from PyQt6.QtCore import QEvent, Qt
from PyQt6.QtWidgets import QDialog, QListWidgetItem
# Third party imports
from sqlalchemy.orm.session import Session
# App imports
from classes import MusicMusterSignals
from dbconfig import scoped_session
from helpers import (
ask_yes_no,
get_relative_date,
@ -21,7 +26,7 @@ class TrackSelectDialog(QDialog):
def __init__(
self,
session: scoped_session,
session: Session,
new_row_number: int,
source_model: PlaylistModel,
add_to_header: Optional[bool] = False,
@ -104,7 +109,9 @@ class TrackSelectDialog(QDialog):
if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.source_model.move_track_to_header(self.new_row_number, existing_prd, note)
self.source_model.move_track_to_header(
self.new_row_number, existing_prd, note
)
else:
self.source_model.add_track_to_header(self.new_row_number, track_id)
# Close dialog - we can only add one track to a header
@ -112,7 +119,9 @@ class TrackSelectDialog(QDialog):
else:
# Adding a new track row
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.source_model.move_track_add_note(self.new_row_number, existing_prd, note)
self.source_model.move_track_add_note(
self.new_row_number, existing_prd, note
)
else:
self.source_model.insert_row(self.new_row_number, track_id, note)

View File

@ -1,4 +1,4 @@
from datetime import datetime
import datetime as dt
from email.message import EmailMessage
from typing import Any, Dict, Optional
import functools
@ -99,7 +99,7 @@ def get_audio_segment(path: str) -> Optional[AudioSegment]:
return None
def get_embedded_time(text: str) -> Optional[datetime]:
def get_embedded_time(text: str) -> Optional[dt.datetime]:
"""Return datetime specified as @hh:mm in text"""
try:
@ -110,7 +110,7 @@ def get_embedded_time(text: str) -> Optional[datetime]:
return None
try:
return datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
return dt.datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
except ValueError:
return None
@ -143,7 +143,7 @@ def get_file_metadata(filepath: str) -> dict:
def get_relative_date(
past_date: Optional[datetime], reference_date: Optional[datetime] = None
past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None
) -> str:
"""
Return how long before reference_date past_date is as string.
@ -158,7 +158,7 @@ def get_relative_date(
if not past_date or past_date == Config.EPOCH:
return "Never"
if not reference_date:
reference_date = datetime.now()
reference_date = dt.datetime.now()
# Check parameters
if past_date > reference_date:

View File

@ -1,6 +1,6 @@
import urllib.parse
from datetime import datetime
import datetime as dt
from slugify import slugify # type: ignore
from typing import Dict
from PyQt6.QtCore import QUrl # type: ignore
@ -24,14 +24,14 @@ class InfoTabs(QTabWidget):
self.signals.search_songfacts_signal.connect(self.open_in_songfacts)
self.signals.search_wikipedia_signal.connect(self.open_in_wikipedia)
# re-use the oldest one later)
self.last_update: Dict[QWebEngineView, datetime] = {}
self.last_update: Dict[QWebEngineView, dt.datetime] = {}
self.tabtitles: Dict[int, str] = {}
# Create one tab which (for some reason) creates flickering if
# done later
widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
self.last_update[widget] = datetime.now()
self.last_update[widget] = dt.datetime.now()
_ = self.addTab(widget, "")
def open_in_songfacts(self, title):
@ -80,7 +80,7 @@ class InfoTabs(QTabWidget):
self.setTabText(tab_index, short_title)
widget.setUrl(QUrl(url))
self.last_update[widget] = datetime.now()
self.last_update[widget] = dt.datetime.now()
self.tabtitles[tab_index] = url
# Show newly updated tab

View File

@ -1,68 +1,46 @@
#!/usr/bin/python3
import re
from config import Config
from dbconfig import scoped_session
from datetime import datetime
from pprint import pprint
# Standard library imports
from typing import List, Optional, Sequence
import datetime as dt
import os
import re
import sys
from sqlalchemy.ext.associationproxy import association_proxy
# PyQt imports
# Third party imports
from alchemical import Alchemical # type:ignore
from sqlalchemy import (
bindparam,
Boolean,
DateTime,
delete,
ForeignKey,
func,
select,
String,
update,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.session import Session
from sqlalchemy.orm import (
DeclarativeBase,
joinedload,
Mapped,
mapped_column,
relationship,
)
from sqlalchemy.orm.exc import (
NoResultFound,
)
from sqlalchemy.exc import (
IntegrityError,
)
# App imports
import dbtables
from config import Config
from log import log
class Base(DeclarativeBase):
pass
# Establish database connection
ALCHEMICAL_DATABASE_URI = os.environ.get("ALCHEMICAL_DATABASE_URI")
if ALCHEMICAL_DATABASE_URI is None:
raise ValueError("ALCHEMICAL_DATABASE_URI is undefined")
if "unittest" in sys.modules and "sqlite" not in ALCHEMICAL_DATABASE_URI:
raise ValueError("Unit tests running on non-Sqlite database")
db = Alchemical(ALCHEMICAL_DATABASE_URI)
# Database classes
class Carts(Base):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
cart_number: Mapped[int] = mapped_column(unique=True)
name: Mapped[str] = mapped_column(String(256), index=True)
duration: Mapped[Optional[int]] = mapped_column(index=True)
path: Mapped[Optional[str]] = mapped_column(String(2048), index=False)
enabled: Mapped[Optional[bool]] = mapped_column(default=False)
def __repr__(self) -> str:
return (
f"<Carts(id={self.id}, cart={self.cart_number}, "
f"name={self.name}, path={self.path}>"
)
class Carts(dbtables.CartsTable):
def __init__(
self,
session: scoped_session,
session: Session,
cart_number: int,
name: str,
duration: Optional[int] = None,
@ -81,26 +59,10 @@ class Carts(Base):
session.commit()
class NoteColours(Base):
__tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=False)
colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True)
is_regex: Mapped[bool] = mapped_column(default=False, index=False)
is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
order: Mapped[Optional[int]] = mapped_column(index=True)
def __repr__(self) -> str:
return (
f"<NoteColour(id={self.id}, substring={self.substring}, "
f"colour={self.colour}>"
)
class NoteColours(dbtables.NoteColoursTable):
def __init__(
self,
session: scoped_session,
session: Session,
substring: str,
colour: str,
enabled: bool = True,
@ -116,10 +78,10 @@ class NoteColours(Base):
self.order = order
session.add(self)
session.flush()
session.commit()
@classmethod
def get_all(cls, session: scoped_session) -> Sequence["NoteColours"]:
def get_all(cls, session: Session) -> Sequence["NoteColours"]:
"""
Return all records
"""
@ -127,7 +89,7 @@ class NoteColours(Base):
return session.scalars(select(cls)).all()
@staticmethod
def get_colour(session: scoped_session, text: str) -> Optional[str]:
def get_colour(session: Session, text: str) -> Optional[str]:
"""
Parse text and return colour string if matched, else empty string
"""
@ -158,30 +120,17 @@ class NoteColours(Base):
return None
class Playdates(Base):
__tablename__ = "playdates"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
lastplayed: Mapped[datetime] = mapped_column(index=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"))
track: Mapped["Tracks"] = relationship("Tracks", back_populates="playdates")
def __repr__(self) -> str:
return (
f"<Playdates(id={self.id}, track_id={self.track_id} "
f"lastplayed={self.lastplayed}>"
)
def __init__(self, session: scoped_session, track_id: int) -> None:
class Playdates(dbtables.PlaydatesTable):
def __init__(self, session: Session, track_id: int) -> None:
"""Record that track was played"""
self.lastplayed = datetime.now()
self.lastplayed = dt.datetime.now()
self.track_id = track_id
session.add(self)
session.commit()
@staticmethod
def last_played(session: scoped_session, track_id: int) -> datetime:
def last_played(session: Session, track_id: int) -> dt.datetime:
"""Return datetime track last played or None"""
last_played = session.execute(
@ -194,10 +143,12 @@ class Playdates(Base):
if last_played:
return last_played[0]
else:
return Config.EPOCH
# Should never be reached as we create record with a
# last_played value
return Config.EPOCH # pragma: no cover
@staticmethod
def played_after(session: scoped_session, since: datetime) -> Sequence["Playdates"]:
def played_after(session: Session, since: dt.datetime) -> Sequence["Playdates"]:
"""Return a list of Playdates objects since passed time"""
return session.scalars(
@ -207,50 +158,20 @@ class Playdates(Base):
).all()
class Playlists(Base):
"""
Manage playlists
"""
__tablename__ = "playlists"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(32), unique=True)
last_used: Mapped[Optional[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)
deleted: Mapped[bool] = mapped_column(default=False)
rows: Mapped[List["PlaylistRows"]] = relationship(
"PlaylistRows",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistRows.plr_rownum",
)
def __repr__(self) -> str:
return (
f"<Playlists(id={self.id}, name={self.name}, "
f"is_templatee={self.is_template}, open={self.open}>"
)
def __init__(self, session: scoped_session, name: str):
class Playlists(dbtables.PlaylistsTable):
def __init__(self, session: Session, name: str):
self.name = name
session.add(self)
session.flush()
session.commit()
@staticmethod
def clear_tabs(session: scoped_session, playlist_ids: List[int]) -> None:
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)
update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
)
def close(self) -> None:
@ -260,7 +181,7 @@ class Playlists(Base):
@classmethod
def create_playlist_from_template(
cls, session: scoped_session, template: "Playlists", playlist_name: str
cls, session: Session, template: "Playlists", playlist_name: str
) -> Optional["Playlists"]:
"""Create a new playlist from template"""
@ -274,16 +195,16 @@ class Playlists(Base):
return playlist
def delete(self, session: scoped_session) -> None:
def delete(self, session: Session) -> None:
"""
Mark as deleted
"""
self.deleted = True
session.flush()
session.commit()
@classmethod
def get_all(cls, session: scoped_session) -> Sequence["Playlists"]:
def get_all(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use"""
return session.scalars(
@ -293,7 +214,7 @@ class Playlists(Base):
).all()
@classmethod
def get_all_templates(cls, session: scoped_session) -> Sequence["Playlists"]:
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name"""
return session.scalars(
@ -301,7 +222,7 @@ class Playlists(Base):
).all()
@classmethod
def get_closed(cls, session: scoped_session) -> Sequence["Playlists"]:
def get_closed(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use"""
return session.scalars(
@ -315,7 +236,7 @@ class Playlists(Base):
).all()
@classmethod
def get_open(cls, session: scoped_session) -> Sequence[Optional["Playlists"]]:
def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]:
"""
Return a list of loaded playlists ordered by tab.
"""
@ -328,10 +249,10 @@ class Playlists(Base):
"""Mark playlist as loaded and used now"""
self.open = True
self.last_used = datetime.now()
self.last_used = dt.datetime.now()
@staticmethod
def name_is_available(session: scoped_session, name: str) -> bool:
def name_is_available(session: Session, name: str) -> bool:
"""
Return True if no playlist of this name exists else false.
"""
@ -341,17 +262,17 @@ class Playlists(Base):
is None
)
def rename(self, session: scoped_session, new_name: str) -> None:
def rename(self, session: Session, new_name: str) -> None:
"""
Rename playlist
"""
self.name = new_name
session.flush()
session.commit()
@staticmethod
def save_as_template(
session: scoped_session, playlist_id: int, template_name: str
session: Session, playlist_id: int, template_name: str
) -> None:
"""Save passed playlist as new template"""
@ -365,35 +286,10 @@ class Playlists(Base):
PlaylistRows.copy_playlist(session, playlist_id, template.id)
class PlaylistRows(Base):
__tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
plr_rownum: Mapped[int]
note: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False
)
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"))
playlist: Mapped[Playlists] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id"))
track: Mapped["Tracks"] = relationship(
"Tracks",
back_populates="playlistrows",
)
played: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
return (
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, "
f"note={self.note}, plr_rownum={self.plr_rownum}>"
)
class PlaylistRows(dbtables.PlaylistRowsTable):
def __init__(
self,
session: scoped_session,
session: Session,
playlist_id: int,
row_number: int,
note: str = "",
@ -406,7 +302,7 @@ class PlaylistRows(Base):
self.plr_rownum = row_number
self.note = note
session.add(self)
session.flush()
session.commit()
def append_note(self, extra_note: str) -> None:
"""Append passed note to any existing note"""
@ -418,7 +314,7 @@ class PlaylistRows(Base):
self.note = extra_note
@staticmethod
def copy_playlist(session: scoped_session, src_id: int, dst_id: int) -> None:
def copy_playlist(session: Session, src_id: int, dst_id: int) -> None:
"""Copy playlist entries"""
src_rows = session.scalars(
@ -436,7 +332,7 @@ class PlaylistRows(Base):
@classmethod
def deep_row(
cls, session: scoped_session, playlist_id: int, row_number: int
cls, session: Session, playlist_id: int, row_number: int
) -> "PlaylistRows":
"""
Return a playlist row that includes full track and lastplayed data for
@ -456,9 +352,7 @@ class PlaylistRows(Base):
return session.execute(stmt).unique().scalar_one()
@classmethod
def deep_rows(
cls, session: scoped_session, playlist_id: int
) -> Sequence["PlaylistRows"]:
def deep_rows(cls, session: Session, playlist_id: int) -> Sequence["PlaylistRows"]:
"""
Return a list of playlist rows that include full track and lastplayed data for
given playlist_id., Sequence
@ -475,9 +369,7 @@ class PlaylistRows(Base):
return session.scalars(stmt).unique().all()
@staticmethod
def delete_higher_rows(
session: scoped_session, playlist_id: int, maxrow: int
) -> None:
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'
@ -489,10 +381,10 @@ class PlaylistRows(Base):
PlaylistRows.plr_rownum > maxrow,
)
)
session.flush()
session.commit()
@staticmethod
def delete_row(session: scoped_session, playlist_id: int, row_number: int) -> None:
def delete_row(session: Session, playlist_id: int, row_number: int) -> None:
"""
Delete passed row in given playlist.
"""
@ -505,7 +397,7 @@ class PlaylistRows(Base):
)
@staticmethod
def fixup_rownumbers(session: scoped_session, playlist_id: int) -> None:
def fixup_rownumbers(session: Session, playlist_id: int) -> None:
"""
Ensure the row numbers for passed playlist have no gaps
"""
@ -524,7 +416,7 @@ class PlaylistRows(Base):
@classmethod
def plrids_to_plrs(
cls, session: scoped_session, playlist_id: int, plr_ids: List[int]
cls, session: Session, playlist_id: int, plr_ids: List[int]
) -> Sequence["PlaylistRows"]:
"""
Take a list of PlaylistRows ids and return a list of corresponding
@ -540,7 +432,7 @@ class PlaylistRows(Base):
return plrs
@staticmethod
def get_last_used_row(session: scoped_session, playlist_id: int) -> Optional[int]:
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(
@ -551,7 +443,7 @@ class PlaylistRows(Base):
@staticmethod
def get_track_plr(
session: scoped_session, track_id: int, playlist_id: int
session: Session, track_id: int, playlist_id: int
) -> Optional["PlaylistRows"]:
"""Return first matching PlaylistRows object or None"""
@ -566,7 +458,7 @@ class PlaylistRows(Base):
@classmethod
def get_played_rows(
cls, session: scoped_session, playlist_id: int
cls, session: Session, playlist_id: int
) -> Sequence["PlaylistRows"]:
"""
For passed playlist, return a list of rows that
@ -584,10 +476,8 @@ class PlaylistRows(Base):
@classmethod
def get_rows_with_tracks(
cls,
session: scoped_session,
session: Session,
playlist_id: int,
from_row: Optional[int] = None,
to_row: Optional[int] = None,
) -> Sequence["PlaylistRows"]:
"""
For passed playlist, return a list of rows that
@ -597,18 +487,13 @@ class PlaylistRows(Base):
query = select(cls).where(
cls.playlist_id == playlist_id, cls.track_id.is_not(None)
)
if from_row is not None:
query = query.where(cls.plr_rownum >= from_row)
if to_row is not None:
query = query.where(cls.plr_rownum <= to_row)
plrs = session.scalars((query).order_by(cls.plr_rownum)).all()
return plrs
@classmethod
def get_unplayed_rows(
cls, session: scoped_session, playlist_id: int
cls, session: Session, playlist_id: int
) -> Sequence["PlaylistRows"]:
"""
For passed playlist, return a list of playlist rows that
@ -629,14 +514,25 @@ class PlaylistRows(Base):
@classmethod
def insert_row(
cls, session: scoped_session, playlist_id: int, new_row_number: int
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, new_row_number)
return cls(
session,
playlist_id=playlist_id,
row_number=new_row_number,
note=note,
track_id=track_id,
)
@staticmethod
def move_rows_down(
session: scoped_session, playlist_id: int, starting_row: int, move_by: int
session: Session, playlist_id: int, starting_row: int, move_by: int
) -> None:
"""
Create space to insert move_by additional rows by incremented row
@ -656,7 +552,7 @@ class PlaylistRows(Base):
@staticmethod
def update_plr_rownumbers(
session: scoped_session, playlist_id: int, sqla_map: List[dict[str, int]]
session: Session, playlist_id: int, sqla_map: List[dict[str, int]]
) -> None:
"""
Take a {plrid: plr_rownum} dictionary and update the row numbers accordingly
@ -675,27 +571,11 @@ class PlaylistRows(Base):
session.connection().execute(stmt, sqla_map)
class Settings(Base):
"""Manage settings"""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
f_datetime: Mapped[Optional[datetime]] = mapped_column(default=None)
f_int: Mapped[Optional[int]] = mapped_column(default=None)
f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str:
return (
f"<Settings(id={self.id}, name={self.name}, "
f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>"
)
def __init__(self, session: scoped_session, name: str):
class Settings(dbtables.SettingsTable):
def __init__(self, session: Session, name: str):
self.name = name
session.add(self)
session.flush()
session.commit()
@classmethod
def all_as_dict(cls, session):
@ -712,7 +592,7 @@ class Settings(Base):
return result
@classmethod
def get_int_settings(cls, session: scoped_session, name: str) -> "Settings":
def get_int_settings(cls, session: Session, name: str) -> "Settings":
"""Get setting for an integer or return new setting record"""
try:
@ -721,45 +601,17 @@ class Settings(Base):
except NoResultFound:
return Settings(session, name)
def update(self, session: scoped_session, data: dict) -> None:
def update(self, session: Session, data: dict) -> None:
for key, value in data.items():
assert hasattr(self, key)
setattr(self, key, value)
session.flush()
session.commit()
class Tracks(Base):
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(256), index=True)
artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[Optional[int]] = mapped_column(default=None)
duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False)
mtime: Mapped[float] = mapped_column(index=True)
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False)
playlistrows: Mapped[List[PlaylistRows]] = relationship(
"PlaylistRows", back_populates="track"
)
playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[List[Playdates]] = relationship(
"Playdates",
back_populates="track",
lazy="joined",
)
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)
class Tracks(dbtables.TracksTable):
def __init__(
self,
session: scoped_session,
session: Session,
path: str,
title: str,
artist: str,
@ -795,7 +647,7 @@ class Tracks(Base):
return session.scalars(select(cls)).unique().all()
@classmethod
def get_by_path(cls, session: scoped_session, path: str) -> Optional["Tracks"]:
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
"""
Return track with passed path, or None.
"""
@ -810,7 +662,7 @@ class Tracks(Base):
return None
@classmethod
def search_artists(cls, session: scoped_session, text: str) -> Sequence["Tracks"]:
def search_artists(cls, session: Session, text: str) -> Sequence["Tracks"]:
"""
Search case-insenstively for artists containing str
@ -831,7 +683,7 @@ class Tracks(Base):
)
@classmethod
def search_titles(cls, session: scoped_session, text: str) -> Sequence["Tracks"]:
def search_titles(cls, session: Session, text: str) -> Sequence["Tracks"]:
"""
Search case-insenstively for titles containing str

View File

@ -1,22 +1,17 @@
#!/usr/bin/env python3
from datetime import datetime, timedelta
from time import sleep
from typing import (
cast,
List,
Optional,
)
# Standard library imports
from os.path import basename
from time import sleep
from typing import cast, List, Optional
import argparse
import datetime as dt
import os
import subprocess
import sys
import threading
import pipeclient
from pygame import mixer
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QDate,
@ -49,8 +44,14 @@ from PyQt6.QtWidgets import (
QProgressBar,
QPushButton,
)
# Third party imports
from pygame import mixer
import pipeclient
from sqlalchemy.orm.session import Session
import stackprinter # type: ignore
# App imports
from classes import (
track_sequence,
FadeCurve,
@ -58,23 +59,18 @@ from classes import (
PlaylistTrack,
)
from config import Config
from dbconfig import (
engine,
scoped_session,
Session,
)
from dialogs import TrackSelectDialog
from log import log
from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from models import db, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab
from ui import icons_rc # noqa F401
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from ui.main_window_ui import Ui_MainWindow # type: ignore
from utilities import check_db, update_bitrates
import helpers
from ui import icons_rc # noqa F401
import music
@ -168,7 +164,7 @@ class ImportTrack(QObject):
Create track objects from passed files and add to visible playlist
"""
with Session() as session:
with db.Session() as session:
for fname in self.filenames:
self.signals.status_message_signal.emit(
f"Importing {basename(fname)}", 5000
@ -208,14 +204,12 @@ class Window(QMainWindow, Ui_MainWindow):
self.music: music.Music = music.Music()
self.playing: bool = False
self.selected_plrs: Optional[List[PlaylistRows]] = None
self.set_main_window_size()
self.lblSumPlaytime = QLabel("")
self.statusbar.addPermanentWidget(self.lblSumPlaytime)
self.txtSearch = QLineEdit()
self.statusbar.addWidget(self.txtSearch)
self.txtSearch.setHidden(True)
self.statusbar.addWidget(self.txtSearch)
self.hide_played_tracks = False
mixer.init()
self.widgetFadeVolume.hideAxis("bottom")
@ -257,7 +251,7 @@ class Window(QMainWindow, Ui_MainWindow):
except subprocess.CalledProcessError as exc_info:
git_tag = str(exc_info.output)
with Session() as session:
with db.Session() as session:
if session.bind:
dbname = session.bind.engine.url.database
@ -319,7 +313,7 @@ class Window(QMainWindow, Ui_MainWindow):
def cart_edit(self, btn: CartButton, event: QEvent):
"""Handle context menu for cart button"""
with Session() as session:
with db.Session() as session:
cart = session.query(Carts).get(btn.cart_id)
if cart is None:
log.error("cart_edit: cart not found")
@ -351,7 +345,7 @@ class Window(QMainWindow, Ui_MainWindow):
def carts_init(self) -> None:
"""Initialse carts data structures"""
with Session() as session:
with db.Session() as session:
# Number carts from 1 for humanity
for cart_number in range(1, Config.CARTS_COUNT + 1):
cart = session.query(Carts).get(cart_number)
@ -428,7 +422,7 @@ class Window(QMainWindow, Ui_MainWindow):
self, "Track playing", "Can't close application while track is playing"
)
else:
with Session() as session:
with db.Session() as session:
settings = Settings.all_as_dict(session)
record = settings["mainwindow_height"]
if record.f_int != self.height():
@ -497,7 +491,7 @@ class Window(QMainWindow, Ui_MainWindow):
return False
# Record playlist as closed and update remaining playlist tabs
with Session() as session:
with db.Session() as session:
playlist = session.get(Playlists, closing_tab_playlist_id)
if playlist:
playlist.close()
@ -568,7 +562,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer1000.timeout.connect(self.tick_1000ms)
def create_playlist(
self, session: scoped_session, playlist_name: Optional[str] = None
self, session: Session, playlist_name: Optional[str] = None
) -> Optional[Playlists]:
"""Create new playlist"""
@ -590,7 +584,7 @@ class Window(QMainWindow, Ui_MainWindow):
def create_and_show_playlist(self) -> None:
"""Create new playlist and display it"""
with Session() as session:
with db.Session() as session:
playlist = self.create_playlist(session)
if playlist:
self.create_playlist_tab(playlist)
@ -638,7 +632,7 @@ class Window(QMainWindow, Ui_MainWindow):
Delete current playlist
"""
with Session() as session:
with db.Session() as session:
playlist_id = self.active_tab().playlist_id
playlist = session.get(Playlists, playlist_id)
if playlist:
@ -672,7 +666,7 @@ class Window(QMainWindow, Ui_MainWindow):
path += ".csv"
with open(path, "w") as f:
with Session() as session:
with db.Session() as session:
for playdate in Playdates.played_after(session, start_dt):
f.write(f"{playdate.track.artist},{playdate.track.title}\n")
@ -704,7 +698,7 @@ class Window(QMainWindow, Ui_MainWindow):
playlist_id = self.active_tab().playlist_id
with Session() as session:
with db.Session() as session:
# Get output filename
playlist = session.get(Playlists, playlist_id)
if not playlist:
@ -755,7 +749,7 @@ class Window(QMainWindow, Ui_MainWindow):
if track_sequence.now.track_id is None or track_sequence.now.start_time is None:
return 0
now = datetime.now()
now = dt.datetime.now()
track_start = track_sequence.now.start_time
elapsed_seconds = (now - track_start).total_seconds()
return int(elapsed_seconds * 1000)
@ -786,7 +780,7 @@ class Window(QMainWindow, Ui_MainWindow):
if not dlg.exec():
return
with Session() as session:
with db.Session() as session:
new_tracks = []
for fname in dlg.selectedFiles():
txt = ""
@ -885,7 +879,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.active_tab().source_model_selected_row_number()
or self.active_proxy_model().rowCount()
)
with Session() as session:
with db.Session() as session:
dlg = TrackSelectDialog(
session=session,
new_row_number=new_row_number,
@ -897,12 +891,12 @@ class Window(QMainWindow, Ui_MainWindow):
"""Load the playlists that were open when the last session closed"""
playlist_ids = []
with Session() as session:
with db.Session() as session:
for playlist in Playlists.get_open(session):
if playlist:
_ = self.create_playlist_tab(playlist)
playlist_ids.append(playlist.id)
log.info(f"load_last_playlists() loaded {playlist=}")
log.debug(f"load_last_playlists() loaded {playlist=}")
# Set active tab
record = Settings.get_int_settings(session, "active_tab")
if record.f_int is not None and record.f_int >= 0:
@ -954,7 +948,7 @@ class Window(QMainWindow, Ui_MainWindow):
visible_tab = self.active_tab()
source_playlist_id = visible_tab.playlist_id
with Session() as session:
with db.Session() as session:
for playlist in Playlists.get_all(session):
if playlist.id == source_playlist_id:
continue
@ -1007,7 +1001,7 @@ class Window(QMainWindow, Ui_MainWindow):
def new_from_template(self) -> None:
"""Create new playlist from template"""
with Session() as session:
with db.Session() as session:
templates = Playlists.get_all_templates(session)
dlg = SelectPlaylistDialog(self, playlists=templates, session=session)
dlg.exec()
@ -1033,7 +1027,7 @@ class Window(QMainWindow, Ui_MainWindow):
def open_playlist(self) -> None:
"""Open existing playlist"""
with Session() as session:
with db.Session() as session:
playlists = Playlists.get_closed(session)
dlg = SelectPlaylistDialog(self, playlists=playlists, session=session)
dlg.exec()
@ -1199,7 +1193,7 @@ class Window(QMainWindow, Ui_MainWindow):
Rename current playlist
"""
with Session() as session:
with db.Session() as session:
playlist_id = self.active_tab().playlist_id
playlist = session.get(Playlists, playlist_id)
if playlist:
@ -1243,12 +1237,12 @@ class Window(QMainWindow, Ui_MainWindow):
and track_sequence.now.resume_marker
):
elapsed_ms = track_sequence.now.duration * track_sequence.now.resume_marker
track_sequence.now.start_time -= timedelta(milliseconds=elapsed_ms)
track_sequence.now.start_time -= dt.timedelta(milliseconds=elapsed_ms)
def save_as_template(self) -> None:
"""Save current playlist as template"""
with Session() as session:
with db.Session() as session:
template_names = [a.name for a in Playlists.get_all_templates(session)]
while True:
@ -1310,21 +1304,39 @@ class Window(QMainWindow, Ui_MainWindow):
def set_main_window_size(self) -> None:
"""Set size of window from database"""
with Session() as session:
with db.Session() as session:
settings = Settings.all_as_dict(session)
record = settings["mainwindow_x"]
x = record.f_int or 1
record = settings["mainwindow_y"]
y = record.f_int or 1
record = settings["mainwindow_width"]
width = record.f_int or 1599
record = settings["mainwindow_height"]
height = record.f_int or 981
if "mainwindow_x" in settings:
record = settings["mainwindow_x"]
x = record.f_int or 1
else:
x = 100
if "mainwindow_y" in settings:
record = settings["mainwindow_y"]
y = record.f_int or 1
else:
y = 100
if "mainwindow_width" in settings:
record = settings["mainwindow_width"]
width = record.f_int or 1599
else:
width = 100
if "mainwindow_height" in settings:
record = settings["mainwindow_height"]
height = record.f_int or 981
else:
height = 100
self.setGeometry(x, y, width, height)
record = settings["splitter_top"]
splitter_top = record.f_int or 256
record = settings["splitter_bottom"]
splitter_bottom = record.f_int or 256
if "splitter_top" in settings:
record = settings["splitter_top"]
splitter_top = record.f_int or 256
else:
splitter_top = 100
if "splitter_bottom" in settings:
record = settings["splitter_bottom"]
splitter_bottom = record.f_int or 256
else:
splitter_bottom = 100
self.splitter.setSizes([splitter_top, splitter_bottom])
return
@ -1397,7 +1409,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.tabPlaylist.currentWidget().scroll_to_top(display_row)
def solicit_playlist_name(
self, session: scoped_session, default: str = ""
self, session: Session, default: str = ""
) -> Optional[str]:
"""Get name of new playlist from user"""
@ -1498,13 +1510,19 @@ class Window(QMainWindow, Ui_MainWindow):
"""
# Update volume fade curve
if (
track_sequence.now.fade_graph_start_updates is None
or track_sequence.now.fade_graph_start_updates > dt.datetime.now()
):
return
if (
track_sequence.now.track_id
and track_sequence.now.fade_graph
and track_sequence.now.start_time
):
play_time = (
datetime.now() - track_sequence.now.start_time
dt.datetime.now() - track_sequence.now.start_time
).total_seconds() * 1000
track_sequence.now.fade_graph.tick(play_time)
@ -1513,7 +1531,7 @@ class Window(QMainWindow, Ui_MainWindow):
Called every 500ms
"""
self.lblTOD.setText(datetime.now().strftime(Config.TOD_TIME_FORMAT))
self.lblTOD.setText(dt.datetime.now().strftime(Config.TOD_TIME_FORMAT))
# Update carts
# self.cart_tick()
@ -1541,8 +1559,8 @@ class Window(QMainWindow, Ui_MainWindow):
and track_sequence.now.start_time
and (
self.music.player.is_playing()
or (datetime.now() - track_sequence.now.start_time)
< timedelta(microseconds=Config.PLAY_SETTLE)
or (dt.datetime.now() - track_sequence.now.start_time)
< dt.timedelta(microseconds=Config.PLAY_SETTLE)
)
):
playtime = self.get_playtime()
@ -1631,7 +1649,7 @@ class CartDialog(QDialog):
"""Edit cart details"""
def __init__(
self, musicmuster: Window, session: scoped_session, cart: Carts, *args, **kwargs
self, musicmuster: Window, session: Session, cart: Carts, *args, **kwargs
) -> None:
"""
Manage carts
@ -1756,18 +1774,15 @@ if __name__ == "__main__":
# Run as required
if args.check_db:
log.debug("Updating database")
with Session() as session:
log.debug("Checking database")
with db.Session() as session:
check_db(session)
engine.dispose()
elif args.update_bitrates:
log.debug("Update bitrates")
with Session() as session:
with db.Session() as session:
update_bitrates(session)
engine.dispose()
else:
try:
Base.metadata.create_all(engine)
app = QApplication(sys.argv)
# PyQt6 defaults to a grey for labels
palette = app.palette()
@ -1785,7 +1800,6 @@ if __name__ == "__main__":
win = Window()
win.show()
status = app.exec()
engine.dispose()
sys.exit(status)
except Exception as exc:
if os.environ["MM_ENV"] == "PRODUCTION":

View File

@ -81,22 +81,22 @@ import argparse
if sys.version_info[0] < 3:
raise RuntimeError('PipeClient Error: Python 3.x required')
raise RuntimeError("PipeClient Error: Python 3.x required")
# Platform specific constants
if sys.platform == 'win32':
WRITE_NAME: str = '\\\\.\\pipe\\ToSrvPipe'
READ_NAME: str = '\\\\.\\pipe\\FromSrvPipe'
EOL: str = '\r\n\0'
if sys.platform == "win32":
WRITE_NAME: str = "\\\\.\\pipe\\ToSrvPipe"
READ_NAME: str = "\\\\.\\pipe\\FromSrvPipe"
EOL: str = "\r\n\0"
else:
# Linux or Mac
PIPE_BASE: str = '/tmp/audacity_script_pipe.'
WRITE_NAME: str = PIPE_BASE + 'to.' + str(os.getuid())
READ_NAME: str = PIPE_BASE + 'from.' + str(os.getuid())
EOL: str = '\n'
PIPE_BASE: str = "/tmp/audacity_script_pipe."
WRITE_NAME: str = PIPE_BASE + "to." + str(os.getuid())
READ_NAME: str = PIPE_BASE + "from." + str(os.getuid())
EOL: str = "\n"
class PipeClient():
class PipeClient:
"""Write / read client access to Audacity via named pipes.
Normally there should be just one instance of this class. If
@ -141,7 +141,7 @@ class PipeClient():
self.timer: bool = False # type: ignore
self._start_time: float = 0 # type: ignore
self._write_pipe = None
self.reply: str = '' # type: ignore
self.reply: str = "" # type: ignore
if not self._write_pipe:
self._write_thread_start()
self._read_thread_start()
@ -156,11 +156,11 @@ class PipeClient():
# Allow a little time for connection to be made.
time.sleep(0.1)
if not self._write_pipe:
raise RuntimeError('PipeClientError: Write pipe cannot be opened.')
raise RuntimeError("PipeClientError: Write pipe cannot be opened.")
def _write_pipe_open(self) -> None:
"""Open _write_pipe."""
self._write_pipe = open(WRITE_NAME, 'w')
self._write_pipe = open(WRITE_NAME, "w")
def _read_thread_start(self) -> None:
"""Start read_pipe thread."""
@ -187,16 +187,16 @@ class PipeClient():
self._write_pipe.write(command + EOL)
# Check that read pipe is alive
if PipeClient.reader_pipe_broken.is_set():
raise RuntimeError('PipeClient: Read-pipe error.')
raise RuntimeError("PipeClient: Read-pipe error.")
try:
self._write_pipe.flush()
if self.timer:
self._start_time = time.time()
self.reply = ''
self.reply = ""
PipeClient.reply_ready.clear()
except IOError as err:
if err.errno == errno.EPIPE:
raise RuntimeError('PipeClient: Write-pipe error.')
raise RuntimeError("PipeClient: Write-pipe error.")
else:
raise
@ -204,27 +204,27 @@ class PipeClient():
"""Read FIFO in worker thread."""
# Thread will wait at this read until it connects.
# Connection should occur as soon as _write_pipe has connected.
with open(READ_NAME, 'r') as read_pipe:
message = ''
with open(READ_NAME, "r") as read_pipe:
message = ""
pipe_ok = True
while pipe_ok:
line = read_pipe.readline()
# Stop timer as soon as we get first line of response.
stop_time = time.time()
while pipe_ok and line != '\n':
while pipe_ok and line != "\n":
message += line
line = read_pipe.readline()
if line == '':
if line == "":
# No data in read_pipe indicates that the pipe
# is broken (Audacity may have crashed).
PipeClient.reader_pipe_broken.set()
pipe_ok = False
if self.timer:
xtime = (stop_time - self._start_time) * 1000
message += f'Execution time: {xtime:.2f}ms'
message += f"Execution time: {xtime:.2f}ms"
self.reply = message
PipeClient.reply_ready.set()
message = ''
message = ""
def read(self) -> str:
"""Read Audacity's reply from pipe.
@ -238,31 +238,45 @@ class PipeClient():
"""
if not PipeClient.reply_ready.is_set():
return ''
return ""
return self.reply
def bool_from_string(strval) -> bool:
"""Return boolean value from string"""
if strval.lower() in ('true', 't', '1', 'yes', 'y'):
if strval.lower() in ("true", "t", "1", "yes", "y"):
return True
if strval.lower() in ('false', 'f', '0', 'no', 'n'):
if strval.lower() in ("false", "f", "0", "no", "n"):
return False
raise argparse.ArgumentTypeError('Boolean value expected.')
raise argparse.ArgumentTypeError("Boolean value expected.")
def main() -> None:
"""Interactive command-line for PipeClient"""
parser = argparse.ArgumentParser()
parser.add_argument('-t', '--timeout', type=float, metavar='', default=10,
help="timeout for reply in seconds (default: 10")
parser.add_argument('-s', '--show-time', metavar='True/False',
nargs='?', type=bool_from_string,
const='t', default='t', dest='show',
help='show command execution time (default: True)')
parser.add_argument('-d', '--docs', action='store_true',
help='show documentation and exit')
parser.add_argument(
"-t",
"--timeout",
type=float,
metavar="",
default=10,
help="timeout for reply in seconds (default: 10",
)
parser.add_argument(
"-s",
"--show-time",
metavar="True/False",
nargs="?",
type=bool_from_string,
const="t",
default="t",
dest="show",
help="show command execution time (default: True)",
)
parser.add_argument(
"-d", "--docs", action="store_true", help="show documentation and exit"
)
args = parser.parse_args()
if args.docs:
@ -271,23 +285,23 @@ def main() -> None:
client: PipeClient = PipeClient()
while True:
reply: str = ''
reply: str = ""
message: str = input("\nEnter command or 'Q' to quit: ")
start = time.time()
if message.upper() == 'Q':
if message.upper() == "Q":
sys.exit(0)
elif message == '':
elif message == "":
pass
else:
client.write(message, timer=args.show)
while reply == '':
while reply == "":
time.sleep(0.1) # allow time for reply
if time.time() - start > args.timeout:
reply = 'PipeClient: Reply timed-out.'
reply = "PipeClient: Reply timed-out."
else:
reply = client.read()
print(reply)
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@ -1,13 +1,15 @@
# Standard library imports
# Allow forward reference to PlaylistModel
from __future__ import annotations
import obsws_python as obs # type: ignore
import re
from datetime import datetime, timedelta
from enum import auto, Enum
from operator import attrgetter
from random import shuffle
from typing import List, Optional
import datetime as dt
import re
# PyQt imports
from PyQt6.QtCore import (
QAbstractTableModel,
QModelIndex,
@ -23,9 +25,14 @@ from PyQt6.QtGui import (
QFont,
)
# Third party imports
import obsws_python as obs # type: ignore
# import snoop # type: ignore
# App imports
from classes import track_sequence, MusicMusterSignals, PlaylistTrack
from config import Config
from dbconfig import scoped_session, Session
from helpers import (
file_is_unreadable,
get_embedded_time,
@ -34,7 +41,7 @@ from helpers import (
set_track_metadata,
)
from log import log
from models import NoteColours, Playdates, PlaylistRows, Tracks
from models import db, NoteColours, Playdates, PlaylistRows, Tracks
HEADER_NOTES_COLUMN = 1
@ -62,13 +69,13 @@ class PlaylistRowData:
self.artist: str = ""
self.bitrate = 0
self.duration: int = 0
self.lastplayed: datetime = Config.EPOCH
self.lastplayed: dt.datetime = Config.EPOCH
self.path = ""
self.played = False
self.start_gap: Optional[int] = None
self.title: str = ""
self.start_time: Optional[datetime] = None
self.end_time: Optional[datetime] = None
self.start_time: Optional[dt.datetime] = None
self.end_time: Optional[dt.datetime] = None
self.plrid: int = plr.id
self.plr_rownum: int = plr.plr_rownum
@ -116,7 +123,7 @@ class PlaylistModel(QAbstractTableModel):
*args,
**kwargs,
):
log.info(f"PlaylistModel.__init__({playlist_id=})")
log.debug(f"PlaylistModel.__init__({playlist_id=})")
self.playlist_id = playlist_id
super().__init__(*args, **kwargs)
@ -129,7 +136,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.end_reset_model_signal.connect(self.end_reset_model)
self.signals.row_order_changed_signal.connect(self.row_order_changed)
with Session() as session:
with db.Session() as session:
# Ensure row numbers in playlist are contiguous
PlaylistRows.fixup_rownumbers(session, playlist_id)
# Populate self.playlist_rows
@ -148,7 +155,7 @@ class PlaylistModel(QAbstractTableModel):
Add track to existing header row
"""
log.info(f"add_track_to_header({row_number=}, {track_id=}, {note=}")
log.debug(f"add_track_to_header({row_number=}, {track_id=}, {note=}")
# Get existing row
try:
@ -165,7 +172,7 @@ class PlaylistModel(QAbstractTableModel):
"Header row already has track associated"
)
return
with Session() as session:
with db.Session() as session:
plr = session.get(PlaylistRows, prd.plrid)
if plr:
# Add track to PlaylistRows
@ -187,7 +194,7 @@ class PlaylistModel(QAbstractTableModel):
# Header row
if self.is_header_row(row):
# Check for specific header colouring
with Session() as session:
with db.Session() as session:
note_colour = NoteColours.get_colour(session, prd.note)
if note_colour:
return QBrush(QColor(note_colour))
@ -216,7 +223,7 @@ class PlaylistModel(QAbstractTableModel):
return QBrush(QColor(Config.COLOUR_BITRATE_OK))
if column == Col.NOTE.value:
if prd.note:
with Session() as session:
with db.Session() as session:
note_colour = NoteColours.get_colour(session, prd.note)
if note_colour:
return QBrush(QColor(note_colour))
@ -275,7 +282,7 @@ class PlaylistModel(QAbstractTableModel):
log.debug("Call OBS scene change")
self.obs_scene_change(row_number)
with Session() as session:
with db.Session() as session:
# Update Playdates in database
log.debug("update playdates")
Playdates(session, track_sequence.now.track_id)
@ -377,7 +384,7 @@ class PlaylistModel(QAbstractTableModel):
Delete from highest row back so that not yet deleted row numbers don't change.
"""
with Session() as session:
with db.Session() as session:
for row_number in sorted(row_numbers, reverse=True):
log.info(f"delete_rows(), {row_number=}")
super().beginRemoveRows(QModelIndex(), row_number, row_number)
@ -454,7 +461,7 @@ class PlaylistModel(QAbstractTableModel):
if playlist_id != self.playlist_id:
log.debug(f"end_reset_model: not us ({self.playlist_id=})")
return
with Session() as session:
with db.Session() as session:
self.refresh_data(session)
super().endResetModel()
self.reset_track_sequence_row_numbers()
@ -541,7 +548,7 @@ class PlaylistModel(QAbstractTableModel):
If not given, return row number to add to end of model.
"""
log.info(f"_get_new_row_number({proposed_row_number=})")
log.debug(f"_get_new_row_number({proposed_row_number=})")
if proposed_row_number is None or proposed_row_number > len(self.playlist_rows):
# We are adding to the end of the list
@ -552,7 +559,7 @@ class PlaylistModel(QAbstractTableModel):
else:
new_row_number = proposed_row_number
log.info(f"get_new_row_number() return: {new_row_number=}")
log.debug(f"get_new_row_number() return: {new_row_number=}")
return new_row_number
def get_row_info(self, row_number: int) -> PlaylistRowData:
@ -688,8 +695,9 @@ class PlaylistModel(QAbstractTableModel):
< prd.plr_rownum
)
):
section_end_time = track_sequence.now.end_time + timedelta(
milliseconds=duration
section_end_time = (
track_sequence.now.end_time
+ dt.timedelta(milliseconds=duration)
)
end_time_str = (
", section end time "
@ -744,23 +752,25 @@ class PlaylistModel(QAbstractTableModel):
self,
proposed_row_number: Optional[int],
track_id: Optional[int] = None,
note: Optional[str] = None,
note: str = "",
) -> None:
"""
Insert a row.
"""
log.info(f"insert_row({proposed_row_number=}, {track_id=}, {note=})")
log.debug(f"insert_row({proposed_row_number=}, {track_id=}, {note=})")
new_row_number = self._get_new_row_number(proposed_row_number)
with Session() as session:
with db.Session() as session:
super().beginInsertRows(QModelIndex(), new_row_number, new_row_number)
plr = PlaylistRows.insert_row(session, self.playlist_id, new_row_number)
plr.track_id = track_id
if note:
plr.note = note
_ = PlaylistRows.insert_row(
session=session,
playlist_id=self.playlist_id,
new_row_number=new_row_number,
note=note,
track_id=track_id,
)
self.refresh_data(session)
super().endInsertRows()
@ -820,7 +830,7 @@ class PlaylistModel(QAbstractTableModel):
Mark row as unplayed
"""
with Session() as session:
with db.Session() as session:
for row_number in row_numbers:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid)
if not plr:
@ -835,7 +845,7 @@ class PlaylistModel(QAbstractTableModel):
Move the playlist rows given to to_row and below.
"""
log.info(f"move_rows({from_rows=}, {to_row_number=}")
log.debug(f"move_rows({from_rows=}, {to_row_number=}")
# Build a {current_row_number: new_row_number} dictionary
row_map: dict[int, int] = {}
@ -883,7 +893,7 @@ class PlaylistModel(QAbstractTableModel):
plrid = self.playlist_rows[oldrow].plrid
sqla_map.append({"plrid": plrid, "plr_rownum": newrow})
with Session() as session:
with db.Session() as session:
PlaylistRows.update_plr_rownumbers(session, self.playlist_id, sqla_map)
# Update playlist_rows
self.refresh_data(session)
@ -899,7 +909,7 @@ class PlaylistModel(QAbstractTableModel):
Move the playlist rows given to to_row and below of to_playlist.
"""
log.info(
log.debug(
f"move_rows_between_playlists({from_rows=}, {to_row_number=}, {to_playlist_id=}"
)
@ -912,7 +922,7 @@ class PlaylistModel(QAbstractTableModel):
# Prepare destination playlist for a reset
self.signals.begin_reset_model_signal.emit(to_playlist_id)
with Session() as session:
with db.Session() as session:
# Make room in destination playlist
max_destination_row_number = PlaylistRows.get_last_used_row(
session, to_playlist_id
@ -962,7 +972,7 @@ class PlaylistModel(QAbstractTableModel):
log.info(f"move_track_add_note({new_row_number=}, {existing_prd=}, {note=}")
if note:
with Session() as session:
with db.Session() as session:
plr = session.get(PlaylistRows, existing_prd.plrid)
if plr:
if plr.note:
@ -1050,7 +1060,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display
self.invalidate_row(track_sequence.previous.plr_rownum)
def refresh_data(self, session: scoped_session):
def refresh_data(self, session: db.session):
"""Populate dicts for data calls"""
# Populate self.playlist_rows with playlist data
@ -1071,7 +1081,7 @@ class PlaylistModel(QAbstractTableModel):
log.info(f"remove_track({row_number=})")
with Session() as session:
with db.Session() as session:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid)
if plr:
plr.track_id = None
@ -1085,7 +1095,7 @@ class PlaylistModel(QAbstractTableModel):
track_id = self.playlist_rows[row_number].track_id
if track_id:
with Session() as session:
with db.Session() as session:
track = session.get(Tracks, track_id)
set_track_metadata(track)
self.refresh_row(session, row_number)
@ -1097,11 +1107,11 @@ class PlaylistModel(QAbstractTableModel):
Signal handler for when row ordering has changed
"""
log.info("reset_track_sequence_row_numbers()")
log.debug("reset_track_sequence_row_numbers()")
# Check the track_sequence next, now and previous plrs and
# update the row number
with Session() as session:
with db.Session() as session:
if track_sequence.next.plr_rownum:
next_plr = session.get(PlaylistRows, track_sequence.next.plr_id)
if next_plr:
@ -1129,7 +1139,7 @@ class PlaylistModel(QAbstractTableModel):
return: [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]]
"""
log.info(f"_reversed_contiguous_row_groups({row_numbers=} called")
log.debug(f"_reversed_contiguous_row_groups({row_numbers=} called")
result: List[List[int]] = []
temp: List[int] = []
@ -1145,7 +1155,7 @@ class PlaylistModel(QAbstractTableModel):
result.append(temp)
result.reverse()
log.info(f"_reversed_contiguous_row_groups() returned: {result=}")
log.debug(f"_reversed_contiguous_row_groups() returned: {result=}")
return result
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
@ -1158,7 +1168,7 @@ class PlaylistModel(QAbstractTableModel):
Signal handler for when row ordering has changed
"""
log.info(f"row_order_changed({playlist_id=}) {self.playlist_id=}")
log.debug(f"row_order_changed({playlist_id=}) {self.playlist_id=}")
# Only action if this is for us
if playlist_id != self.playlist_id:
@ -1205,8 +1215,8 @@ class PlaylistModel(QAbstractTableModel):
self.signals.next_track_changed_signal.emit()
return
# Update playing_track
with Session() as session:
# Update track_sequence
with db.Session() as session:
track_sequence.next = PlaylistTrack()
try:
plrid = self.playlist_rows[row_number].plrid
@ -1246,7 +1256,7 @@ class PlaylistModel(QAbstractTableModel):
row_number = index.row()
column = index.column()
with Session() as session:
with db.Session() as session:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid)
if not plr:
print(
@ -1342,12 +1352,15 @@ class PlaylistModel(QAbstractTableModel):
Update track start/end times in self.playlist_rows
"""
log.info("update_track_times()")
log.debug("update_track_times()")
next_start_time: Optional[datetime] = None
next_start_time: Optional[dt.datetime] = None
update_rows: List[int] = []
playlist_length = len(self.playlist_rows)
if not playlist_length:
return
for row_number in range(len(self.playlist_rows)):
for row_number in range(playlist_length):
prd = self.playlist_rows[row_number]
# Reset start_time if this is the current row
@ -1365,7 +1378,7 @@ class PlaylistModel(QAbstractTableModel):
and track_sequence.now.end_time
):
prd.start_time = track_sequence.now.end_time
prd.end_time = prd.start_time + timedelta(milliseconds=prd.duration)
prd.end_time = prd.start_time + dt.timedelta(milliseconds=prd.duration)
next_start_time = prd.end_time
update_rows.append(row_number)
continue
@ -1410,7 +1423,7 @@ class PlaylistModel(QAbstractTableModel):
update_rows.append(row_number)
# Calculate next start time
next_start_time += timedelta(milliseconds=prd.duration)
next_start_time += dt.timedelta(milliseconds=prd.duration)
# Update end time of this row if it's incorrect
if prd.end_time != next_start_time:
@ -1455,7 +1468,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if self.source_model.played_tracks_hidden:
if self.source_model.is_played_row(source_row):
# Don't hide current or next track
with Session() as session:
with db.Session() as session:
if track_sequence.next.plr_id:
next_plr = session.get(PlaylistRows, track_sequence.next.plr_id)
if (
@ -1486,9 +1499,9 @@ class PlaylistProxyModel(QSortFilterProxyModel):
== self.source_model.playlist_id
):
if track_sequence.now.start_time:
if datetime.now() > (
if dt.datetime.now() > (
track_sequence.now.start_time
+ timedelta(
+ dt.timedelta(
milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET
)
):
@ -1556,7 +1569,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
self,
proposed_row_number: Optional[int],
track_id: Optional[int] = None,
note: Optional[str] = None,
note: str = "",
) -> None:
return self.source_model.insert_row(proposed_row_number, track_id, note)

View File

@ -1,8 +1,9 @@
# Standard library imports
from typing import Callable, cast, List, Optional, TYPE_CHECKING
import psutil
import time
from pprint import pprint
from typing import Callable, cast, List, Optional, TYPE_CHECKING
# PyQt imports
from PyQt6.QtCore import (
QEvent,
QModelIndex,
@ -30,10 +31,12 @@ from PyQt6.QtWidgets import (
QStyleOption,
)
from dbconfig import Session
from dialogs import TrackSelectDialog
# Third party imports
# App imports
from classes import MusicMusterSignals, track_sequence
from config import Config
from dialogs import TrackSelectDialog
from helpers import (
ask_yes_no,
ms_to_mmss,
@ -41,11 +44,11 @@ from helpers import (
show_warning,
)
from log import log
from models import Settings
from models import db, Settings
from playlistmodel import PlaylistModel, PlaylistProxyModel
if TYPE_CHECKING:
from musicmuster import Window
from playlistmodel import PlaylistModel, PlaylistProxyModel
class EscapeDelegate(QStyledItemDelegate):
@ -335,7 +338,7 @@ class PlaylistTab(QTableView):
if model_row_number is None:
return
with Session() as session:
with db.Session() as session:
dlg = TrackSelectDialog(
session=session,
new_row_number=model_row_number,
@ -536,7 +539,7 @@ class PlaylistTab(QTableView):
# Resize rows if necessary
self.resizeRowsToContents()
with Session() as session:
with db.Session() as session:
attr_name = f"playlist_col_{column_number}_width"
record = Settings.get_int_settings(session, attr_name)
record.f_int = self.columnWidth(column_number)
@ -830,7 +833,7 @@ class PlaylistTab(QTableView):
return
# Last column is set to stretch so ignore it here
with Session() as session:
with db.Session() as session:
for column_number in range(header.count() - 1):
attr_name = f"playlist_col_{column_number}_width"
record = Settings.get_int_settings(session, attr_name)

View File

@ -4,20 +4,25 @@
# the current directory contains a "better" version of the file than the
# parent (eg, bettet bitrate).
# Standard library imports
import os
import pydymenu # type: ignore
import shutil
import sys
from typing import List
# PyQt imports
# Third party imports
import pydymenu # type: ignore
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session
# App imports
from helpers import (
get_tags,
set_track_metadata,
)
from models import Tracks
from dbconfig import Session
from sqlalchemy.exc import IntegrityError
from typing import List
from models import db, Tracks
# ###################### SETTINGS #########################
process_name_and_tags_matches = True
@ -42,7 +47,7 @@ def main():
# We only want to run this against the production database because
# we will affect files in the common pool of tracks used by all
# databases
if "musicmuster_prod" not in os.environ.get("MM_DB"):
if "musicmuster_prod" not in os.environ.get("ALCHEMICAL_DATABASE_URI"):
response = input("Not on production database - c to continue: ")
if response != "c":
sys.exit(0)
@ -51,7 +56,7 @@ def main():
assert source_dir != parent_dir
# Scan parent directory
with Session() as session:
with db.Session() as session:
all_tracks = Tracks.get_all(session)
parent_tracks = [a for a in all_tracks if parent_dir in a.path]
parent_fnames = [os.path.basename(a.path) for a in parent_tracks]
@ -239,7 +244,7 @@ def process_track(src, dst, title, artist, bitrate):
if not do_processing:
return
with Session() as session:
with db.Session() as session:
track = Tracks.get_by_path(session, dst)
if track:
# Update path, but workaround MariaDB bug

View File

@ -15,7 +15,11 @@ class Ui_MainWindow(object):
MainWindow.resize(1280, 857)
MainWindow.setMinimumSize(QtCore.QSize(1280, 0))
icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap(":/icons/musicmuster"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon.addPixmap(
QtGui.QPixmap(":/icons/musicmuster"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
MainWindow.setWindowIcon(icon)
MainWindow.setStyleSheet("")
self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
@ -27,39 +31,62 @@ class Ui_MainWindow(object):
self.verticalLayout_3 = QtWidgets.QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.previous_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.previous_track_2.sizePolicy().hasHeightForWidth())
sizePolicy.setHeightForWidth(
self.previous_track_2.sizePolicy().hasHeightForWidth()
)
self.previous_track_2.setSizePolicy(sizePolicy)
self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.previous_track_2.setFont(font)
self.previous_track_2.setStyleSheet("background-color: #f8d7da;\n"
"border: 1px solid rgb(85, 87, 83);")
self.previous_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.previous_track_2.setStyleSheet(
"background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.previous_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.current_track_2.sizePolicy().hasHeightForWidth())
sizePolicy.setHeightForWidth(
self.current_track_2.sizePolicy().hasHeightForWidth()
)
self.current_track_2.setSizePolicy(sizePolicy)
self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.current_track_2.setFont(font)
self.current_track_2.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);")
self.current_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.current_track_2.setStyleSheet(
"background-color: #d4edda;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.current_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth())
@ -69,19 +96,29 @@ class Ui_MainWindow(object):
font.setFamily("Sans")
font.setPointSize(20)
self.next_track_2.setFont(font)
self.next_track_2.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);")
self.next_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.next_track_2.setStyleSheet(
"background-color: #fff3cd;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.next_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.next_track_2.setObjectName("next_track_2")
self.verticalLayout_3.addWidget(self.next_track_2)
self.horizontalLayout_3.addLayout(self.verticalLayout_3)
self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.hdrPreviousTrack = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrPreviousTrack.sizePolicy().hasHeightForWidth())
sizePolicy.setHeightForWidth(
self.hdrPreviousTrack.sizePolicy().hasHeightForWidth()
)
self.hdrPreviousTrack.setSizePolicy(sizePolicy)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0))
self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215))
@ -89,32 +126,43 @@ class Ui_MainWindow(object):
font.setFamily("Sans")
font.setPointSize(20)
self.hdrPreviousTrack.setFont(font)
self.hdrPreviousTrack.setStyleSheet("background-color: #f8d7da;\n"
"border: 1px solid rgb(85, 87, 83);")
self.hdrPreviousTrack.setStyleSheet(
"background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.hdrPreviousTrack.setText("")
self.hdrPreviousTrack.setWordWrap(False)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrCurrentTrack.sizePolicy().hasHeightForWidth())
sizePolicy.setHeightForWidth(
self.hdrCurrentTrack.sizePolicy().hasHeightForWidth()
)
self.hdrCurrentTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;\n"
"")
self.hdrCurrentTrack.setStyleSheet(
"background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;\n"
""
)
self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth())
@ -122,10 +170,12 @@ class Ui_MainWindow(object):
font = QtGui.QFont()
font.setPointSize(20)
self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;")
self.hdrNextTrack.setStyleSheet(
"background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;"
)
self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack")
@ -160,7 +210,12 @@ class Ui_MainWindow(object):
self.cartsWidget.setObjectName("cartsWidget")
self.horizontalLayout_Carts = QtWidgets.QHBoxLayout(self.cartsWidget)
self.horizontalLayout_Carts.setObjectName("horizontalLayout_Carts")
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
spacerItem = QtWidgets.QSpacerItem(
40,
20,
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Minimum,
)
self.horizontalLayout_Carts.addItem(spacerItem)
self.gridLayout_4.addWidget(self.cartsWidget, 2, 0, 1, 1)
self.frame_6 = QtWidgets.QFrame(parent=self.centralwidget)
@ -205,7 +260,11 @@ class Ui_MainWindow(object):
self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame)
self.btnPreview.setMinimumSize(QtCore.QSize(132, 41))
icon1 = QtGui.QIcon()
icon1.addPixmap(QtGui.QPixmap(":/icons/headphones"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon1.addPixmap(
QtGui.QPixmap(":/icons/headphones"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnPreview.setIcon(icon1)
self.btnPreview.setIconSize(QtCore.QSize(30, 30))
self.btnPreview.setCheckable(True)
@ -289,10 +348,15 @@ class Ui_MainWindow(object):
self.label_silent_timer.setObjectName("label_silent_timer")
self.horizontalLayout.addWidget(self.frame_silent)
self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.widgetFadeVolume.sizePolicy().hasHeightForWidth())
sizePolicy.setHeightForWidth(
self.widgetFadeVolume.sizePolicy().hasHeightForWidth()
)
self.widgetFadeVolume.setSizePolicy(sizePolicy)
self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0))
self.widgetFadeVolume.setObjectName("widgetFadeVolume")
@ -309,7 +373,11 @@ class Ui_MainWindow(object):
self.btnFade.setMinimumSize(QtCore.QSize(132, 32))
self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215))
icon2 = QtGui.QIcon()
icon2.addPixmap(QtGui.QPixmap(":/icons/fade"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon2.addPixmap(
QtGui.QPixmap(":/icons/fade"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnFade.setIcon(icon2)
self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade")
@ -317,7 +385,11 @@ class Ui_MainWindow(object):
self.btnStop = QtWidgets.QPushButton(parent=self.frame)
self.btnStop.setMinimumSize(QtCore.QSize(0, 36))
icon3 = QtGui.QIcon()
icon3.addPixmap(QtGui.QPixmap(":/icons/stopsign"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon3.addPixmap(
QtGui.QPixmap(":/icons/stopsign"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnStop.setIcon(icon3)
self.btnStop.setObjectName("btnStop")
self.verticalLayout_5.addWidget(self.btnStop)
@ -343,39 +415,69 @@ class Ui_MainWindow(object):
MainWindow.setStatusBar(self.statusbar)
self.actionPlay_next = QtGui.QAction(parent=MainWindow)
icon4 = QtGui.QIcon()
icon4.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon4.addPixmap(
QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionPlay_next.setIcon(icon4)
self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkipToNext = QtGui.QAction(parent=MainWindow)
icon5 = QtGui.QIcon()
icon5.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon5.addPixmap(
QtGui.QPixmap(":/icons/next"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionSkipToNext.setIcon(icon5)
self.actionSkipToNext.setObjectName("actionSkipToNext")
self.actionInsertTrack = QtGui.QAction(parent=MainWindow)
icon6 = QtGui.QIcon()
icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon6.addPixmap(
QtGui.QPixmap(
"app/ui/../../../../.designer/backup/icon_search_database.png"
),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionInsertTrack.setIcon(icon6)
self.actionInsertTrack.setObjectName("actionInsertTrack")
self.actionAdd_file = QtGui.QAction(parent=MainWindow)
icon7 = QtGui.QIcon()
icon7.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon7.addPixmap(
QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionAdd_file.setIcon(icon7)
self.actionAdd_file.setObjectName("actionAdd_file")
self.actionFade = QtGui.QAction(parent=MainWindow)
icon8 = QtGui.QIcon()
icon8.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-fade.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon8.addPixmap(
QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-fade.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionFade.setIcon(icon8)
self.actionFade.setObjectName("actionFade")
self.actionStop = QtGui.QAction(parent=MainWindow)
icon9 = QtGui.QIcon()
icon9.addPixmap(QtGui.QPixmap(":/icons/stop"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon9.addPixmap(
QtGui.QPixmap(":/icons/stop"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionStop.setIcon(icon9)
self.actionStop.setObjectName("actionStop")
self.action_Clear_selection = QtGui.QAction(parent=MainWindow)
self.action_Clear_selection.setObjectName("action_Clear_selection")
self.action_Resume_previous = QtGui.QAction(parent=MainWindow)
icon10 = QtGui.QIcon()
icon10.addPixmap(QtGui.QPixmap(":/icons/previous"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
icon10.addPixmap(
QtGui.QPixmap(":/icons/previous"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.action_Resume_previous.setIcon(icon10)
self.action_Resume_previous.setObjectName("action_Resume_previous")
self.actionE_xit = QtGui.QAction(parent=MainWindow)
@ -422,7 +524,9 @@ class Ui_MainWindow(object):
self.actionImport = QtGui.QAction(parent=MainWindow)
self.actionImport.setObjectName("actionImport")
self.actionDownload_CSV_of_played_tracks = QtGui.QAction(parent=MainWindow)
self.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks")
self.actionDownload_CSV_of_played_tracks.setObjectName(
"actionDownload_CSV_of_played_tracks"
)
self.actionSearch = QtGui.QAction(parent=MainWindow)
self.actionSearch.setObjectName("actionSearch")
self.actionInsertSectionHeader = QtGui.QAction(parent=MainWindow)
@ -450,9 +554,13 @@ class Ui_MainWindow(object):
self.actionResume = QtGui.QAction(parent=MainWindow)
self.actionResume.setObjectName("actionResume")
self.actionSearch_title_in_Wikipedia = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Wikipedia.setObjectName("actionSearch_title_in_Wikipedia")
self.actionSearch_title_in_Wikipedia.setObjectName(
"actionSearch_title_in_Wikipedia"
)
self.actionSearch_title_in_Songfacts = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts")
self.actionSearch_title_in_Songfacts.setObjectName(
"actionSearch_title_in_Songfacts"
)
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.menuFile.addAction(self.actionNewPlaylist)
@ -503,7 +611,7 @@ class Ui_MainWindow(object):
self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1)
self.tabInfolist.setCurrentIndex(-1)
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
@ -539,38 +647,58 @@ class Ui_MainWindow(object):
self.actionFade.setShortcut(_translate("MainWindow", "Ctrl+Z"))
self.actionStop.setText(_translate("MainWindow", "S&top"))
self.actionStop.setShortcut(_translate("MainWindow", "Ctrl+Alt+S"))
self.action_Clear_selection.setText(_translate("MainWindow", "Clear &selection"))
self.action_Clear_selection.setText(
_translate("MainWindow", "Clear &selection")
)
self.action_Clear_selection.setShortcut(_translate("MainWindow", "Esc"))
self.action_Resume_previous.setText(_translate("MainWindow", "&Resume previous"))
self.action_Resume_previous.setText(
_translate("MainWindow", "&Resume previous")
)
self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
self.actionTest.setText(_translate("MainWindow", "&Test"))
self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen..."))
self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
self.actionSkipToFade.setText(_translate("MainWindow", "&Skip to start of fade"))
self.actionSkipToFade.setText(
_translate("MainWindow", "&Skip to start of fade")
)
self.actionSkipToEnd.setText(_translate("MainWindow", "Skip to &end of track"))
self.actionClosePlaylist.setText(_translate("MainWindow", "&Close"))
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
self.actionMoveSelected.setText(_translate("MainWindow", "Mo&ve selected tracks to..."))
self.actionMoveSelected.setText(
_translate("MainWindow", "Mo&ve selected tracks to...")
)
self.actionExport_playlist.setText(_translate("MainWindow", "E&xport..."))
self.actionSetNext.setText(_translate("MainWindow", "Set &next"))
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
self.actionSelect_next_track.setText(_translate("MainWindow", "Select next track"))
self.actionSelect_next_track.setText(
_translate("MainWindow", "Select next track")
)
self.actionSelect_next_track.setShortcut(_translate("MainWindow", "J"))
self.actionSelect_previous_track.setText(_translate("MainWindow", "Select previous track"))
self.actionSelect_previous_track.setText(
_translate("MainWindow", "Select previous track")
)
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
self.actionSelect_played_tracks.setText(_translate("MainWindow", "Select played tracks"))
self.actionMoveUnplayed.setText(_translate("MainWindow", "Move &unplayed tracks to..."))
self.actionSelect_played_tracks.setText(
_translate("MainWindow", "Select played tracks")
)
self.actionMoveUnplayed.setText(
_translate("MainWindow", "Move &unplayed tracks to...")
)
self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
self.actionImport.setText(_translate("MainWindow", "Import track..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks..."))
self.actionDownload_CSV_of_played_tracks.setText(
_translate("MainWindow", "Download CSV of played tracks...")
)
self.actionSearch.setText(_translate("MainWindow", "Search..."))
self.actionSearch.setShortcut(_translate("MainWindow", "/"))
self.actionInsertSectionHeader.setText(_translate("MainWindow", "Insert &section header..."))
self.actionInsertSectionHeader.setText(
_translate("MainWindow", "Insert &section header...")
)
self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H"))
self.actionRemove.setText(_translate("MainWindow", "&Remove track"))
self.actionFind_next.setText(_translate("MainWindow", "Find next"))
@ -578,8 +706,12 @@ class Ui_MainWindow(object):
self.actionFind_previous.setText(_translate("MainWindow", "Find previous"))
self.actionFind_previous.setShortcut(_translate("MainWindow", "P"))
self.action_About.setText(_translate("MainWindow", "&About"))
self.actionSave_as_template.setText(_translate("MainWindow", "Save as template..."))
self.actionNew_from_template.setText(_translate("MainWindow", "New from template..."))
self.actionSave_as_template.setText(
_translate("MainWindow", "Save as template...")
)
self.actionNew_from_template.setText(
_translate("MainWindow", "New from template...")
)
self.actionDebug.setText(_translate("MainWindow", "Debug"))
self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1..."))
self.actionMark_for_moving.setText(_translate("MainWindow", "Mark for moving"))
@ -588,10 +720,22 @@ class Ui_MainWindow(object):
self.actionPaste.setShortcut(_translate("MainWindow", "Ctrl+V"))
self.actionResume.setText(_translate("MainWindow", "Resume"))
self.actionResume.setShortcut(_translate("MainWindow", "Ctrl+R"))
self.actionSearch_title_in_Wikipedia.setText(_translate("MainWindow", "Search title in Wikipedia"))
self.actionSearch_title_in_Wikipedia.setShortcut(_translate("MainWindow", "Ctrl+W"))
self.actionSearch_title_in_Songfacts.setText(_translate("MainWindow", "Search title in Songfacts"))
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
self.actionSearch_title_in_Wikipedia.setText(
_translate("MainWindow", "Search title in Wikipedia")
)
self.actionSearch_title_in_Wikipedia.setShortcut(
_translate("MainWindow", "Ctrl+W")
)
self.actionSearch_title_in_Songfacts.setText(
_translate("MainWindow", "Search title in Songfacts")
)
self.actionSearch_title_in_Songfacts.setShortcut(
_translate("MainWindow", "Ctrl+S")
)
self.actionSelect_duplicate_rows.setText(
_translate("MainWindow", "Select duplicate rows...")
)
from infotabs import InfoTabs
from pyqtgraph import PlotWidget

View File

@ -1,11 +1,11 @@
#!/usr/bin/python3
from datetime import datetime, timedelta
import datetime as dt
from threading import Timer
from pydub import AudioSegment
from time import sleep
from timeloop import Timeloop
import vlc
from timeloop import Timeloop # type: ignore
import vlc # type: ignore
class RepeatedTimer(object):
@ -49,9 +49,9 @@ def leading_silence(audio_segment, silence_threshold=-50.0, chunk_size=10):
trim_ms = 0 # ms
assert chunk_size > 0 # to avoid infinite loop
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < silence_threshold
and trim_ms < len(audio_segment)):
while audio_segment[
trim_ms : trim_ms + chunk_size
].dBFS < silence_threshold and trim_ms < len(audio_segment):
trim_ms += chunk_size
# if there is no end it should return the length of the segment
@ -72,8 +72,9 @@ def significant_fade(audio_segment, fade_threshold=-20.0, chunk_size=10):
segment_length = audio_segment.duration_seconds * 1000 # ms
trim_ms = segment_length - chunk_size
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0):
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0
):
trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less
@ -94,8 +95,9 @@ def trailing_silence(audio_segment, silence_threshold=-50.0, chunk_size=10):
segment_length = audio_segment.duration_seconds * 1000 # ms
trim_ms = segment_length - chunk_size
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < silence_threshold
and trim_ms > 0):
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < silence_threshold
and trim_ms > 0
):
trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less
@ -124,15 +126,17 @@ def update_progress(player, talk_at, silent_at):
remaining_time = total_time - elapsed_time
talk_time = remaining_time - (total_time - talk_at)
silent_time = remaining_time - (total_time - silent_at)
end_time = (datetime.now() + timedelta(
milliseconds=remaining_time)).strftime("%H:%M:%S")
end_time = (dt.datetime.now() + timedelta(milliseconds=remaining_time)).strftime(
"%H:%M:%S"
)
print(
f"\t{ms_to_mmss(elapsed_time)}/"
f"{ms_to_mmss(total_time)}\t\t"
f"Talk in: {ms_to_mmss(talk_time)} "
f"Silent in: {ms_to_mmss(silent_time)} "
f"Ends at: {end_time} [{ms_to_mmss(remaining_time)}]"
, end="\r")
f"Ends at: {end_time} [{ms_to_mmss(remaining_time)}]",
end="\r",
)
# Print name of current song, print name of next song. Play current when
@ -163,21 +167,21 @@ def test():
test()
# next_song = get_next_song
#
#
# def play_track():
# r = run_aud_cmd("--current-song-length
#
#
#
#
#
#
# def play():
# play_track()
# songtimer_start()
#
#
#
#
# print("Start playing in 3 seconds")
#
#
# sleep(3)
#
#
# play()

View File

@ -1,5 +1,3 @@
# tl = Timeloop()
#
#
@ -48,34 +46,34 @@
# rt.stop() # better in a try/finally block to make sure the program ends!
# print("End")
#def kae2(self, index):
# print(f"table header click, index={index}")
# def kae2(self, index):
# print(f"table header click, index={index}")
#def kae(self, a, b, c):
# self.data.append(f"a={a}, b={b}, c={c}")
# def kae(self, a, b, c):
# self.data.append(f"a={a}, b={b}, c={c}")
#def mousePressEvent(self, QMouseEvent):
# print("mouse press")
# def mousePressEvent(self, QMouseEvent):
# print("mouse press")
#def mouseReleaseEvent(self, QMouseEvent):
# print("mouse release")
# # QMessageBox.about(
# # self,
# # "About Sample Editor",
# # "\n".join(self.data)
# # )
#def eventFilter(self, obj, event):
# # you could be doing different groups of actions
# # for different types of widgets and either filtering
# # the event or not.
# # Here we just check if its one of the layout widgets
# # if self.layout.indexOf(obj) != -1:
# # print(f"event received: {event.type()}")
# if event.type() == QEvent.MouseButtonPress:
# print("Widget click")
# # if I returned True right here, the event
# # would be filtered and not reach the obj,
# # meaning that I decided to handle it myself
# def mouseReleaseEvent(self, QMouseEvent):
# print("mouse release")
# # QMessageBox.about(
# # self,
# # "About Sample Editor",
# # "\n".join(self.data)
# # )
# def eventFilter(self, obj, event):
# # you could be doing different groups of actions
# # for different types of widgets and either filtering
# # the event or not.
# # Here we just check if its one of the layout widgets
# # if self.layout.indexOf(obj) != -1:
# # print(f"event received: {event.type()}")
# if event.type() == QEvent.MouseButtonPress:
# print("Widget click")
# # if I returned True right here, the event
# # would be filtered and not reach the obj,
# # meaning that I decided to handle it myself
# # regardless, just do the default
# return super().eventFilter(obj, event)
# # regardless, just do the default
# return super().eventFilter(obj, event)

View File

@ -7,19 +7,19 @@ from PyQt5.QtCore import Qt
qt_creator_file = "mainwindow.ui"
Ui_MainWindow, QtBaseClass = uic.loadUiType(qt_creator_file)
tick = QtGui.QImage('tick.png')
tick = QtGui.QImage("tick.png")
class TodoModel(QtCore.QAbstractListModel):
def __init__(self, *args, todos=None, **kwargs):
super(TodoModel, self).__init__(*args, **kwargs)
self.todos = todos or []
def data(self, index, role):
if role == Qt.DisplayRole:
_, text = self.todos[index.row()]
return text
if role == Qt.DecorationRole:
status, _ = self.todos[index.row()]
if status:
@ -51,15 +51,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
and then clearing it.
"""
text = self.todoEdit.text()
if text: # Don't add empty strings.
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
# Trigger refresh.
self.model.layoutChanged.emit()
# Empty the input
# Empty the input
self.todoEdit.setText("")
self.save()
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
@ -71,7 +71,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def complete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
@ -79,22 +79,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
row = index.row()
status, text = self.model.todos[row]
self.model.todos[row] = (True, text)
# .dataChanged takes top-left and bottom right, which are equal
# .dataChanged takes top-left and bottom right, which are equal
# for a single selection.
self.model.dataChanged.emit(index, index)
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def load(self):
try:
with open('data.db', 'r') as f:
with open("data.db", "r") as f:
self.model.todos = json.load(f)
except Exception:
pass
def save(self):
with open('data.db', 'w') as f:
with open("data.db", "w") as f:
data = json.dump(self.model.todos, f)
@ -102,5 +102,3 @@ app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

View File

@ -1,49 +0,0 @@
# https://itnext.io/setting-up-transactional-tests-with-pytest-and-sqlalchemy-b2d726347629
import pytest
import helpers
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from app.models import Base, Tracks
DB_CONNECTION = "mysql+mysqldb://musicmuster_testing:musicmuster_testing@localhost/dev_musicmuster_testing"
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine(DB_CONNECTION, isolation_level="READ COMMITTED")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture(scope="function")
def session(db_engine):
connection = db_engine.connect()
transaction = connection.begin()
sm = sessionmaker(bind=connection)
session = scoped_session(sm)
# print(f"PyTest SqlA: session acquired [{hex(id(session))}]")
yield session
# print(f" PyTest SqlA: session released and cleaned up [{hex(id(session))}]")
session.remove()
transaction.rollback()
connection.close()
@pytest.fixture(scope="function")
def track1(session):
track_path = "testdata/isa.mp3"
metadata = helpers.get_file_metadata(track_path)
track = Tracks(session, **metadata)
return track
@pytest.fixture(scope="function")
def track2(session):
track_path = "testdata/mom.mp3"
metadata = helpers.get_file_metadata(track_path)
track = Tracks(session, **metadata)
return track

View File

@ -10,23 +10,23 @@ import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '0c604bf490f8'
down_revision = '29c0d7ffc741'
revision = "0c604bf490f8"
down_revision = "29c0d7ffc741"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_rows', sa.Column('played', sa.Boolean(), nullable=False))
op.drop_index('ix_tracks_lastplayed', table_name='tracks')
op.drop_column('tracks', 'lastplayed')
op.add_column("playlist_rows", sa.Column("played", sa.Boolean(), nullable=False))
op.drop_index("ix_tracks_lastplayed", table_name="tracks")
op.drop_column("tracks", "lastplayed")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('lastplayed', mysql.DATETIME(), nullable=True))
op.create_index('ix_tracks_lastplayed', 'tracks', ['lastplayed'], unique=False)
op.drop_column('playlist_rows', 'played')
op.add_column("tracks", sa.Column("lastplayed", mysql.DATETIME(), nullable=True))
op.create_index("ix_tracks_lastplayed", "tracks", ["lastplayed"], unique=False)
op.drop_column("playlist_rows", "played")
# ### end Alembic commands ###

View File

@ -10,21 +10,21 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2cc37d3cf07f'
down_revision = 'e3b04db5506f'
revision = "2cc37d3cf07f"
down_revision = "e3b04db5506f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlists', sa.Column('last_used', sa.DateTime(), nullable=True))
op.add_column('playlists', sa.Column('loaded', sa.Boolean(), nullable=True))
op.add_column("playlists", sa.Column("last_used", sa.DateTime(), nullable=True))
op.add_column("playlists", sa.Column("loaded", sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'loaded')
op.drop_column('playlists', 'last_used')
op.drop_column("playlists", "loaded")
op.drop_column("playlists", "last_used")
# ### end Alembic commands ###

View File

@ -10,27 +10,28 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b0983648595e'
down_revision = '1bc727e5e87f'
revision = "b0983648595e"
down_revision = "1bc727e5e87f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('settings',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=32), nullable=False),
sa.Column('f_datetime', sa.DateTime(), nullable=True),
sa.Column('f_int', sa.Integer(), nullable=True),
sa.Column('f_string', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
op.create_table(
"settings",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=32), nullable=False),
sa.Column("f_datetime", sa.DateTime(), nullable=True),
sa.Column("f_int", sa.Integer(), nullable=True),
sa.Column("f_string", sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('settings')
op.drop_table("settings")
# ### end Alembic commands ###

View File

@ -10,43 +10,54 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f07b96a5e60f'
down_revision = 'b0983648595e'
revision = "f07b96a5e60f"
down_revision = "b0983648595e"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('playdates',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('lastplayed', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
op.create_table(
"playdates",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("lastplayed", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_playdates_lastplayed'), 'playdates', ['lastplayed'], unique=False)
op.create_table('playlists',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
op.create_index(
op.f("ix_playdates_lastplayed"), "playdates", ["lastplayed"], unique=False
)
op.create_table('playlistracks',
sa.Column('playlist_id', sa.Integer(), nullable=True),
sa.Column('track_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], ),
sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], )
op.create_table(
"playlists",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.add_column('tracks', sa.Column('playdates_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'tracks', 'playdates', ['playdates_id'], ['id'])
op.create_table(
"playlistracks",
sa.Column("playlist_id", sa.Integer(), nullable=True),
sa.Column("track_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["playlist_id"],
["playlists.id"],
),
sa.ForeignKeyConstraint(
["track_id"],
["tracks.id"],
),
)
op.add_column("tracks", sa.Column("playdates_id", sa.Integer(), nullable=True))
op.create_foreign_key(None, "tracks", "playdates", ["playdates_id"], ["id"])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'tracks', type_='foreignkey')
op.drop_column('tracks', 'playdates_id')
op.drop_table('playlistracks')
op.drop_table('playlists')
op.drop_index(op.f('ix_playdates_lastplayed'), table_name='playdates')
op.drop_table('playdates')
op.drop_constraint(None, "tracks", type_="foreignkey")
op.drop_column("tracks", "playdates_id")
op.drop_table("playlistracks")
op.drop_table("playlists")
op.drop_index(op.f("ix_playdates_lastplayed"), table_name="playdates")
op.drop_table("playdates")
# ### end Alembic commands ###

654
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,45 +5,48 @@ description = "Music player for internet radio"
authors = ["Keith Edmunds <kae@midnighthax.com>"]
[tool.poetry.dependencies]
python = "^3.9"
tinytag = "^1.7.0"
SQLAlchemy = "^2.0.22"
python-vlc = "^3.0.12118"
mysqlclient = "^2.1.0"
mutagen = "^1.45.1"
alembic = "^1.7.5"
psutil = "^5.9.0"
python = "^3.11"
tinytag = "^1.10.1"
SQLAlchemy = "^2.0.29"
python-vlc = "^3.0.20123"
mysqlclient = "^2.2.4"
mutagen = "^1.47.0"
alembic = "^1.13.1"
psutil = "^5.9.8"
pydub = "^0.25.1"
types-psutil = "^5.8.22"
python-slugify = "^6.1.2"
types-psutil = "^5.9.5.20240316"
python-slugify = "^8.0.4"
thefuzz = "^0.19.0"
python-Levenshtein = "^0.12.2"
pyfzf = "^0.3.1"
pydymenu = "^0.5.2"
stackprinter = "^0.2.10"
obsws-python = "^1.4.2"
pyqt6 = "^6.5.0"
pyqt6-webengine = "^6.5.0"
pygame = "^2.4.0"
obsws-python = "^1.7.0"
pyqt6 = "^6.6.1"
pyqt6-webengine = "^6.6.0"
pygame = "^2.5.2"
pyqtgraph = "^0.13.3"
colorlog = "^6.8.0"
colorlog = "^6.8.2"
alchemical = "^1.0.1"
[tool.poetry.dev-dependencies]
ipdb = "^0.13.9"
pytest = "^7.0.1"
pytest-qt = "^4.0.2"
pytest-qt = "^4.4.0"
pydub-stubs = "^0.25.1"
line-profiler = "^4.0.2"
line-profiler = "^4.1.2"
flakehell = "^0.9.0"
[tool.poetry.group.dev.dependencies]
pudb = "^2023.1"
pudb = "*"
sphinx = "^7.0.1"
furo = "^2023.5.20"
black = "^24.2.0"
flakehell = "^0.9.0"
mypy = "^1.7.0"
pdbp = "^1.5.0"
pytest-cov = "^5.0.0"
pytest = "^8.1.1"
snoop = "^0.4.3"
black = "^24.3.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
@ -56,7 +59,7 @@ explicit_package_bases = true
[tool.pytest.ini_options]
addopts = "--exitfirst --showlocals --capture=no"
pythonpath = [".", "app"]
filterwarnings = "ignore:'audioop' is deprecated"
filterwarnings = ["ignore:'audioop' is deprecated", "ignore:pkg_resources"]
[tool.vulture]
exclude = ["migrations", "app/ui", "archive"]

View File

@ -1,76 +0,0 @@
from datetime import datetime, timedelta
from helpers import (
fade_point,
get_audio_segment,
get_tags,
get_relative_date,
leading_silence,
ms_to_mmss,
)
def test_fade_point():
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
fade_at = fade_point(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert fade_at < testdata["fade_at"] + 1000
assert fade_at > testdata["fade_at"] - 1000
def test_get_tags():
test_track_path = "testdata/mom.mp3"
test_track_data = "testdata/mom.py"
tags = get_tags(test_track_path)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
assert tags["artist"] == testdata["artist"]
assert tags["title"] == testdata["title"]
def test_get_relative_date():
assert get_relative_date(None) == "Never"
today_at_10 = datetime.now().replace(hour=10, minute=0)
today_at_11 = 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 - timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
sixteen_days_ago = today_at_10 - timedelta(days=16)
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
def test_leading_silence():
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
silence_at = leading_silence(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert silence_at < testdata["leading_silence"] + 1000
assert silence_at > testdata["leading_silence"] - 1000
def test_ms_to_mmss():
assert ms_to_mmss(None) == "-"
assert ms_to_mmss(59600) == "0:59"
assert ms_to_mmss((5 * 60 * 1000) + 23000) == "5:23"

View File

@ -1,186 +0,0 @@
import os.path
import helpers
from app.models import (
NoteColours,
Playdates,
Playlists,
Tracks,
)
def test_notecolours_get_colour(session):
"""Create a colour record and retrieve all colours"""
print(">>>text_notcolours_get_colour")
note_colour = "#0bcdef"
NoteColours(session, substring="substring", colour=note_colour)
records = NoteColours.get_all(session)
assert len(records) == 1
record = records[0]
assert record.colour == note_colour
def test_notecolours_get_all(session):
"""Create two colour records and retrieve them all"""
print(">>>text_notcolours_get_all")
note1_colour = "#1bcdef"
note2_colour = "#20ff00"
NoteColours(session, substring="note1", colour=note1_colour)
NoteColours(session, substring="note2", colour=note2_colour)
records = NoteColours.get_all(session)
assert len(records) == 2
assert note1_colour in [n.colour for n in records]
assert note2_colour in [n.colour for n in records]
def test_notecolours_get_colour_none(session):
note_colour = "#3bcdef"
NoteColours(session, substring="substring", colour=note_colour)
result = NoteColours.get_colour(session, "xyz")
assert result is None
def test_notecolours_get_colour_match(session):
note_colour = "#4bcdef"
nc = NoteColours(session, substring="sub", colour=note_colour)
assert nc
result = NoteColours.get_colour(session, "The substring")
assert result == note_colour
def test_playdates_add_playdate(session, track1):
"""Test playdate and last_played retrieval"""
playdate = Playdates(session, track1.id)
assert playdate
last_played = Playdates.last_played(session, track1.id)
assert abs((playdate.lastplayed - last_played).total_seconds()) < 2
def test_playlist_create(session):
playlist = Playlists(session, "my playlist")
assert playlist
# def test_playlist_add_track(session, track):
# # We need a playlist
# playlist = Playlists(session, "my playlist")
# row = 17
# playlist.add_track(session, track.id, row)
# assert len(playlist.tracks) == 1
# playlist_track = playlist.tracks[row]
# assert playlist_track.path == track_path
# def test_playlist_tracks(session):
# # We need a playlist
# playlist = Playlists(session, "my playlist")
# # We need two tracks
# track1_path = "/a/b/c"
# track1_row = 17
# track1 = Tracks(session, track1_path)
# track2_path = "/x/y/z"
# track2_row = 29
# track2 = Tracks(session, track2_path)
# playlist.add_track(session, track1.id, track1_row)
# playlist.add_track(session, track2.id, track2_row)
# tracks = playlist.tracks
# assert tracks[track1_row] == track1
# assert tracks[track2_row] == track2
# def test_playlist_notes(session):
# # We need a playlist
# playlist = Playlists(session, "my playlist")
# # We need two notes
# note1_text = "note1 text"
# note1_row = 11
# _ = Notes(session, playlist.id, note1_row, note1_text)
# note2_text = "note2 text"
# note2_row = 19
# _ = Notes(session, playlist.id, note2_row, note2_text)
# notes = playlist.notes
# assert note1_text in [n.note for n in notes]
# assert note1_row in [n.row for n in notes]
# assert note2_text in [n.note for n in notes]
# assert note2_row in [n.row for n in notes]
def test_playlist_open_and_close(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
playlist.mark_open()
assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0
playlist.close()
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
def test_playlist_get_all_and_by_id(session):
# We need two playlists
p1_name = "playlist one"
p2_name = "playlist two"
playlist1 = Playlists(session, p1_name)
_ = Playlists(session, p2_name)
all_playlists = Playlists.get_all(session)
assert len(all_playlists) == 2
assert p1_name in [p.name for p in all_playlists]
assert p2_name in [p.name for p in all_playlists]
assert session.get(Playlists, playlist1.id).name == p1_name
def test_tracks_get_all_tracks(session, track1, track2):
# Need two tracks
result = [a.path for a in Tracks.get_all(session)]
assert track1.path in result
assert track2.path in result
def test_tracks_by_path(session, track1):
assert Tracks.get_by_path(session, track1.path) is track1
def test_tracks_by_id(session, track1):
assert session.get(Tracks, track1.id) is track1
def test_tracks_search_artists(session, track1):
track1_artist = "Fleetwood Mac"
assert len(Tracks.search_artists(session, track1_artist)) == 1
def test_tracks_search_titles(session, track1):
track1_title = "I'm So Afraid"
assert len(Tracks.search_titles(session, track1_title)) == 1

View File

@ -1,380 +0,0 @@
from pprint import pprint
from typing import Optional
from app.models import (
Playlists,
Tracks,
)
from PyQt6.QtCore import Qt, QModelIndex
from app.helpers import get_file_metadata
from app import playlistmodel
from dbconfig import scoped_session
test_tracks = [
"testdata/isa.mp3",
"testdata/isa_with_gap.mp3",
"testdata/loser.mp3",
"testdata/lovecats-10seconds.mp3",
"testdata/lovecats.mp3",
"testdata/mom.mp3",
"testdata/sitting.mp3",
]
def create_model_with_tracks(session: scoped_session, name: Optional[str] = None) -> "playlistmodel.PlaylistModel":
playlist = Playlists(session, name or "test playlist")
model = playlistmodel.PlaylistModel(playlist.id)
for row in range(len(test_tracks)):
track_path = test_tracks[row % len(test_tracks)]
metadata = get_file_metadata(track_path)
track = Tracks(session, **metadata)
model.insert_row(proposed_row_number=row, track_id=track.id, note=f"{row=}")
session.commit()
return model
def create_model_with_playlist_rows(
session: scoped_session, rows: int, name: Optional[str] = None
) -> "playlistmodel.PlaylistModel":
playlist = Playlists(session, name or "test playlist")
# Create a model
model = playlistmodel.PlaylistModel(playlist.id)
for row in range(rows):
model.insert_row(proposed_row_number=row, note=str(row))
session.commit()
return model
def test_11_row_playlist(monkeypatch, session):
# Create multirow playlist
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
assert model.rowCount() == 11
assert max(model.playlist_rows.keys()) == 10
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
def test_move_rows_test2(monkeypatch, session):
# move row 3 to row 5
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([3], 5)
# Check we have all rows and plr_rownums are correct
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
if row not in [3, 4, 5]:
assert model.playlist_rows[row].note == str(row)
elif row == 3:
assert model.playlist_rows[row].note == str(4)
elif row == 4:
assert model.playlist_rows[row].note == str(5)
elif row == 5:
assert model.playlist_rows[row].note == str(3)
def test_move_rows_test3(monkeypatch, session):
# move row 4 to row 3
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([4], 3)
# Check we have all rows and plr_rownums are correct
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
if row not in [3, 4]:
assert model.playlist_rows[row].note == str(row)
elif row == 3:
assert model.playlist_rows[row].note == str(4)
elif row == 4:
assert model.playlist_rows[row].note == str(3)
def test_move_rows_test4(monkeypatch, session):
# move row 4 to row 2
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([4], 2)
# Check we have all rows and plr_rownums are correct
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
if row not in [2, 3, 4]:
assert model.playlist_rows[row].note == str(row)
elif row == 2:
assert model.playlist_rows[row].note == str(4)
elif row == 3:
assert model.playlist_rows[row].note == str(2)
elif row == 4:
assert model.playlist_rows[row].note == str(3)
def test_move_rows_test5(monkeypatch, session):
# move rows [1, 4, 5, 10] → 8
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([1, 4, 5, 10], 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
new_order.append(int(model.playlist_rows[row].note))
assert new_order == [0, 2, 3, 6, 7, 8, 9, 1, 4, 5, 10]
def test_move_rows_test6(monkeypatch, session):
# move rows [3, 6] → 5
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([3, 6], 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
new_order.append(int(model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 5, 3, 6, 7, 8, 9, 10]
def test_move_rows_test7(monkeypatch, session):
# move rows [3, 5, 6] → 8
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([3, 5, 6], 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
new_order.append(int(model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 7, 8, 9, 10, 3, 5, 6]
def test_move_rows_test8(monkeypatch, session):
# move rows [7, 8, 10] → 5
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([7, 8, 10], 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
new_order.append(int(model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
def test_insert_header_row_end(monkeypatch, session):
# insert header row at end of playlist
monkeypatch.setattr(playlistmodel, "Session", session)
note_text = "test text"
initial_row_count = 11
model = create_model_with_playlist_rows(session, initial_row_count)
model.insert_row(proposed_row_number=None, note=note_text)
assert model.rowCount() == initial_row_count + 1
prd = model.playlist_rows[model.rowCount() - 1]
# Test against edit_role because display_role for headers is
# handled differently (sets up row span)
assert (
model.edit_role(model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd)
== note_text
)
def test_insert_header_row_middle(monkeypatch, session):
# insert 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_row(proposed_row_number=insert_row, note=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() - 1, playlistmodel.Col.NOTE.value, prd)
== note_text
)
def test_create_model_with_tracks(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_tracks(session)
assert len(model.playlist_rows) == len(test_tracks)
def test_timing_one_track(monkeypatch, session):
START_ROW = 0
END_ROW = 2
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_tracks(session)
model.insert_row(proposed_row_number=START_ROW, note="start+")
model.insert_row(proposed_row_number=END_ROW, note="-")
prd = model.playlist_rows[START_ROW]
qv_value = model.display_role(START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd)
assert qv_value.value() == "start [1 tracks, 4:23 unplayed]"
def test_insert_track_new_playlist(monkeypatch, session):
# insert a track into a new playlist
monkeypatch.setattr(playlistmodel, "Session", session)
playlist = Playlists(session, "test playlist")
# Create a model
model = playlistmodel.PlaylistModel(playlist.id)
track_path = test_tracks[0]
metadata = get_file_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]
assert (
model.edit_role(model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd)
== metadata["title"]
)
def test_reverse_row_groups_one_row(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
rows_to_move = [3]
model_src = create_model_with_playlist_rows(session, 5, name="source")
result = model_src._reversed_contiguous_row_groups(rows_to_move)
assert len(result) == 1
assert result[0] == [3]
def test_reverse_row_groups_multiple_row(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
rows_to_move = [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21]
model_src = create_model_with_playlist_rows(session, 5, name="source")
result = model_src._reversed_contiguous_row_groups(rows_to_move)
assert result == [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]]
def test_move_one_row_between_playlists_to_end(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [3]
to_row = create_rowcount
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst)
model_dst.refresh_data(session)
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list(
range(len(model_src.playlist_rows))
)
def test_move_one_row_between_playlists_to_middle(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [3]
to_row = 2
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst)
model_dst.refresh_data(session)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(row_number, playlistmodel.Col.TITLE.value, QModelIndex())
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert [int(a) for a in row_notes] == [0, 1, 3, 2, 3, 4]
def test_move_multiple_rows_between_playlists_to_end(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [1, 3, 4]
to_row = 2
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst)
model_dst.refresh_data(session)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(row_number, playlistmodel.Col.TITLE.value, QModelIndex())
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert [int(a) for a in row_notes] == [0, 1, 3, 4, 1, 2, 3, 4]
# 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
# )

101
tests/test_helpers.py Normal file
View File

@ -0,0 +1,101 @@
# Standard library imports
import datetime as dt
import shutil
import tempfile
import unittest
# PyQt imports
# Third party imports
# App imports
from helpers import (
fade_point,
get_audio_segment,
get_tags,
get_relative_date,
leading_silence,
ms_to_mmss,
normalise_track,
)
class TestMMHelpers(unittest.TestCase):
def setUp(self):
pass
def tearDown(self):
pass
def test_fade_point(self):
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
fade_at = fade_point(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert fade_at < testdata["fade_at"] + 1000
assert fade_at > testdata["fade_at"] - 1000
def test_get_tags(self):
test_track_path = "testdata/mom.mp3"
test_track_data = "testdata/mom.py"
tags = get_tags(test_track_path)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
assert tags["artist"] == testdata["artist"]
assert tags["title"] == testdata["title"]
def test_get_relative_date(self):
assert get_relative_date(None) == "Never"
today_at_10 = dt.datetime.now().replace(hour=10, minute=0)
today_at_11 = dt.datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
eight_days_ago = today_at_10 - dt.timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
sixteen_days_ago = today_at_10 - dt.timedelta(days=16)
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
def test_leading_silence(self):
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
silence_at = leading_silence(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert silence_at < testdata["leading_silence"] + 1000
assert silence_at > testdata["leading_silence"] - 1000
def test_ms_to_mmss(self):
assert ms_to_mmss(None) == "-"
assert ms_to_mmss(59600) == "0:59"
assert ms_to_mmss((5 * 60 * 1000) + 23000) == "5:23"
def test_normalise(self):
"""Make copies to normalise to avoid corrupting source"""
_, mp3_temp_path = tempfile.mkstemp(suffix=".mp3")
shutil.copyfile("testdata/isa.mp3", mp3_temp_path)
normalise_track(mp3_temp_path)
_, flac_temp_path = tempfile.mkstemp(suffix=".flac")
shutil.copyfile("testdata/isa.flac", flac_temp_path)
normalise_track(flac_temp_path)

50
tests/test_misc.py Normal file
View File

@ -0,0 +1,50 @@
# Standard library imports
import os
import unittest
# PyQt imports
# Third party imports
import pytest
# App imports
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
DB_FILE = "/tmp/mm.db"
if os.path.exists(DB_FILE):
os.unlink(DB_FILE)
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from models import db, Settings # noqa: E402
class TestMMMisc(unittest.TestCase):
def setUp(self):
db.create_all()
def tearDown(self):
db.drop_all()
def test_log_exception(self):
"""Test deliberate exception"""
with pytest.raises(Exception):
1 / 0
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.update(session, dict(f_int=VALUE))
_ = Settings.all_as_dict(session)
test = Settings.get_int_settings(session, SETTING_NAME)
assert test.name == SETTING_NAME
assert test.f_int == VALUE
test_new = Settings.get_int_settings(session, NO_SUCH_SETTING)
assert test_new.name == NO_SUCH_SETTING

304
tests/test_models.py Normal file
View File

@ -0,0 +1,304 @@
# Standard library imports
import datetime as dt
import os
import unittest
# PyQt imports
# Third party imports
# App imports
from app import helpers
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
DB_FILE = "/tmp/mm.db"
if os.path.exists(DB_FILE):
os.unlink(DB_FILE)
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from app.models import ( # noqa: E402
db,
Carts,
NoteColours,
Playdates,
Playlists,
PlaylistRows,
Tracks,
)
class TestMMModels(unittest.TestCase):
def setUp(self):
db.create_all()
with db.Session() as session:
track1_path = "testdata/isa.mp3"
metadata1 = helpers.get_file_metadata(track1_path)
self.track1 = Tracks(session, **metadata1)
track2_path = "testdata/mom.mp3"
metadata2 = helpers.get_file_metadata(track2_path)
self.track2 = Tracks(session, **metadata2)
def tearDown(self):
db.drop_all()
def test_track_repr(self):
with db.Session() as session:
session.add(self.track1)
_ = str(self.track1)
def test_notecolours_get_colour(self):
"""Create a colour record and retrieve all colours"""
note_colour = "#0bcdef"
with db.Session() as session:
NoteColours(session, substring="substring", colour=note_colour)
records = NoteColours.get_all(session)
assert len(records) == 1
record = records[0]
assert record.colour == note_colour
def test_notecolours_get_all(self):
"""Create two colour records and retrieve them all"""
note1_colour = "#1bcdef"
note2_colour = "#20ff00"
with db.Session() as session:
NoteColours(session, substring="note1", colour=note1_colour)
NoteColours(session, substring="note2", colour=note2_colour)
records = NoteColours.get_all(session)
assert len(records) == 2
assert note1_colour in [n.colour for n in records]
assert note2_colour in [n.colour for n in records]
def test_notecolours_get_colour_none(self):
note_colour = "#3bcdef"
with db.Session() as session:
NoteColours(session, substring="substring", colour=note_colour)
result = NoteColours.get_colour(session, "xyz")
assert result is None
def test_notecolours_get_colour_match(self):
note_colour = "#4bcdef"
with db.Session() as session:
nc = NoteColours(session, substring="sub", colour=note_colour)
assert nc
result = NoteColours.get_colour(session, "The substring")
assert result == note_colour
def test_playdates_add_playdate(self):
"""Test playdate and last_played retrieval"""
with db.Session() as session:
session.add(self.track1)
playdate = Playdates(session, self.track1.id)
assert playdate
# test repr
_ = str(playdate)
last_played = Playdates.last_played(session, self.track1.id)
assert abs((playdate.lastplayed - last_played).total_seconds()) < 2
def test_playdates_played_after(self):
with db.Session() as session:
session.add(self.track1)
playdate = Playdates(session, self.track1.id)
yesterday = dt.datetime.now() - dt.timedelta(days=1)
played = Playdates.played_after(session, yesterday)
assert len(played) == 1
assert played[0] == playdate
def test_playlist_create(self):
TEMPLATE_NAME = "my template"
with db.Session() as session:
playlist = Playlists(session, "my playlist")
assert playlist
# test repr
_ = str(playlist)
# test clear tabs
Playlists.clear_tabs(session, [playlist.id])
# create template
Playlists.save_as_template(session, playlist.id, TEMPLATE_NAME)
# test create template
_ = Playlists.create_playlist_from_template(
session, playlist, "my new name"
)
# get all templates
all_templates = Playlists.get_all_templates(session)
assert len(all_templates) == 1
# Save as template creates new playlist
assert all_templates[0] != playlist
# test delete playlist
playlist.delete(session)
def test_playlist_open_and_close(self):
# We need a playlist
with db.Session() as session:
playlist = Playlists(session, "my playlist")
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
playlist.mark_open()
assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0
playlist.close()
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
def test_playlist_get_all_and_by_id(self):
# We need two playlists
p1_name = "playlist one"
p2_name = "playlist two"
with db.Session() as session:
playlist1 = Playlists(session, p1_name)
_ = Playlists(session, p2_name)
all_playlists = Playlists.get_all(session)
assert len(all_playlists) == 2
assert p1_name in [p.name for p in all_playlists]
assert p2_name in [p.name for p in all_playlists]
assert session.get(Playlists, playlist1.id).name == p1_name
def test_tracks_get_all_tracks(self):
# Need two tracks
with db.Session() as session:
session.add(self.track1)
session.add(self.track2)
result = [a.path for a in Tracks.get_all(session)]
assert self.track1.path in result
assert self.track2.path in result
def test_tracks_by_path(self):
with db.Session() as session:
session.add(self.track1)
assert Tracks.get_by_path(session, self.track1.path) is self.track1
def test_tracks_by_id(self):
with db.Session() as session:
session.add(self.track1)
assert session.get(Tracks, self.track1.id) is self.track1
def test_tracks_search_artists(self):
track1_artist = "Fleetwood Mac"
with db.Session() as session:
session.add(self.track1)
assert len(Tracks.search_artists(session, track1_artist)) == 1
def test_tracks_search_titles(self):
track1_title = "I'm So Afraid"
with db.Session() as session:
session.add(self.track1)
assert len(Tracks.search_titles(session, track1_title)) == 1
def test_repr(self):
"""Just check for error retrieving reprs"""
with db.Session() as session:
nc = NoteColours(session, substring="x", colour="x")
_ = str(nc)
def test_get_colour(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
BAD_STRING = "ericTheBee"
SUBSTR = "ant"
COLOUR = "blue"
with db.Session() as session:
nc1 = NoteColours(
session, substring=SUBSTR, colour=COLOUR, is_casesensitive=True
)
_ = nc1.get_colour(session, "")
colour = nc1.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc1.get_colour(session, BAD_STRING)
assert colour is None
nc2 = NoteColours(
session, substring=".*" + SUBSTR, colour=COLOUR, is_regex=True
)
colour = nc2.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc2.get_colour(session, BAD_STRING)
assert colour is None
nc3 = NoteColours(
session,
substring=".*" + SUBSTR,
colour=COLOUR,
is_regex=True,
is_casesensitive=True,
)
colour = nc3.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc3.get_colour(session, BAD_STRING)
assert colour is None
def test_create_cart(self):
with db.Session() as session:
cart = Carts(session, 1, "name")
assert cart
_ = str(cart)
def test_name_available(self):
PLAYLIST_NAME = "a name"
RENAME = "new name"
with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME)
assert playlist
assert Playlists.name_is_available(session, PLAYLIST_NAME) is False
playlist.rename(session, RENAME)
def test_create_playlist_row(self):
PLAYLIST_NAME = "a name"
with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME)
plr = PlaylistRows(session, playlist.id, 1)
assert plr
_ = str(plr)
plr.append_note("a note")
plr.append_note("another note")
def test_delete_plr(self):
PLAYLIST_NAME = "a name"
with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME)
plr = PlaylistRows(session, playlist.id, 1)
assert plr
PlaylistRows.delete_higher_rows(session, plr.playlist_id, 10)
assert PlaylistRows.get_track_plr(session, 12, plr.playlist_id) is None

420
tests/test_playlistmodel.py Normal file
View File

@ -0,0 +1,420 @@
# Standard library imports
import os
import unittest
from typing import Optional
# PyQt imports
from PyQt6.QtCore import Qt, QModelIndex
# Third party imports
from sqlalchemy.orm.session import Session
# App imports
from app.log import log
from app.helpers import get_file_metadata
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
DB_FILE = "/tmp/mm.db"
if os.path.exists(DB_FILE):
os.unlink(DB_FILE)
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from app import playlistmodel # noqa: E402
from app.models import ( # noqa: E402
db,
Playlists,
Settings,
Tracks,
)
class TestMMMiscTracks(unittest.TestCase):
def setUp(self):
PLAYLIST_NAME = "tracks playlist"
self.test_tracks = [
"testdata/isa.mp3",
"testdata/isa_with_gap.mp3",
"testdata/loser.mp3",
"testdata/lovecats-10seconds.mp3",
"testdata/lovecats.mp3",
"testdata/mom.mp3",
"testdata/sitting.mp3",
"testdata/wrb.flac",
]
db.create_all()
# Create a playlist and model
with db.Session() as session:
self.playlist = Playlists(session, PLAYLIST_NAME)
self.model = playlistmodel.PlaylistModel(self.playlist.id)
for row in range(len(self.test_tracks)):
track_path = self.test_tracks[row % len(self.test_tracks)]
metadata = get_file_metadata(track_path)
track = Tracks(session, **metadata)
self.model.insert_row(
proposed_row_number=row, track_id=track.id, note=f"{row=}"
)
session.commit()
def tearDown(self):
db.drop_all()
def test_8_row_playlist(self):
# Test auto-created playlist
assert self.model.rowCount() == 8
assert max(self.model.playlist_rows.keys()) == 7
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
def test_timing_one_track(self):
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="-")
prd = self.model.playlist_rows[START_ROW]
qv_value = self.model.display_role(
START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd
)
assert qv_value.value() == "start [1 tracks, 4:23 unplayed]"
class TestMMMiscNoPlaylist(unittest.TestCase):
PLAYLIST_NAME = "tracks playlist"
test_tracks = [
"testdata/isa.mp3",
"testdata/isa_with_gap.mp3",
"testdata/loser.mp3",
"testdata/lovecats-10seconds.mp3",
"testdata/lovecats.mp3",
"testdata/mom.mp3",
"testdata/sitting.mp3",
]
def setUp(self):
db.create_all()
def tearDown(self):
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)
# Create a model
model = playlistmodel.PlaylistModel(playlist.id)
# test repr
_ = str(model)
track_path = self.test_tracks[0]
metadata = get_file_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"]
)
class TestMMMiscRowMove(unittest.TestCase):
PLAYLIST_NAME = "rowmove playlist"
ROWS_TO_CREATE = 11
def setUp(self):
db.create_all()
with db.Session() as session:
self.playlist = Playlists(session, self.PLAYLIST_NAME)
self.model = playlistmodel.PlaylistModel(self.playlist.id)
for row in range(self.ROWS_TO_CREATE):
self.model.insert_row(proposed_row_number=row, note=str(row))
session.commit()
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].plr_rownum == 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(5)
elif row == 5:
assert self.model.playlist_rows[row].note == str(3)
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].plr_rownum == 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].plr_rownum == 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].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 2, 3, 6, 7, 8, 9, 1, 4, 5, 10]
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].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 5, 3, 6, 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].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 7, 8, 9, 10, 3, 5, 6]
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].plr_rownum == 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_insert_header_row_end(self):
# insert header row at end of playlist
note_text = "test text"
assert self.model.rowCount() == self.ROWS_TO_CREATE
self.model.insert_row(proposed_row_number=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
# handled differently (sets up row span)
assert (
self.model.edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
)
== note_text
)
def test_insert_header_row_middle(self):
# insert header row in middle of playlist
note_text = "test text"
insert_row = 6
self.model.insert_row(proposed_row_number=insert_row, 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
# handled differently (sets up row span)
assert (
self.model.edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
)
== note_text
)
def test_add_track_to_header(self):
note_text = "test text"
insert_row = 6
self.model.insert_row(proposed_row_number=insert_row, note=note_text)
assert self.model.rowCount() == self.ROWS_TO_CREATE + 1
prd = self.model.playlist_rows[1]
self.model.add_track_to_header(insert_row, prd.track_id)
def test_reverse_row_groups_one_row(self):
rows_to_move = [3]
result = self.model._reversed_contiguous_row_groups(rows_to_move)
assert len(result) == 1
assert result[0] == [3]
def test_reverse_row_groups_multiple_row(self):
rows_to_move = [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21]
result = self.model._reversed_contiguous_row_groups(rows_to_move)
assert result == [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]]
def test_move_one_row_between_playlists_to_end(self):
from_rows = [3]
to_row = self.ROWS_TO_CREATE
destination_playlist = "destination"
model_src = self.model
with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
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)
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 sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list(
range(len(model_src.playlist_rows))
)
def test_move_one_row_between_playlists_to_middle(self):
from_rows = [3]
to_row = 2
destination_playlist = "destination"
model_src = self.model
with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
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)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex()
)
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
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 [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"
model_src = self.model
with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
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)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex()
)
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
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 [int(a) for a in row_notes] == [
0,
1,
3,
4,
1,
2,
3,
4,
5,
6,
7,
8,
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
# # )

360
tests/test_playlists.py Normal file
View File

@ -0,0 +1,360 @@
# Standard library imports
import os
import unittest
# PyQt imports
from PyQt6.QtCore import Qt
# Third party imports
import pytest
from pytestqt.plugin import QtBot # type: ignore
# App imports
from app import helpers
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
DB_FILE = "/tmp/mm.db"
if os.path.exists(DB_FILE):
os.unlink(DB_FILE)
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from app import playlistmodel, utilities
from app.models import ( # noqa: E402
db,
Carts,
NoteColours,
Playdates,
Playlists,
PlaylistRows,
Tracks,
)
from app import playlists
from app import musicmuster
# Custom fixture to adapt qtbot for use with unittest.TestCase
@pytest.fixture(scope="class")
def qtbot_adapter(qapp, request):
"""Adapt qtbot fixture for usefixtures and unittest.TestCase"""
request.cls.qtbot = QtBot(request)
# Wrapper to handle setup/teardown operations
def with_updown(function):
def test_wrapper(self, *args, **kwargs):
if callable(getattr(self, "up", None)):
self.up()
try:
function(self, *args, **kwargs)
finally:
if callable(getattr(self, "down", None)):
self.down()
test_wrapper.__doc__ = function.__doc__
return test_wrapper
# Apply the custom fixture to the test class
@pytest.mark.usefixtures("qtbot_adapter")
class MyTestCase(unittest.TestCase):
def up(self):
db.create_all()
self.widget = musicmuster.Window()
# self.widget.show()
# Add two tracks to database
self.tracks = {
1: {
"path": "testdata/isa.mp3",
"title": "I'm so afraid",
"artist": "Fleetwood Mac",
"bitrate": 64,
"duration": 263000,
"start_gap": 60,
"fade_at": 236263,
"silence_at": 260343,
"mtime": 371900000,
},
2: {
"path": "testdata/mom.mp3",
"title": "Man of Mystery",
"artist": "The Shadows",
"bitrate": 64,
"duration": 120000,
"start_gap": 70,
"fade_at": 115000,
"silence_at": 118000,
"mtime": 1642760000,
},
}
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()
@with_updown
def test_init(self):
"""Just check we can create a playlist_tab"""
playlist_name = "test_init playlist"
with db.Session() as session:
playlist = Playlists(session, playlist_name)
self.widget.create_playlist_tab(playlist)
with self.qtbot.waitExposed(self.widget):
self.widget.show()
@with_updown
def test_save_and_restore(self):
"""Playlist with one track, one note, save and restore"""
note_text = "my note"
playlist_name = "test_save_and_restore playlist"
with db.Session() as session:
playlist = Playlists(session, playlist_name)
model = playlistmodel.PlaylistModel(playlist.id)
# Add a track with a note
model.insert_row(proposed_row_number=0, track_id=self.tracks[1]['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
@with_updown
def test_utilities(self):
"""Test check_db utility"""
from config import Config
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")
# 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")
# 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")
# 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")
# 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)
# 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)