Compare commits

..

132 Commits

Author SHA1 Message Date
Keith Edmunds
c56e097f75 Merge v3 2023-12-01 09:56:52 +00:00
Keith Edmunds
30b836895e Change intro gap warning to 300ms 2023-12-01 09:53:59 +00:00
Keith Edmunds
4816520343 Fix bug with unended timed section 2023-12-01 09:51:42 +00:00
Keith Edmunds
ef651dbc0a Fix replace_files after other updates 2023-12-01 09:41:42 +00:00
Keith Edmunds
1502b10701 Fix (innocuous) mypy warning 2023-11-29 22:01:38 +00:00
Keith Edmunds
9cbdccb98b V3 polish 2023-11-29 15:04:50 +00:00
Keith Edmunds
3af9bef3f6 V3: fix preview button behaviour
Was asking user to select a track when next track selected.
2023-11-29 08:02:14 +00:00
Keith Edmunds
1db3990cd6 V3: add note colouring 2023-11-29 07:57:36 +00:00
Keith Edmunds
6061b20398 V3 polish 2023-11-28 21:56:20 +00:00
Keith Edmunds
2e090b192c V3: remove debug print statement 2023-11-28 21:19:23 +00:00
Keith Edmunds
63340a408d V3: fix display corruption when moving a header row 2023-11-28 21:13:16 +00:00
Keith Edmunds
f9b8f1d8d3 V3 tweaks and polishes 2023-11-28 19:59:45 +00:00
Keith Edmunds
f8093bc642 V3: track highlighting fix
When a track is moved to above the marked next track, and the moved
track is made the next track, the original next track remained marked
as next.
2023-11-28 18:29:19 +00:00
Keith Edmunds
cf4d06db16 V3 tidying 2023-11-28 14:36:12 +00:00
Keith Edmunds
95aadb867a V3 hide played tracks
Don't hide previous track until delay after playing next track.
2023-11-28 14:29:49 +00:00
Keith Edmunds
3179c6f5de V3 tweaks and polishes 2023-11-28 14:29:09 +00:00
Keith Edmunds
63a38b5bf9 V3 polish: fix @starttime in headers 2023-11-28 07:28:33 +00:00
Keith Edmunds
15c10431e6 V3 polish: header with "-" echoes section start text 2023-11-28 07:19:09 +00:00
Keith Edmunds
0f1d5117cc V3 tweaks 2023-11-27 22:44:20 +00:00
Keith Edmunds
4eabf4a02a WIP V3: ready for testing 2023-11-27 21:46:19 +00:00
Keith Edmunds
00d7258afd WIP V3: OBS scene changes working 2023-11-27 21:27:27 +00:00
Keith Edmunds
b1442b2c7d WIP V3: check track already present in playlist when adding 2023-11-27 20:55:24 +00:00
Keith Edmunds
3cab9f737c WIP V3: click on current/next header scrolls to track 2023-11-27 16:16:33 +00:00
Keith Edmunds
04f0e95653 WIP V3: fix minor issues 2023-11-27 15:21:20 +00:00
Keith Edmunds
dfb45dd0ff WIP V3: Don't hide next/current row 2023-11-27 11:52:29 +00:00
Keith Edmunds
02391f04b1 WIP V3: hide played tracks working 2023-11-27 11:27:25 +00:00
Keith Edmunds
31f7122a7f WIP V3: fixup tests from earlier changes 2023-11-26 15:27:14 +00:00
Keith Edmunds
480c832852 WIP V3: implement searching with QSortFilterProxyModel (ooo!) 2023-11-26 15:22:01 +00:00
Keith Edmunds
6f5c371510 Git ignore tmp directory 2023-11-25 18:02:39 +00:00
Keith Edmunds
23a9eff43b WIP V3 wire in QSortFilterProxyModel 2023-11-23 18:28:10 +00:00
Keith Edmunds
25e3be6fae WIP V3: add track to header working 2023-11-23 17:12:03 +00:00
Keith Edmunds
c626d91f26 WIP V3: copy and paste rows to same or other playlist works 2023-11-23 10:59:03 +00:00
Keith Edmunds
551a574eac WIP V3: move unplayed rows 2023-11-23 04:44:36 +00:00
Keith Edmunds
80c363c316 WIP V3: better handle row order changing 2023-11-23 04:44:17 +00:00
Keith Edmunds
48b180e280 WIP V3: move selected tracks works 2023-11-22 19:57:14 +00:00
Keith Edmunds
223fb3bdec WIP V3: tests for moving rows between playlists pass 2023-11-22 16:57:16 +00:00
Keith Edmunds
5769e34412 WIP V3: move ImportTrack back to musicmuster.py 2023-11-20 12:40:45 +00:00
Keith Edmunds
e3d20c9bdc WIP V3: cleanup 2023-11-20 11:24:12 +00:00
Keith Edmunds
5add1f01c6 WIP V3: use signals to open wikipedia/songfacts pages
Also open wikipedia page on selecting next track
2023-11-19 21:50:39 +00:00
Keith Edmunds
88e638a56e WIP V3: search wikipedia/songfacts from menu 2023-11-19 21:31:09 +00:00
Keith Edmunds
4ca5eb24c3 WIP V3: remove track from row implemented 2023-11-19 20:56:46 +00:00
Keith Edmunds
05ef2d766c WIP V3: Black 2023-11-19 20:49:50 +00:00
Keith Edmunds
db547cbdb7 WIP V3: import tracks working 2023-11-19 16:02:44 +00:00
Keith Edmunds
005d17ee0a Check for no title/artist tag in replace_files 2023-11-19 11:44:43 +00:00
Keith Edmunds
262ab202fc WIP V3: catch proposed duplicate playlist name
Fixes #197
2023-11-19 11:13:49 +00:00
Keith Edmunds
4f4408400f WIP V3: info popup implemented 2023-11-19 03:11:03 +00:00
Keith Edmunds
f4a374f68c WIP V3: select duplicate rows working 2023-11-19 03:09:58 +00:00
Keith Edmunds
77774dc403 WIP V3: marn new playlist as open 2023-11-18 15:46:07 +00:00
Keith Edmunds
8f2ab98be0 Fix create playlist from template and tab handlding
Tab restore code rewritten.
2023-11-18 14:29:52 +00:00
Keith Edmunds
199f0e27fa WIP V3: fixup row insertion/deletion
All row insertions and deletions are now wrapped in beginRemoveRows /
endRemoveRows (and similar for insertions).
2023-11-17 22:17:47 +00:00
Keith Edmunds
e37f62fe87 WIP V3: fixup closing tabs 2023-11-17 22:14:51 +00:00
Keith Edmunds
be7071aae0 Change intro gap warning to 300ms 2023-11-16 22:23:22 +00:00
Keith Edmunds
eae8870d4d WIP V3: resume working 2023-11-16 19:09:41 +00:00
Keith Edmunds
93c5475a29 WIP V3: preview button works 2023-11-16 18:06:21 +00:00
Keith Edmunds
2861511f1f WIP V3: remove functions, formatting 2023-11-16 00:08:12 +00:00
Keith Edmunds
a8aa157484 Remove lots of unuse functions from playlists.py 2023-11-15 23:54:06 +00:00
Keith Edmunds
71f3e4cda8 WIP V3: delete rows works 2023-11-15 23:40:48 +00:00
Keith Edmunds
9467ae4ee5 WIP V3: show selected time plus drag 'n' drop refinements 2023-11-15 22:37:42 +00:00
Keith Edmunds
de710b1dc7 WIP V3: start/end times, moving row bug
Start/end times now stored separately from self.playlist_rows. Moving
next row to above current row now works.
2023-11-15 20:09:00 +00:00
Keith Edmunds
3cbc69b11e Fix off-by-one errors in tests 2023-11-15 19:07:23 +00:00
Keith Edmunds
56087870f4 WIP V3: recalculate start/end times after moving rows 2023-11-15 15:14:23 +00:00
Keith Edmunds
b83bd0d5c3 WIP V3: display last played date 2023-11-15 15:09:41 +00:00
Keith Edmunds
3e49ad08b9 WIP V3: sort by each element implemented 2023-11-15 08:41:06 +00:00
Keith Edmunds
d5871fe77f WIP V3: context menu started
Sort by title implemented
2023-11-14 23:45:47 +00:00
Keith Edmunds
1b4411d7e5 Set up fade graph before playing track 2023-11-13 21:24:21 +00:00
Keith Edmunds
d2254b6ddd WIP V3: Use config settings for warning timers 2023-11-13 21:22:05 +00:00
Keith Edmunds
0d2dad9f3c WIP V3: remove references to HEADER_NOTES_COLUMN in playlists.py 2023-11-12 22:36:17 +00:00
Keith Edmunds
0f77cef37a WIP V3: editing header rows works 2023-11-12 22:35:44 +00:00
Keith Edmunds
bfc7a8508c WIP V3: fix moving tracks repaint bug
When a header row moved down to make room for a track row,
the column spanning is now reset on the now-track row.
2023-11-12 22:15:35 +00:00
Keith Edmunds
9e9bc8b4c7 WIP V3: end time of playing subsection implemented 2023-11-10 03:57:33 +00:00
Keith Edmunds
f311721386 Update packages 2023-11-09 18:47:34 +00:00
Keith Edmunds
2907514eb7 WIP V3: smarten up section timings 2023-11-08 23:34:17 +00:00
Keith Edmunds
ab084ccf97 Fixup tests for section timings 2023-11-08 23:22:32 +00:00
Keith Edmunds
b399abb471 WIP V3: section timings in place 2023-11-08 23:18:33 +00:00
Keith Edmunds
6d648a56b7 WIP V3: fix editing headers rows 2023-11-08 18:34:10 +00:00
Keith Edmunds
b3262b2ede WIP V3: track start/end times working 2023-11-08 18:15:57 +00:00
Keith Edmunds
698fa4625a WIP V3: track start/stop times basics working
Only updates from header rows or current track. Changing
current track doesn't update correctly.
2023-11-07 23:14:26 +00:00
Keith Edmunds
b042ea10ec Move test_playlists.py to X_test_playlists for now 2023-11-07 20:50:39 +00:00
Keith Edmunds
9b682564ee WIP V3: remove redundant test.py 2023-11-07 20:42:34 +00:00
Keith Edmunds
813588e8e9 WIP V3: track stop implemented 2023-11-07 20:11:12 +00:00
Keith Edmunds
ad3ec45a76 WIP V3: unplayed rows in bold 2023-11-06 20:01:35 +00:00
Keith Edmunds
6f31ed7afc WIP V3: set up track_sequence handling 2023-11-06 20:00:04 +00:00
Keith Edmunds
c20dc0288f V3 WIP: implement playing_track structure 2023-11-05 08:15:59 +00:00
Keith Edmunds
a8ac67b9e3 V3 WIP Black 2023-11-05 08:03:02 +00:00
Keith Edmunds
a35905dee8 WIP V3: play track working 2023-11-03 15:16:27 +00:00
Keith Edmunds
bd2fa1cab0 Initialise FadeCurve in a thread
Stops a UI delay of half a second or so when marking a track 'next'
2023-11-03 09:08:06 +00:00
Keith Edmunds
4d3dc1fd00 WIP V3: don't select headers or unplayable track as next 2023-11-01 23:12:10 +00:00
Keith Edmunds
e137045812 WIP V3: select next track works with caveats
Peformance isn't great
Selecting a non-existent track isn't caught
2023-11-01 22:53:25 +00:00
Keith Edmunds
d9ad001c75 Relayout files
Created classes.py and moved common classes to classes.py. Ordered
imports.
2023-11-01 19:08:22 +00:00
Keith Edmunds
15ecae54cf Move MusicMusterSignals into helpers 2023-11-01 07:49:40 +00:00
Keith Edmunds
fedcfc3eea WIP V3: Add track to header row implemented 2023-10-31 20:09:45 +00:00
Keith Edmunds
9554336860 Move SQLAlchemy statements to models.py 2023-10-31 13:04:21 +00:00
Keith Edmunds
813b325029 Black reformatting, tidying 2023-10-31 08:15:24 +00:00
Keith Edmunds
734d5cb545 Make MusicMusterSignals a singleton class
Moved into datastructures.py
2023-10-31 08:14:34 +00:00
Keith Edmunds
3557d22c54 WIP V3: insert track works 2023-10-30 21:55:02 +00:00
Keith Edmunds
e4b986fd2e Implement active_tab and active_model 2023-10-30 16:39:02 +00:00
Keith Edmunds
3832d9300c move_rows implemented; all tests pass 2023-10-28 11:30:37 +01:00
Keith Edmunds
afb8ddfaf5 Added archive/db_experiments.py for testing 2023-10-27 12:01:43 +01:00
Keith Edmunds
617c39c0de Reworked inserting rows into model
_insert_row() handles database
insert_header() handles playlist_rows and display updates
2023-10-27 12:01:09 +01:00
Keith Edmunds
f57bcc37f6 WIP V3 model development 2023-10-27 06:58:22 +01:00
Keith Edmunds
37cdaf3e3f Call scalars() from session rather than row results 2023-10-27 06:41:40 +01:00
Keith Edmunds
858c86d907 test_insert_header_row passes 2023-10-25 22:17:52 +01:00
Keith Edmunds
b12b1501e7 WIP V3: Black formatting 2023-10-24 21:47:32 +01:00
Keith Edmunds
87172c8757 WIP V3: drag 'n' drop rows working with tests 2023-10-24 21:46:21 +01:00
Keith Edmunds
86a1678f41 WIP V3: move row initial tests working
More tests to write
2023-10-24 20:48:28 +01:00
Keith Edmunds
da658f0ae3 V3 WIP testing working for test_models 2023-10-23 17:39:56 +01:00
Keith Edmunds
da23ae9732 Move pytest configuration to pyproject.toml 2023-10-23 12:34:05 +01:00
Keith Edmunds
36b3b8c323 Remove old profile directory 2023-10-23 12:22:18 +01:00
Keith Edmunds
d25beeda89 Added reference drag 'n' drop to archive 2023-10-22 22:55:10 +01:00
Keith Edmunds
9d3e4b8d0c V3 WIP Drag and drop partly implemented
UI works but outputs model changes needed to stdout
2023-10-22 22:53:59 +01:00
Keith Edmunds
4903330e44 V3 WIP Add ROWS_FROM_ZERO option 2023-10-22 22:51:37 +01:00
Keith Edmunds
d81b4c84b8 WIP V3: add drag and drop example to archive 2023-10-21 14:44:57 +01:00
Keith Edmunds
d6572c13b5 V3 WIP Black formatting 2023-10-21 14:07:42 +01:00
Keith Edmunds
95c7ccbf34 WIP V3: editing saves 2023-10-21 13:49:13 +01:00
Keith Edmunds
5d19d1ed9f Move playlists_v3 to playlists 2023-10-21 11:07:25 +01:00
Keith Edmunds
93d780f75a V3 WIP: ESC works in editing 2023-10-21 11:03:03 +01:00
Keith Edmunds
b75dc4256a WIP V3 don't send session to playlist tab 2023-10-21 09:02:36 +01:00
Keith Edmunds
d0645a1768 Tidy up InterceptEscapeWhenEditingTableCellInView.py 2023-10-21 07:25:55 +01:00
Keith Edmunds
0690a66806 Edit partially working
setData called but not implemented
ESC not detected in edit
2023-10-20 23:17:19 +01:00
Keith Edmunds
07669043eb WIP V3 2023-10-20 20:49:52 +01:00
Keith Edmunds
d579eb81b4 WIP V3 2023-10-20 20:47:08 +01:00
Keith Edmunds
cbdcd5f4fc Fix column spanning to not be recursive 2023-10-20 16:25:48 +01:00
Keith Edmunds
bb14b34c2e WIP V3: column widths set/save works 2023-10-20 11:30:54 +01:00
Keith Edmunds
dbbced7401 Fix repr() for Settings 2023-10-20 11:06:50 +01:00
Keith Edmunds
5fb5e12bb8 WIP: V3: All headers displaying 2023-10-20 08:54:48 +01:00
Keith Edmunds
978b83ba67 WIP: V3 header rows span columns 2023-10-19 18:29:09 +01:00
Keith Edmunds
9a01bf2c2c Don't error on header rows 2023-10-19 15:09:49 +01:00
Keith Edmunds
1c8fb05ffa WIP V3: gap and bitrate column background working 2023-10-19 15:05:30 +01:00
Keith Edmunds
bec336d2a3 WIP V3: playlist populates from database 2023-10-19 13:49:07 +01:00
Keith Edmunds
51a827093a Add return type in music.py 2023-10-19 13:49:07 +01:00
Keith Edmunds
8acd279cfe Clean up music.py interface 2023-10-17 22:52:51 +01:00
Keith Edmunds
d2444159ac Improved fading
fade() takes an optional parameter, fade_seconds
fading is now logarithmic
2023-10-17 22:38:57 +01:00
41 changed files with 4447 additions and 7872 deletions

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ StudioPlaylist.png
*.otl
*.howto
.direnv
tmp/

View File

@ -51,7 +51,7 @@ class MyTableWidget(QTableWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setItemDelegate(EscapeDelegate(self))
self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
# self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
class MainWindow(QMainWindow):

View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
from PyQt6.QtCore import Qt, QEvent, QObject, QVariant, QAbstractTableModel
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QStyledItemDelegate,
QTableView,
)
from PyQt6.QtGui import QKeyEvent
from typing import cast
class EscapeDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
def createEditor(self, parent, option, index):
return QPlainTextEdit(parent)
def eventFilter(self, editor: QObject, event: QEvent):
"""By default, QPlainTextEdit doesn't handle enter or return"""
if event.type() == QEvent.Type.KeyPress:
key_event = cast(QKeyEvent, event)
print(key_event.key())
if key_event.key() == Qt.Key.Key_Return:
if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
print("save data")
self.commitData.emit(editor)
self.closeEditor.emit(editor)
return True
elif key_event.key() == Qt.Key.Key_Escape:
discard_edits = QMessageBox.question(
self.parent(), "Abandon edit", "Discard changes?"
)
if discard_edits == QMessageBox.StandardButton.Yes:
print("abandon edit")
self.closeEditor.emit(editor)
return True
return False
class MyTableWidget(QTableView):
def __init__(self, parent=None):
super().__init__(parent)
self.setItemDelegate(EscapeDelegate(self))
self.setModel(MyModel())
class MyModel(QAbstractTableModel):
def columnCount(self, index):
return 2
def rowCount(self, index):
return 2
def data(self, index, role):
if not index.isValid() or not (0 <= index.row() < 2):
return QVariant()
row = index.row()
column = index.column()
if role == Qt.ItemDataRole.DisplayRole:
return QVariant(f"Row {row}, Col {column}")
return QVariant()
def flags(self, index):
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable
class MainWindow(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.table_widget = MyTableWidget(self)
self.setCentralWidget(self.table_widget)
self.table_widget.resizeColumnsToContents()
self.table_widget.resizeRowsToContents()
if __name__ == "__main__":
app = QApplication([])
window = MainWindow()
window.show()
app.exec()

View File

@ -1,4 +1,4 @@
from PyQt5.QtCore import Qt
from PyQt6.QtCore import Qt
from app import playlists
from app import models

223
app/classes.py Normal file
View File

@ -0,0 +1,223 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
from PyQt6.QtCore import pyqtSignal, QObject, QThread
import numpy as np
import pyqtgraph as pg # type: ignore
from config import Config
from dbconfig import scoped_session
from models import PlaylistRows
import helpers
class FadeCurve:
GraphWidget = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms = max(0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.end_ms = track_silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.region = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self):
self.curve = self.GraphWidget.plot(self.graph_array)
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
def tick(self, play_time) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
@helpers.singleton
@dataclass
class MusicMusterSignals(QObject):
"""
Class for all MusicMuster signals. See:
- https://zetcode.com/gui/pyqt5/eventssignals/
- https://stackoverflow.com/questions/62654525/
emit-a-signal-from-another-class-to-main-class
and Singleton class at
https://refactoring.guru/design-patterns/singleton/python/example#example-0
"""
begin_reset_model_signal = pyqtSignal(int)
enable_escape_signal = pyqtSignal(bool)
end_reset_model_signal = pyqtSignal(int)
next_track_changed_signal = pyqtSignal()
resize_rows_signal = pyqtSignal(int)
row_order_changed_signal = pyqtSignal(int)
search_songfacts_signal = pyqtSignal(str)
search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str)
span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int)
def __post_init__(self):
super().__init__()
class PlaylistTrack:
"""
Used to provide a single reference point for specific playlist tracks,
typically the previous, current and next track.
"""
def __init__(self) -> None:
"""
Only initialises data structure. Call set_plr to populate.
"""
self.artist: Optional[str] = None
self.duration: Optional[int] = None
self.end_time: Optional[datetime] = None
self.fade_at: Optional[int] = None
self.fade_graph: Optional[FadeCurve] = None
self.fade_length: Optional[int] = None
self.path: Optional[str] = None
self.playlist_id: Optional[int] = None
self.plr_id: Optional[int] = None
self.plr_rownum: Optional[int] = None
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.title: Optional[str] = None
self.track_id: Optional[int] = None
def __repr__(self) -> str:
return (
f"<PlaylistTrack(title={self.title}, artist={self.artist}, "
f"plr_rownum={self.plr_rownum}, playlist_id={self.playlist_id}>"
)
def set_plr(self, session: scoped_session, plr: PlaylistRows) -> None:
"""
Update with new plr information
"""
session.add(plr)
self.plr_rownum = plr.plr_rownum
if not plr.track:
return
track = plr.track
self.artist = track.artist
self.duration = track.duration
self.end_time = None
self.fade_at = track.fade_at
self.path = track.path
self.playlist_id = plr.playlist_id
self.plr_id = plr.id
self.silence_at = track.silence_at
self.start_gap = track.start_gap
self.start_time = None
self.title = track.title
self.track_id = track.id
if track.silence_at and track.fade_at:
self.fade_length = track.silence_at - track.fade_at
# Initialise and add FadeCurve in a thread as it's slow
# Import in separate thread
self.fadecurve_thread = QThread()
self.worker = AddFadeCurve(
self,
track_path=track.path,
track_fade_at=track.fade_at,
track_silence_at=track.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def start(self) -> None:
"""
Called when track starts playing
"""
self.start_time = datetime.now()
if self.duration:
self.end_time = self.start_time + timedelta(milliseconds=self.duration)
class AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
playlist_track: PlaylistTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
):
super().__init__()
self.playlist_track = playlist_track
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self):
"""
Create fade curve and add to PlaylistTrack object
"""
self.playlist_track.fade_graph = FadeCurve(
self.track_path, self.track_fade_at, self.track_silence_at
)
self.finished.emit()
class TrackSequence:
next = PlaylistTrack()
now = PlaylistTrack()
previous = PlaylistTrack()
track_sequence = TrackSequence()

View File

@ -32,57 +32,60 @@ class Config(object):
COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107"
COLUMN_NAME_ARTIST = "Artist"
COLUMN_NAME_AUTOPLAY = "A"
COLUMN_NAME_BITRATE = "bps"
COLUMN_NAME_END_TIME = "End"
COLUMN_NAME_LAST_PLAYED = "Last played"
COLUMN_NAME_LEADING_SILENCE = "Gap"
COLUMN_NAME_LENGTH = "Length"
COLUMN_NAME_NOTES = "Notes"
COLUMN_NAME_START_TIME = "Start"
COLUMN_NAME_TITLE = "Title"
DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ['dbconfig']
DEBUG_MODULES: List[Optional[str]] = ["dbconfig"]
DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False
EPOCH = datetime.datetime(1970, 1, 1)
ERRORS_FROM = ['noreply@midnighthax.com']
ERRORS_TO = ['kae@midnighthax.com']
ERRORS_FROM = ["noreply@midnighthax.com"]
ERRORS_TO = ["kae@midnighthax.com"]
FADE_CURVE_BACKGROUND = "lightyellow"
FADE_CURVE_FOREGROUND = "blue"
FADE_CURVE_MS_BEFORE_FADE = 5000
FADEOUT_DB = -10
FADEOUT_SECONDS = 5
FADEOUT_STEPS_PER_SECOND = 5
HEADER_ARTIST = "Artist"
HEADER_BITRATE = "bps"
HEADER_DURATION = "Length"
HEADER_END_TIME = "End"
HEADER_LAST_PLAYED = "Last played"
HEADER_NOTE = "Notes"
HEADER_START_GAP = "Gap"
HEADER_START_TIME = "Start"
HEADER_TITLE = "Title"
HIDE_AFTER_PLAYING_OFFSET = 5000
INFO_TAB_TITLE_LENGTH = 15
LAST_PLAYED_TODAY_STRING = "Today"
LOG_LEVEL_STDERR = logging.ERROR
LOG_LEVEL_SYSLOG = logging.DEBUG
LOG_NAME = "musicmuster"
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
MAIL_SERVER = os.environ.get('MAIL_SERVER') or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
MAIL_PORT = int(os.environ.get("MAIL_PORT") or 25)
MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
MAX_IMPORT_MATCHES = 5
MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0
MINIMUM_ROW_HEIGHT = 30
NOTE_TIME_FORMAT = "%H:%M:%S"
NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost"
OBS_PASSWORD = "auster"
OBS_PORT = 4455
PLAY_SETTLE = 500000
ROOT = os.environ.get('ROOT') or "/home/kae/music"
ROOT = os.environ.get("ROOT") or "/home/kae/music"
ROWS_FROM_ZERO = True
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
SCROLL_TOP_MARGIN = 3
START_GAP_WARNING_THRESHOLD = 300
TEXT_NO_TRACK_NO_NOTE = "[Section header]"
TOD_TIME_FORMAT = "%H:%M:%S"
TRACK_TIME_FORMAT = "%H:%M:%S"
VOLUME_VLC_DEFAULT = 75
VOLUME_VLC_DROP3db = 65
WARNING_MS_BEFORE_FADE = 5500
WARNING_MS_BEFORE_SILENCE = 5500
WEB_ZOOM_FACTOR = 1.2

View File

@ -15,19 +15,6 @@ else:
dbname = MYSQL_CONNECT.split("/")[-1]
log.debug(f"Database: {dbname}")
# MM_ENV = os.environ.get('MM_ENV', 'PRODUCTION')
# testing = False
# if MM_ENV == 'TESTING':
# dbname = os.environ.get('MM_TESTING_DBNAME', 'musicmuster_testing')
# dbuser = os.environ.get('MM_TESTING_DBUSER', 'musicmuster_testing')
# dbpw = os.environ.get('MM_TESTING_DBPW', 'musicmuster_testing')
# dbhost = os.environ.get('MM_TESTING_DBHOST', 'localhost')
# testing = True
# else:
# raise ValueError(f"Unknown MusicMuster environment: {MM_ENV=}")
#
# MYSQL_CONNECT = f"mysql+mysqldb://{dbuser}:{dbpw}@{dbhost}/{dbname}"
engine = create_engine(
MYSQL_CONNECT,
echo=Config.DISPLAY_SQL,
@ -43,12 +30,9 @@ def Session() -> Generator[scoped_session, None, None]:
file = frame.filename
function = frame.function
lineno = frame.lineno
Session = scoped_session(sessionmaker(bind=engine, future=True))
log.debug(f"SqlA: session acquired [{hex(id(Session))}]")
log.debug(
f"Session acquisition: {file}:{function}:{lineno} " f"[{hex(id(Session))}]"
)
Session = scoped_session(sessionmaker(bind=engine))
log.debug(f"Session acquired: {file}:{function}:{lineno} " f"[{hex(id(Session))}]")
yield Session
log.debug(f" SqlA: session released [{hex(id(Session))}]")
log.debug(f" Session released [{hex(id(Session))}]")
Session.commit()
Session.close()

196
app/dialogs.py Normal file
View File

@ -0,0 +1,196 @@
from typing import Optional
from PyQt6.QtCore import QEvent, Qt
from PyQt6.QtWidgets import QDialog, QListWidgetItem
from classes import MusicMusterSignals
from dbconfig import scoped_session
from helpers import (
ask_yes_no,
get_relative_date,
ms_to_mmss,
)
from models import Settings, Tracks
from playlistmodel import PlaylistModel
from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore
class TrackSelectDialog(QDialog):
"""Select track from database"""
def __init__(
self,
session: scoped_session,
new_row_number: int,
model: PlaylistModel,
add_to_header: Optional[bool] = False,
*args,
**kwargs,
) -> None:
"""
Subclassed QDialog to manage track selection
"""
super().__init__(*args, **kwargs)
self.session = session
self.new_row_number = new_row_number
self.model = model
self.add_to_header = add_to_header
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
self.ui.btnClose.clicked.connect(self.close)
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
record = Settings.get_int_settings(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
def add_selected(self) -> None:
"""Handle Add button"""
track = None
if self.ui.matchList.selectedItems():
item = self.ui.matchList.currentItem()
if item:
track = item.data(Qt.ItemDataRole.UserRole)
note = self.ui.txtNote.text()
if not note and not track:
return
self.ui.txtNote.clear()
self.select_searchtext()
track_id = None
if track:
track_id = track.id
else:
return
# Check whether track is already in playlist
move_existing = False
existing_prd = self.model.is_track_in_playlist(track_id)
if existing_prd is not None:
if ask_yes_no(
"Duplicate row",
"Track already in playlist. " "Move to new location?",
default_yes=True,
):
move_existing = True
if self.add_to_header and existing_prd: # "and existing_prd" for mypy's benefit
if move_existing:
self.model.move_track_to_header(self.new_row_number, existing_prd, note)
else:
self.model.add_track_to_header(self.new_row_number, track_id)
# Close dialog - we can only add one track to a header
self.accept()
else:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.model.move_track_add_note(self.new_row_number, existing_prd, note)
else:
self.model.insert_row(self.new_row_number, track_id, note)
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
self.accept()
def chars_typed(self, s: str) -> None:
"""Handle text typed in search box"""
self.ui.matchList.clear()
if len(s) > 0:
if self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, "%" + s)
else:
matches = Tracks.search_artists(self.session, "%" + s)
if matches:
for track in matches:
last_played = None
last_playdate = max(
track.playdates, key=lambda p: p.lastplayed, default=None
)
if last_playdate:
last_played = last_playdate.lastplayed
t = QListWidgetItem()
track_text = (
f"{track.title} - {track.artist} "
f"[{ms_to_mmss(track.duration)}] "
f"({get_relative_date(last_played)})"
)
t.setText(track_text)
t.setData(Qt.ItemDataRole.UserRole, track)
self.ui.matchList.addItem(t)
def closeEvent(self, event: Optional[QEvent]) -> None:
"""
Override close and save dialog coordinates
"""
if not event:
return
record = Settings.get_int_settings(self.session, "dbdialog_height")
if record.f_int != self.height():
record.update(self.session, {"f_int": self.height()})
record = Settings.get_int_settings(self.session, "dbdialog_width")
if record.f_int != self.width():
record.update(self.session, {"f_int": self.width()})
event.accept()
def keyPressEvent(self, event):
"""
Clear selection on ESC if there is one
"""
if event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return
super(TrackSelectDialog, self).keyPressEvent(event)
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None:
"""Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track = item.data(Qt.ItemDataRole.UserRole)
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
if last_playdate:
last_played = last_playdate.lastplayed
else:
last_played = None
path_text = f"{track.path} ({get_relative_date(last_played)})"
self.ui.dbPath.setText(path_text)
def title_artist_toggle(self) -> None:
"""
Handle switching between searching for artists and searching for
titles
"""
# Logic is handled already in chars_typed(), so just call that.
self.chars_typed(self.ui.searchString.text())

View File

@ -1,21 +1,29 @@
from datetime import datetime
from email.message import EmailMessage
from typing import Any, Dict, Optional
import functools
import os
import psutil
import re
import shutil
import smtplib
import ssl
import tempfile
from config import Config
from datetime import datetime
from email.message import EmailMessage
from log import log
from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects
from pydub.utils import mediainfo
from PyQt6.QtWidgets import QMainWindow, QMessageBox # type: ignore
from PyQt6.QtWidgets import QMainWindow, QMessageBox
from tinytag import TinyTag # type: ignore
from typing import Any, Dict, Optional
from config import Config
from log import log
start_time_re = re.compile(r"@\d\d:\d\d")
# Classes are defined after global functions so that classes can use
# those functions.
def ask_yes_no(title: str, question: str, default_yes: bool = False) -> bool:
@ -90,20 +98,47 @@ def get_audio_segment(path: str) -> Optional[AudioSegment]:
return None
def get_tags(path: str) -> Dict[str, Any]:
"""
Return a dictionary of title, artist, duration-in-milliseconds and path.
"""
def get_embedded_time(text: str) -> Optional[datetime]:
"""Return datetime specified as @hh:mm in text"""
tag = TinyTag.get(path)
try:
match = start_time_re.search(text)
except TypeError:
return None
if not match:
return None
return dict(
title=tag.title,
artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path,
)
try:
return datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
except ValueError:
return None
def get_file_metadata(filepath: str) -> dict:
"""Return track metadata"""
# Get title, artist, bitrate, duration, path
metadata: Dict[str, str | int | float] = get_tags(filepath)
metadata["mtime"] = os.path.getmtime(filepath)
# Set start_gap, fade_at and silence_at
audio = get_audio_segment(filepath)
if not audio:
audio_values = dict(start_gap=0, fade_at=0, silence_at=0)
else:
audio_values = dict(
start_gap=leading_silence(audio),
fade_at=int(
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
),
silence_at=int(
round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
),
)
metadata |= audio_values
return metadata
def get_relative_date(
@ -136,7 +171,7 @@ def get_relative_date(
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0:
# Same day so return time instead
return past_date.strftime("%H:%M")
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M")
if weeks == 1:
weeks_str = "week"
else:
@ -148,33 +183,20 @@ def get_relative_date(
return f"{weeks} {weeks_str}, {days} {days_str} ago"
def get_file_metadata(filepath: str) -> dict:
"""Return track metadata"""
def get_tags(path: str) -> Dict[str, Any]:
"""
Return a dictionary of title, artist, duration-in-milliseconds and path.
"""
# Get title, artist, bitrate, duration, path
metadata: Dict[str, str | int | float] = get_tags(filepath)
tag = TinyTag.get(path)
metadata['mtime'] = os.path.getmtime(filepath)
# Set start_gap, fade_at and silence_at
audio = get_audio_segment(filepath)
if not audio:
audio_values = dict(
start_gap=0,
fade_at=0,
silence_at=0
)
else:
audio_values = dict(
start_gap=leading_silence(audio),
fade_at=int(round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000),
silence_at=int(
round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
)
)
metadata |= audio_values
return metadata
return dict(
title=tag.title,
artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path,
)
def leading_silence(
@ -204,33 +226,12 @@ def leading_silence(
return min(trim_ms, len(audio_segment))
def send_mail(to_addr, from_addr, subj, body):
# From https://docs.python.org/3/library/email.examples.html
# Create a text/plain message
msg = EmailMessage()
msg.set_content(body)
msg["Subject"] = subj
msg["From"] = from_addr
msg["To"] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
try:
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
if Config.MAIL_USE_TLS:
s.starttls(context=context)
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
s.send_message(msg)
except Exception as e:
print(e)
finally:
s.quit()
def ms_to_mmss(ms: Optional[int], decimals: int = 0, negative: bool = False) -> str:
def ms_to_mmss(
ms: Optional[int],
decimals: int = 0,
negative: bool = False,
none: Optional[str] = None,
) -> str:
"""Convert milliseconds to mm:ss"""
minutes: int
@ -238,7 +239,10 @@ def ms_to_mmss(ms: Optional[int], decimals: int = 0, negative: bool = False) ->
seconds: float
if not ms:
return "-"
if none:
return none
else:
return "-"
sign = ""
if ms < 0:
if negative:
@ -360,6 +364,32 @@ def open_in_audacity(path: str) -> bool:
return True
def send_mail(to_addr, from_addr, subj, body):
# From https://docs.python.org/3/library/email.examples.html
# Create a text/plain message
msg = EmailMessage()
msg.set_content(body)
msg["Subject"] = subj
msg["From"] = from_addr
msg["To"] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
try:
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
if Config.MAIL_USE_TLS:
s.starttls(context=context)
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
s.send_message(msg)
except Exception as e:
print(e)
finally:
s.quit()
def set_track_metadata(track):
"""Set/update track metadata in database"""
@ -381,6 +411,22 @@ def show_warning(parent: QMainWindow, title: str, msg: str) -> None:
QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel)
def singleton(cls):
"""
Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons)
"""
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if not wrapper_singleton.instance:
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None
return wrapper_singleton
def trailing_silence(
audio_segment: AudioSegment,
silence_threshold: int = -50,

View File

@ -1 +0,0 @@
ui/icons_rc.py

View File

@ -8,6 +8,8 @@ from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QTabWidget
from config import Config
from classes import MusicMusterSignals
class InfoTabs(QTabWidget):
"""
@ -17,7 +19,9 @@ class InfoTabs(QTabWidget):
def __init__(self, parent=None) -> None:
super().__init__(parent)
# Dictionary to record when tabs were last updated (so we can
self.signals = MusicMusterSignals()
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.tabtitles: Dict[int, str] = {}

View File

@ -6,11 +6,13 @@ from config import Config
from dbconfig import scoped_session
from datetime import datetime
from pprint import pprint
from typing import List, Optional, Sequence
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy import (
bindparam,
Boolean,
DateTime,
delete,
@ -24,7 +26,6 @@ from sqlalchemy import (
from sqlalchemy.orm import (
DeclarativeBase,
joinedload,
lazyload,
Mapped,
mapped_column,
relationship,
@ -49,9 +50,9 @@ class Carts(Base):
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[int] = mapped_column(index=True)
path: Mapped[str] = mapped_column(String(2048), index=False)
enabled: Mapped[bool] = mapped_column(default=False)
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 (
@ -63,7 +64,7 @@ class Carts(Base):
self,
session: scoped_session,
cart_number: int,
name: Optional[str] = None,
name: str,
duration: Optional[int] = None,
path: Optional[str] = None,
enabled: bool = True,
@ -97,6 +98,34 @@ class NoteColours(Base):
f"colour={self.colour}>"
)
def __init__(
self,
session: scoped_session,
substring: str,
colour: str,
enabled: bool = True,
is_regex: bool = False,
is_casesensitive: bool = False,
order: Optional[int] = 0,
) -> None:
self.substring = substring
self.colour = colour
self.enabled = enabled
self.is_regex = is_regex
self.is_casesensitive = is_casesensitive
self.order = order
session.add(self)
session.flush()
@classmethod
def get_all(cls, session: scoped_session) -> Sequence["NoteColours"]:
"""
Return all records
"""
return session.scalars(select(cls)).all()
@staticmethod
def get_colour(session: scoped_session, text: str) -> Optional[str]:
"""
@ -106,15 +135,11 @@ class NoteColours(Base):
if not text:
return None
for rec in (
session.execute(
select(NoteColours)
.filter(NoteColours.enabled.is_(True))
.order_by(NoteColours.order)
)
.scalars()
.all()
):
for rec in session.scalars(
select(NoteColours)
.filter(NoteColours.enabled.is_(True))
.order_by(NoteColours.order)
).all():
if rec.is_regex:
flags = re.UNICODE
if not rec.is_casesensitive:
@ -175,15 +200,11 @@ class Playdates(Base):
def played_after(session: scoped_session, since: datetime) -> Sequence["Playdates"]:
"""Return a list of Playdates objects since passed time"""
return (
session.execute(
select(Playdates)
.where(Playdates.lastplayed >= since)
.order_by(Playdates.lastplayed)
)
.scalars()
.all()
)
return session.scalars(
select(Playdates)
.where(Playdates.lastplayed >= since)
.order_by(Playdates.lastplayed)
).all()
class Playlists(Base):
@ -196,7 +217,8 @@ class Playlists(Base):
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, unique=True)
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(
@ -209,7 +231,7 @@ class Playlists(Base):
def __repr__(self) -> str:
return (
f"<Playlists(id={self.id}, name={self.name}, "
f"is_templatee={self.is_template}>"
f"is_templatee={self.is_template}, open={self.open}>"
)
def __init__(self, session: scoped_session, name: str):
@ -217,19 +239,10 @@ class Playlists(Base):
session.add(self)
session.flush()
def close(self, session: scoped_session) -> None:
def close(self) -> None:
"""Mark playlist as unloaded"""
closed_idx = self.tab
self.tab = None
# Closing this tab will mean all higher-number tabs have moved
# down by one
session.execute(
update(Playlists)
.where(Playlists.tab > closed_idx)
.values(tab=Playlists.tab - 1)
)
self.open = False
@classmethod
def create_playlist_from_template(
@ -259,77 +272,60 @@ class Playlists(Base):
def get_all(cls, session: scoped_session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use"""
return (
session.execute(
select(cls)
.filter(cls.is_template.is_(False))
.order_by(cls.tab.desc(), cls.last_used.desc())
)
.scalars()
.all()
)
return session.scalars(
select(cls)
.filter(cls.is_template.is_(False))
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_all_templates(cls, session: scoped_session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name"""
return (
session.execute(
select(cls).filter(cls.is_template.is_(True)).order_by(cls.name)
)
.scalars()
.all()
)
return session.scalars(
select(cls).filter(cls.is_template.is_(True)).order_by(cls.name)
).all()
@classmethod
def get_closed(cls, session: scoped_session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use"""
return (
session.execute(
select(cls)
.filter(
cls.tab.is_(None),
cls.is_template.is_(False),
cls.deleted.is_(False),
)
.order_by(cls.last_used.desc())
return session.scalars(
select(cls)
.filter(
cls.open.is_(False),
cls.is_template.is_(False),
cls.deleted.is_(False),
)
.scalars()
.all()
)
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_open(cls, session: scoped_session) -> Sequence[Optional["Playlists"]]:
"""
Return a list of loaded playlists ordered by tab order.
Return a list of loaded playlists ordered by tab.
"""
return (
session.execute(select(cls).where(cls.tab.is_not(None)).order_by(cls.tab))
.scalars()
.all()
)
return session.scalars(
select(cls).where(cls.open.is_(True)).order_by(cls.tab)
).all()
def mark_open(self, session: scoped_session, tab_index: int) -> None:
def mark_open(self) -> None:
"""Mark playlist as loaded and used now"""
self.tab = tab_index
self.open = True
self.last_used = datetime.now()
@staticmethod
def move_tab(session: scoped_session, frm: int, to: int) -> None:
"""Move tabs"""
def name_is_available(session: scoped_session, name: str) -> bool:
"""
Return True if no playlist of this name exists else false.
"""
row_frm = session.execute(select(Playlists).filter_by(tab=frm)).scalar_one()
row_to = session.execute(select(Playlists).filter_by(tab=to)).scalar_one()
row_frm.tab = None
row_to.tab = None
session.commit()
row_to.tab = frm
row_frm.tab = to
return (
session.execute(select(Playlists).where(Playlists.name == name)).first()
is None
)
def rename(self, session: scoped_session, new_name: str) -> None:
"""
@ -385,9 +381,9 @@ class PlaylistRows(Base):
self,
session: scoped_session,
playlist_id: int,
track_id: Optional[int],
row_number: int,
note: str = "",
track_id: Optional[int] = None,
) -> None:
"""Create PlaylistRows object"""
@ -411,33 +407,39 @@ class PlaylistRows(Base):
def copy_playlist(session: scoped_session, src_id: int, dst_id: int) -> None:
"""Copy playlist entries"""
src_rows = (
session.execute(
select(PlaylistRows).filter(PlaylistRows.playlist_id == src_id)
)
.scalars()
.all()
)
src_rows = session.scalars(
select(PlaylistRows).filter(PlaylistRows.playlist_id == src_id)
).all()
for plr in src_rows:
PlaylistRows(session, dst_id, plr.track_id, plr.plr_rownum, plr.note)
@staticmethod
def delete_higher_rows(
session: scoped_session, playlist_id: int, maxrow: int
) -> None:
"""
Delete rows in given playlist that have a higher row number
than 'maxrow'
"""
session.execute(
delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum > maxrow,
PlaylistRows(
session=session,
playlist_id=dst_id,
row_number=plr.plr_rownum,
note=plr.note,
track_id=plr.track_id,
)
@classmethod
def deep_row(
cls, session: scoped_session, playlist_id: int, row_number: int
) -> "PlaylistRows":
"""
Return a playlist row that includes full track and lastplayed data for
given playlist_id and row
"""
stmt = (
select(PlaylistRows)
.options(joinedload(cls.track))
.where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum == row_number,
)
# .options(joinedload(Tracks.playdates))
)
session.flush()
return session.execute(stmt).unique().scalar_one()
@classmethod
def deep_rows(
@ -458,21 +460,47 @@ class PlaylistRows(Base):
return session.scalars(stmt).unique().all()
@staticmethod
def delete_higher_rows(
session: scoped_session, playlist_id: int, maxrow: int
) -> None:
"""
Delete rows in given playlist that have a higher row number
than 'maxrow'
"""
session.execute(
delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum > maxrow,
)
)
session.flush()
@staticmethod
def delete_row(session: scoped_session, playlist_id: int, row_number: int) -> None:
"""
Delete passed row in given playlist.
"""
session.execute(
delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum == row_number,
)
)
@staticmethod
def fixup_rownumbers(session: scoped_session, playlist_id: int) -> None:
"""
Ensure the row numbers for passed playlist have no gaps
"""
plrs = (
session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum)
)
.scalars()
.all()
)
plrs = session.scalars(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum)
).all()
for i, plr in enumerate(plrs):
plr.plr_rownum = i
@ -489,15 +517,11 @@ class PlaylistRows(Base):
PlaylistRows objects
"""
plrs = (
session.execute(
select(cls)
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
.order_by(cls.plr_rownum)
)
.scalars()
.all()
)
plrs = session.scalars(
select(cls)
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
.order_by(cls.plr_rownum)
).all()
return plrs
@ -535,15 +559,11 @@ class PlaylistRows(Base):
have been played.
"""
plrs = (
session.execute(
select(cls)
.where(cls.playlist_id == playlist_id, cls.played.is_(True))
.order_by(cls.plr_rownum)
)
.scalars()
.all()
)
plrs = session.scalars(
select(cls)
.where(cls.playlist_id == playlist_id, cls.played.is_(True))
.order_by(cls.plr_rownum)
).all()
return plrs
@ -568,7 +588,7 @@ class PlaylistRows(Base):
if to_row is not None:
query = query.where(cls.plr_rownum <= to_row)
plrs = session.execute((query).order_by(cls.plr_rownum)).scalars().all()
plrs = session.scalars((query).order_by(cls.plr_rownum)).all()
return plrs
@ -581,22 +601,25 @@ class PlaylistRows(Base):
have not been played.
"""
plrs = (
session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None),
cls.played.is_(False),
)
.order_by(cls.plr_rownum)
plrs = session.scalars(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None),
cls.played.is_(False),
)
.scalars()
.all()
)
.order_by(cls.plr_rownum)
).all()
return plrs
@classmethod
def insert_row(
cls, session: scoped_session, playlist_id: int, new_row_number: int
) -> "PlaylistRows":
cls.move_rows_down(session, playlist_id, new_row_number, 1)
return cls(session, playlist_id, new_row_number)
@staticmethod
def move_rows_down(
session: scoped_session, playlist_id: int, starting_row: int, move_by: int
@ -615,6 +638,26 @@ class PlaylistRows(Base):
.values(plr_rownum=PlaylistRows.plr_rownum + move_by)
)
@staticmethod
def update_plr_rownumbers(
session: scoped_session, playlist_id: int, sqla_map: List[dict[str, int]]
) -> None:
"""
Take a {plrid: plr_rownum} dictionary and update the row numbers accordingly
"""
# Update database. Ref:
# https://docs.sqlalchemy.org/en/20/tutorial/data_update.html#the-update-sql-expression-construct
stmt = (
update(PlaylistRows)
.where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.id == bindparam("plrid"),
)
.values(plr_rownum=bindparam("plr_rownum"))
)
session.connection().execute(stmt, sqla_map)
class Settings(Base):
"""Manage settings"""
@ -628,8 +671,10 @@ class Settings(Base):
f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str:
value = self.f_datetime or self.f_int or self.f_string
return f"<Settings(id={self.id}, name={self.name}, {value=}>"
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):
self.name = name
@ -644,7 +689,7 @@ class Settings(Base):
result = {}
settings = session.execute(select(cls)).scalars().all()
settings = session.scalars(select(cls)).all()
for setting in settings:
result[setting.name] = setting
@ -660,7 +705,7 @@ class Settings(Base):
except NoResultFound:
return Settings(session, name)
def update(self, session: scoped_session, data: dict):
def update(self, session: scoped_session, data: dict) -> None:
for key, value in data.items():
assert hasattr(self, key)
setattr(self, key, value)
@ -707,7 +752,7 @@ class Tracks(Base):
fade_at: int,
silence_at: int,
mtime: int,
bitrate: int
bitrate: int,
):
self.path = path
self.title = title
@ -731,7 +776,7 @@ class Tracks(Base):
def get_all(cls, session) -> List["Tracks"]:
"""Return a list of all tracks"""
return session.execute(select(cls)).scalars().unique().all()
return session.scalars(select(cls)).unique().all()
@classmethod
def get_by_path(cls, session: scoped_session, path: str) -> Optional["Tracks"]:
@ -740,9 +785,11 @@ class Tracks(Base):
"""
try:
return session.execute(
select(Tracks).where(Tracks.path == path)
).unique().scalar_one()
return (
session.execute(select(Tracks).where(Tracks.path == path))
.unique()
.scalar_one()
)
except NoResultFound:
return None
@ -757,13 +804,12 @@ class Tracks(Base):
"""
return (
session.execute(
session.scalars(
select(cls)
.options(joinedload(Tracks.playdates))
.where(cls.artist.ilike(f"%{text}%"))
.order_by(cls.title)
)
.scalars()
.unique()
.all()
)
@ -778,13 +824,12 @@ class Tracks(Base):
https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading
"""
return (
session.execute(
session.scalars(
select(cls)
.options(joinedload(Tracks.playdates))
.where(cls.title.like(f"{text}%"))
.order_by(cls.title)
)
.scalars()
.unique()
.all()
)

View File

@ -1,8 +1,6 @@
# import os
import threading
import vlc # type: ignore
#
from config import Config
from helpers import file_is_unreadable
from typing import Optional
@ -10,7 +8,7 @@ from time import sleep
from log import log
from PyQt6.QtCore import ( # type: ignore
from PyQt6.QtCore import (
QRunnable,
QThreadPool,
)

File diff suppressed because it is too large Load Diff

1543
app/playlistmodel.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'app/ui/dlg_SearchDatabase.ui'
# Form implementation generated from reading ui file 'dlg_TrackSelect.ui'
#
# Created by: PyQt6 UI code generator 6.5.2
# Created by: PyQt6 UI code generator 6.5.3
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.

View File

@ -785,11 +785,6 @@ padding-left: 8px;</string>
<string>&amp;Search</string>
</property>
<addaction name="actionSearch"/>
<addaction name="actionFind_next"/>
<addaction name="actionFind_previous"/>
<addaction name="separator"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSelect_previous_track"/>
<addaction name="separator"/>
<addaction name="actionSearch_title_in_Wikipedia"/>
<addaction name="actionSearch_title_in_Songfacts"/>

View File

@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
#
# Created by: PyQt6 UI code generator 6.5.3
# Created by: PyQt6 UI code generator 6.6.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
@ -492,11 +492,6 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionMark_for_moving)
self.menuPlaylist.addAction(self.actionPaste)
self.menuSearc_h.addAction(self.actionSearch)
self.menuSearc_h.addAction(self.actionFind_next)
self.menuSearc_h.addAction(self.actionFind_previous)
self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSelect_next_track)
self.menuSearc_h.addAction(self.actionSelect_previous_track)
self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSearch_title_in_Wikipedia)
self.menuSearc_h.addAction(self.actionSearch_title_in_Songfacts)

View File

@ -1,4 +1,4 @@
# #!/usr/bin/env python
#!/usr/bin/env python
#
import os

190
archive/DragAndDropReference.py Executable file
View File

@ -0,0 +1,190 @@
#!/usr/bin/python3
# vim: set expandtab tabstop=4 shiftwidth=4:
# PyQt Functionality Snippet by Apocalyptech
# "Licensed" in the Public Domain under CC0 1.0 Universal (CC0 1.0)
# Public Domain Dedication. Use it however you like!
#
# https://creativecommons.org/publicdomain/zero/1.0/
# https://creativecommons.org/publicdomain/zero/1.0/legalcode
from PyQt6 import QtWidgets, QtCore
# class MyModel(QtGui.QStandardItemModel):
class MyModel(QtCore.QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
def columnCount(self, parent=None):
return 5
def rowCount(self, parent=None):
return 20
# def headerData(self, column: int, orientation, role: QtCore.Qt.ItemDataRole):
# return (('Regex', 'Category')[column]
# if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal
# else None)
def headerData(self, column, orientation, role):
if role == QtCore.Qt.ItemDataRole.DisplayRole and orientation == QtCore.Qt.Orientation.Horizontal:
return f"{column=}"
return None
def data(self, index: QtCore.QModelIndex, role: QtCore.Qt.ItemDataRole):
if not index.isValid() or role not in {
QtCore.Qt.ItemDataRole.DisplayRole,
QtCore.Qt.ItemDataRole.EditRole,
}:
return None
# return (self._data[index.row()][index.column()] if index.row() < len(self._data) else
# "edit me" if role == QtCore.Qt.DisplayRole else "")
# def data(self, index, role):
# if not index.isValid() or role not in [QtCore.Qt.DisplayRole,
# QtCore.Qt.EditRole]:
# return None
# return (self._data[index.row()][index.column()] if index.row() < len(self._data) else
# "edit me" if role == QtCore.Qt.DisplayRole else "")
row = index.row()
column = index.column()
return f"Row {row}, Col {column}"
def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag:
# https://doc.qt.io/qt-5/qt.html#ItemFlag-enum
if not index.isValid():
return QtCore.Qt.ItemFlag.ItemIsEnabled
if index.row() < 20:
return (
QtCore.Qt.ItemFlag.ItemIsEnabled
| QtCore.Qt.ItemFlag.ItemIsEditable
| QtCore.Qt.ItemFlag.ItemIsSelectable
| QtCore.Qt.ItemFlag.ItemIsDragEnabled
)
return QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsEditable
# def flags(self, index):
# if not index.isValid():
# return QtCore.Qt.ItemIsDropEnabled
# if index.row() < 5:
# return (
# QtCore.Qt.ItemIsEnabled
# | QtCore.Qt.ItemIsEditable
# | QtCore.Qt.ItemIsSelectable
# | QtCore.Qt.ItemIsDragEnabled
# )
# return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable
# def supportedDragOptions(self):
# return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction
# def supportedDropActions(self) -> bool:
# return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction
def relocateRow(self, row_source, row_target) -> None:
return
row_a, row_b = max(row_source, row_target), min(row_source, row_target)
self.beginMoveRows(
QtCore.QModelIndex(), row_a, row_a, QtCore.QModelIndex(), row_b
)
self._data.insert(row_target, self._data.pop(row_source))
self.endMoveRows()
def supportedDropActions(self):
return QtCore.Qt.DropAction.MoveAction | QtCore.Qt.DropAction.CopyAction
# def relocateRow(self, src, dst):
# print("relocateRow")
# def dropMimeData(self, data, action, row, col, parent):
# """
# Always move the entire row, and don't allow column "shifting"
# """
# # return super().dropMimeData(data, action, row, 0, parent)
# print("dropMimeData")
# super().dropMimeData(data, action, row, col, parent)
class MyStyle(QtWidgets.QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Draw a line across the entire row rather than just the column
we're hovering over. This may not always work depending on global
style - for instance I think it won't work on OSX.
"""
if element == QtWidgets.QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop and not option.rect.isNull():
option_new = QtWidgets.QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class MyTableView(QtWidgets.QTableView):
def __init__(self, parent):
super().__init__(parent)
self.verticalHeader().hide()
self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.InternalMove)
self.setDragDropOverwriteMode(False)
self.setAcceptDrops(True)
# self.horizontalHeader().hide()
# self.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
# self.setShowGrid(False)
# Set our custom style - this draws the drop indicator across the whole row
self.setStyle(MyStyle())
# Set our custom model - this prevents row "shifting"
# self.model = MyModel()
# self.setModel(self.model)
self.setModel(MyModel())
# for (idx, data) in enumerate(['foo', 'bar', 'baz']):
# item_1 = QtGui.QStandardItem('Item {}'.format(idx))
# item_1.setEditable(False)
# item_1.setDropEnabled(False)
# item_2 = QtGui.QStandardItem(data)
# item_2.setEditable(False)
# item_2.setDropEnabled(False)
# self.model.appendRow([item_1, item_2])
def dropEvent(self, event):
if event.source() is not self or (
event.dropAction() != QtCore.Qt.DropAction.MoveAction
and self.dragDropMode() != QtWidgets.QAbstractItemView.InternalMove
):
super().dropEvent(event)
from_rows = list(set([a.row() for a in self.selectedIndexes()]))
to_row = self.indexAt(event.position().toPoint()).row()
if (
0 <= min(from_rows) <= self.model().rowCount()
and 0 <= max(from_rows) <= self.model().rowCount()
and 0 <= to_row <= self.model().rowCount()
):
print(f"move_rows({from_rows=}, {to_row=})")
event.accept()
super().dropEvent(event)
class Testing(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
view = MyTableView(self)
view.setModel(MyModel())
self.setCentralWidget(view)
self.show()
if __name__ == "__main__":
app = QtWidgets.QApplication([])
test = Testing()
raise SystemExit(app.exec())

84
archive/db_experiments.py Executable file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
from sqlalchemy import create_engine, String, update, bindparam, case
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
mapped_column,
sessionmaker,
scoped_session,
)
from typing import Generator
from contextlib import contextmanager
db_url = "sqlite:////tmp/rhys.db"
class Base(DeclarativeBase):
pass
class Rhys(Base):
__tablename__ = "rhys"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ref_number: Mapped[int] = mapped_column()
name: Mapped[str] = mapped_column(String(256), index=True)
def __init__(self, session, ref_number: int, name: str) -> None:
self.ref_number = ref_number
self.name = name
session.add(self)
session.flush()
@contextmanager
def Session() -> Generator[scoped_session, None, None]:
Session = scoped_session(sessionmaker(bind=engine))
yield Session
Session.commit()
Session.close()
engine = create_engine(db_url)
Base.metadata.create_all(engine)
inital_number_of_records = 10
def move_rows(session):
new_row = 6
with Session() as session:
# new_record = Rhys(session, new_row, f"new {new_row=}")
# Move rows
stmt = (
update(Rhys)
.where(Rhys.ref_number > new_row)
# .where(Rhys.id.in_(session.query(Rhys.id).order_by(Rhys.id.desc())))
.values({Rhys.ref_number: Rhys.ref_number + 1})
)
session.execute(stmt)
sqla_map = []
for k, v in zip(range(11), [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]):
sqla_map.append({"oldrow": k, "newrow": v})
# for a, b in sqla_map.items():
# print(f"{a} > {b}")
with Session() as session:
for a in range(inital_number_of_records):
_ = Rhys(session, a, f"record: {a}")
stmt = update(Rhys).values(
ref_number=case(
{item['oldrow']: item['newrow'] for item in sqla_map},
value=Rhys.ref_number
)
)
session.connection().execute(stmt, sqla_map)

View File

@ -0,0 +1,98 @@
#!/usr/bin/env python
# vim: set expandtab tabstop=4 shiftwidth=4:
# PyQt Functionality Snippet by Apocalyptech
# "Licensed" in the Public Domain under CC0 1.0 Universal (CC0 1.0)
# Public Domain Dedication. Use it however you like!
#
# https://creativecommons.org/publicdomain/zero/1.0/
# https://creativecommons.org/publicdomain/zero/1.0/legalcode
import sys
from PyQt5 import QtWidgets, QtGui, QtCore
class MyModel(QtGui.QStandardItemModel):
def dropMimeData(self, data, action, row, col, parent):
"""
Always move the entire row, and don't allow column "shifting"
"""
return super().dropMimeData(data, action, row, 0, parent)
class MyStyle(QtWidgets.QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Draw a line across the entire row rather than just the column
we're hovering over. This may not always work depending on global
style - for instance I think it won't work on OSX.
"""
if element == self.PE_IndicatorItemViewItemDrop and not option.rect.isNull():
option_new = QtWidgets.QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class MyTableView(QtWidgets.QTableView):
def __init__(self, parent):
super().__init__(parent)
self.verticalHeader().hide()
self.horizontalHeader().hide()
self.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
self.setSelectionBehavior(self.SelectRows)
self.setSelectionMode(self.SingleSelection)
self.setShowGrid(False)
self.setDragDropMode(self.InternalMove)
self.setDragDropOverwriteMode(False)
# Set our custom style - this draws the drop indicator across the whole row
self.setStyle(MyStyle())
# Set our custom model - this prevents row "shifting"
self.model = MyModel()
self.setModel(self.model)
for (idx, data) in enumerate(['foo', 'bar', 'baz']):
item_1 = QtGui.QStandardItem('Item {}'.format(idx))
item_1.setEditable(False)
item_1.setDropEnabled(False)
item_2 = QtGui.QStandardItem(data)
item_2.setEditable(False)
item_2.setDropEnabled(False)
self.model.appendRow([item_1, item_2])
class Testing(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
# Main widget
w = QtWidgets.QWidget()
l = QtWidgets.QVBoxLayout()
w.setLayout(l)
self.setCentralWidget(w)
# spacer
l.addWidget(QtWidgets.QLabel('top'), 1)
# Combo Box
l.addWidget(MyTableView(self))
# spacer
l.addWidget(QtWidgets.QLabel('bottom'), 1)
# A bit of window housekeeping
self.resize(400, 400)
self.setWindowTitle('Testing')
self.show()
if __name__ == '__main__':
app = QtWidgets.QApplication([])
test = Testing()
sys.exit(app.exec_())

BIN
archive/todo/.DS_Store vendored Normal file

Binary file not shown.

1
archive/todo/data.db Normal file
View File

@ -0,0 +1 @@
[[false, "My first todo"], [true, "My second todo"], [true, "Another todo"], [false, "as"]]

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>275</width>
<height>314</height>
</rect>
</property>
<property name="windowTitle">
<string>Todo</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QListView" name="todoView">
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
</widget>
</item>
<item>
<widget class="QWidget" name="widget" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="deleteButton">
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="completeButton">
<property name="text">
<string>Complete</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QLineEdit" name="todoEdit"/>
</item>
<item>
<widget class="QPushButton" name="addButton">
<property name="text">
<string>Add Todo</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>275</width>
<height>22</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<resources/>
<connections/>
</ui>

BIN
archive/todo/tick.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

106
archive/todo/todo.py Normal file
View File

@ -0,0 +1,106 @@
import sys
import datetime
import json
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import Qt
qt_creator_file = "mainwindow.ui"
Ui_MainWindow, QtBaseClass = uic.loadUiType(qt_creator_file)
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:
return tick
def rowCount(self, index):
return len(self.todos)
def flags(self, index):
print(datetime.datetime.now().time().strftime("%H:%M:%S"))
return super().flags(index)
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def __init__(self):
QtWidgets.QMainWindow.__init__(self)
Ui_MainWindow.__init__(self)
self.setupUi(self)
self.model = TodoModel()
self.load()
self.todoView.setModel(self.model)
self.addButton.pressed.connect(self.add)
self.deleteButton.pressed.connect(self.delete)
self.completeButton.pressed.connect(self.complete)
def add(self):
"""
Add an item to our todo list, getting the text from the QLineEdit .todoEdit
and then clearing it.
"""
text = self.todoEdit.text()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit()
# Empty the input
self.todoEdit.setText("")
self.save()
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
# Indexes is a list of a single item in single-select mode.
index = indexes[0]
# Remove the item and refresh.
del self.model.todos[index.row()]
self.model.layoutChanged.emit()
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def complete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
index = indexes[0]
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
# 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:
self.model.todos = json.load(f)
except Exception:
pass
def save(self):
with open('data.db', 'w') as f:
data = json.dump(self.model.todos, f)
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

View File

@ -1,41 +1,49 @@
# https://itnext.io/setting-up-transactional-tests-with-pytest-and-sqlalchemy-b2d726347629
import pytest
# Flake8 doesn't like the sys.append within imports
# import sys
# sys.path.append("app")
import helpers
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from app.models import Base, Tracks
@pytest.fixture(scope="session")
def connection():
engine = create_engine(
"mysql+mysqldb://musicmuster_testing:musicmuster_testing@"
"localhost/musicmuster_testing"
)
return engine.connect()
DB_CONNECTION = "mysql+mysqldb://musicmuster_testing:musicmuster_testing@localhost/dev_musicmuster_testing"
@pytest.fixture(scope="session")
def setup_database(connection):
from app.models import Base # noqa E402
Base.metadata.bind = connection
Base.metadata.create_all()
# seed_database()
yield
Base.metadata.drop_all()
def db_engine():
engine = create_engine(DB_CONNECTION, isolation_level="READ COMMITTED")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(setup_database, connection):
@pytest.fixture(scope="function")
def session(db_engine):
connection = db_engine.connect()
transaction = connection.begin()
yield scoped_session(
sessionmaker(autocommit=False, autoflush=False, bind=connection)
)
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

@ -0,0 +1,60 @@
"""Add 'open' field to Playlists
Revision ID: 5bb2c572e1e5
Revises: 3a53a9fb26ab
Create Date: 2023-11-18 14:19:02.643914
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '5bb2c572e1e5'
down_revision = '3a53a9fb26ab'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('carts', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('carts', 'path',
existing_type=mysql.VARCHAR(length=2048),
nullable=True)
op.alter_column('carts', 'enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.alter_column('playlist_rows', 'note',
existing_type=mysql.VARCHAR(length=2048),
nullable=False)
op.add_column('playlists', sa.Column('open', sa.Boolean(), nullable=False))
op.alter_column('settings', 'name',
existing_type=mysql.VARCHAR(length=32),
type_=sa.String(length=64),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('settings', 'name',
existing_type=sa.String(length=64),
type_=mysql.VARCHAR(length=32),
existing_nullable=False)
op.drop_column('playlists', 'open')
op.alter_column('playlist_rows', 'note',
existing_type=mysql.VARCHAR(length=2048),
nullable=True)
op.alter_column('carts', 'enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
op.alter_column('carts', 'path',
existing_type=mysql.VARCHAR(length=2048),
nullable=False)
op.alter_column('carts', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
# ### end Alembic commands ###

136
poetry.lock generated
View File

@ -936,48 +936,37 @@ files = [
[[package]]
name = "numpy"
version = "1.26.2"
version = "1.25.2"
description = "Fundamental package for array computing in Python"
category = "main"
optional = false
python-versions = ">=3.9"
files = [
{file = "numpy-1.26.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3703fc9258a4a122d17043e57b35e5ef1c5a5837c3db8be396c82e04c1cf9b0f"},
{file = "numpy-1.26.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc392fdcbd21d4be6ae1bb4475a03ce3b025cd49a9be5345d76d7585aea69440"},
{file = "numpy-1.26.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36340109af8da8805d8851ef1d74761b3b88e81a9bd80b290bbfed61bd2b4f75"},
{file = "numpy-1.26.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc008217145b3d77abd3e4d5ef586e3bdfba8fe17940769f8aa09b99e856c00"},
{file = "numpy-1.26.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3ced40d4e9e18242f70dd02d739e44698df3dcb010d31f495ff00a31ef6014fe"},
{file = "numpy-1.26.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b272d4cecc32c9e19911891446b72e986157e6a1809b7b56518b4f3755267523"},
{file = "numpy-1.26.2-cp310-cp310-win32.whl", hash = "sha256:22f8fc02fdbc829e7a8c578dd8d2e15a9074b630d4da29cda483337e300e3ee9"},
{file = "numpy-1.26.2-cp310-cp310-win_amd64.whl", hash = "sha256:26c9d33f8e8b846d5a65dd068c14e04018d05533b348d9eaeef6c1bd787f9919"},
{file = "numpy-1.26.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b96e7b9c624ef3ae2ae0e04fa9b460f6b9f17ad8b4bec6d7756510f1f6c0c841"},
{file = "numpy-1.26.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa18428111fb9a591d7a9cc1b48150097ba6a7e8299fb56bdf574df650e7d1f1"},
{file = "numpy-1.26.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06fa1ed84aa60ea6ef9f91ba57b5ed963c3729534e6e54055fc151fad0423f0a"},
{file = "numpy-1.26.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96ca5482c3dbdd051bcd1fce8034603d6ebfc125a7bd59f55b40d8f5d246832b"},
{file = "numpy-1.26.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:854ab91a2906ef29dc3925a064fcd365c7b4da743f84b123002f6139bcb3f8a7"},
{file = "numpy-1.26.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f43740ab089277d403aa07567be138fc2a89d4d9892d113b76153e0e412409f8"},
{file = "numpy-1.26.2-cp311-cp311-win32.whl", hash = "sha256:a2bbc29fcb1771cd7b7425f98b05307776a6baf43035d3b80c4b0f29e9545186"},
{file = "numpy-1.26.2-cp311-cp311-win_amd64.whl", hash = "sha256:2b3fca8a5b00184828d12b073af4d0fc5fdd94b1632c2477526f6bd7842d700d"},
{file = "numpy-1.26.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a4cd6ed4a339c21f1d1b0fdf13426cb3b284555c27ac2f156dfdaaa7e16bfab0"},
{file = "numpy-1.26.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d5244aabd6ed7f312268b9247be47343a654ebea52a60f002dc70c769048e75"},
{file = "numpy-1.26.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a3cdb4d9c70e6b8c0814239ead47da00934666f668426fc6e94cce869e13fd7"},
{file = "numpy-1.26.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa317b2325f7aa0a9471663e6093c210cb2ae9c0ad824732b307d2c51983d5b6"},
{file = "numpy-1.26.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:174a8880739c16c925799c018f3f55b8130c1f7c8e75ab0a6fa9d41cab092fd6"},
{file = "numpy-1.26.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f79b231bf5c16b1f39c7f4875e1ded36abee1591e98742b05d8a0fb55d8a3eec"},
{file = "numpy-1.26.2-cp312-cp312-win32.whl", hash = "sha256:4a06263321dfd3598cacb252f51e521a8cb4b6df471bb12a7ee5cbab20ea9167"},
{file = "numpy-1.26.2-cp312-cp312-win_amd64.whl", hash = "sha256:b04f5dc6b3efdaab541f7857351aac359e6ae3c126e2edb376929bd3b7f92d7e"},
{file = "numpy-1.26.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4eb8df4bf8d3d90d091e0146f6c28492b0be84da3e409ebef54349f71ed271ef"},
{file = "numpy-1.26.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1a13860fdcd95de7cf58bd6f8bc5a5ef81c0b0625eb2c9a783948847abbef2c2"},
{file = "numpy-1.26.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64308ebc366a8ed63fd0bf426b6a9468060962f1a4339ab1074c228fa6ade8e3"},
{file = "numpy-1.26.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baf8aab04a2c0e859da118f0b38617e5ee65d75b83795055fb66c0d5e9e9b818"},
{file = "numpy-1.26.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d73a3abcac238250091b11caef9ad12413dab01669511779bc9b29261dd50210"},
{file = "numpy-1.26.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b361d369fc7e5e1714cf827b731ca32bff8d411212fccd29ad98ad622449cc36"},
{file = "numpy-1.26.2-cp39-cp39-win32.whl", hash = "sha256:bd3f0091e845164a20bd5a326860c840fe2af79fa12e0469a12768a3ec578d80"},
{file = "numpy-1.26.2-cp39-cp39-win_amd64.whl", hash = "sha256:2beef57fb031dcc0dc8fa4fe297a742027b954949cabb52a2a376c144e5e6060"},
{file = "numpy-1.26.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1cc3d5029a30fb5f06704ad6b23b35e11309491c999838c31f124fee32107c79"},
{file = "numpy-1.26.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94cc3c222bb9fb5a12e334d0479b97bb2df446fbe622b470928f5284ffca3f8d"},
{file = "numpy-1.26.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe6b44fb8fcdf7eda4ef4461b97b3f63c466b27ab151bec2366db8b197387841"},
{file = "numpy-1.26.2.tar.gz", hash = "sha256:f65738447676ab5777f11e6bbbdb8ce11b785e105f690bc45966574816b6d3ea"},
{file = "numpy-1.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db3ccc4e37a6873045580d413fe79b68e47a681af8db2e046f1dacfa11f86eb3"},
{file = "numpy-1.25.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:90319e4f002795ccfc9050110bbbaa16c944b1c37c0baeea43c5fb881693ae1f"},
{file = "numpy-1.25.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4a913e29b418d096e696ddd422d8a5d13ffba4ea91f9f60440a3b759b0187"},
{file = "numpy-1.25.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08f2e037bba04e707eebf4bc934f1972a315c883a9e0ebfa8a7756eabf9e357"},
{file = "numpy-1.25.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bec1e7213c7cb00d67093247f8c4db156fd03075f49876957dca4711306d39c9"},
{file = "numpy-1.25.2-cp310-cp310-win32.whl", hash = "sha256:7dc869c0c75988e1c693d0e2d5b26034644399dd929bc049db55395b1379e044"},
{file = "numpy-1.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:834b386f2b8210dca38c71a6e0f4fd6922f7d3fcff935dbe3a570945acb1b545"},
{file = "numpy-1.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5462d19336db4560041517dbb7759c21d181a67cb01b36ca109b2ae37d32418"},
{file = "numpy-1.25.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5652ea24d33585ea39eb6a6a15dac87a1206a692719ff45d53c5282e66d4a8f"},
{file = "numpy-1.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d60fbae8e0019865fc4784745814cff1c421df5afee233db6d88ab4f14655a2"},
{file = "numpy-1.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e7f0f7f6d0eee8364b9a6304c2845b9c491ac706048c7e8cf47b83123b8dbf"},
{file = "numpy-1.25.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bb33d5a1cf360304754913a350edda36d5b8c5331a8237268c48f91253c3a364"},
{file = "numpy-1.25.2-cp311-cp311-win32.whl", hash = "sha256:5883c06bb92f2e6c8181df7b39971a5fb436288db58b5a1c3967702d4278691d"},
{file = "numpy-1.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:5c97325a0ba6f9d041feb9390924614b60b99209a71a69c876f71052521d42a4"},
{file = "numpy-1.25.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b79e513d7aac42ae918db3ad1341a015488530d0bb2a6abcbdd10a3a829ccfd3"},
{file = "numpy-1.25.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eb942bfb6f84df5ce05dbf4b46673ffed0d3da59f13635ea9b926af3deb76926"},
{file = "numpy-1.25.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e0746410e73384e70d286f93abf2520035250aad8c5714240b0492a7302fdca"},
{file = "numpy-1.25.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7806500e4f5bdd04095e849265e55de20d8cc4b661b038957354327f6d9b295"},
{file = "numpy-1.25.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8b77775f4b7df768967a7c8b3567e309f617dd5e99aeb886fa14dc1a0791141f"},
{file = "numpy-1.25.2-cp39-cp39-win32.whl", hash = "sha256:2792d23d62ec51e50ce4d4b7d73de8f67a2fd3ea710dcbc8563a51a03fb07b01"},
{file = "numpy-1.25.2-cp39-cp39-win_amd64.whl", hash = "sha256:76b4115d42a7dfc5d485d358728cdd8719be33cc5ec6ec08632a5d6fca2ed380"},
{file = "numpy-1.25.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1a1329e26f46230bf77b02cc19e900db9b52f398d6722ca853349a782d4cff55"},
{file = "numpy-1.25.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c3abc71e8b6edba80a01a52e66d83c5d14433cbcd26a40c329ec7ed09f37901"},
{file = "numpy-1.25.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1b9735c27cea5d995496f46a8b1cd7b408b3f34b6d50459d9ac8fe3a20cc17bf"},
{file = "numpy-1.25.2.tar.gz", hash = "sha256:fd608e19c8d7c55021dffd43bfe5492fab8cc105cc8986f813f8c3c048b38760"},
]
[[package]]
@ -1039,6 +1028,23 @@ files = [
{file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"},
]
[[package]]
name = "pdbp"
version = "1.5.0"
description = "pdbp (Pdb+): A drop-in replacement for pdb and pdbpp."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pdbp-1.5.0-py3-none-any.whl", hash = "sha256:7640598c336ec3e3e0b2aeec71d20a1e810ba49e3e1b3effac5b862a798dea7d"},
{file = "pdbp-1.5.0.tar.gz", hash = "sha256:23e03897fe950794a487238b64d8b0cec66760083c4697e3b7bc5ca0fae617ea"},
]
[package.dependencies]
colorama = {version = ">=0.4.6", markers = "platform_system == \"Windows\""}
pygments = ">=2.16.1"
tabcompleter = ">=1.3.0"
[[package]]
name = "pexpect"
version = "4.8.0"
@ -1056,14 +1062,14 @@ ptyprocess = ">=0.5"
[[package]]
name = "platformdirs"
version = "4.0.0"
version = "3.11.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"},
{file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"},
{file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"},
{file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"},
]
[package.extras]
@ -1088,14 +1094,14 @@ testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "prompt-toolkit"
version = "3.0.40"
version = "3.0.39"
description = "Library for building powerful interactive command lines in Python"
category = "dev"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "prompt_toolkit-3.0.40-py3-none-any.whl", hash = "sha256:99ba3dfb23d5b5af89712f89e60a5f3d9b8b67a9482ca377c5771d0e9047a34b"},
{file = "prompt_toolkit-3.0.40.tar.gz", hash = "sha256:a371c06bb1d66cd499fecd708e50c0b6ae00acba9822ba33c586e2f16d1b739e"},
{file = "prompt_toolkit-3.0.39-py3-none-any.whl", hash = "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"},
{file = "prompt_toolkit-3.0.39.tar.gz", hash = "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac"},
]
[package.dependencies]
@ -1144,13 +1150,13 @@ files = [
[[package]]
name = "pudb"
version = "2022.1.3"
version = "2023.1"
description = "A full-screen, console-based Python debugger"
category = "dev"
optional = false
python-versions = "~=3.6"
python-versions = "~=3.8"
files = [
{file = "pudb-2022.1.3.tar.gz", hash = "sha256:58e83ada9e19ffe92c1fdc78ae5458ef91aeb892a5b8f0e7379e6fa61e0e664a"},
{file = "pudb-2023.1.tar.gz", hash = "sha256:15df3c603aba87a918a666ef8e1bf63f764238cc3589db3c5b7a5f1b01ea2f03"},
]
[package.dependencies]
@ -1449,6 +1455,18 @@ files = [
[package.dependencies]
numpy = ">=1.20.0"
[[package]]
name = "pyreadline3"
version = "3.4.1"
description = "A python implementation of GNU readline."
category = "dev"
optional = false
python-versions = "*"
files = [
{file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"},
{file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"},
]
[[package]]
name = "pytest"
version = "7.4.3"
@ -1913,6 +1931,21 @@ files = [
{file = "stackprinter-0.2.10.tar.gz", hash = "sha256:99d1ea6b91ffad96b28241edd7bcf071752b0cf694cab58d2335df5353acd086"},
]
[[package]]
name = "tabcompleter"
version = "1.3.0"
description = "tabcompleter --- Autocompletion in the Python console."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "tabcompleter-1.3.0-py3-none-any.whl", hash = "sha256:59dfe825f4d88a51d486c0a513763eca6224f2146518d185ee2ebfc4f2398b80"},
{file = "tabcompleter-1.3.0.tar.gz", hash = "sha256:47b9d4f783d14ebca5c66223c7f82cc1ef89f7313ba9ea0ce75265670178bb6e"},
]
[package.dependencies]
pyreadline3 = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "text-unidecode"
version = "1.3"
@ -2021,18 +2054,19 @@ files = [
[[package]]
name = "urllib3"
version = "2.1.0"
version = "2.0.7"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "dev"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.7"
files = [
{file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"},
{file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"},
{file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"},
{file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
@ -2155,4 +2189,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
content-hash = "97f122b0c15850e806e764ab7d3df23ce115e8aa9cc7a775c64834b18beef664"
content-hash = "5bd0a9ae09f61079a0325639485adb206357cd5ea942944ccb5855f2a83d4db6"

Binary file not shown.

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

View File

@ -36,12 +36,13 @@ line-profiler = "^4.0.2"
flakehell = "^0.9.0"
[tool.poetry.group.dev.dependencies]
pudb = "^2022.1.3"
pudb = "^2023.1"
sphinx = "^7.0.1"
furo = "^2023.5.20"
black = "^23.3.0"
flakehell = "^0.9.0"
mypy = "^1.6.0"
mypy = "^1.7.0"
pdbp = "^1.5.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
@ -51,6 +52,11 @@ build-backend = "poetry.core.masonry.api"
# mypy_path = "/home/kae/.cache/pypoetry/virtualenvs/musicmuster-oWgGw1IG-py3.9:/home/kae/git/musicmuster/app"
mypy_path = "/home/kae/git/musicmuster/app"
[tool.pytest.ini_options]
addopts = "--exitfirst --showlocals --capture=no"
pythonpath = [".", "app"]
filterwarnings = "ignore:'audioop' is deprecated"
[tool.vulture]
exclude = ["migrations", "app/ui", "archive"]
paths = ["app"]

View File

@ -1,2 +0,0 @@
[pytest]
addopts = -xls

33
test.py
View File

@ -1,33 +0,0 @@
#!/usr/bin/env python
from PyQt5 import QtGui, QtWidgets
class TabBar(QtWidgets.QTabBar):
def paintEvent(self, event):
painter = QtWidgets.QStylePainter(self)
option = QtWidgets.QStyleOptionTab()
for index in range(self.count()):
self.initStyleOption(option, index)
bgcolor = QtGui.QColor(self.tabText(index))
option.palette.setColor(QtGui.QPalette.Window, bgcolor)
painter.drawControl(QtWidgets.QStyle.CE_TabBarTabShape, option)
painter.drawControl(QtWidgets.QStyle.CE_TabBarTabLabel, option)
class Window(QtWidgets.QTabWidget):
def __init__(self):
QtWidgets.QTabWidget.__init__(self)
self.setTabBar(TabBar(self))
for color in "tomato orange yellow lightgreen skyblue plum".split():
self.addTab(QtWidgets.QWidget(self), color)
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
window = Window()
window.resize(420, 200)
window.show()
sys.exit(app.exec_())

View File

@ -45,7 +45,7 @@ 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) == "10:00"
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)

View File

@ -1,8 +1,9 @@
import os.path
import helpers
from app.models import (
NoteColours,
Notes,
Playdates,
Playlists,
Tracks,
@ -12,6 +13,7 @@ from app.models import (
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)
@ -24,6 +26,7 @@ def test_notecolours_get_colour(session):
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)
@ -52,185 +55,92 @@ def test_notecolours_get_colour_match(session):
assert result == note_colour
def test_notes_creation(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
note_text = "note text"
note = Notes(session, playlist.id, 0, note_text)
assert note
notes = session.query(Notes).all()
assert len(notes) == 1
assert notes[0].note == note_text
def test_notes_delete(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
note_text = "note text"
note = Notes(session, playlist.id, 0, note_text)
assert note
notes = session.query(Notes).all()
assert len(notes) == 1
assert notes[0].note == note_text
note.delete_note(session)
notes = session.query(Notes).all()
assert len(notes) == 0
def test_notes_update_row_only(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
note_text = "note text"
note = Notes(session, playlist.id, 0, note_text)
new_row = 10
note.update_note(session, new_row)
notes = session.query(Notes).all()
assert len(notes) == 1
assert notes[0].row == new_row
def test_notes_update_text(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
note_text = "note text"
note = Notes(session, playlist.id, 0, note_text)
new_text = "This is new"
new_row = 0
note.update_note(session, new_row, new_text)
notes = session.query(Notes).all()
assert len(notes) == 1
assert notes[0].note == new_text
assert notes[0].row == new_row
def test_playdates_add_playdate(session):
def test_playdates_add_playdate(session, track1):
"""Test playdate and last_played retrieval"""
# We need a track
track_path = "/a/b/c"
track = Tracks(session, track_path)
playdate = Playdates(session, track.id)
playdate = Playdates(session, track1.id)
assert playdate
last_played = Playdates.last_played(session, track.id)
last_played = Playdates.last_played(session, track1.id)
assert abs((playdate.lastplayed - last_played).total_seconds()) < 2
def test_playdates_remove_track(session):
"""Test removing a track from a playdate"""
# We need a track
track_path = "/a/b/c"
track = Tracks(session, track_path)
Playdates.remove_track(session, track.id)
last_played = Playdates.last_played(session, track.id)
assert last_played is None
def test_playlist_create(session):
playlist = Playlists(session, "my playlist")
assert playlist
def test_playlist_add_note(session):
note_text = "my note"
# def test_playlist_add_track(session, track):
# # We need a playlist
# playlist = Playlists(session, "my playlist")
playlist = Playlists(session, "my playlist")
# row = 17
assert len(playlist.notes) == 1
playlist_note = playlist.notes[0]
assert playlist_note.note == note_text
# 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_add_track(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
# def test_playlist_tracks(session):
# # We need a playlist
# playlist = Playlists(session, "my playlist")
# We need a track
track_path = "/a/b/c"
track = Tracks(session, track_path)
# # We need two tracks
# track1_path = "/a/b/c"
# track1_row = 17
# track1 = Tracks(session, track1_path)
row = 17
# track2_path = "/x/y/z"
# track2_row = 29
# track2 = Tracks(session, track2_path)
playlist.add_track(session, track.id, row)
# playlist.add_track(session, track1.id, track1_row)
# playlist.add_track(session, track2.id, track2_row)
assert len(playlist.tracks) == 1
playlist_track = playlist.tracks[row]
assert playlist_track.path == track_path
# tracks = playlist.tracks
# assert tracks[track1_row] == track1
# assert tracks[track2_row] == track2
def test_playlist_tracks(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
# def test_playlist_notes(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)
# # We need two notes
# note1_text = "note1 text"
# note1_row = 11
# _ = Notes(session, playlist.id, note1_row, note1_text)
track2_path = "/x/y/z"
track2_row = 29
track2 = Tracks(session, track2_path)
# note2_text = "note2 text"
# note2_row = 19
# _ = Notes(session, playlist.id, note2_row, note2_text)
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]
# 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)) == 1
assert len(Playlists.get_closed(session)) == 0
playlist.close(session)
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
playlist.mark_open(session)
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
@ -243,250 +153,34 @@ def test_playlist_get_all_and_by_id(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 Playlists.get_by_id(session, playlist1.id).name == p1_name
assert session.get(Playlists, playlist1.id).name == p1_name
def test_playlist_remove_tracks(session):
# Need two playlists and three tracks
p1_name = "playlist one"
playlist1 = Playlists(session, p1_name)
p2_name = "playlist two"
playlist2 = Playlists(session, p2_name)
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
track2_path = "/m/n/o"
track2 = Tracks(session, track2_path)
track3_path = "/x/y/z"
track3 = Tracks(session, track3_path)
# Add all tracks to both playlists
for p in [playlist1, playlist2]:
for t in [track1, track2, track3]:
p.add_track(session, t.id)
assert len(playlist1.tracks) == 3
assert len(playlist2.tracks) == 3
playlist1.remove_track(session, 1)
assert len(playlist1.tracks) == 2
# Check the track itself still exists
original_track = Tracks.get_by_id(session, track1.id)
assert original_track
playlist1.remove_all_tracks(session)
assert len(playlist1.tracks) == 0
assert len(playlist2.tracks) == 3
def test_playlist_get_track_playlists(session):
# Need two playlists and two tracks
p1_name = "playlist one"
playlist1 = Playlists(session, p1_name)
p2_name = "playlist two"
playlist2 = Playlists(session, p2_name)
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
track2_path = "/m/n/o"
track2 = Tracks(session, track2_path)
# Put track1 in both playlists, track2 only in playlist1
playlist1.add_track(session, track1.id)
playlist2.add_track(session, track1.id)
playlist1.add_track(session, track2.id)
playlists_track1 = track1.playlists
playlists_track2 = track2.playlists
assert p1_name in [a.playlist.name for a in playlists_track1]
assert p2_name in [a.playlist.name for a in playlists_track1]
assert p1_name in [a.playlist.name for a in playlists_track2]
assert p2_name not in [a.playlist.name for a in playlists_track2]
def test_playlist_move_track(session):
# We need two playlists
p1_name = "playlist one"
p2_name = "playlist two"
playlist1 = Playlists(session, p1_name)
playlist2 = Playlists(session, p2_name)
def test_tracks_get_all_tracks(session, track1, track2):
# Need two tracks
track1_row = 17
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
track2_row = 29
track2_path = "/m/n/o"
track2 = Tracks(session, track2_path)
# Add both to playlist1 and check
playlist1.add_track(session, track1.id, track1_row)
playlist1.add_track(session, track2.id, track2_row)
tracks = playlist1.tracks
assert tracks[track1_row] == track1
assert tracks[track2_row] == track2
# Move track2 to playlist2 and check
playlist1.move_track(session, [track2_row], playlist2)
tracks1 = playlist1.tracks
tracks2 = playlist2.tracks
assert len(tracks1) == 1
assert len(tracks2) == 1
assert tracks1[track1_row] == track1
assert tracks2[0] == track2
result = [a.path for a in Tracks.get_all(session)]
assert track1.path in result
assert track2.path in result
def test_tracks_get_all_paths(session):
# Need two tracks
track1_path = "/a/b/c"
_ = Tracks(session, track1_path)
track2_path = "/m/n/o"
_ = Tracks(session, track2_path)
def test_tracks_by_path(session, track1):
result = Tracks.get_all_paths(session)
assert track1_path in result
assert track2_path in result
assert Tracks.get_by_path(session, track1.path) is track1
def test_tracks_get_all_tracks(session):
# Need two tracks
track1_path = "/a/b/c"
track2_path = "/m/n/o"
def test_tracks_by_id(session, track1):
result = Tracks.get_all_tracks(session)
assert track1_path in [a.path for a in result]
assert track2_path in [a.path for a in result]
assert session.get(Tracks, track1.id) is track1
def test_tracks_by_filename(session):
track1_path = "/a/b/c"
def test_tracks_search_artists(session, track1):
track1_artist = "Fleetwood Mac"
track1 = Tracks(session, track1_path)
assert Tracks.get_by_filename(session, os.path.basename(track1_path)) is track1
def test_tracks_by_path(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert Tracks.get_by_path(session, track1_path) is track1
def test_tracks_by_id(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert Tracks.get_by_id(session, track1.id) is track1
def test_tracks_rescan(session):
# Get test track
test_track_path = "./testdata/isa.mp3"
test_track_data = "./testdata/isa.py"
track = Tracks(session, test_track_path)
track.rescan(session)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Re-read the track
track_read = Tracks.get_by_path(session, test_track_path)
assert track_read.duration == testdata["duration"]
assert track_read.start_gap == testdata["leading_silence"]
# Silence detection can vary, so ± 1 second is OK
assert track_read.fade_at < testdata["fade_at"] + 1000
assert track_read.fade_at > testdata["fade_at"] - 1000
assert track_read.silence_at < testdata["trailing_silence"] + 1000
assert track_read.silence_at > testdata["trailing_silence"] - 1000
def test_tracks_remove_by_path(session):
track1_path = "/a/b/c"
assert len(Tracks.get_all_tracks(session)) == 1
Tracks.remove_by_path(session, track1_path)
assert len(Tracks.get_all_tracks(session)) == 0
def test_tracks_search_artists(session):
track1_path = "/a/b/c"
track1_artist = "Artist One"
track1 = Tracks(session, track1_path)
track1.artist = track1_artist
track2_path = "/m/n/o"
track2_artist = "Artist Two"
track2 = Tracks(session, track2_path)
track2.artist = track2_artist
session.commit()
artist_first_word = track1_artist.split()[0].lower()
assert len(Tracks.search_artists(session, artist_first_word)) == 2
assert len(Tracks.search_artists(session, track1_artist)) == 1
def test_tracks_search_titles(session):
track1_path = "/a/b/c"
track1_title = "Title One"
track1 = Tracks(session, track1_path)
track1.title = track1_title
def test_tracks_search_titles(session, track1):
track1_title = "I'm So Afraid"
track2_path = "/m/n/o"
track2_title = "Title Two"
track2 = Tracks(session, track2_path)
track2.title = track2_title
session.commit()
title_first_word = track1_title.split()[0].lower()
assert len(Tracks.search_titles(session, title_first_word)) == 2
assert len(Tracks.search_titles(session, track1_title)) == 1
def test_tracks_update_lastplayed(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert track1.lastplayed is None
track1.update_lastplayed(session, track1.id)
assert track1.lastplayed is not None
def test_tracks_update_info(session):
path = "/a/b/c"
artist = "The Beatles"
title = "Help!"
newinfo = "abcdef"
track1 = Tracks(session, path)
track1.artist = artist
track1.title = title
test1 = Tracks.get_by_id(session, track1.id)
assert test1.artist == artist
assert test1.title == title
assert test1.path == path
track1.path = newinfo
test2 = Tracks.get_by_id(session, track1.id)
assert test2.artist == artist
assert test2.title == title
assert test2.path == newinfo
track1.artist = newinfo
test2 = Tracks.get_by_id(session, track1.id)
assert test2.artist == newinfo
assert test2.title == title
assert test2.path == newinfo
track1.title = newinfo
test3 = Tracks.get_by_id(session, track1.id)
assert test3.artist == newinfo
assert test3.title == newinfo
assert test3.path == newinfo

380
test_playlistmodel.py Normal file
View File

@ -0,0 +1,380 @@
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.playlist_id)
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.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) == 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.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) == 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
# )