Compare commits

...

254 Commits

Author SHA1 Message Date
Keith Edmunds
8956642e05 Remove unused import. 2026-01-04 13:58:57 +00:00
Keith Edmunds
791fad680a Clean up old error logging; log excessive jitter. 2026-01-04 13:54:14 +00:00
Keith Edmunds
8c60d6a03d Jitter monitor phase 0 2026-01-03 21:37:50 +00:00
Keith Edmunds
7391b4e61c Start header in column zero 2026-01-03 18:44:36 +00:00
Keith Edmunds
266be281d0 Don't run update_track_times twice on starting track 2026-01-02 14:34:28 +00:00
Keith Edmunds
ac487a5fa5 Make vlc volume default 100 2026-01-01 17:18:58 +00:00
Keith Edmunds
7d1bb0d3f7 Escape double quotes in filename 2025-12-14 15:58:35 +00:00
Keith Edmunds
e8d9cf8f00 Ensure track marked as played in playlist
Fixes #295
2025-09-26 00:38:44 +01:00
Keith Edmunds
7e7ae7dddf Tidy config 2025-09-25 15:07:30 +01:00
Keith Edmunds
25cb444335 Set default volumen to 80% 2025-09-25 14:53:30 +01:00
Keith Edmunds
fa14fc7c52 Fixup reloading track from Audacity 2025-08-23 12:18:06 +01:00
Keith Edmunds
6e51e65ba8 Add .gitattibutes to define python diffing 2025-08-23 12:17:42 +01:00
Keith Edmunds
19b1bf3fde Fix type hint error 2025-08-17 18:42:01 +01:00
Keith Edmunds
316b4708c6 Check import filetype; install black 2025-08-17 18:26:53 +01:00
Keith Edmunds
4fd9a0381f Hide tracks, not sections 2025-08-16 15:10:45 +01:00
Keith Edmunds
88cce738d7 Move AudacityController management from playlists to musicmuster
Fixes: #292
2025-08-16 15:10:15 +01:00
Keith Edmunds
9720c11ecc Don't track kae.py in git 2025-03-29 18:20:13 +00:00
Keith Edmunds
ca4c490091 Add log_call decorator and issue 287 logging 2025-03-29 18:19:14 +00:00
Keith Edmunds
1749f0a0b8 Actually add tracks chosen from query 2025-03-13 10:41:56 +00:00
Keith Edmunds
c9ff1aa668 Improver performance loading playlists 2025-03-09 19:23:55 +00:00
Keith Edmunds
49776731bf Merge branch 'dev' 2025-03-09 17:26:10 +00:00
Keith Edmunds
9bf1ab29a8 Fixup tests after data() return type fixups 2025-03-09 16:41:28 +00:00
Keith Edmunds
4e51b44b44 More work on data() return types 2025-03-09 16:40:19 +00:00
Keith Edmunds
582803dccc Put more info in ApplicationError dialog
Show it after dumping error to stderr
2025-03-09 16:34:53 +00:00
Keith Edmunds
5f9fd31dfd Merge branch 'issue285' into dev 2025-03-08 21:38:11 +00:00
Keith Edmunds
74402f640f Only invalidate required roles 2025-03-08 21:36:09 +00:00
Keith Edmunds
963da0b5d0 No db calls when servicing data() except for caching 2025-03-08 21:30:37 +00:00
Keith Edmunds
85493de179 Remove profiling decorators 2025-03-08 12:03:47 +00:00
Keith Edmunds
2f8afeb814 WIP Issue 285 2025-03-08 12:02:07 +00:00
Keith Edmunds
3b004567df Implement dogpile cache for Notecolours 2025-03-08 11:45:38 +00:00
Keith Edmunds
76039aa5e6 Only try to show ApplicationError dialog when we have a QApplication 2025-03-08 11:42:59 +00:00
Keith Edmunds
1f10692c15 Make notes substring unique 2025-03-08 09:57:04 +00:00
Keith Edmunds
6dd34b292f Improve ApplicationError reporting 2025-03-07 15:44:21 +00:00
Keith Edmunds
77a9baa34f Merge branch 'dev' 2025-03-07 10:00:09 +00:00
Keith Edmunds
6e2ad86fb2 Merge branch 'mark_preview' into dev 2025-03-07 09:59:32 +00:00
Keith Edmunds
be54187b48 Remove old files 2025-03-07 09:46:49 +00:00
Keith Edmunds
6d56a94bca Don't track .venv 2025-03-07 09:43:19 +00:00
Keith Edmunds
ccc1737f2d Issue 285: additional logging and profiling 2025-03-07 09:30:23 +00:00
Keith Edmunds
58e244af21 Add profiling information for moving rows 2025-03-06 14:30:03 +00:00
Keith Edmunds
93839c69e2 Remove main_window_ui.py 2025-03-06 14:27:42 +00:00
Keith Edmunds
61b00d8531 Put preview track details in status bar 2025-03-06 14:26:47 +00:00
Keith Edmunds
63b1d0dff4 mypy fixups 2025-03-06 11:33:53 +00:00
Keith Edmunds
2293c663b9 Migrate from poetry to uv 2025-03-05 19:05:13 +00:00
Keith Edmunds
f5c77ddffd Merge query tabs 2025-03-05 15:16:24 +00:00
Keith Edmunds
1cf75a5d42 More query tests and remove Optional from Filter 2025-03-05 14:27:19 +00:00
Keith Edmunds
7fd655f96f WIP: queries working, tests so far good 2025-03-05 09:00:41 +00:00
Keith Edmunds
096889d6cb Fix up tests in light of recent changes 2025-03-04 13:22:29 +00:00
Keith Edmunds
67c48f5022 Select from query working (may need tidying) 2025-03-04 10:32:11 +00:00
Keith Edmunds
8e48d63ebb WIP: queries management
Menus and management working. Wrong tracks showing up in queries.
2025-03-02 19:14:53 +00:00
Keith Edmunds
aa6ab03555 Make manage queries and manage templates into classes 2025-02-28 11:25:29 +00:00
Keith Edmunds
fc02a4aa7e Merge branch 'bug283' into dev 2025-02-28 09:21:47 +00:00
Keith Edmunds
6223ef0ef0 Don't allow deletion of current or next track
Fixes: #283
2025-02-28 09:21:22 +00:00
Keith Edmunds
76e6084419 Try to speed up tab switching 2025-02-27 18:21:55 +00:00
Keith Edmunds
90d72464cb Clean up handling of separators in dynamic menu 2025-02-27 08:13:29 +00:00
Keith Edmunds
82e707a6f6 Make filter field in queries table non-nullable 2025-02-27 08:12:48 +00:00
Keith Edmunds
b4f5d92f5d WIP: query management 2025-02-26 13:58:13 +00:00
Keith Edmunds
985629446a Create queries table 2025-02-26 13:34:10 +00:00
Keith Edmunds
64ccb485b5 Fix playdates cascade deletes 2025-02-26 13:29:42 +00:00
Keith Edmunds
3f248d363f rebase from dev 2025-02-23 21:06:42 +00:00
Keith Edmunds
40756469ec WIP query tabs 2025-02-23 21:06:42 +00:00
Keith Edmunds
306ab103b6 Add favourite to queries table 2025-02-23 21:06:42 +00:00
Keith Edmunds
994d510ed9 Move querylistmodel from SQL to filter 2025-02-23 21:06:42 +00:00
Keith Edmunds
8b8edba64d Add Filter class to classes 2025-02-23 21:06:42 +00:00
Keith Edmunds
678515403c Guard against erroneous SQL statements in queries 2025-02-23 21:06:42 +00:00
Keith Edmunds
e6404d075e Query searches working
More UI needed
2025-02-23 21:06:42 +00:00
Keith Edmunds
7c0db00b75 Create databases in dbmanager 2025-02-23 21:06:42 +00:00
Keith Edmunds
e4e061cf1c Add open querylist menu 2025-02-23 21:06:42 +00:00
Keith Edmunds
61021b33b8 Fix hide played button 2025-02-23 21:06:42 +00:00
Keith Edmunds
a33589a9a1 "=" header fixes
Fixes: #276
2025-02-23 21:06:42 +00:00
Keith Edmunds
3547046cc1 Misc cleanups from query_tabs branch 2025-02-23 21:06:41 +00:00
Keith Edmunds
95983c73b1 Log to stderr timer10 stop/start 2025-02-23 21:06:41 +00:00
Keith Edmunds
499c0c6b70 Fix "=" header
Fixes: #276
2025-02-23 21:06:41 +00:00
Keith Edmunds
33e2c4bf31 Fix order of playdates on hover
Fixes: #275
2025-02-23 21:06:41 +00:00
Keith Edmunds
589a664971 New template from manage templates correctly marked in db 2025-02-23 17:34:23 +00:00
Keith Edmunds
67bf926ed8 Refactor musicmuster and template management 2025-02-23 17:28:03 +00:00
Keith Edmunds
040020e7ed Refactor playlist management functions 2025-02-23 17:26:43 +00:00
Keith Edmunds
911859ef49 Show red start in tab of templates 2025-02-23 17:24:47 +00:00
Keith Edmunds
68bdff53cf Move menu.yaml into app/ 2025-02-23 09:20:30 +00:00
Keith Edmunds
632937101a WIP dynamic menu for playlist
New playlist shows faves on submenu
2025-02-22 22:27:05 +00:00
Keith Edmunds
639f006a10 Add favourite to playlists 2025-02-22 20:23:07 +00:00
Keith Edmunds
9e27418f80 Remove queries table definition
It mistakenly was introduced to the wrong branch. It persists on the
query_tabs branch.
2025-02-22 20:13:44 +00:00
Keith Edmunds
c1448dfdd5 WIP: manage templates: template rows have different background 2025-02-22 19:42:48 +00:00
Keith Edmunds
5f396a0993 WIP: template management: new, rename, delete working 2025-02-22 19:16:42 +00:00
Keith Edmunds
e10c2adafe WIP: template management: edit and delete working 2025-02-22 11:34:36 +00:00
Keith Edmunds
b0f6e4e819 Framework for dynamic submenus 2025-02-21 15:18:45 +00:00
Keith Edmunds
afd3be608c Move menu definitions to YAML file 2025-02-21 14:16:34 +00:00
Keith Edmunds
aef8cb5cb5 Fix hide played button 2025-02-15 10:39:26 +00:00
Keith Edmunds
53664857c1 "=" header fixes
Fixes: #276
2025-02-14 21:45:23 +00:00
Keith Edmunds
c8b571b38f Misc cleanups from query_tabs branch 2025-02-14 21:44:20 +00:00
Keith Edmunds
b3bd93d71c Only have one db.create_all(), and that in dbmanager 2025-02-14 21:39:10 +00:00
Keith Edmunds
57ffa71c86 Log to stderr timer10 stop/start 2025-02-14 19:49:13 +00:00
Keith Edmunds
a8a38fa5b7 Fix "=" header
Fixes: #276
2025-02-14 19:38:06 +00:00
Keith Edmunds
24b5cb5fe0 Fix order of playdates on hover
Fixes: #275
2025-02-14 19:27:47 +00:00
Keith Edmunds
955bea2037 Query tabs WIP 2025-02-11 21:11:56 +00:00
Keith Edmunds
5ed7b822e1 Put menus in correct order 2025-02-11 19:55:07 +00:00
Keith Edmunds
b40c81e79a Split UI into section files; remove infotabs 2025-02-11 18:18:25 +00:00
Keith Edmunds
7a98fe3920 Create queries table; set up cascade deletes 2025-02-07 16:58:26 +00:00
Keith Edmunds
6792b2a628 Better management of hiding played sections
Only scroll if top visible line is above current header.
2025-02-07 12:54:44 +00:00
Keith Edmunds
c12b30a956 Add pyyaml 2025-02-06 12:56:59 +00:00
Keith Edmunds
256de377cf Update environment 2025-02-06 12:54:01 +00:00
Keith Edmunds
a3c405912a Fixup logging when no module log.debug output specifed 2025-02-05 18:07:22 +00:00
Keith Edmunds
4e73ea6e6a Black formatting 2025-02-05 17:46:16 +00:00
Keith Edmunds
c9b45848dd Refine and fix file_importer tests 2025-02-05 17:43:38 +00:00
Keith Edmunds
fd0d8b15f7 Poetry only for dependency management 2025-02-02 17:54:44 +00:00
Keith Edmunds
7d0e1c809f Update environment 2025-02-02 17:52:15 +00:00
Keith Edmunds
5cae8e4b19 File importer - more tests 2025-02-01 22:11:01 +00:00
Keith Edmunds
8177e03387 Tweak pyproject.toml for v2 2025-01-31 10:00:55 +00:00
Keith Edmunds
f4923314d8 Remove spurious logging. Start 10ms timer at a better time.
The 10ms timer was paused for five seconds when starting a track to
avoid a short pause (issue #223). That fixed the problem. However, it
doesn't need to be started until the fade graph starts changing, so we
now don't start it until then. It's possible that this may help the
occasional 'slow to refresh after moving tracks' issue that has been
seen which may be caused by timer ticks piling up and needing to be
serviced.
2025-01-31 09:55:21 +00:00
Keith Edmunds
24787578bc Tweaks to FileImporter and tests 2025-01-31 09:55:21 +00:00
Keith Edmunds
1f4e7cb054 Cleanup around new logging 2025-01-31 09:55:21 +00:00
Keith Edmunds
92e1a1cac8 New FileImporter working, tests to be written 2025-01-31 09:55:21 +00:00
Keith Edmunds
52a773176c Refine module and function logging to stderr 2025-01-31 09:55:21 +00:00
Keith Edmunds
cedc7180d4 WIP: FileImporter runs but needs more testing 2025-01-31 09:55:21 +00:00
Keith Edmunds
728ac0f8dc Add function name to console log output 2025-01-31 09:55:21 +00:00
Keith Edmunds
4741c1d33f Make failure to connect to OBS a warning, not error 2025-01-31 09:55:21 +00:00
Keith Edmunds
aa52f33d58 Fixup new logging 2025-01-31 09:55:21 +00:00
Keith Edmunds
2f18ef5f44 Cascade deleted tracks to playlist_rows and playdates 2025-01-31 09:55:21 +00:00
Keith Edmunds
4927f237ab Use locking when creating singleton 2025-01-31 09:55:21 +00:00
Keith Edmunds
d3a709642b Migrate pyproject.toml to v2 2025-01-31 09:54:14 +00:00
Keith Edmunds
3afcfd5856 Move to YAML-configured logging 2025-01-27 12:13:13 +00:00
Keith Edmunds
342c0a2285 Add type hints for pyyaml 2025-01-27 12:13:13 +00:00
Keith Edmunds
8161fb00b3 Add pyyaml; update poetry environment 2025-01-27 12:13:13 +00:00
Keith Edmunds
f9943dc1c4 WIP file_importer rewrite, one test written and working 2025-01-21 21:26:06 +00:00
Keith Edmunds
b2000169b3 Add index to notecolours 2025-01-18 11:02:56 +00:00
Keith Edmunds
5e72f17793 Clean up type hints 2025-01-17 21:35:29 +00:00
Keith Edmunds
4a4058d211 Import rewrite WIP 2025-01-13 15:29:50 +00:00
Keith Edmunds
3b71041b66 Remove profiling calls (again) 2025-01-10 20:37:49 +00:00
Keith Edmunds
d30bf49c88 Don't select unplayable track as next track 2025-01-10 20:27:26 +00:00
Keith Edmunds
3a3b1b712d Much improved file importer 2025-01-10 19:50:53 +00:00
Keith Edmunds
85cfebe0f7 Fix crash importing files 2025-01-01 13:13:54 +00:00
Keith Edmunds
e23f6e2cc8 Make getting current row safer 2024-12-30 08:39:01 +00:00
Keith Edmunds
68e524594d Recover from git cockup: reimplement template management 2024-12-29 18:34:44 +00:00
Keith Edmunds
a8931e8b2b Remove references to 'deleted' column 2024-12-29 18:18:39 +00:00
Keith Edmunds
6c05ed8c6f Revert "Implement template management"
This reverts commit 02c0c9c861.

Bugfix to be added
2024-12-29 18:15:27 +00:00
Keith Edmunds
02c0c9c861 Implement template management
Allow template edits and deletions. Deletions are now true deletes,
not just flagged in database as deletes, and this applies to all
playlists. Includes schema changes to cascade deletes.
2024-12-29 18:06:31 +00:00
Keith Edmunds
72930605db Implement File|New to create from template (possibly empty) 2024-12-29 14:32:32 +00:00
Keith Edmunds
712c965095 Clean up data structures in musicmuster.py
Replace self.playlists and self.selection with self.current and a new
Current() class
2024-12-28 17:16:19 +00:00
Keith Edmunds
4bff1a8b59 Update musicmuster to use self.selection 2024-12-28 12:53:19 +00:00
Keith Edmunds
e55fab71cf Clean up direct references to playlist tab from musicmuster 2024-12-28 10:06:33 +00:00
Keith Edmunds
7ce07c1cc7 Handle hide sections/tracks better 2024-12-27 20:01:46 +00:00
Keith Edmunds
839467a5e3 Resove active/proxy model coding 2024-12-27 19:48:16 +00:00
Keith Edmunds
e5dc3dbf03 Fix adding duplicate track and merging comments
Fixes #271
2024-12-26 15:05:07 +00:00
Keith Edmunds
3fde474a5b Save proxy model example in archive 2024-12-26 14:10:26 +00:00
Keith Edmunds
b14b90396f Major update: correct use of proxy model
Fixes #273
2024-12-26 14:09:21 +00:00
Keith Edmunds
937f3cd074 Fix search
Fixed #272
2024-12-23 21:20:59 +00:00
Keith Edmunds
cb16a07451 Menu reorganised. Other minor cleanups. 2024-12-23 19:19:01 +00:00
Keith Edmunds
6da6f7044b Add tooltip to radio buttons on import file choices 2024-12-22 17:26:33 +00:00
Keith Edmunds
a1709e92ae Misc tidying 2024-12-22 15:23:22 +00:00
Keith Edmunds
b389a348c1 Remove mtime from Track 2024-12-22 15:23:04 +00:00
Keith Edmunds
4c53791f4d Rewrite file importer 2024-12-22 15:22:21 +00:00
Keith Edmunds
d400ba3957 Make AudioMetadata a NamedTuple 2024-12-22 15:16:02 +00:00
Keith Edmunds
6e258a0ee2 Split music_manager from classes 2024-12-22 15:14:00 +00:00
Keith Edmunds
205667faa1 Tighten up AudacityController type hints 2024-12-22 15:11:30 +00:00
Keith Edmunds
d9abf72f6a Fix section hiding
We were suppressing hiding when section contained previous track.

Now, when all are played, we hide.
2024-12-21 16:40:51 +00:00
Keith Edmunds
96807a945c Resize rows in config-defined chunks 2024-12-17 20:55:25 +00:00
Keith Edmunds
b9cb7cc326 Fixup section hiding 2024-12-16 22:23:01 +00:00
Keith Edmunds
efde8fe7bc Implement hiding played sections 2024-12-14 20:46:19 +00:00
Keith Edmunds
b16845f352 Add return type hint 2024-12-14 19:42:14 +00:00
Keith Edmunds
42b5c2413c Fix "=" subtotal line 2024-12-14 17:34:46 +00:00
Keith Edmunds
2ce6eb95ed Remove "ago" from last played string 2024-12-14 17:11:35 +00:00
Keith Edmunds
734960e0f3 Set row padding in preferences 2024-12-14 17:11:16 +00:00
Keith Edmunds
17d88ca8fe Optionally remove colour codes from non-timing headers 2024-12-14 15:37:33 +00:00
Keith Edmunds
954b404031 Don't show deleted templates 2024-12-14 14:56:27 +00:00
Keith Edmunds
0391eed88e Optionally remove header colour directives from header 2024-12-14 14:49:07 +00:00
Keith Edmunds
f7f4cdc622 Implement header row foreground colour 2024-12-14 12:01:41 +00:00
Keith Edmunds
b7b825f0ef Restart Alembic migations
Alembic was generating empty migraiton files. Restart Alembic
from scracth which resolved problem.
2024-12-14 11:58:54 +00:00
Keith Edmunds
3c8f5910df Add notes to .envrc 2024-12-14 11:56:37 +00:00
Keith Edmunds
cc01d04fb8 Remove section timing marks from displayed headers 2024-12-14 10:22:25 +00:00
Keith Edmunds
ac18773ebd Merge branch 'dev' 2024-12-13 21:37:11 +00:00
Keith Edmunds
61dcf7fc91 Don't bring Audacity to focus when starting app 2024-12-13 12:54:48 +00:00
Keith Edmunds
d0d3d5b09a Allow combined +- in header rows 2024-12-13 12:48:05 +00:00
Keith Edmunds
ba32473f06 Fix header row heights too large 2024-12-13 12:45:42 +00:00
Keith Edmunds
642e8523a2 Add profiling for drop_event 2024-12-13 10:02:13 +00:00
Keith Edmunds
2a93113c3f merge in expanding edit box changes 2024-12-12 18:08:05 +00:00
Keith Edmunds
e29c7ed0ff Add in delegate for spinbox 2024-12-12 18:02:58 +00:00
Keith Edmunds
0b30a02dde Row resizing WIP
Resizing works, code is clean, rows not too tall, IntegerDelegate to
be provided still.
2024-12-11 22:37:39 +00:00
Keith Edmunds
07d8ce9c41 Add type hints for profiling calls 2024-12-11 22:35:11 +00:00
Keith Edmunds
4860c9f188 Expang edit box working, code untidy 2024-12-11 15:34:48 +00:00
Keith Edmunds
d7751008bd Merge dev 2024-12-11 12:49:01 +00:00
Keith Edmunds
558554d086 Implement "remove comments"
Fixes #185
2024-12-09 08:45:41 +00:00
Keith Edmunds
417bff8663 Put mark/move on context menu 2024-12-08 22:36:05 +00:00
Keith Edmunds
eaac2ef4ca Handle moving next track between playlists
Fixes #266
2024-12-08 17:00:22 +00:00
Keith Edmunds
17ab9c1c65 Temp changes for profiling 2024-12-07 21:10:24 +00:00
Keith Edmunds
2c19981cd8 Add icons to playlist tabs
Green on tab currently playing
Yellow on next tab if different

Fixes #245
2024-12-07 21:09:54 +00:00
Keith Edmunds
27261ff871 Only highlight current/next track in correct playlist
Fixes #259
2024-12-06 21:55:04 +00:00
Keith Edmunds
57765a64a7 Temp changes for profiling 2024-12-05 17:42:46 +00:00
Keith Edmunds
c7253e2211 Fix MariaDB bug workaround
Fixes #265
2024-12-02 18:56:00 +00:00
Keith Edmunds
ecd5c65695 Put cursor at click position on edit
Fixes #150
2024-12-01 15:31:43 +00:00
Keith Edmunds
8c33db170d Add profiling calls 2024-11-28 06:59:10 +00:00
Keith Edmunds
5d5277b028 Minor Audacity interface cleanups 2024-11-27 13:01:10 +00:00
Keith Edmunds
28897500c8 Improve Audacity connections
Replace pipeclient with much simpler audacity_controller
Better error checking
Deal with Audacity going away
Fixes #264
2024-11-27 10:54:04 +00:00
Keith Edmunds
ac2e811ed6 Remove all profiling calls 2024-11-24 21:56:12 +00:00
Keith Edmunds
0737c58dff Add indexes to PlaylistRowsTable 2024-11-23 07:27:49 +00:00
Keith Edmunds
fabf3e18bf Re-add profiling calls 2024-11-23 07:24:03 +00:00
Keith Edmunds
f19fc2e8c0 Remove dummy_for_profiling parameters 2024-11-16 13:06:35 +00:00
Keith Edmunds
40b5fc020d Fix playlist_rows row_number corruption 2024-11-16 13:04:39 +00:00
Keith Edmunds
98a8e20baa Move track to under current makes it next track
Fixes #261
2024-11-16 13:04:11 +00:00
Keith Edmunds
3cec08db85 Remove profiler decorations 2024-11-16 13:03:10 +00:00
Keith Edmunds
f5b26028f5 Improve RowAndTrack repr 2024-11-16 13:02:21 +00:00
Keith Edmunds
4c420d01ca Preserve row order when moving rows 2024-11-16 10:44:30 +00:00
Keith Edmunds
7cfd2a45a2 Speed up moving rows
Fixes #262
Fixed #260
2024-11-16 09:58:08 +00:00
Keith Edmunds
b4fcd5f2c9 Don't try to move rows if no rows selected
Fixes #263
2024-11-15 21:38:24 +00:00
Keith Edmunds
ff81447902 Update environment 2024-11-10 14:59:00 +00:00
Keith Edmunds
00b4f9ac54 Ignore profile_output files 2024-11-08 09:33:15 +00:00
Keith Edmunds
61adc43b45 Add profiling to paste_rows and related functions 2024-11-01 15:18:47 +00:00
Keith Edmunds
3783996ba4 Handle file not found when scanning track 2024-11-01 11:47:38 +00:00
Keith Edmunds
2ce7f671ba Ensure new playlists are marked as open 2024-10-27 19:35:41 +00:00
Keith Edmunds
2fb1974598 Upgrade environment; remove snoop 2024-10-11 15:33:56 +01:00
Keith Edmunds
3d83de20c2 Show Wikipedia/Songfacts on next track if none selected 2024-08-18 11:13:20 +01:00
Keith Edmunds
42ebf2fa7b Remove deep_rows query
Aim to fix sometimes slow moving of rows. Data from the 'deep' part is
no longer used anyway.

Fixes #258
2024-08-09 12:55:43 +01:00
Keith Edmunds
0bcb785b30 Update and clean dependencies 2024-08-05 12:46:10 +01:00
Keith Edmunds
84798fb1c5 Update and clean dependencies 2024-08-05 12:45:52 +01:00
Keith Edmunds
8f94dc6c4f Black formatting and mypy fixups 2024-08-05 08:24:54 +01:00
Keith Edmunds
8ce5c037ef Fix non-release of player when at natural end of track 2024-08-05 08:24:54 +01:00
Keith Edmunds
7ca104e53d Update black 2024-08-05 08:24:54 +01:00
Keith Edmunds
ff76d8eb7e Fix resource leak
After around 1.5h of operation, we'd get messages such as:

vlcpulse audio output error: PulseAudio server connection failure: Connection terminated

Tracked down to not correctly releasing vlc player resources when
track had finished playing. Fixed now, and much simplified the fadeout
code as well.
2024-08-05 08:24:54 +01:00
Keith Edmunds
973096ba3f . 2024-08-05 08:24:54 +01:00
Keith Edmunds
b8fcc79f8e Black formatting and mypy fixups 2024-08-04 17:18:08 +01:00
Keith Edmunds
27012a9658 Fix non-release of player when at natural end of track 2024-08-04 11:57:46 +01:00
Keith Edmunds
1d5fe3e57e Update black 2024-08-04 11:51:53 +01:00
Keith Edmunds
40cad1c98f Fix resource leak
After around 1.5h of operation, we'd get messages such as:

vlcpulse audio output error: PulseAudio server connection failure: Connection terminated

Tracked down to not correctly releasing vlc player resources when
track had finished playing. Fixed now, and much simplified the fadeout
code as well.
2024-08-02 18:35:33 +01:00
Keith Edmunds
5f5bb27a5f . 2024-08-02 18:35:33 +01:00
Keith Edmunds
50d1e8bd4a Fix unmarking row as played
Fixes #254
2024-07-31 13:16:06 +01:00
Keith Edmunds
feb8f0b6d7 Unmark row zero when no longer next track
Fixes #253
2024-07-31 13:16:06 +01:00
Keith Edmunds
1825e48e92 Fix unmarking row as played
Fixes #254
2024-07-31 13:04:04 +01:00
Keith Edmunds
2b8a911a78 Unmark row zero when no longer next track
Fixes #253
2024-07-31 12:57:51 +01:00
Keith Edmunds
a95ded1551 More log quietening 2024-07-30 17:13:30 +01:00
Keith Edmunds
2d582738e3 More log quietening 2024-07-30 16:54:00 +01:00
Keith Edmunds
0c76227bbc Quieten logging: move many info to debug 2024-07-30 16:51:53 +01:00
Keith Edmunds
bd7fb79610 Clear fade graph when clearing next track 2024-07-30 16:36:29 +01:00
Keith Edmunds
59b6b87186 Fixup typos in playlistmodel.py 2024-07-30 04:21:04 +01:00
Keith Edmunds
b15687a4c6 Clean up playlists.py 2024-07-30 04:12:35 +01:00
Keith Edmunds
076451ff89 Cleanup of playlistmodel.py 2024-07-29 21:49:17 +01:00
Keith Edmunds
d6f55c5987 Rewrite of track handling
Combine the old track_manager and playlist data structures into
RowAndTrack data structure.
2024-07-29 18:52:02 +01:00
Keith Edmunds
4a85d7ea84 Fix repr typo 2024-07-28 19:47:05 +01:00
Keith Edmunds
3c01fb63c3 Implement VLC logging 2024-07-28 19:45:55 +01:00
Keith Edmunds
b423ab0624 Log.debug production stackprinter messages 2024-07-26 18:10:53 +01:00
Keith Edmunds
051d8cf0ef Log releasing player and keep player count
Working on issue #251
2024-07-26 11:49:38 +01:00
Keith Edmunds
1513ad96d8 Fix track times bug
When update_track_times runs, it looks as track_sequence.current and
.next, but didn't check that those tracks referred to the current
playlist, which could cause a KeyError.

Fixes #252
2024-07-26 11:38:33 +01:00
Keith Edmunds
04c2c6377a Increase play debounce time 500ms → 1000ms 2024-07-26 11:20:15 +01:00
Keith Edmunds
9973f00055 Enhance debugging for failed fade graph creation 2024-07-26 11:18:29 +01:00
Keith Edmunds
53e169ae6b Add x bit to musicmuster.py 2024-07-23 17:50:14 +01:00
Keith Edmunds
234f6fcdbb Typo fixed 2024-07-23 17:47:18 +01:00
Keith Edmunds
7658dc354c More track timing cleanups 2024-07-22 18:47:29 +01:00
Keith Edmunds
3c884e54ca Refactor set track times 2024-07-22 16:29:17 +01:00
Keith Edmunds
d7a37151b7 Fixup type hints, renamed function 2024-07-22 16:27:31 +01:00
Keith Edmunds
96080cdca0 Simply musicmuster:play_next
Split out return_pressed_in_error()
2024-07-21 09:49:18 +01:00
Keith Edmunds
434e45b080 Reduce complexity of playlistmodel:headerData 2024-07-21 08:58:49 +01:00
Keith Edmunds
829172177c Implement external browser 2024-07-19 19:59:18 +01:00
Keith Edmunds
30d8b0d5c8 Rework track hiding logic
Fixes #248
2024-07-19 15:58:58 +01:00
Keith Edmunds
a51dd3a998 Show preview time in m:ss
Fixes #250
2024-07-19 15:06:22 +01:00
Keith Edmunds
7a6c8a0f95 Mark playlist last used on creation
Fixes #249
2024-07-19 12:46:10 +01:00
Keith Edmunds
faf18f431e Update dependencies 2024-07-08 19:13:42 +01:00
Keith Edmunds
5f3119be1f Tighter mypy testing, fixed up type hints 2024-07-08 19:03:35 +01:00
94 changed files with 12911 additions and 8072 deletions

5
.envrc
View File

@ -1,4 +1,5 @@
layout poetry layout uv
export LINE_PROFILE=1
export MAIL_PASSWORD="ewacyay5seu2qske" export MAIL_PASSWORD="ewacyay5seu2qske"
export MAIL_PORT=587 export MAIL_PORT=587
export MAIL_SERVER="smtp.fastmail.com" export MAIL_SERVER="smtp.fastmail.com"
@ -12,6 +13,8 @@ branch=$(git branch --show-current)
if [ $(pwd) == /home/kae/mm ]; then if [ $(pwd) == /home/kae/mm ]; then
export MM_ENV="PRODUCTION" export MM_ENV="PRODUCTION"
export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod" export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
# on_git_branch is a direnv directive
# See https://github.com/direnv/direnv/blob/master/man/direnv-stdlib.1.md
elif on_git_branch master; then elif on_git_branch master; then
export MM_ENV="PRODUCTION" export MM_ENV="PRODUCTION"
export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod" export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.py diff=python

3
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.pyc *.pyc
*.swp *.swp
tags tags
.venv/
venv/ venv/
Session.vim Session.vim
*.flac *.flac
@ -12,3 +13,5 @@ StudioPlaylist.png
.direnv .direnv
tmp/ tmp/
.coverage .coverage
profile_output*
kae.py

View File

@ -1 +1 @@
musicmuster 3.13

View File

@ -1,78 +0,0 @@
#!/usr/bin/env python3
from PyQt6.QtCore import Qt, QEvent, QObject
from PyQt6.QtWidgets import (
QAbstractItemView,
QApplication,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
)
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"""
print("EscapeDelegate event handler")
if event.type() == QEvent.Type.KeyPress:
key_event = cast(QKeyEvent, event)
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(QTableWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setItemDelegate(EscapeDelegate(self))
# self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
class MainWindow(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.table_widget = MyTableWidget(self)
self.table_widget.setRowCount(2)
self.table_widget.setColumnCount(2)
for row in range(2):
for col in range(2):
item = QTableWidgetItem()
item.setText(f"Row {row}, Col {col}")
self.table_widget.setItem(row, col, item)
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,94 +0,0 @@
#!/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()

155
app/audacity_controller.py Normal file
View File

@ -0,0 +1,155 @@
# Standard library imports
import os
import psutil
import socket
import select
from typing import Optional
# PyQt imports
# Third party imports
# App imports
from classes import ApplicationError
from config import Config
from log import log
class AudacityController:
def __init__(
self,
method: str = "pipe",
socket_host: str = "localhost",
socket_port: int = 12345,
timeout: int = Config.AUDACITY_TIMEOUT_SECONDS,
) -> None:
"""
Initialize the AudacityController.
:param method: Communication method ('pipe' or 'socket').
:param socket_host: Host for socket connection (if using sockets).
:param socket_port: Port for socket connection (if using sockets).
:param timeout: Timeout in seconds for pipe operations.
"""
self.method = method
self.path: Optional[str] = None
self.timeout = timeout
if method == "pipe":
user_uid = os.getuid() # Get the user's UID
self.pipe_to = f"/tmp/audacity_script_pipe.to.{user_uid}"
self.pipe_from = f"/tmp/audacity_script_pipe.from.{user_uid}"
elif method == "socket":
self.socket_host = socket_host
self.socket_port = socket_port
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
self.sock.connect((self.socket_host, self.socket_port))
self.sock.settimeout(self.timeout)
except socket.error as e:
raise ApplicationError(f"Failed to connect to Audacity socket: {e}")
else:
raise ApplicationError("Invalid method. Use 'pipe' or 'socket'.")
self._sanity_check()
def close(self):
"""
Close the connection (for sockets).
"""
if self.method == "socket":
self.sock.close()
def export(self) -> None:
"""
Export file from Audacity
"""
self._sanity_check()
select_status = self._send_command("SelectAll")
log.debug(f"{select_status=}")
# Escape any double quotes in filename
export_cmd = f'Export2: Filename="{self.path.replace('"', '\\"')}" NumChannels=2'
export_status = self._send_command(export_cmd)
log.debug(f"{export_status=}")
self.path = ""
if not export_status.startswith("Exported"):
raise ApplicationError(f"Error writing from Audacity: {export_status=}")
def open(self, path: str) -> None:
"""
Open path in Audacity. Escape filename.
"""
self._sanity_check()
escaped_path = path.replace('"', '\\"')
cmd = f'Import2: Filename="{escaped_path}"'
status = self._send_command(cmd)
self.path = path
log.debug(f"_open_in_audacity {path=}, {status=}")
def _sanity_check(self) -> None:
"""
Check Audactity running and basic connectivity.
"""
# Check Audacity is running
if "audacity" not in [i.name() for i in psutil.process_iter()]:
log.warning("Audactity not running")
raise ApplicationError("Audacity is not running")
# Check pipes exist
if self.method == "pipe":
if not (os.path.exists(self.pipe_to) and os.path.exists(self.pipe_from)):
raise ApplicationError(
"AudacityController: Audacity pipes not found. Ensure scripting is enabled "
f"and pipes exist at {self.pipe_to} and {self.pipe_from}."
)
def _test_connectivity(self) -> None:
"""
Send test command to Audacity
"""
response = self._send_command(Config.AUDACITY_TEST_COMMAND)
if response != Config.AUDACITY_TEST_RESPONSE:
raise ApplicationError(
"Error testing Audacity connectivity\n"
f"Sent: {Config.AUDACITY_TEST_COMMAND}"
f"Received: {response}"
)
def _send_command(self, command: str) -> str:
"""
Send a command to Audacity.
:param command: Command to send (e.g., 'SelectAll').
:return: Response from Audacity.
"""
log.debug(f"_send_command({command=})")
if self.method == "pipe":
try:
with open(self.pipe_to, "w") as to_pipe:
to_pipe.write(command + "\n")
with open(self.pipe_from, "r") as from_pipe:
ready, _, _ = select.select([from_pipe], [], [], self.timeout)
if ready:
response = from_pipe.readline()
else:
raise TimeoutError(
f"Timeout waiting for response from {self.pipe_from}"
)
except Exception as e:
raise RuntimeError(f"Error communicating with Audacity via pipes: {e}")
elif self.method == "socket":
try:
self.sock.sendall((command + "\n").encode("utf-8"))
response = self.sock.recv(1024).decode("utf-8")
except socket.timeout:
raise TimeoutError("Timeout waiting for response from Audacity socket.")
except Exception as e:
raise RuntimeError(f"Error communicating with Audacity via socket: {e}")
return response.strip()

View File

@ -1,15 +1,63 @@
# Standard library imports # Standard library imports
from dataclasses import dataclass, field from __future__ import annotations
from enum import auto, Enum
from typing import Any, Optional, NamedTuple
# PyQt imports from dataclasses import dataclass
from PyQt6.QtCore import pyqtSignal, QObject from enum import auto, Enum
import functools
import threading
from typing import NamedTuple
# Third party imports # Third party imports
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
)
from PyQt6.QtWidgets import (
QProxyStyle,
QStyle,
QStyleOption,
)
# App imports # App imports
import helpers
# Define singleton first as it's needed below
def singleton(cls):
"""
Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons)
Added locking.
"""
lock = threading.Lock()
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if wrapper_singleton.instance is None:
with lock:
if wrapper_singleton.instance is None: # Check still None
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None
return wrapper_singleton
class ApplicationError(Exception):
"""
Custom exception
"""
pass
class AudioMetadata(NamedTuple):
start_gap: int = 0
silence_at: int = 0
fade_at: int = 0
class Col(Enum): class Col(Enum):
@ -25,16 +73,31 @@ class Col(Enum):
NOTE = auto() NOTE = auto()
@helpers.singleton class FileErrors(NamedTuple):
path: str
error: str
@dataclass
class Filter:
version: int = 1
path_type: str = "contains"
path: str = ""
last_played_number: int = 0
last_played_comparator: str = "before"
last_played_unit: str = "years"
duration_type: str = "longer than"
duration_number: int = 0
duration_unit: str = "minutes"
@singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
""" """
Class for all MusicMuster signals. See: Class for all MusicMuster signals. See:
- https://zetcode.com/gui/pyqt5/eventssignals/ - https://zetcode.com/gui/pyqt5/eventssignals/
- https://stackoverflow.com/questions/62654525/ - https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
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) begin_reset_model_signal = pyqtSignal(int)
@ -53,18 +116,37 @@ class MusicMusterSignals(QObject):
super().__init__() super().__init__()
@dataclass class PlaylistStyle(QProxyStyle):
class TrackFileData: def drawPrimitive(self, element, option, painter, widget=None):
""" """
Simple class to track details changes to a track file Draw a line across the entire row rather than just the column
we're hovering over.
""" """
if (
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
and not option.rect.isNull()
):
option_new = QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
new_file_path: str
track_id: int = 0 class QueryCol(Enum):
track_path: Optional[str] = None TITLE = 0
obsolete_path: Optional[str] = None ARTIST = auto()
tags: dict[str, Any] = field(default_factory=dict) DURATION = auto()
audio_metadata: dict[str, str | int | float] = field(default_factory=dict) LAST_PLAYED = auto()
BITRATE = auto()
class Tags(NamedTuple):
artist: str = ""
title: str = ""
bitrate: int = 0
duration: int = 0
class TrackInfo(NamedTuple): class TrackInfo(NamedTuple):

View File

@ -2,7 +2,6 @@
import datetime as dt import datetime as dt
import logging import logging
import os import os
from typing import List, Optional
# PyQt imports # PyQt imports
@ -12,7 +11,9 @@ from typing import List, Optional
class Config(object): class Config(object):
AUDACITY_TIMEOUT_TENTHS = 100 AUDACITY_TEST_COMMAND = "Message"
AUDACITY_TEST_RESPONSE = "Some message"
AUDACITY_TIMEOUT_SECONDS = 20
AUDIO_SEGMENT_CHUNK_SIZE = 10 AUDIO_SEGMENT_CHUNK_SIZE = 10
BITRATE_LOW_THRESHOLD = 192 BITRATE_LOW_THRESHOLD = 192
BITRATE_OK_THRESHOLD = 300 BITRATE_OK_THRESHOLD = 300
@ -30,23 +31,42 @@ class Config(object):
COLOUR_NORMAL_TAB = "#000000" COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2" COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50 DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = []
DEFAULT_COLUMN_WIDTH = 200 DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False DISPLAY_SQL = False
EPOCH = dt.datetime(1970, 1, 1) DO_NOT_IMPORT = "Do not import"
ENGINE_OPTIONS = dict(pool_pre_ping=True) ENGINE_OPTIONS = dict(pool_pre_ping=True)
# ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True)
EPOCH = dt.datetime(1970, 1, 1)
ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_FROM = ["noreply@midnighthax.com"]
ERRORS_TO = ["kae@midnighthax.com"] ERRORS_TO = ["kae@midnighthax.com"]
EXTERNAL_BROWSER_PATH = "/usr/bin/vivaldi"
FADE_CURVE_BACKGROUND = "lightyellow" FADE_CURVE_BACKGROUND = "lightyellow"
FADE_CURVE_FOREGROUND = "blue" FADE_CURVE_FOREGROUND = "blue"
FADE_CURVE_MS_BEFORE_FADE = 5000 FADE_CURVE_MS_BEFORE_FADE = 5000
FADEOUT_DB = -10 FADEOUT_DB = -10
FADEOUT_SECONDS = 5 FADEOUT_SECONDS = 5
FADEOUT_STEPS_PER_SECOND = 5 FADEOUT_STEPS_PER_SECOND = 5
FILTER_DURATION_LONGER = "longer than"
FILTER_DURATION_MINUTES = "minutes"
FILTER_DURATION_SECONDS = "seconds"
FILTER_DURATION_SHORTER = "shorter than"
FILTER_PATH_CONTAINS = "contains"
FILTER_PATH_EXCLUDING = "excluding"
FILTER_PLAYED_COMPARATOR_ANYTIME = "Any time"
FILTER_PLAYED_COMPARATOR_BEFORE = "before"
FILTER_PLAYED_COMPARATOR_NEVER = "never"
FILTER_PLAYED_DAYS = "days"
FILTER_PLAYED_MONTHS = "months"
FILTER_PLAYED_WEEKS = "weeks"
FILTER_PLAYED_YEARS = "years"
FUZZYMATCH_MINIMUM_LIST = 60.0
FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0
FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0
FUZZYMATCH_SHOW_SCORES = True
HEADER_ARTIST = "Artist" HEADER_ARTIST = "Artist"
HEADER_BITRATE = "bps" HEADER_BITRATE = "bps"
HEADER_DURATION = "Length" HEADER_DURATION = "Length"
@ -58,6 +78,9 @@ class Config(object):
HEADER_START_TIME = "Start" HEADER_START_TIME = "Start"
HEADER_TITLE = "Title" HEADER_TITLE = "Title"
HIDE_AFTER_PLAYING_OFFSET = 5000 HIDE_AFTER_PLAYING_OFFSET = 5000
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
IMPORT_AS_NEW = "Import as new track"
INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_TITLE_LENGTH = 15
INTRO_SECONDS_FORMAT = ".1f" INTRO_SECONDS_FORMAT = ".1f"
INTRO_SECONDS_WARNING_MS = 3000 INTRO_SECONDS_WARNING_MS = 3000
@ -71,37 +94,52 @@ class Config(object):
MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com" MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get("MAIL_USERNAME") MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
MAIN_WINDOW_TITLE = "MusicMuster"
MAX_IMPORT_MATCHES = 5 MAX_IMPORT_MATCHES = 5
MAX_IMPORT_THREADS = 3
MAX_INFO_TABS = 5 MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10 MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0 MILLISECOND_SIGFIGS = 0
MINIMUM_ROW_HEIGHT = 30 MINIMUM_ROW_HEIGHT = 30
NO_QUERY_NAME = "Select query"
NO_TEMPLATE_NAME = "None"
NOTE_TIME_FORMAT = "%H:%M" NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost" OBS_HOST = "localhost"
OBS_PASSWORD = "auster" OBS_PASSWORD = "auster"
OBS_PORT = 4455 OBS_PORT = 4455
PLAY_NEXT_GUARD_MS = 10000 PLAY_NEXT_GUARD_MS = 10000
PLAY_SETTLE = 500000 PLAY_SETTLE = 500000
PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png"
PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png"
PLAYLIST_ICON_TEMPLATE = ":/icons/redstar.png"
PREVIEW_ADVANCE_MS = 5000 PREVIEW_ADVANCE_MS = 5000
PREVIEW_BACK_MS = 5000 PREVIEW_BACK_MS = 5000
PREVIEW_END_BUFFER_MS = 1000 PREVIEW_END_BUFFER_MS = 1000
REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp" REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp"
RETURN_KEY_DEBOUNCE_MS = 500 RESIZE_ROW_CHUNK_SIZE = 40
RETURN_KEY_DEBOUNCE_MS = 1000
ROOT = os.environ.get("ROOT") or "/home/kae/music" ROOT = os.environ.get("ROOT") or "/home/kae/music"
ROW_PADDING = 4
ROWS_FROM_ZERO = True ROWS_FROM_ZERO = True
SCROLL_TOP_MARGIN = 3 SCROLL_TOP_MARGIN = 3
SECTION_ENDINGS = ("-", "+-", "-+")
SECTION_HEADER = "[Section header]"
SECTION_STARTS = ("+", "+-", "-+")
SONGFACTS_ON_NEXT = False
START_GAP_WARNING_THRESHOLD = 300 START_GAP_WARNING_THRESHOLD = 300
TEXT_NO_TRACK_NO_NOTE = "[Section header]" SUBTOTAL_ON_ROW_ZERO = "[No subtotal on first row]"
TOD_TIME_FORMAT = "%H:%M:%S" TOD_TIME_FORMAT = "%H:%M:%S"
TRACK_TIME_FORMAT = "%H:%M:%S" TRACK_TIME_FORMAT = "%H:%M:%S"
VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player" VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player"
VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player" VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player"
VLC_VOLUME_DEFAULT = 75 VLC_VOLUME_DEFAULT = 100
VLC_VOLUME_DROP3db = 65 VLC_VOLUME_DROP3db = 70
WARNING_MS_BEFORE_FADE = 5500 WARNING_MS_BEFORE_FADE = 5500
WARNING_MS_BEFORE_SILENCE = 5500 WARNING_MS_BEFORE_SILENCE = 5500
WEB_ZOOM_FACTOR = 1.2 WEB_ZOOM_FACTOR = 1.2
WIKIPEDIA_ON_NEXT = False
# These rely on earlier definitions # These rely on earlier definitions
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_TRACKS
IMPORT_DESTINATION = os.path.join(ROOT, "Singles") IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE) REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE)

View File

@ -15,16 +15,17 @@ class DatabaseManager:
__instance = None __instance = None
def __init__(self, database_url: str, **kwargs): def __init__(self, database_url: str, **kwargs: dict) -> None:
if DatabaseManager.__instance is None: if DatabaseManager.__instance is None:
self.db = Alchemical(database_url, **kwargs) self.db = Alchemical(database_url, **kwargs)
self.db.create_all() # Database managed by Alembic so no create_all() required
# self.db.create_all()
DatabaseManager.__instance = self DatabaseManager.__instance = self
else: else:
raise Exception("Attempted to create a second DatabaseManager instance") raise Exception("Attempted to create a second DatabaseManager instance")
@staticmethod @staticmethod
def get_instance(database_url: str, **kwargs): def get_instance(database_url: str, **kwargs: dict) -> Alchemical:
if DatabaseManager.__instance is None: if DatabaseManager.__instance is None:
DatabaseManager(database_url, **kwargs) DatabaseManager(database_url, **kwargs)
return DatabaseManager.__instance return DatabaseManager.__instance

View File

@ -1,6 +1,8 @@
# Standard library imports # Standard library imports
from typing import List, Optional from typing import Optional
from dataclasses import asdict
import datetime as dt import datetime as dt
import json
# PyQt imports # PyQt imports
@ -13,13 +15,37 @@ from sqlalchemy import (
String, String,
) )
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.orm import ( from sqlalchemy.orm import (
Mapped, Mapped,
mapped_column, mapped_column,
relationship, relationship,
) )
from sqlalchemy.types import TypeDecorator, TEXT
# App imports # App imports
from classes import Filter
class JSONEncodedDict(TypeDecorator):
"""
Custom JSON Type for MariaDB (since native JSON type is just LONGTEXT)
"""
impl = TEXT
def process_bind_param(self, value: dict | None, dialect: Dialect) -> str | None:
"""Convert Python dictionary to JSON string before saving."""
if value is None:
return None
return json.dumps(value, default=lambda o: o.__dict__)
def process_result_value(self, value: str | None, dialect: Dialect) -> dict | None:
"""Convert JSON string back to Python dictionary after retrieval."""
if value is None:
return None
return json.loads(value)
# Database classes # Database classes
@ -27,12 +53,14 @@ class NoteColoursTable(Model):
__tablename__ = "notecolours" __tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=False) substring: Mapped[str] = mapped_column(String(256), index=True, unique=True)
colour: Mapped[str] = mapped_column(String(21), index=False) colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True) enabled: Mapped[bool] = mapped_column(default=True, index=True)
foreground: Mapped[Optional[str]] = mapped_column(String(21), index=False)
is_regex: Mapped[bool] = mapped_column(default=False, index=False) is_regex: Mapped[bool] = mapped_column(default=False, index=False)
is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False) is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
order: Mapped[Optional[int]] = mapped_column(index=True) order: Mapped[Optional[int]] = mapped_column(index=True)
strip_substring: Mapped[bool] = mapped_column(default=True, index=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -46,9 +74,10 @@ class PlaydatesTable(Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
lastplayed: Mapped[dt.datetime] = mapped_column(index=True) lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id")) track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", back_populates="playdates" "TracksTable",
back_populates="playdates",
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -71,12 +100,14 @@ class PlaylistsTable(Model):
tab: Mapped[Optional[int]] = mapped_column(default=None) tab: Mapped[Optional[int]] = mapped_column(default=None)
open: Mapped[bool] = mapped_column(default=False) open: Mapped[bool] = mapped_column(default=False)
is_template: Mapped[bool] = mapped_column(default=False) is_template: Mapped[bool] = mapped_column(default=False)
deleted: Mapped[bool] = mapped_column(default=False) rows: Mapped[list["PlaylistRowsTable"]] = relationship(
rows: Mapped[List["PlaylistRowsTable"]] = relationship(
"PlaylistRowsTable", "PlaylistRowsTable",
back_populates="playlist", back_populates="playlist",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="PlaylistRowsTable.plr_rownum", order_by="PlaylistRowsTable.row_number",
)
favourite: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -90,13 +121,18 @@ class PlaylistRowsTable(Model):
__tablename__ = "playlist_rows" __tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
plr_rownum: Mapped[int] row_number: Mapped[int] = mapped_column(index=True)
note: Mapped[str] = mapped_column( note: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False String(2048), index=False, default="", nullable=False
) )
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id")) playlist_id: Mapped[int] = mapped_column(
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
)
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id")) track_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE")
)
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable",
back_populates="playlistrows", back_populates="playlistrows",
@ -107,12 +143,37 @@ class PlaylistRowsTable(Model):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, " f"<PlaylistRows(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, " f"track_id={self.track_id}, "
f"note={self.note}, plr_rownum={self.plr_rownum}>" f"note={self.note}, row_number={self.row_number}>"
) )
class QueriesTable(Model):
__tablename__ = "queries"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
_filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=False)
favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False)
def _get_filter(self) -> Filter:
"""Convert stored JSON dictionary to a Filter object."""
if isinstance(self._filter_data, dict):
return Filter(**self._filter_data)
return Filter() # Default object if None or invalid data
def _set_filter(self, value: Filter | None) -> None:
"""Convert a Filter object to JSON before storing."""
self._filter_data = asdict(value) if isinstance(value, Filter) else None
# Single definition of `filter`
filter = property(_get_filter, _set_filter)
def __repr__(self) -> str:
return f"<QueriesTable(id={self.id}, name={self.name}, filter={self.filter})>"
class SettingsTable(Model): class SettingsTable(Model):
"""Manage settings""" """Manage settings"""
@ -135,23 +196,26 @@ class TracksTable(Model):
__tablename__ = "tracks" __tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(256), index=True)
artist: Mapped[str] = mapped_column(String(256), index=True) artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[Optional[int]] = mapped_column(default=None) bitrate: Mapped[int] = mapped_column(default=None)
duration: Mapped[int] = mapped_column(index=True) duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False) fade_at: Mapped[int] = mapped_column(index=False)
intro: Mapped[Optional[int]] = mapped_column(default=None) intro: Mapped[Optional[int]] = mapped_column(default=None)
mtime: Mapped[float] = mapped_column(index=True)
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True) path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False) silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False) start_gap: Mapped[int] = mapped_column(index=False)
playlistrows: Mapped[List[PlaylistRowsTable]] = relationship( title: Mapped[str] = mapped_column(String(256), index=True)
"PlaylistRowsTable", back_populates="track"
playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable",
back_populates="track",
cascade="all, delete-orphan",
) )
playlists = association_proxy("playlistrows", "playlist") playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[List[PlaydatesTable]] = relationship( playdates: Mapped[list[PlaydatesTable]] = relationship(
"PlaydatesTable", "PlaydatesTable",
back_populates="track", back_populates="track",
cascade="all, delete-orphan",
lazy="joined", lazy="joined",
) )

View File

@ -1,6 +1,5 @@
# Standard library imports # Standard library imports
from typing import Optional from typing import Optional
import os
# PyQt imports # PyQt imports
from PyQt6.QtCore import QEvent, Qt from PyQt6.QtCore import QEvent, Qt
@ -9,222 +8,22 @@ from PyQt6.QtWidgets import (
QDialog, QDialog,
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
QTableWidgetItem,
) )
# Third party imports # Third party imports
import pydymenu # type: ignore
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
# App imports # App imports
from classes import MusicMusterSignals, TrackFileData from classes import MusicMusterSignals
from config import Config
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
get_relative_date, get_relative_date,
get_tags,
ms_to_mmss, ms_to_mmss,
show_warning,
) )
from log import log from log import log
from models import db, Settings, Tracks from models import Settings, Tracks
from playlistmodel import PlaylistModel from playlistmodel import PlaylistModel
from ui import dlg_TrackSelect_ui, dlg_replace_files_ui from ui import dlg_TrackSelect_ui
class ReplaceFilesDialog(QDialog):
"""Import files as new or replacements"""
def __init__(
self,
session: Session,
main_window: QMainWindow,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.session = session
self.main_window = main_window
self.ui = dlg_replace_files_ui.Ui_Dialog()
self.ui.setupUi(self)
self.ui.lblSourceDirectory.setText(Config.REPLACE_FILES_DEFAULT_SOURCE)
self.ui.lblDestinationDirectory.setText(
Config.REPLACE_FILES_DEFAULT_DESTINATION
)
self.replacement_files: list[TrackFileData] = []
# We only want to run this against the production database because
# we will affect files in the common pool of tracks used by all
# databases
dburi = os.environ.get("DATABASE_URL")
if not dburi or "musicmuster_prod" not in dburi:
if not ask_yes_no(
"Not production database",
"Not on production database - continue?",
default_yes=False,
):
return
if self.ui.lblSourceDirectory.text() == self.ui.lblDestinationDirectory.text():
show_warning(
parent=self.main_window,
title="Error",
msg="Cannot import into source directory",
)
return
self.ui.tableWidget.setHorizontalHeaderLabels(["Path", "Title", "Artist"])
# Work through new files
source_dir = self.ui.lblSourceDirectory.text()
with db.Session() as session:
for new_file_basename in os.listdir(source_dir):
new_file_path = os.path.join(source_dir, new_file_basename)
if not os.path.isfile(new_file_path):
continue
rf = TrackFileData(new_file_path=new_file_path)
rf.tags = get_tags(new_file_path)
if not (
"title" in rf.tags
and "artist" in rf.tags
and rf.tags["title"]
and rf.tags["artist"]
):
show_warning(
parent=self.main_window,
title="Error",
msg=(
f"File {new_file_path} missing tags\n\n:"
f"Title={rf.tags['title']}\n"
f"Artist={rf.tags['artist']}\n"
),
)
return
# Check for same filename
match_track = self.check_by_basename(
session, new_file_path, rf.tags["artist"], rf.tags["title"]
)
if not match_track:
match_track = self.check_by_title(
session, new_file_path, rf.tags["artist"], rf.tags["title"]
)
if not match_track:
match_track = self.get_fuzzy_match(session, new_file_basename)
# Build summary
if match_track:
# We will store new file in the same directory as the
# existing file but with the new file name
rf.track_path = os.path.join(
os.path.dirname(match_track.path), new_file_basename
)
# We will remove existing track file
rf.obsolete_path = match_track.path
rf.track_id = match_track.id
match_basename = os.path.basename(match_track.path)
if match_basename == new_file_basename:
path_text = " " + new_file_basename + " (no change)"
else:
path_text = (
f" {match_basename}\n {new_file_basename} (replace)"
)
filename_item = QTableWidgetItem(path_text)
if match_track.title == rf.tags["title"]:
title_text = " " + rf.tags["title"] + " (no change)"
else:
title_text = (
f" {match_track.title}\n {rf.tags['title']} (update)"
)
title_item = QTableWidgetItem(title_text)
if match_track.artist == rf.tags["artist"]:
artist_text = " " + rf.tags["artist"] + " (no change)"
else:
artist_text = (
f" {match_track.artist}\n {rf.tags['artist']} (update)"
)
artist_item = QTableWidgetItem(artist_text)
else:
rf.track_path = os.path.join(
Config.REPLACE_FILES_DEFAULT_DESTINATION, new_file_basename
)
filename_item = QTableWidgetItem(" " + new_file_basename + " (new)")
title_item = QTableWidgetItem(" " + rf.tags["title"])
artist_item = QTableWidgetItem(" " + rf.tags["artist"])
self.replacement_files.append(rf)
row = self.ui.tableWidget.rowCount()
self.ui.tableWidget.insertRow(row)
self.ui.tableWidget.setItem(row, 0, filename_item)
self.ui.tableWidget.setItem(row, 1, title_item)
self.ui.tableWidget.setItem(row, 2, artist_item)
self.ui.tableWidget.resizeColumnsToContents()
self.ui.tableWidget.resizeRowsToContents()
def check_by_basename(
self, session: Session, new_path: str, new_path_artist: str, new_path_title: str
) -> Optional[Tracks]:
"""
Return Track that matches basename and tags
"""
match_track = None
candidates_by_basename = Tracks.get_by_basename(session, new_path)
if candidates_by_basename:
# Check tags are the same
for cbbn in candidates_by_basename:
cbbn_tags = get_tags(cbbn.path)
if (
"title" in cbbn_tags
and cbbn_tags["title"].lower() == new_path_title.lower()
and "artist" in cbbn_tags
and cbbn_tags["artist"].lower() == new_path_artist.lower()
):
match_track = cbbn
break
return match_track
def check_by_title(
self, session: Session, new_path: str, new_path_artist: str, new_path_title: str
) -> Optional[Tracks]:
"""
Return Track that mathces title and artist
"""
match_track = None
candidates_by_title = Tracks.search_titles(session, new_path_title)
if candidates_by_title:
# Check artist tag
for cbt in candidates_by_title:
try:
cbt_artist = get_tags(cbt.path)["artist"]
if cbt_artist.lower() == new_path_artist.lower():
match_track = cbt
break
except FileNotFoundError:
return None
return match_track
def get_fuzzy_match(self, session: Session, fname: str) -> Optional[Tracks]:
"""
Return Track that matches fuzzy filename search
"""
match_track = None
choice = pydymenu.rofi([a.path for a in Tracks.get_all(session)], prompt=fname)
if choice:
match_track = Tracks.get_by_path(session, choice[0])
return match_track
class TrackSelectDialog(QDialog): class TrackSelectDialog(QDialog):
@ -232,21 +31,22 @@ class TrackSelectDialog(QDialog):
def __init__( def __init__(
self, self,
parent: QMainWindow,
session: Session, session: Session,
new_row_number: int, new_row_number: int,
source_model: PlaylistModel, base_model: PlaylistModel,
add_to_header: Optional[bool] = False, add_to_header: Optional[bool] = False,
*args, *args: Qt.WindowType,
**kwargs, **kwargs: Qt.WindowType,
) -> None: ) -> None:
""" """
Subclassed QDialog to manage track selection Subclassed QDialog to manage track selection
""" """
super().__init__(*args, **kwargs) super().__init__(parent, *args, **kwargs)
self.session = session self.session = session
self.new_row_number = new_row_number self.new_row_number = new_row_number
self.source_model = source_model self.base_model = base_model
self.add_to_header = add_to_header self.add_to_header = add_to_header
self.ui = dlg_TrackSelect_ui.Ui_Dialog() self.ui = dlg_TrackSelect_ui.Ui_Dialog()
self.ui.setupUi(self) self.ui.setupUi(self)
@ -290,7 +90,7 @@ class TrackSelectDialog(QDialog):
track_id = track.id track_id = track.id
if note and not track_id: if note and not track_id:
self.source_model.insert_row(self.new_row_number, track_id, note) self.base_model.insert_row(self.new_row_number, track_id, note)
self.ui.txtNote.clear() self.ui.txtNote.clear()
self.new_row_number += 1 self.new_row_number += 1
return return
@ -304,7 +104,7 @@ class TrackSelectDialog(QDialog):
# Check whether track is already in playlist # Check whether track is already in playlist
move_existing = False move_existing = False
existing_prd = self.source_model.is_track_in_playlist(track_id) existing_prd = self.base_model.is_track_in_playlist(track_id)
if existing_prd is not None: if existing_prd is not None:
if ask_yes_no( if ask_yes_no(
"Duplicate row", "Duplicate row",
@ -315,21 +115,21 @@ class TrackSelectDialog(QDialog):
if self.add_to_header: if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.source_model.move_track_to_header( self.base_model.move_track_to_header(
self.new_row_number, existing_prd, note self.new_row_number, existing_prd, note
) )
else: else:
self.source_model.add_track_to_header(self.new_row_number, track_id) self.base_model.add_track_to_header(self.new_row_number, track_id)
# Close dialog - we can only add one track to a header # Close dialog - we can only add one track to a header
self.accept() self.accept()
else: else:
# Adding a new track row # Adding a new track row
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.source_model.move_track_add_note( self.base_model.move_track_add_note(
self.new_row_number, existing_prd, note self.new_row_number, existing_prd, note
) )
else: else:
self.source_model.insert_row(self.new_row_number, track_id, note) self.base_model.insert_row(self.new_row_number, track_id, note)
self.new_row_number += 1 self.new_row_number += 1

777
app/file_importer.py Normal file
View File

@ -0,0 +1,777 @@
from __future__ import annotations
from dataclasses import dataclass, field
from fuzzywuzzy import fuzz # type: ignore
import os.path
import threading
from typing import Optional, Sequence
import os
import shutil
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QThread,
)
from PyQt6.QtWidgets import (
QButtonGroup,
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QVBoxLayout,
)
# Third party imports
# App imports
from classes import (
ApplicationError,
MusicMusterSignals,
singleton,
Tags,
)
from config import Config
from helpers import (
audio_file_extension,
file_is_unreadable,
get_tags,
show_OK,
)
from log import log
from models import db, Tracks
from music_manager import track_sequence
from playlistmodel import PlaylistModel
import helpers
@dataclass
class ThreadData:
"""
Data structure to hold details of the import thread context
"""
base_model: PlaylistModel
row_number: int
@dataclass
class TrackFileData:
"""
Data structure to hold details of file to be imported
"""
source_path: str
tags: Tags = Tags()
destination_path: str = ""
import_this_file: bool = False
error: str = ""
file_path_to_remove: Optional[str] = None
track_id: int = 0
track_match_data: list[TrackMatchData] = field(default_factory=list)
@dataclass
class TrackMatchData:
"""
Data structure to hold details of existing files that are similar to
the file being imported.
"""
artist: str
artist_match: float
title: str
title_match: float
track_id: int
@singleton
class FileImporter:
"""
Class to manage the import of new tracks. Sanity checks are carried
out before processing each track.
They may replace existing tracks, be imported as new tracks, or the
import may be skipped altogether. The user decides which of these in
the UI managed by the PickMatch class.
The actual import is handled by the DoTrackImport class.
"""
# Place to keep a reference to importer workers. This is an instance
# variable to allow tests access. As this is a singleton, a class
# variable or an instance variable are effectively the same thing.
workers: dict[str, DoTrackImport] = {}
def __init__(self, base_model: PlaylistModel, row_number: int) -> None:
"""
Initialise the FileImporter singleton instance.
"""
log.debug(f"FileImporter.__init__({base_model=}, {row_number=})")
# Create ModelData
self.model_data = ThreadData(base_model=base_model, row_number=row_number)
# Data structure to track files to import
self.import_files_data: list[TrackFileData] = []
# Get signals
self.signals = MusicMusterSignals()
def _get_existing_tracks(self) -> Sequence[Tracks]:
"""
Return a list of all existing Tracks
"""
with db.Session() as session:
return Tracks.get_all(session)
def start(self) -> None:
"""
Build a TrackFileData object for each new file to import, add it
to self.import_files_data, and trigger importing.
"""
new_files: list[str] = []
if not os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE):
show_OK(
"File import",
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
None,
)
return
# Refresh list of existing tracks as they may have been updated
# by previous imports
self.existing_tracks = self._get_existing_tracks()
for infile in [
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
for f in os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE)
if f.endswith((".mp3", ".flac"))
]:
if infile in [a.source_path for a in self.import_files_data]:
log.debug(f"file_importer.start skipping {infile=}, already queued")
else:
new_files.append(infile)
self.import_files_data.append(self.populate_trackfiledata(infile))
# Tell user which files won't be imported and why
self.inform_user(
[
a
for a in self.import_files_data
if a.source_path in new_files and a.import_this_file is False
]
)
# Remove do-not-import entries from queue
self.import_files_data[:] = [
a for a in self.import_files_data if a.import_this_file is not False
]
# Start the import if necessary
log.debug(
f"Import files prepared: {[a.source_path for a in self.import_files_data]}"
)
self._import_next_file()
def populate_trackfiledata(self, path: str) -> TrackFileData:
"""
Populate TrackFileData object for path:
- Validate file to be imported
- Find matches and similar files
- Get user choices for each import file
- Validate self.import_files_data integrity
- Tell the user which files won't be imported and why
- Import the files, one by one.
"""
tfd = TrackFileData(source_path=path)
if self.check_file_readable(tfd):
if self.check_file_tags(tfd):
self.find_similar(tfd)
if len(tfd.track_match_data) > 1:
self.sort_track_match_data(tfd)
selection = self.get_user_choices(tfd)
if self.process_selection(tfd, selection):
if self.extension_check(tfd):
if self.validate_file_data(tfd):
tfd.import_this_file = True
return tfd
def check_file_readable(self, tfd: TrackFileData) -> bool:
"""
Check file is readable.
Return True if it is.
Populate error and return False if not.
"""
if file_is_unreadable(tfd.source_path):
tfd.import_this_file = False
tfd.error = f"{tfd.source_path} is unreadable"
return False
return True
def check_file_tags(self, tfd: TrackFileData) -> bool:
"""
Add tags to tfd
Return True if successful.
Populate error and return False if not.
"""
try:
tfd.tags = get_tags(tfd.source_path)
except ApplicationError as e:
tfd.import_this_file = False
tfd.error = f"of tag errors ({str(e)})"
return False
return True
def extension_check(self, tfd: TrackFileData) -> bool:
"""
If we are replacing an existing file, check that the correct file
extension of the replacement file matches the existing file
extension and return True if it does (or if there is no exsting
file), else False.
"""
if not tfd.file_path_to_remove:
return True
if tfd.file_path_to_remove.endswith(audio_file_extension(tfd.source_path)):
return True
tfd.error = (
f"Existing file ({tfd.file_path_to_remove}) has a different "
f"extension to replacement file ({tfd.source_path})"
)
return False
def find_similar(self, tfd: TrackFileData) -> None:
"""
- Search title in existing tracks
- if score >= Config.FUZZYMATCH_MINIMUM_LIST:
- get artist score
- add TrackMatchData to self.import_files_data[path].track_match_data
"""
title = tfd.tags.title
artist = tfd.tags.artist
for existing_track in self.existing_tracks:
title_score = self._get_match_score(title, existing_track.title)
if title_score >= Config.FUZZYMATCH_MINIMUM_LIST:
artist_score = self._get_match_score(artist, existing_track.artist)
tfd.track_match_data.append(
TrackMatchData(
artist=existing_track.artist,
artist_match=artist_score,
title=existing_track.title,
title_match=title_score,
track_id=existing_track.id,
)
)
def sort_track_match_data(self, tfd: TrackFileData) -> None:
"""
Sort matched tracks in artist-similarity order
"""
tfd.track_match_data.sort(key=lambda x: x.artist_match, reverse=True)
def _get_match_score(self, str1: str, str2: str) -> float:
"""
Return the score of how well str1 matches str2.
"""
ratio = fuzz.ratio(str1, str2)
partial_ratio = fuzz.partial_ratio(str1, str2)
token_sort_ratio = fuzz.token_sort_ratio(str1, str2)
token_set_ratio = fuzz.token_set_ratio(str1, str2)
# Combine scores
combined_score = (
ratio * 0.25
+ partial_ratio * 0.25
+ token_sort_ratio * 0.25
+ token_set_ratio * 0.25
)
return combined_score
def get_user_choices(self, tfd: TrackFileData) -> int:
"""
Find out whether user wants to import this as a new track,
overwrite an existing track or not import it at all.
Return -1 (user cancelled) 0 (import as new) >0 (replace track id)
"""
# Build a list of (track title and artist, track_id, track path)
choices: list[tuple[str, int, str]] = []
# First choices are always a) don't import 2) import as a new track
choices.append((Config.DO_NOT_IMPORT, -1, ""))
choices.append((Config.IMPORT_AS_NEW, 0, ""))
# New track details
new_track_description = f"{tfd.tags.title} ({tfd.tags.artist})"
# Select 'import as new' as default unless the top match is good
# enough
default = 1
track_match_data = tfd.track_match_data
if track_match_data:
if (
track_match_data[0].artist_match
>= Config.FUZZYMATCH_MINIMUM_SELECT_ARTIST
and track_match_data[0].title_match
>= Config.FUZZYMATCH_MINIMUM_SELECT_TITLE
):
default = 2
for xt in track_match_data:
xt_description = f"{xt.title} ({xt.artist})"
if Config.FUZZYMATCH_SHOW_SCORES:
xt_description += f" ({xt.title_match:.0f}%)"
existing_track_path = self._get_existing_track(xt.track_id).path
choices.append(
(
xt_description,
xt.track_id,
existing_track_path,
)
)
dialog = PickMatch(
new_track_description=new_track_description,
choices=choices,
default=default,
)
if dialog.exec():
return dialog.selected_track_id
else:
return -1
def process_selection(self, tfd: TrackFileData, selection: int) -> bool:
"""
Process selection from PickMatch
"""
if selection < 0:
# User cancelled
tfd.import_this_file = False
tfd.error = "you asked not to import this file"
return False
elif selection > 0:
# Import and replace track
self.replace_file(tfd, track_id=selection)
else:
# Import as new
self.import_as_new(tfd)
return True
def replace_file(self, tfd: TrackFileData, track_id: int) -> None:
"""
Set up to replace an existing file.
"""
log.debug(f"replace_file({tfd=}, {track_id=})")
if track_id < 1:
raise ApplicationError(f"No track ID: replace_file({tfd=}, {track_id=})")
tfd.track_id = track_id
existing_track_path = self._get_existing_track(track_id).path
tfd.file_path_to_remove = existing_track_path
# If the existing file in the Config.IMPORT_DESTINATION
# directory, replace it with the imported file name; otherwise,
# use the existing file name. This so that we don't change file
# names from CDs, etc.
if os.path.dirname(existing_track_path) == Config.IMPORT_DESTINATION:
tfd.destination_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(tfd.source_path)
)
else:
tfd.destination_path = existing_track_path
def _get_existing_track(self, track_id: int) -> Tracks:
"""
Lookup in existing track in the local cache and return it
"""
existing_track_records = [a for a in self.existing_tracks if a.id == track_id]
if len(existing_track_records) != 1:
raise ApplicationError(
f"Internal error in _get_existing_track: {existing_track_records=}"
)
return existing_track_records[0]
def import_as_new(self, tfd: TrackFileData) -> None:
"""
Set up to import as a new file.
"""
tfd.destination_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(tfd.source_path)
)
def validate_file_data(self, tfd: TrackFileData) -> bool:
"""
Check the data structures for integrity
Return True if all OK
Populate error and return False if not.
"""
# Check tags
if not (tfd.tags.artist and tfd.tags.title):
raise ApplicationError(
f"validate_file_data: {tfd.tags=}, {tfd.source_path=}"
)
# Check file_path_to_remove
if tfd.file_path_to_remove and not os.path.exists(tfd.file_path_to_remove):
# File to remove is missing, but this isn't a major error. We
# may be importing to replace a deleted file.
tfd.file_path_to_remove = ""
# Check destination_path
if not tfd.destination_path:
raise ApplicationError(
f"validate_file_data: no destination path set ({tfd.source_path=})"
)
# If destination path is the same as file_path_to_remove, that's
# OK, otherwise if this is a new import then check that
# destination path doesn't already exists
if tfd.track_id == 0 and tfd.destination_path != tfd.file_path_to_remove:
while os.path.exists(tfd.destination_path):
msg = (
"New import requested but default destination path"
f" ({tfd.destination_path})"
" already exists. Click OK and choose where to save this track"
)
show_OK(title="Desintation path exists", msg=msg, parent=None)
# Get output filename
pathspec = QFileDialog.getSaveFileName(
None,
"Save imported track",
directory=Config.IMPORT_DESTINATION,
)
if pathspec:
if pathspec == "":
# User cancelled
tfd.error = "You did not select a location to save this track"
return False
tfd.destination_path = pathspec[0]
else:
tfd.error = "destination file already exists"
return False
# The desintation path should not already exist in the
# database (becquse if it does, it points to a non-existent
# file). Check that because the path field in the database is
# unique and so adding a duplicate will give a db integrity
# error.
with db.Session() as session:
if Tracks.get_by_path(session, tfd.destination_path):
tfd.error = (
"Importing a new track but destination path already exists "
f"in database ({tfd.destination_path})"
)
return False
# Check track_id
if tfd.track_id < 0:
raise ApplicationError(
f"validate_file_data: track_id < 0, {tfd.source_path=}"
)
return True
def inform_user(self, tfds: list[TrackFileData]) -> None:
"""
Tell user about files that won't be imported
"""
msgs: list[str] = []
for tfd in tfds:
msgs.append(
f"{os.path.basename(tfd.source_path)} will not be imported because {tfd.error}"
)
if msgs:
show_OK("File not imported", "\r\r".join(msgs))
log.debug("\r\r".join(msgs))
def _import_next_file(self) -> None:
"""
Import the next file sequentially.
This is called when an import completes so will be called asynchronously.
Protect with a lock.
"""
lock = threading.Lock()
with lock:
while len(self.workers) < Config.MAX_IMPORT_THREADS:
try:
tfd = self.import_files_data.pop()
filename = os.path.basename(tfd.source_path)
log.debug(f"Processing {filename}")
log.debug(
f"remaining files: {[a.source_path for a in self.import_files_data]}"
)
self.signals.status_message_signal.emit(
f"Importing {filename}", 10000
)
self._start_import(tfd)
except IndexError:
log.debug("import_next_file: no files remaining in queue")
break
def _start_import(self, tfd: TrackFileData) -> None:
"""
Start thread to import track
"""
filename = os.path.basename(tfd.source_path)
log.debug(f"_start_import({filename=})")
self.workers[tfd.source_path] = DoTrackImport(
import_file_path=tfd.source_path,
tags=tfd.tags,
destination_path=tfd.destination_path,
track_id=tfd.track_id,
file_path_to_remove=tfd.file_path_to_remove,
)
log.debug(f"{self.workers[tfd.source_path]=} created")
self.workers[tfd.source_path].import_finished.connect(
self.post_import_processing
)
self.workers[tfd.source_path].finished.connect(lambda: self.cleanup_thread(tfd))
self.workers[tfd.source_path].finished.connect(
self.workers[tfd.source_path].deleteLater
)
self.workers[tfd.source_path].start()
def cleanup_thread(self, tfd: TrackFileData) -> None:
"""
Remove references to finished threads/workers to prevent leaks.
"""
log.debug(f"cleanup_thread({tfd.source_path=})")
if tfd.source_path in self.workers:
del self.workers[tfd.source_path]
else:
log.error(f"Couldn't find {tfd.source_path=} in {self.workers.keys()=}")
log.debug(f"After cleanup_thread: {self.workers.keys()=}")
def post_import_processing(self, source_path: str, track_id: int) -> None:
"""
If track already in playlist, refresh it else insert it
"""
log.debug(f"post_import_processing({source_path=}, {track_id=})")
if self.model_data:
if self.model_data.base_model:
self.model_data.base_model.update_or_insert(
track_id, self.model_data.row_number
)
# Process next file(s)
self._import_next_file()
class DoTrackImport(QThread):
"""
Class to manage the actual import of tracks in a thread.
"""
import_finished = pyqtSignal(str, int)
def __init__(
self,
import_file_path: str,
tags: Tags,
destination_path: str,
track_id: int,
file_path_to_remove: Optional[str] = None,
) -> None:
"""
Save parameters
"""
super().__init__()
self.import_file_path = import_file_path
self.tags = tags
self.destination_track_path = destination_path
self.track_id = track_id
self.file_path_to_remove = file_path_to_remove
self.signals = MusicMusterSignals()
def __repr__(self) -> str:
return f"<DoTrackImport(id={hex(id(self))}, import_file_path={self.import_file_path}"
def run(self) -> None:
"""
Either create track objects from passed files or update exising track
objects.
And add to visible playlist or update playlist if track already present.
"""
self.signals.status_message_signal.emit(
f"Importing {os.path.basename(self.import_file_path)}", 5000
)
# Get audio metadata in this thread rather than calling
# function to save interactive time
self.audio_metadata = helpers.get_audio_metadata(self.import_file_path)
# Remove old file if so requested
if self.file_path_to_remove and os.path.exists(self.file_path_to_remove):
os.unlink(self.file_path_to_remove)
# Move new file to destination
shutil.move(self.import_file_path, self.destination_track_path)
with db.Session() as session:
if self.track_id == 0:
# Import new track
try:
track = Tracks(
session,
path=self.destination_track_path,
**self.tags._asdict(),
**self.audio_metadata._asdict(),
)
except Exception as e:
self.signals.show_warning_signal.emit(
"Error importing track", str(e)
)
return
else:
track = session.get(Tracks, self.track_id)
if track:
for key, value in self.tags._asdict().items():
if hasattr(track, key):
setattr(track, key, value)
for key, value in self.audio_metadata._asdict().items():
if hasattr(track, key):
setattr(track, key, value)
track.path = self.destination_track_path
else:
log.error(f"Unable to retrieve {self.track_id=}")
return
session.commit()
helpers.normalise_track(self.destination_track_path)
self.signals.status_message_signal.emit(
f"{os.path.basename(self.import_file_path)} imported", 10000
)
self.import_finished.emit(self.import_file_path, track.id)
class PickMatch(QDialog):
"""
Dialog for user to select which existing track to replace or to
import to a new track
"""
def __init__(
self,
new_track_description: str,
choices: list[tuple[str, int, str]],
default: int,
) -> None:
super().__init__()
self.new_track_description = new_track_description
self.default = default
self.init_ui(choices)
self.selected_track_id = -1
def init_ui(self, choices: list[tuple[str, int, str]]) -> None:
"""
Set up dialog
"""
self.setWindowTitle("New or replace")
layout = QVBoxLayout()
# Add instructions
instructions = (
f"Importing {self.new_track_description}.\n"
"Import as a new track or replace existing track?"
)
instructions_label = QLabel(instructions)
layout.addWidget(instructions_label)
# Create a button group for radio buttons
self.button_group = QButtonGroup()
# Add radio buttons for each item
for idx, (track_description, track_id, track_path) in enumerate(choices):
if (
track_sequence.current
and track_id
and track_sequence.current.track_id == track_id
):
# Don't allow current track to be replaced
track_description = "(Currently playing) " + track_description
radio_button = QRadioButton(track_description)
radio_button.setDisabled(True)
self.button_group.addButton(radio_button, -1)
else:
radio_button = QRadioButton(track_description)
radio_button.setToolTip(track_path)
self.button_group.addButton(radio_button, track_id)
layout.addWidget(radio_button)
# Select the second item by default (import as new)
if idx == self.default:
radio_button.setChecked(True)
# Add OK and Cancel buttons
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
cancel_button = QPushButton("Cancel")
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
# Connect buttons to actions
ok_button.clicked.connect(self.on_ok)
cancel_button.clicked.connect(self.reject)
def on_ok(self):
# Get the ID of the selected button
self.selected_track_id = self.button_group.checkedId()
self.accept()

View File

@ -1,8 +1,7 @@
# Standard library imports # Standard library imports
import datetime as dt import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import Any, Dict, Optional from typing import Optional
import functools
import os import os
import re import re
import shutil import shutil
@ -11,16 +10,18 @@ import ssl
import tempfile import tempfile
# PyQt imports # PyQt imports
from PyQt6.QtWidgets import QMainWindow, QMessageBox from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget
# Third party imports # Third party imports
import filetype
from mutagen.flac import FLAC # type: ignore from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore from mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects from pydub import AudioSegment, effects
from pydub.utils import mediainfo from pydub.utils import mediainfo
from tinytag import TinyTag # type: ignore from tinytag import TinyTag, TinyTagException # type: ignore
# App imports # App imports
from classes import AudioMetadata, ApplicationError, Tags
from config import Config from config import Config
from log import log from log import log
from models import Tracks from models import Tracks
@ -50,6 +51,14 @@ def ask_yes_no(
return button == QMessageBox.StandardButton.Yes return button == QMessageBox.StandardButton.Yes
def audio_file_extension(fpath: str) -> str | None:
"""
Return the correct extension for this type of file.
"""
return filetype.guess(fpath).extension
def fade_point( def fade_point(
audio_segment: AudioSegment, audio_segment: AudioSegment,
fade_threshold: float = 0.0, fade_threshold: float = 0.0,
@ -94,6 +103,9 @@ def file_is_unreadable(path: Optional[str]) -> bool:
def get_audio_segment(path: str) -> Optional[AudioSegment]: def get_audio_segment(path: str) -> Optional[AudioSegment]:
if not path.endswith(audio_file_extension(path)):
return None
try: try:
if path.endswith(".mp3"): if path.endswith(".mp3"):
return AudioSegment.from_mp3(path) return AudioSegment.from_mp3(path)
@ -121,25 +133,25 @@ def get_embedded_time(text: str) -> Optional[dt.datetime]:
return None return None
def get_all_track_metadata(filepath: str) -> Dict[str, str | int | float]: def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]:
"""Return all track metadata""" """Return all track metadata"""
return get_audio_metadata(filepath) | get_tags(filepath) | dict(path=filepath) return (
get_audio_metadata(filepath)._asdict()
| get_tags(filepath)._asdict()
| dict(path=filepath)
)
def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]: def get_audio_metadata(filepath: str) -> AudioMetadata:
"""Return audio metadata""" """Return audio metadata"""
metadata: Dict[str, str | int | float] = {}
metadata["mtime"] = os.path.getmtime(filepath)
# Set start_gap, fade_at and silence_at # Set start_gap, fade_at and silence_at
audio = get_audio_segment(filepath) audio = get_audio_segment(filepath)
if not audio: if not audio:
audio_values = dict(start_gap=0, fade_at=0, silence_at=0) return AudioMetadata()
else: else:
audio_values = dict( return AudioMetadata(
start_gap=leading_silence(audio), start_gap=leading_silence(audio),
fade_at=int( fade_at=int(
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
@ -148,9 +160,23 @@ def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]:
round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
), ),
) )
metadata |= audio_values
return metadata
def get_name(prompt: str, default: str = "") -> str | None:
"""Get a name from the user"""
dlg = QInputDialog()
dlg.setInputMode(QInputDialog.InputMode.TextInput)
dlg.setLabelText(prompt)
while True:
if default:
dlg.setTextValue(default)
dlg.resize(500, 100)
ok = dlg.exec()
if ok:
return dlg.textValue()
return None
def get_relative_date( def get_relative_date(
@ -192,17 +218,30 @@ def get_relative_date(
days_str = "day" days_str = "day"
else: else:
days_str = "days" days_str = "days"
return f"{weeks} {weeks_str}, {days} {days_str} ago" return f"{weeks} {weeks_str}, {days} {days_str}"
def get_tags(path: str) -> Dict[str, Any]: def get_tags(path: str) -> Tags:
""" """
Return a dictionary of title, artist, duration-in-milliseconds and path. Return a dictionary of title, artist, bitrate and duration-in-milliseconds.
""" """
try:
tag = TinyTag.get(path) tag = TinyTag.get(path)
except FileNotFoundError:
raise ApplicationError(f"File not found: {path}")
except TinyTagException:
raise ApplicationError(f"Can't read tags in {path}")
return dict( if (
tag.title is None
or tag.artist is None
or tag.bitrate is None
or tag.duration is None
):
raise ApplicationError(f"Missing tags in {path}")
return Tags(
title=tag.title, title=tag.title,
artist=tag.artist, artist=tag.artist,
bitrate=round(tag.bitrate), bitrate=round(tag.bitrate),
@ -278,7 +317,7 @@ def normalise_track(path: str) -> None:
# Check type # Check type
ftype = os.path.splitext(path)[1][1:] ftype = os.path.splitext(path)[1][1:]
if ftype not in ["mp3", "flac"]: if ftype not in ["mp3", "flac"]:
log.info( log.error(
f"helpers.normalise_track({path}): " f"File type {ftype} not implemented" f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
) )
@ -326,6 +365,32 @@ def normalise_track(path: str) -> None:
os.remove(temp_path) os.remove(temp_path)
def remove_substring_case_insensitive(parent_string: str, substring: str) -> str:
"""
Remove all instances of substring from parent string, case insensitively
"""
# Convert both strings to lowercase for case-insensitive comparison
lower_parent = parent_string.lower()
lower_substring = substring.lower()
# Initialize the result string
result = parent_string
# Continue removing the substring until it's no longer found
while lower_substring in lower_parent:
# Find the index of the substring
index = lower_parent.find(lower_substring)
# Remove the substring
result = result[:index] + result[index + len(substring) :]
# Update the lowercase versions
lower_parent = result.lower()
return result
def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None: def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
# From https://docs.python.org/3/library/email.examples.html # From https://docs.python.org/3/library/email.examples.html
@ -358,16 +423,22 @@ def set_track_metadata(track: Tracks) -> None:
audio_metadata = get_audio_metadata(track.path) audio_metadata = get_audio_metadata(track.path)
tags = get_tags(track.path) tags = get_tags(track.path)
for audio_key in audio_metadata: for audio_key in AudioMetadata._fields:
setattr(track, audio_key, audio_metadata[audio_key]) setattr(track, audio_key, getattr(audio_metadata, audio_key))
for tag_key in tags: for tag_key in Tags._fields:
setattr(track, tag_key, tags[tag_key]) setattr(track, tag_key, getattr(tags, tag_key))
def show_OK(parent: QMainWindow, title: str, msg: str) -> None: def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
"""Display a message to user""" """Display a message to user"""
QMessageBox.information(parent, title, msg, buttons=QMessageBox.StandardButton.Ok) dlg = QMessageBox(parent)
dlg.setIcon(QMessageBox.Icon.Information)
dlg.setWindowTitle(title)
dlg.setText(msg)
dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
_ = dlg.exec()
def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None: def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
@ -376,22 +447,6 @@ def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel) 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( def trailing_silence(
audio_segment: AudioSegment, audio_segment: AudioSegment,
silence_threshold: int = -50, silence_threshold: int = -50,

View File

@ -1,92 +0,0 @@
# Standard library imports
import urllib.parse
import datetime as dt
from slugify import slugify # type: ignore
from typing import Dict
# PyQt imports
from PyQt6.QtCore import QUrl # type: ignore
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QTabWidget
# Third party imports
# App imports
from config import Config
from classes import MusicMusterSignals
from log import log
class InfoTabs(QTabWidget):
"""
Class to manage info tabs
"""
def __init__(self, parent=None) -> None:
super().__init__(parent)
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, dt.datetime] = {}
self.tabtitles: Dict[int, str] = {}
# Create one tab which (for some reason) creates flickering if
# done later
widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
self.last_update[widget] = dt.datetime.now()
_ = self.addTab(widget, "")
def open_in_songfacts(self, title: str) -> None:
"""Search Songfacts for title"""
slug = slugify(title, replacements=([["'", ""]]))
log.info(f"Songfacts Infotab for {title=}")
url = f"https://www.songfacts.com/search/songs/{slug}"
self.open_tab(url, title)
def open_in_wikipedia(self, title: str) -> None:
"""Search Wikipedia for title"""
str = urllib.parse.quote_plus(title)
log.info(f"Wikipedia Infotab for {title=}")
url = f"https://www.wikipedia.org/w/index.php?search={str}"
self.open_tab(url, title)
def open_tab(self, url: str, title: str) -> None:
"""
Open passed URL. If URL currently displayed, switch to that tab.
Create new tab if we're below the maximum
number otherwise reuse oldest content tab.
"""
if url in self.tabtitles.values():
self.setCurrentIndex(
list(self.tabtitles.keys())[list(self.tabtitles.values()).index(url)]
)
return
short_title = title[: Config.INFO_TAB_TITLE_LENGTH]
if self.count() < Config.MAX_INFO_TABS:
# Create a new tab
widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
tab_index = self.addTab(widget, short_title)
else:
# Reuse oldest widget
widget = min(self.last_update, key=self.last_update.get) # type: ignore
tab_index = self.indexOf(widget)
self.setTabText(tab_index, short_title)
widget.setUrl(QUrl(url))
self.last_update[widget] = dt.datetime.now()
self.tabtitles[tab_index] = url
# Show newly updated tab
self.setCurrentIndex(tab_index)

56
app/jittermonitor.py Normal file
View File

@ -0,0 +1,56 @@
from PyQt6.QtCore import QObject, QTimer, QElapsedTimer
import logging
import time
from config import Config
class EventLoopJitterMonitor(QObject):
def __init__(
self,
parent=None,
interval_ms: int = 20,
jitter_threshold_ms: int = 100,
log_cooldown_s: float = 1.0,
):
super().__init__(parent)
self._interval = interval_ms
self._jitter_threshold = jitter_threshold_ms
self._log_cooldown_s = log_cooldown_s
self._timer = QTimer(self)
self._timer.setInterval(self._interval)
self._timer.timeout.connect(self._on_timeout)
self._elapsed = QElapsedTimer()
self._elapsed.start()
self._last = self._elapsed.elapsed()
# child logger: e.g. "musicmuster.jitter"
self._log = logging.getLogger(f"{Config.LOG_NAME}.jitter")
self._last_log_time = 0.0
def start(self) -> None:
self._timer.start()
def _on_timeout(self) -> None:
now_ms = self._elapsed.elapsed()
delta = now_ms - self._last
self._last = now_ms
if delta > (self._interval + self._jitter_threshold):
self._log_jitter(now_ms, delta)
def _log_jitter(self, now_ms: int, gap_ms: int) -> None:
now = time.monotonic()
# simple rate limit: only one log every log_cooldown_s
if now - self._last_log_time < self._log_cooldown_s:
return
self._last_log_time = now
self._log.warning(
"Event loop gap detected: t=%d ms, gap=%d ms (interval=%d ms)",
now_ms,
gap_ms,
self._interval,
)

View File

@ -1,74 +1,138 @@
#!/usr/bin/python3 #!/usr/bin/env python3
# Standard library imports # Standard library imports
from collections import defaultdict
from functools import wraps
import logging import logging
import logging.config
import logging.handlers import logging.handlers
import os import os
import sys import sys
from traceback import print_exception import traceback
import yaml
# PyQt imports # PyQt imports
from PyQt6.QtWidgets import QApplication, QMessageBox
# Third party imports # Third party imports
import colorlog
import stackprinter # type: ignore import stackprinter # type: ignore
# App imports # App imports
from config import Config from config import Config
from classes import ApplicationError
class FunctionFilter(logging.Filter):
"""Filter to allow category-based logging to stderr."""
def __init__(self, module_functions: dict[str, list[str]]):
super().__init__()
self.modules: list[str] = []
self.functions: defaultdict[str, list[str]] = defaultdict(list)
if module_functions:
for module in module_functions.keys():
if module_functions[module]:
for function in module_functions[module]:
self.functions[module].append(function)
else:
self.modules.append(module)
def filter(self, record: logging.LogRecord) -> bool:
if not getattr(record, "levelname", None) == "DEBUG":
# Only prcess DEBUG messages
return False
module = getattr(record, "module", None)
if not module:
# No module in record
return False
# Process if this is a module we're tracking
if module in self.modules:
return True
# Process if this is a function we're tracking
if getattr(record, "funcName", None) in self.functions[module]:
return True
return False
class LevelTagFilter(logging.Filter): class LevelTagFilter(logging.Filter):
"""Add leveltag""" """Add leveltag"""
def filter(self, record: logging.LogRecord): def filter(self, record: logging.LogRecord) -> bool:
# Extract the first character of the level name # Extract the first character of the level name
record.leveltag = record.levelname[0] record.leveltag = record.levelname[0]
# We never actually filter messages out, just add an extra field
# We never actually filter messages out, just abuse filtering to add an # to the LogRecord
# extra field to the LogRecord
return True return True
# Load YAML logging configuration
with open("app/logging.yaml", "r") as f:
config = yaml.safe_load(f)
logging.config.dictConfig(config)
# Get logger
log = logging.getLogger(Config.LOG_NAME) log = logging.getLogger(Config.LOG_NAME)
log.setLevel(logging.DEBUG)
local_filter = LevelTagFilter()
# stderr
stderr = colorlog.StreamHandler()
stderr.setLevel(Config.LOG_LEVEL_STDERR)
stderr.addFilter(local_filter)
stderr_fmt = colorlog.ColoredFormatter(
"%(log_color)s[%(asctime)s] %(filename)s:%(lineno)s %(message)s", datefmt="%H:%M:%S"
)
stderr.setFormatter(stderr_fmt)
log.addHandler(stderr)
# syslog
syslog = logging.handlers.SysLogHandler(address="/dev/log")
syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
syslog.addFilter(local_filter)
syslog_fmt = logging.Formatter(
"[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
)
syslog.setFormatter(syslog_fmt)
log.addHandler(syslog)
def log_uncaught_exceptions(type_, value, traceback): def handle_exception(exc_type, exc_value, exc_traceback):
error = str(exc_value)
if issubclass(exc_type, ApplicationError):
log.error(error)
else:
# Handle unexpected errors (log and display)
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg'))
msg = stackprinter.format(exc_value)
log.error(msg)
log.error(error_msg)
print("Critical error:", error_msg) # Consider logging instead of print
if os.environ["MM_ENV"] == "PRODUCTION":
from helpers import send_mail from helpers import send_mail
print("\033[1;31;47m")
print_exception(type_, value, traceback)
print("\033[1;37;40m")
print(
stackprinter.format(
value, suppressed_paths=["/pypoetry/virtualenvs/"], style="darkbg"
)
)
if os.environ["MM_ENV"] == "PRODUCTION":
msg = stackprinter.format(value)
send_mail( send_mail(
Config.ERRORS_TO, Config.ERRORS_FROM, "Exception from musicmuster", msg Config.ERRORS_TO,
Config.ERRORS_FROM,
"Exception (log_uncaught_exceptions) from musicmuster",
msg,
) )
if QApplication.instance() is not None:
fname = os.path.split(exc_traceback.tb_frame.f_code.co_filename)[1]
msg = f"ApplicationError: {error}\nat {fname}:{exc_traceback.tb_lineno}"
QMessageBox.critical(None, "Application Error", msg)
sys.excepthook = log_uncaught_exceptions def truncate_large(obj, limit=5):
"""Helper to truncate large lists or other iterables."""
if isinstance(obj, (list, tuple, set)):
if len(obj) > limit:
return f"{type(obj).__name__}(len={len(obj)}, items={list(obj)[:limit]}...)"
return repr(obj)
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
args_repr = [truncate_large(a) for a in args]
kwargs_repr = [f"{k}={truncate_large(v)}" for k, v in kwargs.items()]
params_repr = ", ".join(args_repr + kwargs_repr)
log.debug(f"call {func.__name__}({params_repr})")
try:
result = func(*args, **kwargs)
log.debug(f"return {func.__name__}: {truncate_large(result)}")
return result
except Exception as e:
log.debug(f"exception in {func.__name__}: {e}")
raise
return wrapper
sys.excepthook = handle_exception

55
app/logging.yaml Normal file
View File

@ -0,0 +1,55 @@
version: 1
disable_existing_loggers: True
formatters:
colored:
(): colorlog.ColoredFormatter
format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(blue)s%(message)s"
datefmt: "%H:%M:%S"
syslog:
format: "[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
filters:
leveltag:
(): log.LevelTagFilter
category_filter:
(): log.FunctionFilter
module_functions:
# Optionally additionally log some debug calls to stderr
# log all debug calls in a module:
# module-name: []
# log debug calls for some functions in a module:
# module-name:
# - function-name-1
# - function-name-2
musicmuster:
- play_next
jittermonitor: []
handlers:
stderr:
class: colorlog.StreamHandler
level: INFO
formatter: colored
filters: [leveltag]
stream: ext://sys.stderr
syslog:
class: logging.handlers.SysLogHandler
level: DEBUG
formatter: syslog
filters: [leveltag]
address: "/dev/log"
debug_stderr:
class: colorlog.StreamHandler
level: DEBUG
formatter: colored
filters: [leveltag, category_filter]
stream: ext://sys.stderr
loggers:
musicmuster:
level: DEBUG
handlers: [stderr, syslog, debug_stderr]
propagate: false

29
app/logging_tester.py Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env python3
from log import log
# Testing
def fa():
log.debug("fa Debug message")
log.info("fa Info message")
log.warning("fa Warning message")
log.error("fa Error message")
log.critical("fa Critical message")
print()
def fb():
log.debug("fb Debug message")
log.info("fb Info message")
log.warning("fb Warning message")
log.error("fb Error message")
log.critical("fb Critical message")
print()
def testing():
fa()
fb()
testing()

104
app/menu.yaml Normal file
View File

@ -0,0 +1,104 @@
menus:
- title: "&File"
actions:
- text: "Save as Template"
handler: "save_as_template"
- text: "Manage Templates"
handler: "manage_templates_wrapper"
- separator: true
- text: "Manage Queries"
handler: "manage_queries_wrapper"
- separator: true
- text: "Exit"
handler: "close"
- title: "&Playlist"
actions:
- text: "Open Playlist"
handler: "open_existing_playlist"
shortcut: "Ctrl+O"
- text: "New Playlist"
handler: "new_playlist_dynamic_submenu"
submenu: true
- text: "Close Playlist"
handler: "close_playlist_tab"
- text: "Rename Playlist"
handler: "rename_playlist"
- text: "Delete Playlist"
handler: "delete_playlist"
- separator: true
- text: "Insert Track"
handler: "insert_track"
shortcut: "Ctrl+T"
- text: "Select Track from Query"
handler: "query_dynamic_submenu"
submenu: true
- text: "Insert Section Header"
handler: "insert_header"
shortcut: "Ctrl+H"
- text: "Import Files"
handler: "import_files_wrapper"
shortcut: "Ctrl+Shift+I"
- separator: true
- text: "Mark for Moving"
handler: "mark_rows_for_moving"
shortcut: "Ctrl+C"
- text: "Paste"
handler: "paste_rows"
shortcut: "Ctrl+V"
- separator: true
- text: "Export Playlist"
handler: "export_playlist_tab"
- text: "Download CSV of Played Tracks"
handler: "download_played_tracks"
- separator: true
- text: "Select Duplicate Rows"
handler: "select_duplicate_rows"
- text: "Move Selected"
handler: "move_selected"
- text: "Move Unplayed"
handler: "move_unplayed"
- separator: true
- text: "Clear Selection"
handler: "clear_selection"
shortcut: "Esc"
store_reference: true # So we can enable/disable later
- title: "&Music"
actions:
- text: "Set Next"
handler: "set_selected_track_next"
shortcut: "Ctrl+N"
- text: "Play Next"
handler: "play_next"
shortcut: "Return"
- text: "Fade"
handler: "fade"
shortcut: "Ctrl+Z"
- text: "Stop"
handler: "stop"
shortcut: "Ctrl+Alt+S"
- text: "Resume"
handler: "resume"
shortcut: "Ctrl+R"
- text: "Skip to Next"
handler: "play_next"
shortcut: "Ctrl+Alt+Return"
- separator: true
- text: "Search"
handler: "search_playlist"
shortcut: "/"
- text: "Search Title in Wikipedia"
handler: "lookup_row_in_wikipedia"
shortcut: "Ctrl+W"
- text: "Search Title in Songfacts"
handler: "lookup_row_in_songfacts"
shortcut: "Ctrl+S"
- title: "Help"
actions:
- text: "About"
handler: "about"
- text: "Debug"
handler: "debug"

View File

@ -1,5 +1,7 @@
# Standard library imports # Standard library imports
from typing import List, Optional, Sequence from __future__ import annotations
from typing import Optional, Sequence
import datetime as dt import datetime as dt
import os import os
import re import re
@ -8,22 +10,27 @@ import sys
# PyQt imports # PyQt imports
# Third party imports # Third party imports
from dogpile.cache import make_region
from dogpile.cache.api import NO_VALUE
from sqlalchemy import ( from sqlalchemy import (
bindparam, bindparam,
delete, delete,
func, func,
select, select,
text,
update, update,
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.engine.row import RowMapping
# App imports # App imports
from classes import ApplicationError, Filter
from config import Config
from dbmanager import DatabaseManager from dbmanager import DatabaseManager
import dbtables import dbtables
from config import Config
from log import log from log import log
@ -34,11 +41,28 @@ if DATABASE_URL is None:
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL: if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
raise ValueError("Unit tests running on non-Sqlite database") raise ValueError("Unit tests running on non-Sqlite database")
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
db.create_all()
# Configure the cache region
cache_region = make_region().configure(
'dogpile.cache.memory', # Use in-memory caching for now (switch to Redis if needed)
expiration_time=600 # Cache expires after 10 minutes
)
def run_sql(session: Session, sql: str) -> Sequence[RowMapping]:
"""
Run a sql string and return results
"""
try:
return session.execute(text(sql)).mappings().all()
except ProgrammingError as e:
raise ApplicationError(e)
# Database classes # Database classes
class NoteColours(dbtables.NoteColoursTable): class NoteColours(dbtables.NoteColoursTable):
def __init__( def __init__(
self, self,
session: Session, session: Session,
@ -65,62 +89,95 @@ class NoteColours(dbtables.NoteColoursTable):
Return all records Return all records
""" """
return session.scalars(select(cls)).all() cache_key = "note_colours_all"
cached_result = cache_region.get(cache_key)
if cached_result is not NO_VALUE:
return cached_result
# Query the database
result = session.scalars(
select(cls)
.where(
cls.enabled.is_(True),
)
.order_by(cls.order)
).all()
cache_region.set(cache_key, result)
return result
@staticmethod @staticmethod
def get_colour(session: Session, text: str) -> Optional[str]: def get_colour(
session: Session, text: str, foreground: bool = False
) -> str:
""" """
Parse text and return colour string if matched, else empty string Parse text and return background (foreground if foreground==True) colour
string if matched, else None
""" """
if not text: if not text:
return None return ""
for rec in session.scalars( match = False
select(NoteColours) for rec in NoteColours.get_all(session):
.filter(NoteColours.enabled.is_(True))
.order_by(NoteColours.order)
).all():
if rec.is_regex: if rec.is_regex:
flags = re.UNICODE flags = re.UNICODE
if not rec.is_casesensitive: if not rec.is_casesensitive:
flags |= re.IGNORECASE flags |= re.IGNORECASE
p = re.compile(rec.substring, flags) p = re.compile(rec.substring, flags)
if p.match(text): if p.match(text):
return rec.colour match = True
else: else:
if rec.is_casesensitive: if rec.is_casesensitive:
if rec.substring in text: if rec.substring in text:
return rec.colour match = True
else: else:
if rec.substring.lower() in text.lower(): if rec.substring.lower() in text.lower():
return rec.colour match = True
return None if match:
if foreground:
return rec.foreground or ""
else:
return rec.colour
return ""
@staticmethod
def invalidate_cache() -> None:
"""Invalidate dogpile cache"""
cache_region.delete("note_colours_all")
class Playdates(dbtables.PlaydatesTable): class Playdates(dbtables.PlaydatesTable):
def __init__(self, session: Session, track_id: int) -> None: def __init__(
self, session: Session, track_id: int, when: Optional[dt.datetime] = None
) -> None:
"""Record that track was played""" """Record that track was played"""
if not when:
self.lastplayed = dt.datetime.now() self.lastplayed = dt.datetime.now()
else:
self.lastplayed = when
self.track_id = track_id self.track_id = track_id
session.add(self) session.add(self)
session.commit() session.commit()
@staticmethod @staticmethod
def last_playdates( def last_playdates(
session: Session, track_id: int, limit=5 session: Session, track_id: int, limit: int = 5
) -> Sequence["Playdates"]: ) -> Sequence["Playdates"]:
""" """
Return a list of the last limit playdates for this track, sorted Return a list of the last limit playdates for this track, sorted
earliest to latest. latest to earliest.
""" """
return session.scalars( return session.scalars(
Playdates.select() Playdates.select()
.where(Playdates.track_id == track_id) .where(Playdates.track_id == track_id)
.order_by(Playdates.lastplayed.asc()) .order_by(Playdates.lastplayed.desc())
.limit(limit) .limit(limit)
).all() ).all()
@ -143,7 +200,7 @@ class Playdates(dbtables.PlaydatesTable):
return Config.EPOCH # pragma: no cover return Config.EPOCH # pragma: no cover
@staticmethod @staticmethod
def last_played_tracks(session: Session, limit=5) -> Sequence["Playdates"]: def last_played_tracks(session: Session, limit: int = 5) -> Sequence["Playdates"]:
""" """
Return a list of the last limit tracks played, sorted Return a list of the last limit tracks played, sorted
earliest to latest. earliest to latest.
@ -165,13 +222,20 @@ class Playdates(dbtables.PlaydatesTable):
class Playlists(dbtables.PlaylistsTable): class Playlists(dbtables.PlaylistsTable):
def __init__(self, session: Session, name: str): def __init__(self, session: Session, name: str, template_id: int) -> None:
"""Create playlist with passed name"""
self.name = name self.name = name
self.last_used = dt.datetime.now()
session.add(self) session.add(self)
session.commit() session.commit()
# If a template is specified, copy from it
if template_id:
PlaylistRows.copy_playlist(session, template_id, self.id)
@staticmethod @staticmethod
def clear_tabs(session: Session, playlist_ids: List[int]) -> None: def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
""" """
Make all tab records NULL Make all tab records NULL
""" """
@ -186,30 +250,6 @@ class Playlists(dbtables.PlaylistsTable):
self.open = False self.open = False
session.commit() session.commit()
@classmethod
def create_playlist_from_template(
cls, session: Session, template: "Playlists", playlist_name: str
) -> Optional["Playlists"]:
"""Create a new playlist from template"""
playlist = cls(session, playlist_name)
# Sanity / mypy checks
if not playlist or not playlist.id or not template.id:
return None
PlaylistRows.copy_playlist(session, template.id, playlist.id)
return playlist
def delete(self, session: Session) -> None:
"""
Mark as deleted
"""
self.deleted = True
session.commit()
@classmethod @classmethod
def get_all(cls, session: Session) -> Sequence["Playlists"]: def get_all(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use""" """Returns a list of all playlists ordered by last use"""
@ -225,7 +265,17 @@ class Playlists(dbtables.PlaylistsTable):
"""Returns a list of all templates ordered by name""" """Returns a list of all templates ordered by name"""
return session.scalars( return session.scalars(
select(cls).filter(cls.is_template.is_(True)).order_by(cls.name) select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
).all()
@classmethod
def get_favourite_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of favourite templates ordered by name"""
return session.scalars(
select(cls)
.where(cls.is_template.is_(True), cls.favourite.is_(True))
.order_by(cls.name)
).all() ).all()
@classmethod @classmethod
@ -237,7 +287,6 @@ class Playlists(dbtables.PlaylistsTable):
.filter( .filter(
cls.open.is_(False), cls.open.is_(False),
cls.is_template.is_(False), cls.is_template.is_(False),
cls.deleted.is_(False),
) )
.order_by(cls.last_used.desc()) .order_by(cls.last_used.desc())
).all() ).all()
@ -283,7 +332,7 @@ class Playlists(dbtables.PlaylistsTable):
) -> None: ) -> None:
"""Save passed playlist as new template""" """Save passed playlist as new template"""
template = Playlists(session, template_name) template = Playlists(session, template_name, template_id=0)
if not template or not template.id: if not template or not template.id:
return return
@ -306,7 +355,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.track_id = track_id self.track_id = track_id
self.plr_rownum = row_number self.row_number = row_number
self.note = note self.note = note
session.add(self) session.add(self)
session.commit() session.commit()
@ -332,7 +381,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
PlaylistRows( PlaylistRows(
session=session, session=session,
playlist_id=dst_id, playlist_id=dst_id,
row_number=plr.plr_rownum, row_number=plr.row_number,
note=plr.note, note=plr.note,
track_id=plr.track_id, track_id=plr.track_id,
) )
@ -351,30 +400,13 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
.options(joinedload(cls.track)) .options(joinedload(cls.track))
.where( .where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum == row_number, PlaylistRows.row_number == row_number,
) )
# .options(joinedload(Tracks.playdates)) # .options(joinedload(Tracks.playdates))
) )
return session.execute(stmt).unique().scalar_one() return session.execute(stmt).unique().scalar_one()
@classmethod
def deep_rows(cls, session: Session, playlist_id: int) -> Sequence["PlaylistRows"]:
"""
Return a list of playlist rows that include full track and lastplayed data for
given playlist_id., Sequence
"""
stmt = (
select(PlaylistRows)
.options(joinedload(cls.track))
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum)
# .options(joinedload(Tracks.playdates))
)
return session.scalars(stmt).unique().all()
@staticmethod @staticmethod
def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None: def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None:
""" """
@ -385,7 +417,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.execute( session.execute(
delete(PlaylistRows).where( delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum > maxrow, PlaylistRows.row_number > maxrow,
) )
) )
session.commit() session.commit()
@ -399,7 +431,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.execute( session.execute(
delete(PlaylistRows).where( delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum == row_number, PlaylistRows.row_number == row_number,
) )
) )
@ -412,18 +444,18 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(PlaylistRows) select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id) .where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum) .order_by(PlaylistRows.row_number)
).all() ).all()
for i, plr in enumerate(plrs): for i, plr in enumerate(plrs):
plr.plr_rownum = i plr.row_number = i
# Ensure new row numbers are available to the caller # Ensure new row numbers are available to the caller
session.commit() session.commit()
@classmethod @classmethod
def plrids_to_plrs( def plrids_to_plrs(
cls, session: Session, playlist_id: int, plr_ids: List[int] cls, session: Session, playlist_id: int, plr_ids: list[int]
) -> Sequence["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
Take a list of PlaylistRows ids and return a list of corresponding Take a list of PlaylistRows ids and return a list of corresponding
@ -433,7 +465,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(cls) select(cls)
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids)) .where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
.order_by(cls.plr_rownum) .order_by(cls.row_number)
).all() ).all()
return plrs return plrs
@ -443,7 +475,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
"""Return the last used row for playlist, or None if no rows""" """Return the last used row for playlist, or None if no rows"""
return session.execute( return session.execute(
select(func.max(PlaylistRows.plr_rownum)).where( select(func.max(PlaylistRows.row_number)).where(
PlaylistRows.playlist_id == playlist_id PlaylistRows.playlist_id == playlist_id
) )
).scalar_one() ).scalar_one()
@ -475,11 +507,29 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(cls) select(cls)
.where(cls.playlist_id == playlist_id, cls.played.is_(True)) .where(cls.playlist_id == playlist_id, cls.played.is_(True))
.order_by(cls.plr_rownum) .order_by(cls.row_number)
).all() ).all()
return plrs return plrs
@classmethod
def get_playlist_rows(
cls, session: Session, playlist_id: int
) -> Sequence["PlaylistRows"]:
"""
For passed playlist, return a list of rows.
"""
stmt = (
select(cls)
.where(cls.playlist_id == playlist_id)
.options(selectinload(cls.track))
.order_by(cls.row_number)
)
plrs = session.execute(stmt).scalars().all()
return plrs
@classmethod @classmethod
def get_rows_with_tracks( def get_rows_with_tracks(
cls, cls,
@ -494,7 +544,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
query = select(cls).where( query = select(cls).where(
cls.playlist_id == playlist_id, cls.track_id.is_not(None) cls.playlist_id == playlist_id, cls.track_id.is_not(None)
) )
plrs = session.scalars((query).order_by(cls.plr_rownum)).all() plrs = session.scalars((query).order_by(cls.row_number)).all()
return plrs return plrs
@ -514,7 +564,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
cls.track_id.is_not(None), cls.track_id.is_not(None),
cls.played.is_(False), cls.played.is_(False),
) )
.order_by(cls.plr_rownum) .order_by(cls.row_number)
).all() ).all()
return plrs return plrs
@ -552,17 +602,19 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
update(PlaylistRows) update(PlaylistRows)
.where( .where(
(PlaylistRows.playlist_id == playlist_id), (PlaylistRows.playlist_id == playlist_id),
(PlaylistRows.plr_rownum >= starting_row), (PlaylistRows.row_number >= starting_row),
) )
.values(plr_rownum=PlaylistRows.plr_rownum + move_by) .values(row_number=PlaylistRows.row_number + move_by)
) )
@staticmethod @staticmethod
def update_plr_rownumbers( def update_plr_row_numbers(
session: Session, playlist_id: int, sqla_map: List[dict[str, int]] session: Session,
playlist_id: int,
sqla_map: list[dict[str, int]],
) -> None: ) -> None:
""" """
Take a {plrid: plr_rownum} dictionary and update the row numbers accordingly Take a {plrid: row_number} dictionary and update the row numbers accordingly
""" """
# Update database. Ref: # Update database. Ref:
@ -571,15 +623,46 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
update(PlaylistRows) update(PlaylistRows)
.where( .where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.id == bindparam("plrid"), PlaylistRows.id == bindparam("playlistrow_id"),
) )
.values(plr_rownum=bindparam("plr_rownum")) .values(row_number=bindparam("row_number"))
) )
session.connection().execute(stmt, sqla_map) session.connection().execute(stmt, sqla_map)
class Queries(dbtables.QueriesTable):
def __init__(
self,
session: Session,
name: str,
filter: dbtables.Filter,
favourite: bool = False,
) -> None:
"""Create new query"""
self.name = name
self.filter = filter
self.favourite = favourite
session.add(self)
session.commit()
@classmethod
def get_all(cls, session: Session) -> Sequence["Queries"]:
"""Returns a list of all queries ordered by name"""
return session.scalars(select(cls).order_by(cls.name)).all()
@classmethod
def get_favourites(cls, session: Session) -> Sequence["Queries"]:
"""Returns a list of favourite queries ordered by name"""
return session.scalars(
select(cls).where(cls.favourite.is_(True)).order_by(cls.name)
).all()
class Settings(dbtables.SettingsTable): class Settings(dbtables.SettingsTable):
def __init__(self, session: Session, name: str): def __init__(self, session: Session, name: str) -> None:
self.name = name self.name = name
session.add(self) session.add(self)
session.commit() session.commit()
@ -606,9 +689,8 @@ class Tracks(dbtables.TracksTable):
start_gap: int, start_gap: int,
fade_at: int, fade_at: int,
silence_at: int, silence_at: int,
mtime: int,
bitrate: int, bitrate: int,
): ) -> None:
self.path = path self.path = path
self.title = title self.title = title
self.artist = artist self.artist = artist
@ -617,7 +699,6 @@ class Tracks(dbtables.TracksTable):
self.start_gap = start_gap self.start_gap = start_gap
self.fade_at = fade_at self.fade_at = fade_at
self.silence_at = silence_at self.silence_at = silence_at
self.mtime = mtime
try: try:
session.add(self) session.add(self)
@ -628,25 +709,112 @@ class Tracks(dbtables.TracksTable):
raise ValueError(error) raise ValueError(error)
@classmethod @classmethod
def get_all(cls, session) -> List["Tracks"]: def get_all(cls, session: Session) -> Sequence["Tracks"]:
"""Return a list of all tracks""" """Return a list of all tracks"""
return session.scalars(select(cls)).unique().all() return session.scalars(select(cls)).unique().all()
@classmethod @classmethod
def get_by_basename( def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
cls, session: Session, basename: str
) -> Optional[Sequence["Tracks"]]:
""" """
Return track(s) with passed basename, or None. Return a dictionary of all tracks, keyed by title
""" """
try: result: dict[int, Tracks] = {}
return session.scalars(
Tracks.select().where(Tracks.path.like("%/" + basename)) for track in cls.get_all(session):
).all() result[track.id] = track
except NoResultFound:
return None return result
@classmethod
def exact_title_and_artist(
cls, session: Session, title: str, artist: str
) -> Sequence["Tracks"]:
"""
Search for exact but case-insensitive match of title and artist
"""
return (
session.scalars(
select(cls)
.where(cls.title.ilike(title), cls.artist.ilike(artist))
.order_by(cls.title)
)
.unique()
.all()
)
@classmethod
def get_filtered_tracks(
cls, session: Session, filter: Filter
) -> Sequence["Tracks"]:
"""
Return tracks matching filter
"""
query = select(cls)
# Path specification
if filter.path:
if filter.path_type == "contains":
query = query.where(cls.path.ilike(f"%{filter.path}%"))
elif filter.path_type == "excluding":
query = query.where(cls.path.notilike(f"%{filter.path}%"))
else:
raise ApplicationError(f"Can't process filter path ({filter=})")
# Duration specification
seconds_duration = filter.duration_number
if filter.duration_unit == Config.FILTER_DURATION_MINUTES:
seconds_duration *= 60
elif filter.duration_unit != Config.FILTER_DURATION_SECONDS:
raise ApplicationError(f"Can't process filter duration ({filter=})")
if filter.duration_type == Config.FILTER_DURATION_LONGER:
query = query.where(cls.duration >= seconds_duration)
elif filter.duration_unit == Config.FILTER_DURATION_SHORTER:
query = query.where(cls.duration <= seconds_duration)
else:
raise ApplicationError(f"Can't process filter duration type ({filter=})")
# Process comparator
if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER:
# Select tracks that have never been played
query = query.outerjoin(Playdates, cls.id == Playdates.track_id).where(
Playdates.id.is_(None)
)
else:
# Last played specification
now = dt.datetime.now()
# Set sensible default, and correct for Config.FILTER_PLAYED_COMPARATOR_ANYTIME
before = now
# If not ANYTIME, set 'before' appropriates
if filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME:
if filter.last_played_unit == Config.FILTER_PLAYED_DAYS:
before = now - dt.timedelta(days=filter.last_played_number)
elif filter.last_played_unit == Config.FILTER_PLAYED_WEEKS:
before = now - dt.timedelta(days=7 * filter.last_played_number)
elif filter.last_played_unit == Config.FILTER_PLAYED_MONTHS:
before = now - dt.timedelta(days=30 * filter.last_played_number)
elif filter.last_played_unit == Config.FILTER_PLAYED_YEARS:
before = now - dt.timedelta(days=365 * filter.last_played_number)
subquery = (
select(
Playdates.track_id,
func.max(Playdates.lastplayed).label("max_last_played"),
)
.group_by(Playdates.track_id)
.subquery()
)
query = query.join(subquery, Tracks.id == subquery.c.track_id).where(
subquery.c.max_last_played < before
)
records = session.scalars(query).unique().all()
return records
@classmethod @classmethod
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]: def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:

View File

@ -2,39 +2,66 @@
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
import threading
from time import sleep from time import sleep
from typing import Optional from typing import Optional
# Third party imports # Third party imports
# import line_profiler
import numpy as np import numpy as np
import pyqtgraph as pg # type: ignore import pyqtgraph as pg # type: ignore
from sqlalchemy.orm.session import Session
import vlc # type: ignore import vlc # type: ignore
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QObject, QObject,
QRunnable,
QThread, QThread,
QThreadPool,
) )
from pyqtgraph import PlotWidget from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
# App imports # App imports
from classes import MusicMusterSignals from classes import ApplicationError, MusicMusterSignals
from config import Config from config import Config
import helpers
from log import log from log import log
from models import db, PlaylistRows, Tracks from models import PlaylistRows
from helpers import ( from vlcmanager import VLCManager
file_is_unreadable,
get_audio_segment,
show_warning,
)
lock = threading.Lock()
# Define the VLC callback function type
# import ctypes
# import platform
# VLC logging is very noisy so comment out unless needed
# VLC_LOG_CB = ctypes.CFUNCTYPE(
# None,
# ctypes.c_void_p,
# ctypes.c_int,
# ctypes.c_void_p,
# ctypes.c_char_p,
# ctypes.c_void_p,
# )
# # Determine the correct C library for vsnprintf based on the platform
# if platform.system() == "Windows":
# libc = ctypes.CDLL("msvcrt")
# elif platform.system() == "Linux":
# libc = ctypes.CDLL("libc.so.6")
# elif platform.system() == "Darwin": # macOS
# libc = ctypes.CDLL("libc.dylib")
# else:
# raise OSError("Unsupported operating system")
# # Define the vsnprintf function
# libc.vsnprintf.argtypes = [
# ctypes.c_char_p,
# ctypes.c_size_t,
# ctypes.c_char_p,
# ctypes.c_void_p,
# ]
# libc.vsnprintf.restype = ctypes.c_int
class _AddFadeCurve(QObject): class _AddFadeCurve(QObject):
@ -47,13 +74,13 @@ class _AddFadeCurve(QObject):
def __init__( def __init__(
self, self,
track_manager: _TrackManager, rat: RowAndTrack,
track_path: str, track_path: str,
track_fade_at: int, track_fade_at: int,
track_silence_at: int, track_silence_at: int,
) -> None: ) -> None:
super().__init__() super().__init__()
self.track_manager = track_manager self.rat = rat
self.track_path = track_path self.track_path = track_path
self.track_fade_at = track_fade_at self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at self.track_silence_at = track_silence_at
@ -67,7 +94,7 @@ class _AddFadeCurve(QObject):
if not fc: if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}") log.error(f"Failed to create FadeCurve for {self.track_path=}")
else: else:
self.track_manager.fade_graph = fc self.rat.fade_graph = fc
self.finished.emit() self.finished.emit()
@ -81,7 +108,7 @@ class _FadeCurve:
Set up fade graph array Set up fade graph array
""" """
audio = get_audio_segment(track_path) audio = helpers.get_audio_segment(track_path)
if not audio: if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}") log.error(f"FadeCurve: could not get audio for {track_path=}")
return None return None
@ -92,8 +119,8 @@ class _FadeCurve:
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1 0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
) )
self.end_ms: int = track_silence_at self.end_ms: int = track_silence_at
self.audio_segment = audio[self.start_ms : self.end_ms] audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples()) self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array # 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.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
@ -112,8 +139,12 @@ class _FadeCurve:
self.curve = self.GraphWidget.plot(self.graph_array) self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve: if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND) self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time) -> None: def tick(self, play_time: int) -> None:
"""Update volume fade curve""" """Update volume fade curve"""
if not self.GraphWidget: if not self.GraphWidget:
@ -125,21 +156,21 @@ class _FadeCurve:
if self.region is None: if self.region is None:
# Create the region now that we're into fade # Create the region now that we're into fade
log.debug("issue223: _FadeCurve: create region")
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)]) self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region) self.GraphWidget.addItem(self.region)
# Update region position # Update region position
if self.region: if self.region:
log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor]) self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QRunnable): class _FadeTrack(QThread):
def __init__(self, player: vlc.MediaPlayer, fade_seconds) -> None: finished = pyqtSignal()
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__() super().__init__()
self.player: vlc.MediaPlayer = player self.player = player
self.fade_seconds: int = fade_seconds self.fade_seconds = fade_seconds
def run(self) -> None: def run(self) -> None:
""" """
@ -151,6 +182,7 @@ class _FadeTrack(QRunnable):
# Reduce volume logarithmically # Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
if total_steps > 0:
db_reduction_per_step = Config.FADEOUT_DB / total_steps db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20)) reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
@ -162,9 +194,11 @@ class _FadeTrack(QRunnable):
) )
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND) sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.player.stop() self.finished.emit()
log.debug(f"Releasing player {self.player=}")
self.player.release()
# TODO can we move this into the _Music class?
vlc_instance = VLCManager().vlc_instance
class _Music: class _Music:
@ -172,14 +206,39 @@ class _Music:
Manage the playing of music tracks Manage the playing of music tracks
""" """
def __init__(self, name) -> None: def __init__(self, name: str) -> None:
self.VLC = vlc.Instance() vlc_instance.set_user_agent(name, name)
self.VLC.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None self.player: Optional[vlc.MediaPlayer] = None
self.name: str = name self.name = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None self.start_dt: Optional[dt.datetime] = None
# Set up logging
# self._set_vlc_log()
# VLC logging very noisy so comment out unless needed
# @VLC_LOG_CB
# def log_callback(data, level, ctx, fmt, args):
# try:
# # Create a ctypes string buffer to hold the formatted message
# buf = ctypes.create_string_buffer(1024)
# # Use vsnprintf to format the string with the va_list
# libc.vsnprintf(buf, len(buf), fmt, args)
# # Decode the formatted message
# message = buf.value.decode("utf-8", errors="replace")
# log.debug("VLC: " + message)
# except Exception as e:
# log.error(f"Error in VLC log callback: {e}")
# def _set_vlc_log(self):
# try:
# vlc.libvlc_log_set(vlc_instance, self.log_callback, None)
# log.debug("VLC logging set up successfully")
# except Exception as e:
# log.error(f"Failed to set up VLC logging: {e}")
def adjust_by_ms(self, ms: int) -> None: def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds""" """Move player position by ms milliseconds"""
@ -201,25 +260,6 @@ class _Music:
else: else:
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms) self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
def stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
p = self.player
self.player = None
self.start_dt = None
with lock:
p.stop()
p.release()
p = None
def fade(self, fade_seconds: int) -> None: def fade(self, fade_seconds: int) -> None:
""" """
Fade the currently playing track. Fade the currently playing track.
@ -234,23 +274,10 @@ class _Music:
if not self.player.get_position() > 0 and self.player.is_playing(): if not self.player.get_position() > 0 and self.player.is_playing():
return return
if fade_seconds <= 0: self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds)
self.stop() self.fader_worker.finished.connect(self.player.release)
return self.fader_worker.start()
# Take a copy of current player to allow another track to be
# started without interfering here
with lock:
p = self.player
self.player = None
pool = QThreadPool.globalInstance()
if pool:
fader = _FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
self.start_dt = None self.start_dt = None
else:
log.error("_Music: failed to allocate QThreadPool")
def get_playtime(self) -> int: def get_playtime(self) -> int:
""" """
@ -308,17 +335,18 @@ class _Music:
log.debug(f"Music[{self.name}].play({path=}, {position=}") log.debug(f"Music[{self.name}].play({path=}, {position=}")
if file_is_unreadable(path): if helpers.file_is_unreadable(path):
log.error(f"play({path}): path not readable") log.error(f"play({path}): path not readable")
return None return None
media = self.VLC.media_new_path(path) self.player = vlc.MediaPlayer(vlc_instance, path)
if media is None: if self.player is None:
log.error(f"_Music:play: failed to create media ({path=})") log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
show_warning(None, "Error loading file", f"Cannot play file ({path})") helpers.show_warning(
None, "Error creating MediaPlayer", f"Cannot play file ({path})"
)
return return
self.player = media.player_new_from_media()
if self.player:
_ = self.player.play() _ = self.player.play()
self.set_volume(self.max_volume) self.set_volume(self.max_volume)
@ -326,23 +354,6 @@ class _Music:
self.player.set_position(position) self.player.set_position(position)
self.start_dt = start_time self.start_dt = start_time
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
for _ in range(3):
if self.player:
volume = self.player.audio_get_volume()
if volume < Config.VLC_VOLUME_DEFAULT:
self.set_volume(Config.VLC_VOLUME_DEFAULT)
log.error(f"Reset from {volume=}")
sleep(0.1)
else:
log.error("_Music:play: failed to create media player")
show_warning(None, "Media player", "Unable to create media player")
def set_position(self, position: float) -> None: def set_position(self, position: float) -> None:
""" """
Set player position Set player position
@ -366,85 +377,100 @@ class _Music:
volume = Config.VLC_VOLUME_DEFAULT volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume) self.player.audio_set_volume(volume)
# Ensure volume correct
# For as-yet unknown reasons. sometimes the volume gets def stop(self) -> None:
# reset to zero within 200mS or so of starting play. This """Immediately stop playing"""
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant). log.debug(f"Music[{self.name}].stop()")
for _ in range(3):
current_volume = self.player.audio_get_volume() self.start_dt = None
if current_volume < volume:
self.player.audio_set_volume(volume) if not self.player:
log.debug(f"Reset from {volume=}") return
sleep(0.1)
if self.player.is_playing():
self.player.stop()
self.player.release()
self.player = None
class _TrackManager: class RowAndTrack:
""" """
Object to manage active playlist tracks, Object to manage playlist rows and tracks.
typically the previous, current and next track.
""" """
def __init__( def __init__(self, playlist_row: PlaylistRows) -> None:
self,
session: db.Session,
player_name: str,
track_id: int,
row_number: int,
) -> None:
""" """
Initialises data structure. Initialises data structure.
Define a player.
Raise ValueError if no track in passed plr. The passed PlaylistRows object will include a Tracks object if this
row has a track.
""" """
track = session.get(Tracks, track_id) # Collect playlistrow data
if not track: self.note = playlist_row.note
raise ValueError(f"_TrackPlayer: unable to retreived {track_id=}") self.played = playlist_row.played
self.player_name = player_name self.playlist_id = playlist_row.playlist_id
self.row_number = row_number self.playlistrow_id = playlist_row.id
self.row_number = playlist_row.row_number
self.track_id = playlist_row.track_id
# Check file readable # Playlist display data
if file_is_unreadable(track.path): self.row_fg: Optional[str] = None
raise ValueError(f"_TrackManager.__init__: {track.path=} unreadable") self.row_bg: Optional[str] = None
self.note_fg: Optional[str] = None
self.note_bg: Optional[str] = None
self.artist = track.artist # Collect track data if there's a track
self.bitrate = track.bitrate if playlist_row.track_id:
self.duration = track.duration self.artist = playlist_row.track.artist
self.fade_at = track.fade_at self.bitrate = playlist_row.track.bitrate
self.intro = track.intro self.duration = playlist_row.track.duration
self.path = track.path self.fade_at = playlist_row.track.fade_at
self.silence_at = track.silence_at self.intro = playlist_row.track.intro
self.start_gap = track.start_gap if playlist_row.track.playdates:
self.title = track.title self.lastplayed = max(
self.track_id = track.id [a.lastplayed for a in playlist_row.track.playdates]
)
else:
self.lastplayed = Config.EPOCH
self.path = playlist_row.track.path
self.silence_at = playlist_row.track.silence_at
self.start_gap = playlist_row.track.start_gap
self.title = playlist_row.track.title
else:
self.artist = ""
self.bitrate = 0
self.duration = 0
self.fade_at = 0
self.intro = None
self.lastplayed = Config.EPOCH
self.path = ""
self.silence_at = 0
self.start_gap = 0
self.title = ""
# Track playing data
self.end_of_track_signalled: bool = False
self.end_time: Optional[dt.datetime] = None self.end_time: Optional[dt.datetime] = None
self.fade_graph: Optional[_FadeCurve] = None self.fade_graph: Optional[_FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None self.fade_graph_start_updates: Optional[dt.datetime] = None
self.resume_marker: Optional[float] self.resume_marker: Optional[float] = 0.0
self.forecast_end_time: Optional[dt.datetime] = None
self.forecast_start_time: Optional[dt.datetime] = None
self.start_time: Optional[dt.datetime] = None self.start_time: Optional[dt.datetime] = None
self.end_of_track_signalled: bool = False
# Other object initialisation
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
# Initialise player def __repr__(self) -> str:
self.player = _Music(name=player_name) return (
f"<RowAndTrack(playlist_id={self.playlist_id}, "
# Initialise and add FadeCurve in a thread as it's slow f"row_number={self.row_number}, "
self.fadecurve_thread = QThread() f"playlistrow_id={self.playlistrow_id}, "
self.worker = _AddFadeCurve( f"note={self.note}, track_id={self.track_id}>"
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 check_for_end_of_track(self) -> None: def check_for_end_of_track(self) -> None:
""" """
@ -457,29 +483,52 @@ class _TrackManager:
if self.end_of_track_signalled: if self.end_of_track_signalled:
return return
if not self.player.is_playing(): if self.music.is_playing():
return
self.start_time = None self.start_time = None
if self.fade_graph: if self.fade_graph:
self.fade_graph.clear() self.fade_graph.clear()
self.signal_end_of_track() # Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True self.end_of_track_signalled = True
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=self.path,
track_fade_at=self.fade_at,
track_silence_at=self.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def drop3db(self, enable: bool) -> None: def drop3db(self, enable: bool) -> None:
""" """
If enable is true, drop output by 3db else restore to full volume If enable is true, drop output by 3db else restore to full volume
""" """
if enable: if enable:
self.player.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False) self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else: else:
self.player.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False) self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None: def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music""" """Fade music"""
self.resume_marker = self.player.get_position() self.resume_marker = self.music.get_position()
self.player.fade(fade_seconds) self.music.fade(fade_seconds)
self.signal_end_of_track() self.signals.track_ended_signal.emit()
def is_playing(self) -> bool: def is_playing(self) -> bool:
""" """
@ -489,30 +538,30 @@ class _TrackManager:
if self.start_time is None: if self.start_time is None:
return False return False
return self.player.is_playing() return self.music.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None: def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
""" """
Rewind player by ms milliseconds Rewind player by ms milliseconds
""" """
self.player.adjust_by_ms(ms * -1) self.music.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None: def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
""" """
Rewind player by ms milliseconds Rewind player by ms milliseconds
""" """
self.player.adjust_by_ms(ms) self.music.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None: def play(self, position: Optional[float] = None) -> None:
"""Play track""" """Play track"""
log.debug(f"issue223: _TrackManager: play {self.track_id=}")
now = dt.datetime.now() now = dt.datetime.now()
self.start_time = now self.start_time = now
self.player.play(self.path, start_time=now, position=position) # Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration) self.end_time = now + dt.timedelta(milliseconds=self.duration)
@ -530,21 +579,47 @@ class _TrackManager:
Restart player Restart player
""" """
self.player.adjust_by_ms(self.time_playing() * -1) self.music.adjust_by_ms(self.time_playing() * -1)
def signal_end_of_track(self) -> None: def set_forecast_start_time(
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
""" """
Send end of track signal unless we are a preview player Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
""" """
self.signals.track_ended_signal.emit() changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None: def stop(self, fade_seconds: int = 0) -> None:
""" """
Stop this track playing Stop this track playing
""" """
self.resume_marker = self.player.get_position() self.resume_marker = self.music.get_position()
self.fade(fade_seconds) self.fade(fade_seconds)
# Reset fade graph # Reset fade graph
@ -559,7 +634,7 @@ class _TrackManager:
if self.start_time is None: if self.start_time is None:
return 0 return 0
return self.player.get_playtime() return self.music.get_playtime()
def time_remaining_intro(self) -> int: def time_remaining_intro(self) -> int:
""" """
@ -611,46 +686,39 @@ class _TrackManager:
self.fade_graph.tick(self.time_playing()) self.fade_graph.tick(self.time_playing())
def update_playlist_and_row(self, session: Session) -> None:
class MainTrackManager(_TrackManager):
""" """
Manage playing tracks from the playlist with associated data Update local playlist_id and row_number from playlistrow_id
""" """
def __init__(self, session: db.Session, plr_id: int) -> None: plr = session.get(PlaylistRows, self.playlistrow_id)
"""
Set up manager for playlist tracks
"""
# Ensure we have a track
plr = session.get(PlaylistRows, plr_id)
if not plr: if not plr:
raise ValueError(f"PlaylistTrack: unable to retreive plr {plr_id=}") raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
self.playlist_id = plr.playlist_id
self.track_id: int = plr.track_id self.row_number = plr.row_number
super().__init__(
session=session,
player_name=Config.VLC_MAIN_PLAYER_NAME,
track_id=self.track_id,
row_number=plr.plr_rownum,
)
# Save non-track plr info
self.plr_id: int = plr.id
self.playlist_id: int = plr.playlist_id
def __repr__(self) -> str:
return (
f"<MainTrackManager(plr_id={self.plr_id}, playlist_id={self.playlist_id}, "
f"row_number={self.row_number}>"
)
class TrackSequence: class TrackSequence:
next: Optional[MainTrackManager] = None next: Optional[RowAndTrack] = None
current: Optional[MainTrackManager] = None current: Optional[RowAndTrack] = None
previous: Optional[MainTrackManager] = None previous: Optional[RowAndTrack] = None
def set_next(self, rat: Optional[RowAndTrack]) -> None:
"""
Set the 'next' track to be passed rat. Clear
any previous next track. If passed rat is None
just clear existing next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if rat is None:
self.next = None
else:
self.next = rat
self.next.create_fade_graph()
track_sequence = TrackSequence() track_sequence = TrackSequence()

File diff suppressed because it is too large Load Diff

View File

@ -1,305 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Automate Audacity via mod-script-pipe.
Pipe Client may be used as a command-line script to send commands to
Audacity via the mod-script-pipe interface, or loaded as a module.
Requires Python 3.
(Python 2.7 is now obsolete, so no longer supported)
======================
Command Line Interface
======================
usage: pipeclient.py [-h] [-t] [-s ] [-d]
Arguments
---------
-h,--help: optional
show short help and exit
-t, --timeout: float, optional
timeout for reply in seconds (default: 10)
-s, --show-time: bool, optional
show command execution time (default: True)
-d, --docs: optional
show this documentation and exit
Example
-------
$ python3 pipeclient.py -t 20 -s False
Launches command line interface with 20 second time-out for
returned message, and don't show the execution time.
When prompted, enter the command to send (not quoted), or 'Q' to quit.
$ Enter command or 'Q' to quit: GetInfo: Type=Tracks Format=LISP
============
Module Usage
============
Note that on a critical error (such as broken pipe), the module just exits.
If a more graceful shutdown is required, replace the sys.exit()'s with
exceptions.
Example
-------
# Import the module:
>>> import pipeclient
# Create a client instance:
>>> client = pipeclient.PipeClient()
# Send a command:
>>> client.write("Command", timer=True)
# Read the last reply:
>>> print(client.read())
See Also
--------
PipeClient.write : Write a command to _write_pipe.
PipeClient.read : Read Audacity's reply from pipe.
Copyright Steve Daulton 2018
Released under terms of the GNU General Public License version 2:
<http://www.gnu.org/licenses/old-licenses/gpl-2.0.html />
"""
import os
import sys
import threading
import time
import errno
import argparse
if sys.version_info[0] < 3:
raise RuntimeError("PipeClient Error: Python 3.x required")
# Platform specific constants
if sys.platform == "win32":
WRITE_NAME: str = "\\\\.\\pipe\\ToSrvPipe"
READ_NAME: str = "\\\\.\\pipe\\FromSrvPipe"
EOL: str = "\r\n\0"
else:
# Linux or Mac
PIPE_BASE: str = "/tmp/audacity_script_pipe."
WRITE_NAME: str = PIPE_BASE + "to." + str(os.getuid())
READ_NAME: str = PIPE_BASE + "from." + str(os.getuid())
EOL: str = "\n"
class PipeClient:
"""Write / read client access to Audacity via named pipes.
Normally there should be just one instance of this class. If
more instances are created, they all share the same state.
__init__ calls _write_thread_start() and _read_thread_start() on
first instantiation.
Parameters
----------
None
Attributes
----------
reader_pipe_broken : event object
Set if pipe reader fails. Audacity may have crashed
reply_ready : event object
flag cleared when command sent and set when response received
timer : bool
When true, time the command execution (default False)
reply : string
message received when Audacity completes the command
See Also
--------
write : Write a command to _write_pipe.
read : Read Audacity's reply from pipe.
"""
reader_pipe_broken = threading.Event()
reply_ready = threading.Event()
_shared_state: dict = {}
def __new__(cls, *p, **k):
self = object.__new__(cls, *p, **k)
self.__dict__ = cls._shared_state
return self
def __init__(self):
self.timer: bool = False # type: ignore
self._start_time: float = 0 # type: ignore
self._write_pipe = None
self.reply: str = "" # type: ignore
if not self._write_pipe:
self._write_thread_start()
self._read_thread_start()
def _write_thread_start(self) -> None:
"""Start _write_pipe thread"""
# Pipe is opened in a new thread so that we don't
# freeze if Audacity is not running.
write_thread = threading.Thread(target=self._write_pipe_open)
write_thread.daemon = True
write_thread.start()
# Allow a little time for connection to be made.
time.sleep(0.1)
if not self._write_pipe:
raise RuntimeError("PipeClientError: Write pipe cannot be opened.")
def _write_pipe_open(self) -> None:
"""Open _write_pipe."""
self._write_pipe = open(WRITE_NAME, "w")
def _read_thread_start(self) -> None:
"""Start read_pipe thread."""
read_thread = threading.Thread(target=self._reader)
read_thread.daemon = True
read_thread.start()
def write(self, command, timer=False) -> None:
"""Write a command to _write_pipe.
Parameters
----------
command : string
The command to send to Audacity
timer : bool, optional
If true, time the execution of the command
Example
-------
write("GetInfo: Type=Labels", timer=True):
"""
self.timer = timer
self._write_pipe.write(command + EOL)
# Check that read pipe is alive
if PipeClient.reader_pipe_broken.is_set():
raise RuntimeError("PipeClient: Read-pipe error.")
try:
self._write_pipe.flush()
if self.timer:
self._start_time = time.time()
self.reply = ""
PipeClient.reply_ready.clear()
except IOError as err:
if err.errno == errno.EPIPE:
raise RuntimeError("PipeClient: Write-pipe error.")
else:
raise
def _reader(self) -> None:
"""Read FIFO in worker thread."""
# Thread will wait at this read until it connects.
# Connection should occur as soon as _write_pipe has connected.
with open(READ_NAME, "r") as read_pipe:
message = ""
pipe_ok = True
while pipe_ok:
line = read_pipe.readline()
# Stop timer as soon as we get first line of response.
stop_time = time.time()
while pipe_ok and line != "\n":
message += line
line = read_pipe.readline()
if line == "":
# No data in read_pipe indicates that the pipe
# is broken (Audacity may have crashed).
PipeClient.reader_pipe_broken.set()
pipe_ok = False
if self.timer:
xtime = (stop_time - self._start_time) * 1000
message += f"Execution time: {xtime:.2f}ms"
self.reply = message
PipeClient.reply_ready.set()
message = ""
def read(self) -> str:
"""Read Audacity's reply from pipe.
Returns
-------
string
The reply from the last command sent to Audacity, or null string
if reply not received. Null string usually indicates that Audacity
is still processing the last command.
"""
if not PipeClient.reply_ready.is_set():
return ""
return self.reply
def bool_from_string(strval) -> bool:
"""Return boolean value from string"""
if strval.lower() in ("true", "t", "1", "yes", "y"):
return True
if strval.lower() in ("false", "f", "0", "no", "n"):
return False
raise argparse.ArgumentTypeError("Boolean value expected.")
def main() -> None:
"""Interactive command-line for PipeClient"""
parser = argparse.ArgumentParser()
parser.add_argument(
"-t",
"--timeout",
type=float,
metavar="",
default=10,
help="timeout for reply in seconds (default: 10",
)
parser.add_argument(
"-s",
"--show-time",
metavar="True/False",
nargs="?",
type=bool_from_string,
const="t",
default="t",
dest="show",
help="show command execution time (default: True)",
)
parser.add_argument(
"-d", "--docs", action="store_true", help="show documentation and exit"
)
args = parser.parse_args()
if args.docs:
print(__doc__)
sys.exit(0)
client: PipeClient = PipeClient()
while True:
reply: str = ""
message: str = input("\nEnter command or 'Q' to quit: ")
start = time.time()
if message.upper() == "Q":
sys.exit(0)
elif message == "":
pass
else:
client.write(message, timer=args.show)
while reply == "":
time.sleep(0.1) # allow time for reply
if time.time() - start > args.timeout:
reply = "PipeClient: Reply timed-out."
else:
reply = client.read()
print(reply)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

290
app/querylistmodel.py Normal file
View File

@ -0,0 +1,290 @@
# Standard library imports
# Allow forward reference to PlaylistModel
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Optional
import datetime as dt
# PyQt imports
from PyQt6.QtCore import (
QAbstractTableModel,
QModelIndex,
Qt,
QVariant,
)
from PyQt6.QtGui import (
QBrush,
QColor,
QFont,
)
# Third party imports
from sqlalchemy.orm.session import Session
# import snoop # type: ignore
# App imports
from classes import (
ApplicationError,
Filter,
QueryCol,
)
from config import Config
from helpers import (
file_is_unreadable,
get_relative_date,
ms_to_mmss,
show_warning,
)
from log import log
from models import db, Playdates, Tracks
from music_manager import RowAndTrack
@dataclass
class QueryRow:
artist: str
bitrate: int
duration: int
lastplayed: Optional[dt.datetime]
path: str
title: str
track_id: int
class QuerylistModel(QAbstractTableModel):
"""
The Querylist Model
Used to support query lists. The underlying database is never
updated. We just present tracks that match a query and allow the user
to copy those to a playlist.
"""
def __init__(self, session: Session, filter: Filter) -> None:
"""
Load query
"""
log.debug(f"QuerylistModel.__init__({filter=})")
super().__init__()
self.session = session
self.filter = filter
self.querylist_rows: dict[int, QueryRow] = {}
self._selected_rows: set[int] = set()
self.load_data()
def __repr__(self) -> str:
return f"<QuerylistModel: filter={self.filter}, {self.rowCount()} rows>"
def _background_role(self, row: int, column: int, qrow: QueryRow) -> QBrush:
"""Return background setting"""
# Unreadable track file
if file_is_unreadable(qrow.path):
return QBrush(QColor(Config.COLOUR_UNREADABLE))
# Selected row
if row in self._selected_rows:
return QBrush(QColor(Config.COLOUR_QUERYLIST_SELECTED))
# Individual cell colouring
if column == QueryCol.BITRATE.value:
if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD:
return QBrush(QColor(Config.COLOUR_BITRATE_LOW))
elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD:
return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM))
else:
return QBrush(QColor(Config.COLOUR_BITRATE_OK))
return QBrush()
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
"""Standard function for view"""
return len(QueryCol)
def data(
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
) -> QVariant:
"""Return data to view"""
if (
not index.isValid()
or not (0 <= index.row() < len(self.querylist_rows))
or role
in [
Qt.ItemDataRole.CheckStateRole,
Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
Qt.ItemDataRole.InitialSortOrderRole,
Qt.ItemDataRole.SizeHintRole,
Qt.ItemDataRole.StatusTipRole,
Qt.ItemDataRole.TextAlignmentRole,
Qt.ItemDataRole.WhatsThisRole,
]
):
return QVariant()
row = index.row()
column = index.column()
# rat for playlist row data as it's used a lot
qrow = self.querylist_rows[row]
# Dispatch to role-specific functions
dispatch_table: dict[int, Callable] = {
int(Qt.ItemDataRole.BackgroundRole): self._background_role,
int(Qt.ItemDataRole.DisplayRole): self._display_role,
int(Qt.ItemDataRole.ToolTipRole): self._tooltip_role,
}
if role in dispatch_table:
return QVariant(dispatch_table[role](row, column, qrow))
# Fall through to no-op
return QVariant()
def _display_role(self, row: int, column: int, qrow: QueryRow) -> str:
"""
Return text for display
"""
dispatch_table = {
QueryCol.ARTIST.value: qrow.artist,
QueryCol.BITRATE.value: str(qrow.bitrate),
QueryCol.DURATION.value: ms_to_mmss(qrow.duration),
QueryCol.LAST_PLAYED.value: get_relative_date(qrow.lastplayed),
QueryCol.TITLE.value: qrow.title,
}
if column in dispatch_table:
return dispatch_table[column]
return ""
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
"""
Standard model flags
"""
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
def get_selected_track_ids(self) -> list[int]:
"""
Return a list of track_ids from selected tracks
"""
return [self.querylist_rows[row].track_id for row in self._selected_rows]
def headerData(
self,
section: int,
orientation: Qt.Orientation,
role: int = Qt.ItemDataRole.DisplayRole,
) -> QVariant:
"""
Return text for headers
"""
display_dispatch_table = {
QueryCol.TITLE.value: QVariant(Config.HEADER_TITLE),
QueryCol.ARTIST.value: QVariant(Config.HEADER_ARTIST),
QueryCol.DURATION.value: QVariant(Config.HEADER_DURATION),
QueryCol.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED),
QueryCol.BITRATE.value: QVariant(Config.HEADER_BITRATE),
}
if role == Qt.ItemDataRole.DisplayRole:
if orientation == Qt.Orientation.Horizontal:
return display_dispatch_table[section]
else:
if Config.ROWS_FROM_ZERO:
return QVariant(str(section))
else:
return QVariant(str(section + 1))
elif role == Qt.ItemDataRole.FontRole:
boldfont = QFont()
boldfont.setBold(True)
return QVariant(boldfont)
return QVariant()
def load_data(self) -> None:
"""
Populate self.querylist_rows
"""
# Clear any exsiting rows
self.querylist_rows = {}
row = 0
try:
results = Tracks.get_filtered_tracks(self.session, self.filter)
for result in results:
lastplayed = None
if hasattr(result, "playdates"):
pds = result.playdates
if pds:
lastplayed = max([a.lastplayed for a in pds])
queryrow = QueryRow(
artist=result.artist,
bitrate=result.bitrate or 0,
duration=result.duration,
lastplayed=lastplayed,
path=result.path,
title=result.title,
track_id=result.id,
)
self.querylist_rows[row] = queryrow
row += 1
except ApplicationError as e:
show_warning(None, "Query error", f"Error loading query data ({e})")
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
"""Standard function for view"""
return len(self.querylist_rows)
def toggle_row_selection(self, row: int) -> None:
if row in self._selected_rows:
self._selected_rows.discard(row)
else:
self._selected_rows.add(row)
# Emit dataChanged for the entire row
top_left = self.index(row, 0)
bottom_right = self.index(row, self.columnCount() - 1)
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str | QVariant:
"""
Return tooltip. Currently only used for last_played column.
"""
if column != QueryCol.LAST_PLAYED.value:
return QVariant()
with db.Session() as session:
track_id = self.querylist_rows[row].track_id
if not track_id:
return QVariant()
playdates = Playdates.last_playdates(session, track_id)
return (
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in reversed(playdates)
]
)
)

94
app/ui/dlgQuery.ui Normal file
View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>queryDialog</class>
<widget class="QDialog" name="queryDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>762</width>
<height>686</height>
</rect>
</property>
<property name="windowTitle">
<string>Query</string>
</property>
<widget class="QTableView" name="tableView">
<property name="geometry">
<rect>
<x>10</x>
<y>65</y>
<width>741</width>
<height>561</height>
</rect>
</property>
</widget>
<widget class="QLabel" name="label">
<property name="geometry">
<rect>
<x>20</x>
<y>10</y>
<width>61</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Query:</string>
</property>
</widget>
<widget class="QComboBox" name="cboQuery">
<property name="geometry">
<rect>
<x>80</x>
<y>10</y>
<width>221</width>
<height>32</height>
</rect>
</property>
</widget>
<widget class="QPushButton" name="btnAddTracks">
<property name="geometry">
<rect>
<x>530</x>
<y>640</y>
<width>102</width>
<height>36</height>
</rect>
</property>
<property name="text">
<string>Add &amp;tracks</string>
</property>
</widget>
<widget class="QLabel" name="lblDescription">
<property name="geometry">
<rect>
<x>330</x>
<y>10</y>
<width>401</width>
<height>46</height>
</rect>
</property>
<property name="text">
<string>TextLabel</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
<widget class="QPushButton" name="pushButton">
<property name="geometry">
<rect>
<x>650</x>
<y>640</y>
<width>102</width>
<height>36</height>
</rect>
</property>
<property name="text">
<string>Close</string>
</property>
</widget>
</widget>
<resources/>
<connections/>
</ui>

45
app/ui/dlgQuery_ui.py Normal file
View File

@ -0,0 +1,45 @@
# Form implementation generated from reading ui file 'app/ui/dlgQuery.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_queryDialog(object):
def setupUi(self, queryDialog):
queryDialog.setObjectName("queryDialog")
queryDialog.resize(762, 686)
self.tableView = QtWidgets.QTableView(parent=queryDialog)
self.tableView.setGeometry(QtCore.QRect(10, 65, 741, 561))
self.tableView.setObjectName("tableView")
self.label = QtWidgets.QLabel(parent=queryDialog)
self.label.setGeometry(QtCore.QRect(20, 10, 61, 24))
self.label.setObjectName("label")
self.cboQuery = QtWidgets.QComboBox(parent=queryDialog)
self.cboQuery.setGeometry(QtCore.QRect(80, 10, 221, 32))
self.cboQuery.setObjectName("cboQuery")
self.btnAddTracks = QtWidgets.QPushButton(parent=queryDialog)
self.btnAddTracks.setGeometry(QtCore.QRect(530, 640, 102, 36))
self.btnAddTracks.setObjectName("btnAddTracks")
self.lblDescription = QtWidgets.QLabel(parent=queryDialog)
self.lblDescription.setGeometry(QtCore.QRect(330, 10, 401, 46))
self.lblDescription.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblDescription.setObjectName("lblDescription")
self.pushButton = QtWidgets.QPushButton(parent=queryDialog)
self.pushButton.setGeometry(QtCore.QRect(650, 640, 102, 36))
self.pushButton.setObjectName("pushButton")
self.retranslateUi(queryDialog)
QtCore.QMetaObject.connectSlotsByName(queryDialog)
def retranslateUi(self, queryDialog):
_translate = QtCore.QCoreApplication.translate
queryDialog.setWindowTitle(_translate("queryDialog", "Query"))
self.label.setText(_translate("queryDialog", "Query:"))
self.btnAddTracks.setText(_translate("queryDialog", "Add &tracks"))
self.lblDescription.setText(_translate("queryDialog", "TextLabel"))
self.pushButton.setText(_translate("queryDialog", "Close"))

BIN
app/ui/green-circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,5 +1,8 @@
<RCC> <RCC>
<qresource prefix="icons"> <qresource prefix="icons">
<file>yellow-circle.png</file>
<file>redstar.png</file>
<file>green-circle.png</file>
<file>star.png</file> <file>star.png</file>
<file>star_empty.png</file> <file>star_empty.png</file>
<file>record-red-button.png</file> <file>record-red-button.png</file>

File diff suppressed because it is too large Load Diff

View File

@ -967,68 +967,70 @@ padding-left: 8px;</string>
</property> </property>
<widget class="QMenu" name="menuFile"> <widget class="QMenu" name="menuFile">
<property name="title"> <property name="title">
<string>&amp;Playlists</string> <string>&amp;Playlist</string>
</property> </property>
<addaction name="actionNewPlaylist"/> <addaction name="separator"/>
<addaction name="actionNew_from_template"/> <addaction name="actionInsertTrack"/>
<addaction name="actionOpenPlaylist"/> <addaction name="actionRemove"/>
<addaction name="actionClosePlaylist"/> <addaction name="actionInsertSectionHeader"/>
<addaction name="actionRenamePlaylist"/> <addaction name="separator"/>
<addaction name="actionDeletePlaylist"/> <addaction name="actionMark_for_moving"/>
<addaction name="actionPaste"/>
<addaction name="separator"/>
<addaction name="actionExport_playlist"/> <addaction name="actionExport_playlist"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSelect_duplicate_rows"/> <addaction name="actionSelect_duplicate_rows"/>
<addaction name="separator"/>
<addaction name="actionMoveSelected"/> <addaction name="actionMoveSelected"/>
<addaction name="actionMoveUnplayed"/> <addaction name="actionMoveUnplayed"/>
<addaction name="actionDownload_CSV_of_played_tracks"/> <addaction name="action_Clear_selection"/>
<addaction name="actionSave_as_template"/>
<addaction name="separator"/>
<addaction name="actionReplace_files"/>
<addaction name="separator"/>
<addaction name="actionE_xit"/>
</widget> </widget>
<widget class="QMenu" name="menuPlaylist"> <widget class="QMenu" name="menuPlaylist">
<property name="title"> <property name="title">
<string>Sho&amp;wtime</string> <string>&amp;File</string>
</property> </property>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="separator"/>
<addaction name="actionOpenPlaylist"/>
<addaction name="actionNewPlaylist"/>
<addaction name="actionClosePlaylist"/>
<addaction name="actionRenamePlaylist"/>
<addaction name="actionDeletePlaylist"/>
<addaction name="separator"/>
<addaction name="actionOpenQuerylist"/>
<addaction name="actionManage_querylists"/>
<addaction name="separator"/>
<addaction name="actionSave_as_template"/>
<addaction name="actionManage_templates"/>
<addaction name="separator"/>
<addaction name="actionImport_files"/>
<addaction name="separator"/>
<addaction name="actionE_xit"/>
</widget>
<widget class="QMenu" name="menuSearc_h">
<property name="title">
<string>&amp;Music</string>
</property>
<addaction name="actionSetNext"/>
<addaction name="actionPlay_next"/> <addaction name="actionPlay_next"/>
<addaction name="actionFade"/> <addaction name="actionFade"/>
<addaction name="actionStop"/> <addaction name="actionStop"/>
<addaction name="actionResume"/> <addaction name="actionResume"/>
<addaction name="separator"/>
<addaction name="actionSkipToNext"/> <addaction name="actionSkipToNext"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionInsertSectionHeader"/>
<addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/>
<addaction name="actionImport"/>
<addaction name="separator"/>
<addaction name="actionSetNext"/>
<addaction name="action_Clear_selection"/>
<addaction name="separator"/>
<addaction name="actionMark_for_moving"/>
<addaction name="actionPaste"/>
</widget>
<widget class="QMenu" name="menuSearc_h">
<property name="title">
<string>&amp;Search</string>
</property>
<addaction name="actionSearch"/> <addaction name="actionSearch"/>
<addaction name="separator"/>
<addaction name="actionSearch_title_in_Wikipedia"/> <addaction name="actionSearch_title_in_Wikipedia"/>
<addaction name="actionSearch_title_in_Songfacts"/> <addaction name="actionSearch_title_in_Songfacts"/>
</widget> </widget>
<widget class="QMenu" name="menuHelp"> <widget class="QMenu" name="menuHelp">
<property name="title"> <property name="title">
<string>&amp;Help</string> <string>Help</string>
</property> </property>
<addaction name="action_About"/> <addaction name="action_About"/>
<addaction name="actionDebug"/> <addaction name="actionDebug"/>
</widget> </widget>
<addaction name="menuFile"/>
<addaction name="menuPlaylist"/> <addaction name="menuPlaylist"/>
<addaction name="menuFile"/>
<addaction name="menuSearc_h"/> <addaction name="menuSearc_h"/>
<addaction name="menuHelp"/> <addaction name="menuHelp"/>
</widget> </widget>
@ -1305,9 +1307,9 @@ padding-left: 8px;</string>
<string>Save as template...</string> <string>Save as template...</string>
</property> </property>
</action> </action>
<action name="actionNew_from_template"> <action name="actionManage_templates">
<property name="text"> <property name="text">
<string>New from template...</string> <string>Manage templates...</string>
</property> </property>
</action> </action>
<action name="actionDebug"> <action name="actionDebug">
@ -1365,9 +1367,19 @@ padding-left: 8px;</string>
<string>Select duplicate rows...</string> <string>Select duplicate rows...</string>
</property> </property>
</action> </action>
<action name="actionReplace_files"> <action name="actionImport_files">
<property name="text"> <property name="text">
<string>Replace files...</string> <string>Import files...</string>
</property>
</action>
<action name="actionOpenQuerylist">
<property name="text">
<string>Open &amp;querylist...</string>
</property>
</action>
<action name="actionManage_querylists">
<property name="text">
<string>Manage querylists...</string>
</property> </property>
</action> </action>
</widget> </widget>

View File

@ -0,0 +1,589 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>FooterSection</class>
<widget class="QWidget" name="FooterSection">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1237</width>
<height>154</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QFrame" name="InfoFooterFrame">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">background-color: rgb(192, 191, 188)</string>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QFrame" name="FadeStopInfoFrame">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>184</width>
<height>16777215</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QPushButton" name="btnPreview">
<property name="minimumSize">
<size>
<width>132</width>
<height>41</height>
</size>
</property>
<property name="text">
<string> Preview</string>
</property>
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/headphones</normaloff>:/icons/headphones</iconset>
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBoxIntroControls">
<property name="minimumSize">
<size>
<width>132</width>
<height>46</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>132</width>
<height>46</height>
</size>
</property>
<property name="title">
<string/>
</property>
<widget class="QPushButton" name="btnPreviewStart">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string>&lt;&lt;</string>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewArm">
<property name="geometry">
<rect>
<x>44</x>
<y>0</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/record-button.png</normaloff>
<normalon>:/icons/record-red-button.png</normalon>:/icons/record-button.png</iconset>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewEnd">
<property name="geometry">
<rect>
<x>88</x>
<y>0</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string>&gt;&gt;</string>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewBack">
<property name="geometry">
<rect>
<x>0</x>
<y>23</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string>&lt;</string>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewMark">
<property name="enabled">
<bool>false</bool>
</property>
<property name="geometry">
<rect>
<x>44</x>
<y>23</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normalon>:/icons/star.png</normalon>
<disabledoff>:/icons/star_empty.png</disabledoff>
</iconset>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewFwd">
<property name="geometry">
<rect>
<x>88</x>
<y>23</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string>&gt;</string>
</property>
</widget>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_intro">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_9">
<item>
<widget class="QLabel" name="label_7">
<property name="text">
<string>Intro</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_intro_timer">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>40</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>0:0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_toggleplayed_3db">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>184</width>
<height>16777215</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6">
<item>
<widget class="QPushButton" name="btnDrop3db">
<property name="minimumSize">
<size>
<width>132</width>
<height>41</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>164</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>-3dB to talk</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnHidePlayed">
<property name="minimumSize">
<size>
<width>132</width>
<height>41</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>164</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Hide played</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_fade">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="label_4">
<property name="text">
<string>Fade</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_fade_timer">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>40</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>00:00</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_silent">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_7">
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>Silent</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_silent_timer">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>40</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>00:00</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="PlotWidget" name="widgetFadeVolume" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QFrame" name="frame">
<property name="minimumSize">
<size>
<width>151</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>151</width>
<height>112</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QPushButton" name="btnFade">
<property name="minimumSize">
<size>
<width>132</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>164</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string> Fade</string>
</property>
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/fade</normaloff>:/icons/fade</iconset>
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnStop">
<property name="minimumSize">
<size>
<width>0</width>
<height>36</height>
</size>
</property>
<property name="text">
<string> Stop</string>
</property>
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/stopsign</normaloff>:/icons/stopsign</iconset>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>PlotWidget</class>
<extends>QWidget</extends>
<header>pyqtgraph</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources>
<include location="icons.qrc"/>
</resources>
<connections/>
</ui>

View File

@ -0,0 +1,274 @@
# Form implementation generated from reading ui file 'app/ui/main_window_footer.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_FooterSection(object):
def setupUi(self, FooterSection):
FooterSection.setObjectName("FooterSection")
FooterSection.resize(1237, 154)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(FooterSection)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.InfoFooterFrame = QtWidgets.QFrame(parent=FooterSection)
self.InfoFooterFrame.setMaximumSize(QtCore.QSize(16777215, 16777215))
self.InfoFooterFrame.setStyleSheet("background-color: rgb(192, 191, 188)")
self.InfoFooterFrame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.InfoFooterFrame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.InfoFooterFrame.setObjectName("InfoFooterFrame")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.InfoFooterFrame)
self.horizontalLayout.setObjectName("horizontalLayout")
self.FadeStopInfoFrame = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.FadeStopInfoFrame.setMinimumSize(QtCore.QSize(152, 112))
self.FadeStopInfoFrame.setMaximumSize(QtCore.QSize(184, 16777215))
self.FadeStopInfoFrame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.FadeStopInfoFrame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.FadeStopInfoFrame.setObjectName("FadeStopInfoFrame")
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.FadeStopInfoFrame)
self.verticalLayout_4.setObjectName("verticalLayout_4")
self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame)
self.btnPreview.setMinimumSize(QtCore.QSize(132, 41))
icon = QtGui.QIcon()
icon.addPixmap(
QtGui.QPixmap(":/icons/headphones"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnPreview.setIcon(icon)
self.btnPreview.setIconSize(QtCore.QSize(30, 30))
self.btnPreview.setCheckable(True)
self.btnPreview.setObjectName("btnPreview")
self.verticalLayout_4.addWidget(self.btnPreview)
self.groupBoxIntroControls = QtWidgets.QGroupBox(parent=self.FadeStopInfoFrame)
self.groupBoxIntroControls.setMinimumSize(QtCore.QSize(132, 46))
self.groupBoxIntroControls.setMaximumSize(QtCore.QSize(132, 46))
self.groupBoxIntroControls.setTitle("")
self.groupBoxIntroControls.setObjectName("groupBoxIntroControls")
self.btnPreviewStart = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewStart.setGeometry(QtCore.QRect(0, 0, 44, 23))
self.btnPreviewStart.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewStart.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewStart.setObjectName("btnPreviewStart")
self.btnPreviewArm = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewArm.setGeometry(QtCore.QRect(44, 0, 44, 23))
self.btnPreviewArm.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setText("")
icon1 = QtGui.QIcon()
icon1.addPixmap(
QtGui.QPixmap(":/icons/record-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
icon1.addPixmap(
QtGui.QPixmap(":/icons/record-red-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
self.btnPreviewArm.setIcon(icon1)
self.btnPreviewArm.setCheckable(True)
self.btnPreviewArm.setObjectName("btnPreviewArm")
self.btnPreviewEnd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewEnd.setGeometry(QtCore.QRect(88, 0, 44, 23))
self.btnPreviewEnd.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewEnd.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewEnd.setObjectName("btnPreviewEnd")
self.btnPreviewBack = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewBack.setGeometry(QtCore.QRect(0, 23, 44, 23))
self.btnPreviewBack.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewBack.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewBack.setObjectName("btnPreviewBack")
self.btnPreviewMark = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewMark.setEnabled(False)
self.btnPreviewMark.setGeometry(QtCore.QRect(44, 23, 44, 23))
self.btnPreviewMark.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setText("")
icon2 = QtGui.QIcon()
icon2.addPixmap(
QtGui.QPixmap(":/icons/star.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
icon2.addPixmap(
QtGui.QPixmap(":/icons/star_empty.png"),
QtGui.QIcon.Mode.Disabled,
QtGui.QIcon.State.Off,
)
self.btnPreviewMark.setIcon(icon2)
self.btnPreviewMark.setObjectName("btnPreviewMark")
self.btnPreviewFwd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewFwd.setGeometry(QtCore.QRect(88, 23, 44, 23))
self.btnPreviewFwd.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewFwd.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewFwd.setObjectName("btnPreviewFwd")
self.verticalLayout_4.addWidget(self.groupBoxIntroControls)
self.horizontalLayout.addWidget(self.FadeStopInfoFrame)
self.frame_intro = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_intro.setMinimumSize(QtCore.QSize(152, 112))
self.frame_intro.setStyleSheet("")
self.frame_intro.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_intro.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_intro.setObjectName("frame_intro")
self.verticalLayout_9 = QtWidgets.QVBoxLayout(self.frame_intro)
self.verticalLayout_9.setObjectName("verticalLayout_9")
self.label_7 = QtWidgets.QLabel(parent=self.frame_intro)
self.label_7.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_7.setObjectName("label_7")
self.verticalLayout_9.addWidget(self.label_7)
self.label_intro_timer = QtWidgets.QLabel(parent=self.frame_intro)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_intro_timer.setFont(font)
self.label_intro_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_intro_timer.setObjectName("label_intro_timer")
self.verticalLayout_9.addWidget(self.label_intro_timer)
self.horizontalLayout.addWidget(self.frame_intro)
self.frame_toggleplayed_3db = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_toggleplayed_3db.setMinimumSize(QtCore.QSize(152, 112))
self.frame_toggleplayed_3db.setMaximumSize(QtCore.QSize(184, 16777215))
self.frame_toggleplayed_3db.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_toggleplayed_3db.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_toggleplayed_3db.setObjectName("frame_toggleplayed_3db")
self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.frame_toggleplayed_3db)
self.verticalLayout_6.setObjectName("verticalLayout_6")
self.btnDrop3db = QtWidgets.QPushButton(parent=self.frame_toggleplayed_3db)
self.btnDrop3db.setMinimumSize(QtCore.QSize(132, 41))
self.btnDrop3db.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnDrop3db.setCheckable(True)
self.btnDrop3db.setObjectName("btnDrop3db")
self.verticalLayout_6.addWidget(self.btnDrop3db)
self.btnHidePlayed = QtWidgets.QPushButton(parent=self.frame_toggleplayed_3db)
self.btnHidePlayed.setMinimumSize(QtCore.QSize(132, 41))
self.btnHidePlayed.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnHidePlayed.setCheckable(True)
self.btnHidePlayed.setObjectName("btnHidePlayed")
self.verticalLayout_6.addWidget(self.btnHidePlayed)
self.horizontalLayout.addWidget(self.frame_toggleplayed_3db)
self.frame_fade = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_fade.setMinimumSize(QtCore.QSize(152, 112))
self.frame_fade.setStyleSheet("")
self.frame_fade.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_fade.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_fade.setObjectName("frame_fade")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.frame_fade)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.label_4 = QtWidgets.QLabel(parent=self.frame_fade)
self.label_4.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_4.setObjectName("label_4")
self.verticalLayout_2.addWidget(self.label_4)
self.label_fade_timer = QtWidgets.QLabel(parent=self.frame_fade)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_fade_timer.setFont(font)
self.label_fade_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_fade_timer.setObjectName("label_fade_timer")
self.verticalLayout_2.addWidget(self.label_fade_timer)
self.horizontalLayout.addWidget(self.frame_fade)
self.frame_silent = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_silent.setMinimumSize(QtCore.QSize(152, 112))
self.frame_silent.setStyleSheet("")
self.frame_silent.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_silent.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_silent.setObjectName("frame_silent")
self.verticalLayout_7 = QtWidgets.QVBoxLayout(self.frame_silent)
self.verticalLayout_7.setObjectName("verticalLayout_7")
self.label_5 = QtWidgets.QLabel(parent=self.frame_silent)
self.label_5.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_5.setObjectName("label_5")
self.verticalLayout_7.addWidget(self.label_5)
self.label_silent_timer = QtWidgets.QLabel(parent=self.frame_silent)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_silent_timer.setFont(font)
self.label_silent_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_silent_timer.setObjectName("label_silent_timer")
self.verticalLayout_7.addWidget(self.label_silent_timer)
self.horizontalLayout.addWidget(self.frame_silent)
self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.widgetFadeVolume.sizePolicy().hasHeightForWidth()
)
self.widgetFadeVolume.setSizePolicy(sizePolicy)
self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0))
self.widgetFadeVolume.setObjectName("widgetFadeVolume")
self.horizontalLayout.addWidget(self.widgetFadeVolume)
self.frame = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame.setMinimumSize(QtCore.QSize(151, 0))
self.frame.setMaximumSize(QtCore.QSize(151, 112))
self.frame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame.setObjectName("frame")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.frame)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.btnFade = QtWidgets.QPushButton(parent=self.frame)
self.btnFade.setMinimumSize(QtCore.QSize(132, 32))
self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215))
icon3 = QtGui.QIcon()
icon3.addPixmap(
QtGui.QPixmap(":/icons/fade"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnFade.setIcon(icon3)
self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade")
self.verticalLayout_5.addWidget(self.btnFade)
self.btnStop = QtWidgets.QPushButton(parent=self.frame)
self.btnStop.setMinimumSize(QtCore.QSize(0, 36))
icon4 = QtGui.QIcon()
icon4.addPixmap(
QtGui.QPixmap(":/icons/stopsign"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnStop.setIcon(icon4)
self.btnStop.setObjectName("btnStop")
self.verticalLayout_5.addWidget(self.btnStop)
self.horizontalLayout.addWidget(self.frame)
self.horizontalLayout_2.addWidget(self.InfoFooterFrame)
self.retranslateUi(FooterSection)
QtCore.QMetaObject.connectSlotsByName(FooterSection)
def retranslateUi(self, FooterSection):
_translate = QtCore.QCoreApplication.translate
FooterSection.setWindowTitle(_translate("FooterSection", "Form"))
self.btnPreview.setText(_translate("FooterSection", " Preview"))
self.btnPreviewStart.setText(_translate("FooterSection", "<<"))
self.btnPreviewEnd.setText(_translate("FooterSection", ">>"))
self.btnPreviewBack.setText(_translate("FooterSection", "<"))
self.btnPreviewFwd.setText(_translate("FooterSection", ">"))
self.label_7.setText(_translate("FooterSection", "Intro"))
self.label_intro_timer.setText(_translate("FooterSection", "0:0"))
self.btnDrop3db.setText(_translate("FooterSection", "-3dB to talk"))
self.btnHidePlayed.setText(_translate("FooterSection", "Hide played"))
self.label_4.setText(_translate("FooterSection", "Fade"))
self.label_fade_timer.setText(_translate("FooterSection", "00:00"))
self.label_5.setText(_translate("FooterSection", "Silent"))
self.label_silent_timer.setText(_translate("FooterSection", "00:00"))
self.btnFade.setText(_translate("FooterSection", " Fade"))
self.btnStop.setText(_translate("FooterSection", " Stop"))
from pyqtgraph import PlotWidget # type: ignore

View File

@ -0,0 +1,314 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>HeaderSection</class>
<widget class="QWidget" name="HeaderSection">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1273</width>
<height>179</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="previous_track_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>230</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #f8d7da;
border: 1px solid rgb(85, 87, 83);</string>
</property>
<property name="text">
<string>Last track:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="current_track_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>230</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #d4edda;
border: 1px solid rgb(85, 87, 83);</string>
</property>
<property name="text">
<string>Current track:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="next_track_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>230</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #fff3cd;
border: 1px solid rgb(85, 87, 83);</string>
</property>
<property name="text">
<string>Next track:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="hdrPreviousTrack">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #f8d7da;
border: 1px solid rgb(85, 87, 83);</string>
</property>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="hdrCurrentTrack">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #d4edda;
border: 1px solid rgb(85, 87, 83);
text-align: left;
padding-left: 8px;
</string>
</property>
<property name="text">
<string/>
</property>
<property name="flat">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="hdrNextTrack">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #fff3cd;
border: 1px solid rgb(85, 87, 83);
text-align: left;
padding-left: 8px;</string>
</property>
<property name="text">
<string/>
</property>
<property name="flat">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QFrame" name="frame_2">
<property name="minimumSize">
<size>
<width>0</width>
<height>131</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>230</width>
<height>131</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_10">
<item>
<widget class="QLabel" name="lblTOD">
<property name="minimumSize">
<size>
<width>208</width>
<height>0</height>
</size>
</property>
<property name="font">
<font>
<pointsize>35</pointsize>
</font>
</property>
<property name="text">
<string>00:00:00</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_elapsed_timer">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>18</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="styleSheet">
<string notr="true">color: black;</string>
</property>
<property name="text">
<string>00:00 / 00:00</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QFrame" name="frame_4">
<property name="minimumSize">
<size>
<width>0</width>
<height>16</height>
</size>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="styleSheet">
<string notr="true">background-color: rgb(154, 153, 150)</string>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,178 @@
# Form implementation generated from reading ui file 'app/ui/main_window_header.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_HeaderSection(object):
def setupUi(self, HeaderSection):
HeaderSection.setObjectName("HeaderSection")
HeaderSection.resize(1273, 179)
self.horizontalLayout = QtWidgets.QHBoxLayout(HeaderSection)
self.horizontalLayout.setObjectName("horizontalLayout")
self.gridLayout = QtWidgets.QGridLayout()
self.gridLayout.setObjectName("gridLayout")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.verticalLayout_3 = QtWidgets.QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.previous_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.previous_track_2.sizePolicy().hasHeightForWidth())
self.previous_track_2.setSizePolicy(sizePolicy)
self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.previous_track_2.setFont(font)
self.previous_track_2.setStyleSheet("background-color: #f8d7da;\n"
"border: 1px solid rgb(85, 87, 83);")
self.previous_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.current_track_2.sizePolicy().hasHeightForWidth())
self.current_track_2.setSizePolicy(sizePolicy)
self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.current_track_2.setFont(font)
self.current_track_2.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);")
self.current_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth())
self.next_track_2.setSizePolicy(sizePolicy)
self.next_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.next_track_2.setFont(font)
self.next_track_2.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);")
self.next_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.next_track_2.setObjectName("next_track_2")
self.verticalLayout_3.addWidget(self.next_track_2)
self.horizontalLayout_3.addLayout(self.verticalLayout_3)
self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.hdrPreviousTrack = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrPreviousTrack.sizePolicy().hasHeightForWidth())
self.hdrPreviousTrack.setSizePolicy(sizePolicy)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0))
self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrPreviousTrack.setFont(font)
self.hdrPreviousTrack.setStyleSheet("background-color: #f8d7da;\n"
"border: 1px solid rgb(85, 87, 83);")
self.hdrPreviousTrack.setText("")
self.hdrPreviousTrack.setWordWrap(False)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrCurrentTrack.sizePolicy().hasHeightForWidth())
self.hdrCurrentTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;\n"
"")
self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth())
self.hdrNextTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;")
self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack")
self.verticalLayout.addWidget(self.hdrNextTrack)
self.horizontalLayout_3.addLayout(self.verticalLayout)
self.frame_2 = QtWidgets.QFrame(parent=HeaderSection)
self.frame_2.setMinimumSize(QtCore.QSize(0, 131))
self.frame_2.setMaximumSize(QtCore.QSize(230, 131))
self.frame_2.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_2.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_2.setObjectName("frame_2")
self.verticalLayout_10 = QtWidgets.QVBoxLayout(self.frame_2)
self.verticalLayout_10.setObjectName("verticalLayout_10")
self.lblTOD = QtWidgets.QLabel(parent=self.frame_2)
self.lblTOD.setMinimumSize(QtCore.QSize(208, 0))
font = QtGui.QFont()
font.setPointSize(35)
self.lblTOD.setFont(font)
self.lblTOD.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.lblTOD.setObjectName("lblTOD")
self.verticalLayout_10.addWidget(self.lblTOD)
self.label_elapsed_timer = QtWidgets.QLabel(parent=self.frame_2)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(18)
font.setBold(False)
font.setWeight(50)
self.label_elapsed_timer.setFont(font)
self.label_elapsed_timer.setStyleSheet("color: black;")
self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_elapsed_timer.setObjectName("label_elapsed_timer")
self.verticalLayout_10.addWidget(self.label_elapsed_timer)
self.horizontalLayout_3.addWidget(self.frame_2)
self.gridLayout.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
self.frame_4 = QtWidgets.QFrame(parent=HeaderSection)
self.frame_4.setMinimumSize(QtCore.QSize(0, 16))
self.frame_4.setAutoFillBackground(False)
self.frame_4.setStyleSheet("background-color: rgb(154, 153, 150)")
self.frame_4.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_4.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_4.setObjectName("frame_4")
self.gridLayout.addWidget(self.frame_4, 1, 0, 1, 1)
self.horizontalLayout.addLayout(self.gridLayout)
self.retranslateUi(HeaderSection)
QtCore.QMetaObject.connectSlotsByName(HeaderSection)
def retranslateUi(self, HeaderSection):
_translate = QtCore.QCoreApplication.translate
HeaderSection.setWindowTitle(_translate("HeaderSection", "Form"))
self.previous_track_2.setText(_translate("HeaderSection", "Last track:"))
self.current_track_2.setText(_translate("HeaderSection", "Current track:"))
self.next_track_2.setText(_translate("HeaderSection", "Next track:"))
self.lblTOD.setText(_translate("HeaderSection", "00:00:00"))
self.label_elapsed_timer.setText(_translate("HeaderSection", "00:00 / 00:00"))

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PlaylistSection</class>
<widget class="QWidget" name="PlaylistSection">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1249</width>
<height>538</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QTabWidget" name="tabPlaylist">
<property name="currentIndex">
<number>-1</number>
</property>
<property name="documentMode">
<bool>false</bool>
</property>
<property name="tabsClosable">
<bool>true</bool>
</property>
<property name="movable">
<bool>true</bool>
</property>
</widget>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,34 @@
# Form implementation generated from reading ui file 'app/ui/main_window_playlist.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_PlaylistSection(object):
def setupUi(self, PlaylistSection):
PlaylistSection.setObjectName("PlaylistSection")
PlaylistSection.resize(1249, 499)
self.horizontalLayout = QtWidgets.QHBoxLayout(PlaylistSection)
self.horizontalLayout.setObjectName("horizontalLayout")
self.splitter = QtWidgets.QSplitter(parent=PlaylistSection)
self.splitter.setOrientation(QtCore.Qt.Orientation.Vertical)
self.splitter.setObjectName("splitter")
self.tabPlaylist = QtWidgets.QTabWidget(parent=self.splitter)
self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist")
self.horizontalLayout.addWidget(self.splitter)
self.retranslateUi(PlaylistSection)
self.tabPlaylist.setCurrentIndex(-1)
QtCore.QMetaObject.connectSlotsByName(PlaylistSection)
def retranslateUi(self, PlaylistSection):
_translate = QtCore.QCoreApplication.translate
PlaylistSection.setWindowTitle(_translate("PlaylistSection", "Form"))

View File

@ -1,844 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
#
# Created by: PyQt6 UI code generator 6.7.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.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(1280, 857)
MainWindow.setMinimumSize(QtCore.QSize(1280, 0))
icon = QtGui.QIcon()
icon.addPixmap(
QtGui.QPixmap(":/icons/musicmuster"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
MainWindow.setWindowIcon(icon)
MainWindow.setStyleSheet("")
self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.gridLayout_4 = QtWidgets.QGridLayout(self.centralwidget)
self.gridLayout_4.setObjectName("gridLayout_4")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.verticalLayout_3 = QtWidgets.QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.previous_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.previous_track_2.sizePolicy().hasHeightForWidth()
)
self.previous_track_2.setSizePolicy(sizePolicy)
self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.previous_track_2.setFont(font)
self.previous_track_2.setStyleSheet(
"background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.previous_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.current_track_2.sizePolicy().hasHeightForWidth()
)
self.current_track_2.setSizePolicy(sizePolicy)
self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.current_track_2.setFont(font)
self.current_track_2.setStyleSheet(
"background-color: #d4edda;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.current_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth())
self.next_track_2.setSizePolicy(sizePolicy)
self.next_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.next_track_2.setFont(font)
self.next_track_2.setStyleSheet(
"background-color: #fff3cd;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.next_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.next_track_2.setObjectName("next_track_2")
self.verticalLayout_3.addWidget(self.next_track_2)
self.horizontalLayout_3.addLayout(self.verticalLayout_3)
self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.hdrPreviousTrack = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.hdrPreviousTrack.sizePolicy().hasHeightForWidth()
)
self.hdrPreviousTrack.setSizePolicy(sizePolicy)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0))
self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrPreviousTrack.setFont(font)
self.hdrPreviousTrack.setStyleSheet(
"background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.hdrPreviousTrack.setText("")
self.hdrPreviousTrack.setWordWrap(False)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.hdrCurrentTrack.sizePolicy().hasHeightForWidth()
)
self.hdrCurrentTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet(
"background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;\n"
""
)
self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth())
self.hdrNextTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet(
"background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;"
)
self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack")
self.verticalLayout.addWidget(self.hdrNextTrack)
self.horizontalLayout_3.addLayout(self.verticalLayout)
self.frame_2 = QtWidgets.QFrame(parent=self.centralwidget)
self.frame_2.setMinimumSize(QtCore.QSize(0, 131))
self.frame_2.setMaximumSize(QtCore.QSize(230, 131))
self.frame_2.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_2.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_2.setObjectName("frame_2")
self.verticalLayout_10 = QtWidgets.QVBoxLayout(self.frame_2)
self.verticalLayout_10.setObjectName("verticalLayout_10")
self.lblTOD = QtWidgets.QLabel(parent=self.frame_2)
self.lblTOD.setMinimumSize(QtCore.QSize(208, 0))
font = QtGui.QFont()
font.setPointSize(35)
self.lblTOD.setFont(font)
self.lblTOD.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.lblTOD.setObjectName("lblTOD")
self.verticalLayout_10.addWidget(self.lblTOD)
self.label_elapsed_timer = QtWidgets.QLabel(parent=self.frame_2)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(18)
font.setBold(False)
font.setWeight(50)
self.label_elapsed_timer.setFont(font)
self.label_elapsed_timer.setStyleSheet("color: black;")
self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_elapsed_timer.setObjectName("label_elapsed_timer")
self.verticalLayout_10.addWidget(self.label_elapsed_timer)
self.horizontalLayout_3.addWidget(self.frame_2)
self.gridLayout_4.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
self.frame_4 = QtWidgets.QFrame(parent=self.centralwidget)
self.frame_4.setMinimumSize(QtCore.QSize(0, 16))
self.frame_4.setAutoFillBackground(False)
self.frame_4.setStyleSheet("background-color: rgb(154, 153, 150)")
self.frame_4.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_4.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_4.setObjectName("frame_4")
self.gridLayout_4.addWidget(self.frame_4, 1, 0, 1, 1)
self.cartsWidget = QtWidgets.QWidget(parent=self.centralwidget)
self.cartsWidget.setObjectName("cartsWidget")
self.horizontalLayout_Carts = QtWidgets.QHBoxLayout(self.cartsWidget)
self.horizontalLayout_Carts.setObjectName("horizontalLayout_Carts")
spacerItem = QtWidgets.QSpacerItem(
40,
20,
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Minimum,
)
self.horizontalLayout_Carts.addItem(spacerItem)
self.gridLayout_4.addWidget(self.cartsWidget, 2, 0, 1, 1)
self.frame_6 = QtWidgets.QFrame(parent=self.centralwidget)
self.frame_6.setMinimumSize(QtCore.QSize(0, 16))
self.frame_6.setAutoFillBackground(False)
self.frame_6.setStyleSheet("background-color: rgb(154, 153, 150)")
self.frame_6.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_6.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_6.setObjectName("frame_6")
self.gridLayout_4.addWidget(self.frame_6, 3, 0, 1, 1)
self.splitter = QtWidgets.QSplitter(parent=self.centralwidget)
self.splitter.setOrientation(QtCore.Qt.Orientation.Vertical)
self.splitter.setObjectName("splitter")
self.tabPlaylist = QtWidgets.QTabWidget(parent=self.splitter)
self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist")
self.tabInfolist = InfoTabs(parent=self.splitter)
self.tabInfolist.setDocumentMode(False)
self.tabInfolist.setTabsClosable(True)
self.tabInfolist.setMovable(True)
self.tabInfolist.setTabBarAutoHide(False)
self.tabInfolist.setObjectName("tabInfolist")
self.gridLayout_4.addWidget(self.splitter, 4, 0, 1, 1)
self.InfoFooterFrame = QtWidgets.QFrame(parent=self.centralwidget)
self.InfoFooterFrame.setMaximumSize(QtCore.QSize(16777215, 16777215))
self.InfoFooterFrame.setStyleSheet("background-color: rgb(192, 191, 188)")
self.InfoFooterFrame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.InfoFooterFrame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.InfoFooterFrame.setObjectName("InfoFooterFrame")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.InfoFooterFrame)
self.horizontalLayout.setObjectName("horizontalLayout")
self.FadeStopInfoFrame = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.FadeStopInfoFrame.setMinimumSize(QtCore.QSize(152, 112))
self.FadeStopInfoFrame.setMaximumSize(QtCore.QSize(184, 16777215))
self.FadeStopInfoFrame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.FadeStopInfoFrame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.FadeStopInfoFrame.setObjectName("FadeStopInfoFrame")
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.FadeStopInfoFrame)
self.verticalLayout_4.setObjectName("verticalLayout_4")
self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame)
self.btnPreview.setMinimumSize(QtCore.QSize(132, 41))
icon1 = QtGui.QIcon()
icon1.addPixmap(
QtGui.QPixmap(":/icons/headphones"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnPreview.setIcon(icon1)
self.btnPreview.setIconSize(QtCore.QSize(30, 30))
self.btnPreview.setCheckable(True)
self.btnPreview.setObjectName("btnPreview")
self.verticalLayout_4.addWidget(self.btnPreview)
self.groupBoxIntroControls = QtWidgets.QGroupBox(parent=self.FadeStopInfoFrame)
self.groupBoxIntroControls.setMinimumSize(QtCore.QSize(132, 46))
self.groupBoxIntroControls.setMaximumSize(QtCore.QSize(132, 46))
self.groupBoxIntroControls.setTitle("")
self.groupBoxIntroControls.setObjectName("groupBoxIntroControls")
self.btnPreviewStart = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewStart.setGeometry(QtCore.QRect(0, 0, 44, 23))
self.btnPreviewStart.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewStart.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewStart.setObjectName("btnPreviewStart")
self.btnPreviewArm = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewArm.setGeometry(QtCore.QRect(44, 0, 44, 23))
self.btnPreviewArm.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setText("")
icon2 = QtGui.QIcon()
icon2.addPixmap(
QtGui.QPixmap(":/icons/record-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
icon2.addPixmap(
QtGui.QPixmap(":/icons/record-red-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
self.btnPreviewArm.setIcon(icon2)
self.btnPreviewArm.setCheckable(True)
self.btnPreviewArm.setObjectName("btnPreviewArm")
self.btnPreviewEnd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewEnd.setGeometry(QtCore.QRect(88, 0, 44, 23))
self.btnPreviewEnd.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewEnd.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewEnd.setObjectName("btnPreviewEnd")
self.btnPreviewBack = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewBack.setGeometry(QtCore.QRect(0, 23, 44, 23))
self.btnPreviewBack.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewBack.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewBack.setObjectName("btnPreviewBack")
self.btnPreviewMark = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewMark.setEnabled(False)
self.btnPreviewMark.setGeometry(QtCore.QRect(44, 23, 44, 23))
self.btnPreviewMark.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setText("")
icon3 = QtGui.QIcon()
icon3.addPixmap(
QtGui.QPixmap(":/icons/star.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
icon3.addPixmap(
QtGui.QPixmap(":/icons/star_empty.png"),
QtGui.QIcon.Mode.Disabled,
QtGui.QIcon.State.Off,
)
self.btnPreviewMark.setIcon(icon3)
self.btnPreviewMark.setObjectName("btnPreviewMark")
self.btnPreviewFwd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewFwd.setGeometry(QtCore.QRect(88, 23, 44, 23))
self.btnPreviewFwd.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewFwd.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewFwd.setObjectName("btnPreviewFwd")
self.verticalLayout_4.addWidget(self.groupBoxIntroControls)
self.horizontalLayout.addWidget(self.FadeStopInfoFrame)
self.frame_intro = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_intro.setMinimumSize(QtCore.QSize(152, 112))
self.frame_intro.setStyleSheet("")
self.frame_intro.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_intro.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_intro.setObjectName("frame_intro")
self.verticalLayout_9 = QtWidgets.QVBoxLayout(self.frame_intro)
self.verticalLayout_9.setObjectName("verticalLayout_9")
self.label_7 = QtWidgets.QLabel(parent=self.frame_intro)
self.label_7.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_7.setObjectName("label_7")
self.verticalLayout_9.addWidget(self.label_7)
self.label_intro_timer = QtWidgets.QLabel(parent=self.frame_intro)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_intro_timer.setFont(font)
self.label_intro_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_intro_timer.setObjectName("label_intro_timer")
self.verticalLayout_9.addWidget(self.label_intro_timer)
self.horizontalLayout.addWidget(self.frame_intro)
self.frame_toggleplayed_3db = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_toggleplayed_3db.setMinimumSize(QtCore.QSize(152, 112))
self.frame_toggleplayed_3db.setMaximumSize(QtCore.QSize(184, 16777215))
self.frame_toggleplayed_3db.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_toggleplayed_3db.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_toggleplayed_3db.setObjectName("frame_toggleplayed_3db")
self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.frame_toggleplayed_3db)
self.verticalLayout_6.setObjectName("verticalLayout_6")
self.btnDrop3db = QtWidgets.QPushButton(parent=self.frame_toggleplayed_3db)
self.btnDrop3db.setMinimumSize(QtCore.QSize(132, 41))
self.btnDrop3db.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnDrop3db.setCheckable(True)
self.btnDrop3db.setObjectName("btnDrop3db")
self.verticalLayout_6.addWidget(self.btnDrop3db)
self.btnHidePlayed = QtWidgets.QPushButton(parent=self.frame_toggleplayed_3db)
self.btnHidePlayed.setMinimumSize(QtCore.QSize(132, 41))
self.btnHidePlayed.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnHidePlayed.setCheckable(True)
self.btnHidePlayed.setObjectName("btnHidePlayed")
self.verticalLayout_6.addWidget(self.btnHidePlayed)
self.horizontalLayout.addWidget(self.frame_toggleplayed_3db)
self.frame_fade = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_fade.setMinimumSize(QtCore.QSize(152, 112))
self.frame_fade.setStyleSheet("")
self.frame_fade.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_fade.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_fade.setObjectName("frame_fade")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.frame_fade)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.label_4 = QtWidgets.QLabel(parent=self.frame_fade)
self.label_4.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_4.setObjectName("label_4")
self.verticalLayout_2.addWidget(self.label_4)
self.label_fade_timer = QtWidgets.QLabel(parent=self.frame_fade)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_fade_timer.setFont(font)
self.label_fade_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_fade_timer.setObjectName("label_fade_timer")
self.verticalLayout_2.addWidget(self.label_fade_timer)
self.horizontalLayout.addWidget(self.frame_fade)
self.frame_silent = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_silent.setMinimumSize(QtCore.QSize(152, 112))
self.frame_silent.setStyleSheet("")
self.frame_silent.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_silent.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_silent.setObjectName("frame_silent")
self.verticalLayout_7 = QtWidgets.QVBoxLayout(self.frame_silent)
self.verticalLayout_7.setObjectName("verticalLayout_7")
self.label_5 = QtWidgets.QLabel(parent=self.frame_silent)
self.label_5.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_5.setObjectName("label_5")
self.verticalLayout_7.addWidget(self.label_5)
self.label_silent_timer = QtWidgets.QLabel(parent=self.frame_silent)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_silent_timer.setFont(font)
self.label_silent_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_silent_timer.setObjectName("label_silent_timer")
self.verticalLayout_7.addWidget(self.label_silent_timer)
self.horizontalLayout.addWidget(self.frame_silent)
self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.widgetFadeVolume.sizePolicy().hasHeightForWidth()
)
self.widgetFadeVolume.setSizePolicy(sizePolicy)
self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0))
self.widgetFadeVolume.setObjectName("widgetFadeVolume")
self.horizontalLayout.addWidget(self.widgetFadeVolume)
self.frame = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame.setMinimumSize(QtCore.QSize(151, 0))
self.frame.setMaximumSize(QtCore.QSize(151, 112))
self.frame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame.setObjectName("frame")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.frame)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.btnFade = QtWidgets.QPushButton(parent=self.frame)
self.btnFade.setMinimumSize(QtCore.QSize(132, 32))
self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215))
icon4 = QtGui.QIcon()
icon4.addPixmap(
QtGui.QPixmap(":/icons/fade"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnFade.setIcon(icon4)
self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade")
self.verticalLayout_5.addWidget(self.btnFade)
self.btnStop = QtWidgets.QPushButton(parent=self.frame)
self.btnStop.setMinimumSize(QtCore.QSize(0, 36))
icon5 = QtGui.QIcon()
icon5.addPixmap(
QtGui.QPixmap(":/icons/stopsign"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnStop.setIcon(icon5)
self.btnStop.setObjectName("btnStop")
self.verticalLayout_5.addWidget(self.btnStop)
self.horizontalLayout.addWidget(self.frame)
self.gridLayout_4.addWidget(self.InfoFooterFrame, 5, 0, 1, 1)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 29))
self.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(parent=self.menubar)
self.menuFile.setObjectName("menuFile")
self.menuPlaylist = QtWidgets.QMenu(parent=self.menubar)
self.menuPlaylist.setObjectName("menuPlaylist")
self.menuSearc_h = QtWidgets.QMenu(parent=self.menubar)
self.menuSearc_h.setObjectName("menuSearc_h")
self.menuHelp = QtWidgets.QMenu(parent=self.menubar)
self.menuHelp.setObjectName("menuHelp")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
self.statusbar.setEnabled(True)
self.statusbar.setStyleSheet("background-color: rgb(211, 215, 207);")
self.statusbar.setObjectName("statusbar")
MainWindow.setStatusBar(self.statusbar)
self.actionPlay_next = QtGui.QAction(parent=MainWindow)
icon6 = QtGui.QIcon()
icon6.addPixmap(
QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon-play.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionPlay_next.setIcon(icon6)
self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkipToNext = QtGui.QAction(parent=MainWindow)
icon7 = QtGui.QIcon()
icon7.addPixmap(
QtGui.QPixmap(":/icons/next"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionSkipToNext.setIcon(icon7)
self.actionSkipToNext.setObjectName("actionSkipToNext")
self.actionInsertTrack = QtGui.QAction(parent=MainWindow)
icon8 = QtGui.QIcon()
icon8.addPixmap(
QtGui.QPixmap(
"app/ui/../../../../../../.designer/backup/icon_search_database.png"
),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionInsertTrack.setIcon(icon8)
self.actionInsertTrack.setObjectName("actionInsertTrack")
self.actionAdd_file = QtGui.QAction(parent=MainWindow)
icon9 = QtGui.QIcon()
icon9.addPixmap(
QtGui.QPixmap(
"app/ui/../../../../../../.designer/backup/icon_open_file.png"
),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionAdd_file.setIcon(icon9)
self.actionAdd_file.setObjectName("actionAdd_file")
self.actionFade = QtGui.QAction(parent=MainWindow)
icon10 = QtGui.QIcon()
icon10.addPixmap(
QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon-fade.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionFade.setIcon(icon10)
self.actionFade.setObjectName("actionFade")
self.actionStop = QtGui.QAction(parent=MainWindow)
icon11 = QtGui.QIcon()
icon11.addPixmap(
QtGui.QPixmap(":/icons/stop"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionStop.setIcon(icon11)
self.actionStop.setObjectName("actionStop")
self.action_Clear_selection = QtGui.QAction(parent=MainWindow)
self.action_Clear_selection.setObjectName("action_Clear_selection")
self.action_Resume_previous = QtGui.QAction(parent=MainWindow)
icon12 = QtGui.QIcon()
icon12.addPixmap(
QtGui.QPixmap(":/icons/previous"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.action_Resume_previous.setIcon(icon12)
self.action_Resume_previous.setObjectName("action_Resume_previous")
self.actionE_xit = QtGui.QAction(parent=MainWindow)
self.actionE_xit.setObjectName("actionE_xit")
self.actionTest = QtGui.QAction(parent=MainWindow)
self.actionTest.setObjectName("actionTest")
self.actionOpenPlaylist = QtGui.QAction(parent=MainWindow)
self.actionOpenPlaylist.setObjectName("actionOpenPlaylist")
self.actionNewPlaylist = QtGui.QAction(parent=MainWindow)
self.actionNewPlaylist.setObjectName("actionNewPlaylist")
self.actionTestFunction = QtGui.QAction(parent=MainWindow)
self.actionTestFunction.setObjectName("actionTestFunction")
self.actionSkipToFade = QtGui.QAction(parent=MainWindow)
self.actionSkipToFade.setObjectName("actionSkipToFade")
self.actionSkipToEnd = QtGui.QAction(parent=MainWindow)
self.actionSkipToEnd.setObjectName("actionSkipToEnd")
self.actionClosePlaylist = QtGui.QAction(parent=MainWindow)
self.actionClosePlaylist.setEnabled(True)
self.actionClosePlaylist.setObjectName("actionClosePlaylist")
self.actionRenamePlaylist = QtGui.QAction(parent=MainWindow)
self.actionRenamePlaylist.setEnabled(True)
self.actionRenamePlaylist.setObjectName("actionRenamePlaylist")
self.actionDeletePlaylist = QtGui.QAction(parent=MainWindow)
self.actionDeletePlaylist.setEnabled(True)
self.actionDeletePlaylist.setObjectName("actionDeletePlaylist")
self.actionMoveSelected = QtGui.QAction(parent=MainWindow)
self.actionMoveSelected.setObjectName("actionMoveSelected")
self.actionExport_playlist = QtGui.QAction(parent=MainWindow)
self.actionExport_playlist.setObjectName("actionExport_playlist")
self.actionSetNext = QtGui.QAction(parent=MainWindow)
self.actionSetNext.setObjectName("actionSetNext")
self.actionSelect_next_track = QtGui.QAction(parent=MainWindow)
self.actionSelect_next_track.setObjectName("actionSelect_next_track")
self.actionSelect_previous_track = QtGui.QAction(parent=MainWindow)
self.actionSelect_previous_track.setObjectName("actionSelect_previous_track")
self.actionSelect_played_tracks = QtGui.QAction(parent=MainWindow)
self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks")
self.actionMoveUnplayed = QtGui.QAction(parent=MainWindow)
self.actionMoveUnplayed.setObjectName("actionMoveUnplayed")
self.actionAdd_note = QtGui.QAction(parent=MainWindow)
self.actionAdd_note.setObjectName("actionAdd_note")
self.actionEnable_controls = QtGui.QAction(parent=MainWindow)
self.actionEnable_controls.setObjectName("actionEnable_controls")
self.actionImport = QtGui.QAction(parent=MainWindow)
self.actionImport.setObjectName("actionImport")
self.actionDownload_CSV_of_played_tracks = QtGui.QAction(parent=MainWindow)
self.actionDownload_CSV_of_played_tracks.setObjectName(
"actionDownload_CSV_of_played_tracks"
)
self.actionSearch = QtGui.QAction(parent=MainWindow)
self.actionSearch.setObjectName("actionSearch")
self.actionInsertSectionHeader = QtGui.QAction(parent=MainWindow)
self.actionInsertSectionHeader.setObjectName("actionInsertSectionHeader")
self.actionRemove = QtGui.QAction(parent=MainWindow)
self.actionRemove.setObjectName("actionRemove")
self.actionFind_next = QtGui.QAction(parent=MainWindow)
self.actionFind_next.setObjectName("actionFind_next")
self.actionFind_previous = QtGui.QAction(parent=MainWindow)
self.actionFind_previous.setObjectName("actionFind_previous")
self.action_About = QtGui.QAction(parent=MainWindow)
self.action_About.setObjectName("action_About")
self.actionSave_as_template = QtGui.QAction(parent=MainWindow)
self.actionSave_as_template.setObjectName("actionSave_as_template")
self.actionNew_from_template = QtGui.QAction(parent=MainWindow)
self.actionNew_from_template.setObjectName("actionNew_from_template")
self.actionDebug = QtGui.QAction(parent=MainWindow)
self.actionDebug.setObjectName("actionDebug")
self.actionAdd_cart = QtGui.QAction(parent=MainWindow)
self.actionAdd_cart.setObjectName("actionAdd_cart")
self.actionMark_for_moving = QtGui.QAction(parent=MainWindow)
self.actionMark_for_moving.setObjectName("actionMark_for_moving")
self.actionPaste = QtGui.QAction(parent=MainWindow)
self.actionPaste.setObjectName("actionPaste")
self.actionResume = QtGui.QAction(parent=MainWindow)
self.actionResume.setObjectName("actionResume")
self.actionSearch_title_in_Wikipedia = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Wikipedia.setObjectName(
"actionSearch_title_in_Wikipedia"
)
self.actionSearch_title_in_Songfacts = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Songfacts.setObjectName(
"actionSearch_title_in_Songfacts"
)
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.actionReplace_files = QtGui.QAction(parent=MainWindow)
self.actionReplace_files.setObjectName("actionReplace_files")
self.menuFile.addAction(self.actionNewPlaylist)
self.menuFile.addAction(self.actionNew_from_template)
self.menuFile.addAction(self.actionOpenPlaylist)
self.menuFile.addAction(self.actionClosePlaylist)
self.menuFile.addAction(self.actionRenamePlaylist)
self.menuFile.addAction(self.actionDeletePlaylist)
self.menuFile.addAction(self.actionExport_playlist)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionSelect_duplicate_rows)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionMoveSelected)
self.menuFile.addAction(self.actionMoveUnplayed)
self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks)
self.menuFile.addAction(self.actionSave_as_template)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionReplace_files)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionE_xit)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionPlay_next)
self.menuPlaylist.addAction(self.actionFade)
self.menuPlaylist.addAction(self.actionStop)
self.menuPlaylist.addAction(self.actionResume)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSkipToNext)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionInsertSectionHeader)
self.menuPlaylist.addAction(self.actionInsertTrack)
self.menuPlaylist.addAction(self.actionRemove)
self.menuPlaylist.addAction(self.actionImport)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSetNext)
self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionMark_for_moving)
self.menuPlaylist.addAction(self.actionPaste)
self.menuSearc_h.addAction(self.actionSearch)
self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSearch_title_in_Wikipedia)
self.menuSearc_h.addAction(self.actionSearch_title_in_Songfacts)
self.menuHelp.addAction(self.action_About)
self.menuHelp.addAction(self.actionDebug)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuPlaylist.menuAction())
self.menubar.addAction(self.menuSearc_h.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1)
self.tabInfolist.setCurrentIndex(-1)
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "Music Muster"))
self.previous_track_2.setText(_translate("MainWindow", "Last track:"))
self.current_track_2.setText(_translate("MainWindow", "Current track:"))
self.next_track_2.setText(_translate("MainWindow", "Next track:"))
self.lblTOD.setText(_translate("MainWindow", "00:00:00"))
self.label_elapsed_timer.setText(_translate("MainWindow", "00:00 / 00:00"))
self.btnPreview.setText(_translate("MainWindow", " Preview"))
self.btnPreviewStart.setText(_translate("MainWindow", "<<"))
self.btnPreviewEnd.setText(_translate("MainWindow", ">>"))
self.btnPreviewBack.setText(_translate("MainWindow", "<"))
self.btnPreviewFwd.setText(_translate("MainWindow", ">"))
self.label_7.setText(_translate("MainWindow", "Intro"))
self.label_intro_timer.setText(_translate("MainWindow", "0:0"))
self.btnDrop3db.setText(_translate("MainWindow", "-3dB to talk"))
self.btnHidePlayed.setText(_translate("MainWindow", "Hide played"))
self.label_4.setText(_translate("MainWindow", "Fade"))
self.label_fade_timer.setText(_translate("MainWindow", "00:00"))
self.label_5.setText(_translate("MainWindow", "Silent"))
self.label_silent_timer.setText(_translate("MainWindow", "00:00"))
self.btnFade.setText(_translate("MainWindow", " Fade"))
self.btnStop.setText(_translate("MainWindow", " Stop"))
self.menuFile.setTitle(_translate("MainWindow", "&Playlists"))
self.menuPlaylist.setTitle(_translate("MainWindow", "Sho&wtime"))
self.menuSearc_h.setTitle(_translate("MainWindow", "&Search"))
self.menuHelp.setTitle(_translate("MainWindow", "&Help"))
self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next"))
self.actionSkipToNext.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return"))
self.actionInsertTrack.setText(_translate("MainWindow", "Insert &track..."))
self.actionInsertTrack.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionAdd_file.setText(_translate("MainWindow", "Add &file"))
self.actionAdd_file.setShortcut(_translate("MainWindow", "Ctrl+F"))
self.actionFade.setText(_translate("MainWindow", "F&ade"))
self.actionFade.setShortcut(_translate("MainWindow", "Ctrl+Z"))
self.actionStop.setText(_translate("MainWindow", "S&top"))
self.actionStop.setShortcut(_translate("MainWindow", "Ctrl+Alt+S"))
self.action_Clear_selection.setText(
_translate("MainWindow", "Clear &selection")
)
self.action_Clear_selection.setShortcut(_translate("MainWindow", "Esc"))
self.action_Resume_previous.setText(
_translate("MainWindow", "&Resume previous")
)
self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
self.actionTest.setText(_translate("MainWindow", "&Test"))
self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen..."))
self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
self.actionSkipToFade.setText(
_translate("MainWindow", "&Skip to start of fade")
)
self.actionSkipToEnd.setText(_translate("MainWindow", "Skip to &end of track"))
self.actionClosePlaylist.setText(_translate("MainWindow", "&Close"))
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
self.actionMoveSelected.setText(
_translate("MainWindow", "Mo&ve selected tracks to...")
)
self.actionExport_playlist.setText(_translate("MainWindow", "E&xport..."))
self.actionSetNext.setText(_translate("MainWindow", "Set &next"))
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
self.actionSelect_next_track.setText(
_translate("MainWindow", "Select next track")
)
self.actionSelect_next_track.setShortcut(_translate("MainWindow", "J"))
self.actionSelect_previous_track.setText(
_translate("MainWindow", "Select previous track")
)
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
self.actionSelect_played_tracks.setText(
_translate("MainWindow", "Select played tracks")
)
self.actionMoveUnplayed.setText(
_translate("MainWindow", "Move &unplayed tracks to...")
)
self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
self.actionImport.setText(_translate("MainWindow", "Import track..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
self.actionDownload_CSV_of_played_tracks.setText(
_translate("MainWindow", "Download CSV of played tracks...")
)
self.actionSearch.setText(_translate("MainWindow", "Search..."))
self.actionSearch.setShortcut(_translate("MainWindow", "/"))
self.actionInsertSectionHeader.setText(
_translate("MainWindow", "Insert &section header...")
)
self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H"))
self.actionRemove.setText(_translate("MainWindow", "&Remove track"))
self.actionFind_next.setText(_translate("MainWindow", "Find next"))
self.actionFind_next.setShortcut(_translate("MainWindow", "N"))
self.actionFind_previous.setText(_translate("MainWindow", "Find previous"))
self.actionFind_previous.setShortcut(_translate("MainWindow", "P"))
self.action_About.setText(_translate("MainWindow", "&About"))
self.actionSave_as_template.setText(
_translate("MainWindow", "Save as template...")
)
self.actionNew_from_template.setText(
_translate("MainWindow", "New from template...")
)
self.actionDebug.setText(_translate("MainWindow", "Debug"))
self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1..."))
self.actionMark_for_moving.setText(_translate("MainWindow", "Mark for moving"))
self.actionMark_for_moving.setShortcut(_translate("MainWindow", "Ctrl+C"))
self.actionPaste.setText(_translate("MainWindow", "Paste"))
self.actionPaste.setShortcut(_translate("MainWindow", "Ctrl+V"))
self.actionResume.setText(_translate("MainWindow", "Resume"))
self.actionResume.setShortcut(_translate("MainWindow", "Ctrl+R"))
self.actionSearch_title_in_Wikipedia.setText(
_translate("MainWindow", "Search title in Wikipedia")
)
self.actionSearch_title_in_Wikipedia.setShortcut(
_translate("MainWindow", "Ctrl+W")
)
self.actionSearch_title_in_Songfacts.setText(
_translate("MainWindow", "Search title in Songfacts")
)
self.actionSearch_title_in_Songfacts.setShortcut(
_translate("MainWindow", "Ctrl+S")
)
self.actionSelect_duplicate_rows.setText(
_translate("MainWindow", "Select duplicate rows...")
)
self.actionReplace_files.setText(_translate("MainWindow", "Replace files..."))
from infotabs import InfoTabs
from pyqtgraph import PlotWidget # type: ignore

BIN
app/ui/redstar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
app/ui/yellow-circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -16,7 +16,7 @@ from log import log
from models import Tracks from models import Tracks
def check_db(session: Session): def check_db(session: Session) -> None:
""" """
Database consistency check. Database consistency check.
@ -84,7 +84,7 @@ def check_db(session: Session):
print("There were more paths than listed that were not found") print("There were more paths than listed that were not found")
def update_bitrates(session: Session): def update_bitrates(session: Session) -> None:
""" """
Update bitrates on all tracks in database Update bitrates on all tracks in database
""" """
@ -92,6 +92,6 @@ def update_bitrates(session: Session):
for track in Tracks.get_all(session): for track in Tracks.get_all(session):
try: try:
t = get_tags(track.path) t = get_tags(track.path)
track.bitrate = t["bitrate"] track.bitrate = t.bitrate
except FileNotFoundError: except FileNotFoundError:
continue continue

29
app/vlcmanager.py Normal file
View File

@ -0,0 +1,29 @@
# Standard library imports
# PyQt imports
# Third party imports
import vlc # type: ignore
# App imports
class VLCManager:
"""
Singleton class to ensure we only ever have one vlc Instance
"""
__instance = None
def __init__(self) -> None:
if VLCManager.__instance is None:
self.vlc_instance = vlc.Instance()
VLCManager.__instance = self
else:
raise Exception("Attempted to create a second VLCManager instance")
@staticmethod
def get_instance() -> vlc.Instance:
if VLCManager.__instance is None:
VLCManager()
return VLCManager.__instance

View File

@ -23,7 +23,8 @@ def fade_point(audio_segment, fade_threshold=-12, chunk_size=10):
fade_threshold = max_vol fade_threshold = max_vol
while ( while (
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0): # noqa W503 and trim_ms > 0
): # noqa W503
trim_ms -= chunk_size trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less # if there is no trailing silence, return lenght of track (it's less

125
archive/proxymodel.py Executable file
View File

@ -0,0 +1,125 @@
#!/usr/bin/env python
import sys
from PyQt6.QtCore import (Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel)
from PyQt6.QtWidgets import (QApplication, QMainWindow, QTableView, QLineEdit, QVBoxLayout, QWidget)
class CustomTableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
def rowCount(self, parent=QModelIndex()):
return len(self._data)
def columnCount(self, parent=QModelIndex()):
return 2 # Row number and data
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole:
row, col = index.row(), index.column()
if col == 0:
return row + 1 # Row number (1-based index)
elif col == 1:
return self._data[row]
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
if role == Qt.ItemDataRole.EditRole and index.isValid():
self._data[index.row()] = value
self.dataChanged.emit(index, index, [Qt.ItemDataRole.EditRole])
return True
return False
def flags(self, index):
default_flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
if index.isValid():
return default_flags | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsDropEnabled
return default_flags | Qt.ItemFlag.ItemIsDropEnabled
def removeRow(self, row):
self.beginRemoveRows(QModelIndex(), row, row)
self._data.pop(row)
self.endRemoveRows()
def insertRow(self, row, value):
self.beginInsertRows(QModelIndex(), row, row)
self._data.insert(row, value)
self.endInsertRows()
def moveRows(self, sourceParent, sourceRow, count, destinationParent, destinationRow):
if sourceRow < destinationRow:
destinationRow -= 1
self.beginMoveRows(sourceParent, sourceRow, sourceRow, destinationParent, destinationRow)
row_data = self._data.pop(sourceRow)
self._data.insert(destinationRow, row_data)
self.endMoveRows()
return True
class ProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.filterString = ""
def setFilterString(self, text):
self.filterString = text
self.invalidateFilter()
def filterAcceptsRow(self, source_row, source_parent):
if self.filterString:
data = self.sourceModel().data(self.sourceModel().index(source_row, 1), Qt.ItemDataRole.DisplayRole)
return self.filterString in str(data)
return True
class TableView(QTableView):
def __init__(self, model):
super().__init__()
self.setModel(model)
self.setDragDropMode(QTableView.DragDropMode.InternalMove)
self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.setSortingEnabled(False)
self.setDragDropOverwriteMode(False)
def dropEvent(self, event):
source_index = self.indexAt(event.pos())
if not source_index.isValid():
return
destination_row = source_index.row()
dragged_row = self.currentIndex().row()
if dragged_row != destination_row:
self.model().sourceModel().moveRows(QModelIndex(), dragged_row, 1, QModelIndex(), destination_row)
super().dropEvent(event)
self.model().layoutChanged.emit() # Refresh model to update row numbers
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.data = ["dog", "hog", "don", "cat", "bat"]
self.baseModel = CustomTableModel(self.data)
self.proxyModel = ProxyModel()
self.proxyModel.setSourceModel(self.baseModel)
self.view = TableView(self.proxyModel)
self.filterLineEdit = QLineEdit()
self.filterLineEdit.setPlaceholderText("Filter by substring")
self.filterLineEdit.textChanged.connect(self.proxyModel.setFilterString)
layout = QVBoxLayout()
layout.addWidget(self.filterLineEdit)
layout.addWidget(self.view)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

View File

@ -1,73 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Tests the audacity pipe.
Keep pipe_test.py short!!
You can make more complicated longer tests to test other functionality
or to generate screenshots etc in other scripts.
Make sure Audacity is running first and that mod-script-pipe is enabled
before running this script.
Requires Python 2.7 or later. Python 3 is strongly recommended.
"""
import os
import sys
if sys.platform == 'win32':
print("pipe-test.py, running on windows")
TONAME = '\\\\.\\pipe\\ToSrvPipe'
FROMNAME = '\\\\.\\pipe\\FromSrvPipe'
EOL = '\r\n\0'
else:
print("pipe-test.py, running on linux or mac")
TONAME = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
FROMNAME = '/tmp/audacity_script_pipe.from.' + str(os.getuid())
EOL = '\n'
print("Write to \"" + TONAME + "\"")
if not os.path.exists(TONAME):
print(" does not exist. Ensure Audacity is running with mod-script-pipe.")
sys.exit()
print("Read from \"" + FROMNAME + "\"")
if not os.path.exists(FROMNAME):
print(" does not exist. Ensure Audacity is running with mod-script-pipe.")
sys.exit()
print("-- Both pipes exist. Good.")
TOFILE = open(TONAME, 'w')
print("-- File to write to has been opened")
FROMFILE = open(FROMNAME, 'rt')
print("-- File to read from has now been opened too\r\n")
def send_command(command):
"""Send a single command."""
print("Send: >>> \n"+command)
TOFILE.write(command + EOL)
TOFILE.flush()
def get_response():
"""Return the command response."""
result = ''
line = ''
while True:
result += line
line = FROMFILE.readline()
if line == '\n' and len(result) > 0:
break
return result
def do_command(command):
"""Send one command, and return the response."""
send_command(command)
response = get_response()
print("Rcvd: <<< \n" + response)
return response
do_command('Import2: Filename=/home/kae/git/musicmuster/archive/boot.flac')

View File

@ -1 +0,0 @@
Run Flake8 and Black

View File

@ -1,4 +1,7 @@
"""${message} <%!
import re
%>"""${message}
Revision ID: ${up_revision} Revision ID: ${up_revision}
Revises: ${down_revision | comma,n} Revises: ${down_revision | comma,n}
@ -16,9 +19,27 @@ branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)} depends_on = ${repr(depends_on)}
def upgrade(): def upgrade(engine_name: str) -> None:
${upgrades if upgrades else "pass"} globals()["upgrade_%s" % engine_name]()
def downgrade(): def downgrade(engine_name: str) -> None:
${downgrades if downgrades else "pass"} globals()["downgrade_%s" % engine_name]()
<%
db_names = config.get_main_option("databases")
%>
## generate an "upgrade_<xyz>() / downgrade_<xyz>()" function
## for each database name in the ini file.
% for db_name in re.split(r',\s*', db_names):
def upgrade_${db_name}() -> None:
${context.get("%s_upgrades" % db_name, "pass")}
def downgrade_${db_name}() -> None:
${context.get("%s_downgrades" % db_name, "pass")}
% endfor

View File

@ -0,0 +1,58 @@
"""add favouirit to playlists
Revision ID: 04df697e40cd
Revises: 33c04e3c12c8
Create Date: 2025-02-22 20:20:45.030024
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '04df697e40cd'
down_revision = '33c04e3c12c8'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.add_column(sa.Column('strip_substring', sa.Boolean(), nullable=False))
batch_op.create_index(batch_op.f('ix_notecolours_substring'), ['substring'], unique=False)
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint('playlist_rows_ibfk_1', type_='foreignkey')
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.add_column(sa.Column('favourite', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_column('favourite')
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.create_foreign_key('playlist_rows_ibfk_1', 'tracks', ['track_id'], ['id'])
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_notecolours_substring'))
batch_op.drop_column('strip_substring')
# ### end Alembic commands ###

View File

@ -1,32 +0,0 @@
"""Add sort_column, deleted and query to playlists table
Revision ID: 07dcbe6c4f0e
Revises: 4a7b4ab3354f
Create Date: 2022-12-25 10:26:38.200941
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '07dcbe6c4f0e'
down_revision = '4a7b4ab3354f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlists', sa.Column('sort_column', sa.Integer(), nullable=True))
op.add_column('playlists', sa.Column('query', sa.String(length=256), nullable=True))
op.add_column('playlists', sa.Column('deleted', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'deleted')
op.drop_column('playlists', 'query')
op.drop_column('playlists', 'sort_column')
# ### end Alembic commands ###

View File

@ -1,32 +0,0 @@
"""Add 'played' column to playlist_rows
Revision ID: 0c604bf490f8
Revises: 29c0d7ffc741
Create Date: 2022-08-12 14:12:38.419845
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "0c604bf490f8"
down_revision = "29c0d7ffc741"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("playlist_rows", sa.Column("played", sa.Boolean(), nullable=False))
op.drop_index("ix_tracks_lastplayed", table_name="tracks")
op.drop_column("tracks", "lastplayed")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("tracks", sa.Column("lastplayed", mysql.DATETIME(), nullable=True))
op.create_index("ix_tracks_lastplayed", "tracks", ["lastplayed"], unique=False)
op.drop_column("playlist_rows", "played")
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""Remove mtime from Tracks
Revision ID: 164bd5ef3074
Revises: a524796269fa
Create Date: 2024-12-22 14:11:48.045995
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '164bd5ef3074'
down_revision = 'a524796269fa'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.drop_index('ix_tracks_mtime')
batch_op.drop_column('mtime')
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.add_column(sa.Column('mtime', mysql.FLOAT(), nullable=False))
batch_op.create_index('ix_tracks_mtime', ['mtime'], unique=False)
# ### end Alembic commands ###

View File

@ -1,40 +0,0 @@
"""Add columns to track table
Revision ID: 1bc727e5e87f
Revises: 52d82712d218
Create Date: 2021-03-22 22:43:40.458197
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '1bc727e5e87f'
down_revision = '52d82712d218'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('duration', sa.Integer(), nullable=True))
op.add_column('tracks', sa.Column('fade_at', sa.Integer(), nullable=True))
op.add_column('tracks', sa.Column('silence_at', sa.Integer(), nullable=True))
op.add_column('tracks', sa.Column('start_gap', sa.Integer(), nullable=True))
op.drop_index('ix_tracks_length', table_name='tracks')
op.create_index(op.f('ix_tracks_duration'), 'tracks', ['duration'], unique=False)
op.drop_column('tracks', 'length')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('length', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.drop_index(op.f('ix_tracks_duration'), table_name='tracks')
op.create_index('ix_tracks_length', 'tracks', ['length'], unique=False)
op.drop_column('tracks', 'start_gap')
op.drop_column('tracks', 'silence_at')
op.drop_column('tracks', 'fade_at')
op.drop_column('tracks', 'duration')
# ### end Alembic commands ###

View File

@ -1,34 +0,0 @@
"""Add constraint to playlist_tracks
Revision ID: 1c4048efee96
Revises: 52cbded98e7c
Create Date: 2022-03-29 19:26:27.378185
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '1c4048efee96'
down_revision = '52cbded98e7c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint('uniquerow', 'playlist_tracks', ['row', 'playlist_id'])
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.drop_constraint('uniquerow', 'playlist_tracks', type_='unique')
# ### end Alembic commands ###

View File

@ -1,34 +0,0 @@
"""Fixup playdates relationship
Revision ID: 269a002f989d
Revises: 9bf80ba3635f
Create Date: 2021-03-28 14:36:59.103846
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '269a002f989d'
down_revision = '9bf80ba3635f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playdates', sa.Column('track_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'playdates', 'tracks', ['track_id'], ['id'])
op.drop_constraint('tracks_ibfk_1', 'tracks', type_='foreignkey')
op.drop_column('tracks', 'playdates_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('playdates_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.create_foreign_key('tracks_ibfk_1', 'tracks', 'playdates', ['playdates_id'], ['id'])
op.drop_constraint(None, 'playdates', type_='foreignkey')
op.drop_column('playdates', 'track_id')
# ### end Alembic commands ###

View File

@ -1,24 +0,0 @@
"""Drop uniquerow index on playlist_rows
Revision ID: 29c0d7ffc741
Revises: 3b063011ed67
Create Date: 2022-08-06 22:21:46.881378
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '29c0d7ffc741'
down_revision = '3b063011ed67'
branch_labels = None
depends_on = None
def upgrade():
op.drop_index('uniquerow', table_name='playlist_rows')
def downgrade():
op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True)

View File

@ -1,110 +0,0 @@
"""add Tracks.intro column
Revision ID: 2caa3d37f211
Revises: 5bb2c572e1e5
Create Date: 2024-05-07 20:06:00.845979
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '2caa3d37f211'
down_revision = '5bb2c572e1e5'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('carts', schema=None) as batch_op:
batch_op.alter_column('name',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.alter_column('substring',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
batch_op.alter_column('colour',
existing_type=mysql.VARCHAR(length=21),
nullable=False)
batch_op.alter_column('enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
batch_op.alter_column('is_regex',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
batch_op.alter_column('is_casesensitive',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.alter_column('lastplayed',
existing_type=mysql.DATETIME(),
nullable=False)
batch_op.alter_column('track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_index('tab')
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.add_column(sa.Column('intro', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.drop_column('intro')
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.create_index('tab', ['tab'], unique=True)
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.alter_column('track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
batch_op.alter_column('lastplayed',
existing_type=mysql.DATETIME(),
nullable=True)
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.alter_column('is_casesensitive',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
batch_op.alter_column('is_regex',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
batch_op.alter_column('enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
batch_op.alter_column('colour',
existing_type=mysql.VARCHAR(length=21),
nullable=True)
batch_op.alter_column('substring',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
with op.batch_alter_table('carts', schema=None) as batch_op:
batch_op.alter_column('name',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
# ### end Alembic commands ###

View File

@ -1,30 +0,0 @@
"""Add playlist dates and loaded
Revision ID: 2cc37d3cf07f
Revises: e3b04db5506f
Create Date: 2021-04-27 21:55:50.639406
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "2cc37d3cf07f"
down_revision = "e3b04db5506f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("playlists", sa.Column("last_used", sa.DateTime(), nullable=True))
op.add_column("playlists", sa.Column("loaded", sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("playlists", "loaded")
op.drop_column("playlists", "last_used")
# ### end Alembic commands ###

View File

@ -0,0 +1,52 @@
"""Remove playlists.delete and implement Cascade deletes
Revision ID: 33c04e3c12c8
Revises: 164bd5ef3074
Create Date: 2024-12-29 17:56:00.627198
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '33c04e3c12c8'
down_revision = '164bd5ef3074'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint('playlist_rows_ibfk_3', type_='foreignkey')
batch_op.create_foreign_key('playlist_rows_ibfk_3', 'playlists', ['playlist_id'], ['id'], ondelete='CASCADE')
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_column('deleted')
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.add_column(sa.Column('deleted', mysql.TINYINT(display_width=1), autoincrement=False, nullable=False))
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.create_foreign_key(None, 'playlists', ['playlist_id'], ['id'])
# ### end Alembic commands ###

View File

@ -1,72 +0,0 @@
"""Migrate SQLA 2 and remove redundant columns
Revision ID: 3a53a9fb26ab
Revises: 07dcbe6c4f0e
Create Date: 2023-10-15 09:39:16.449419
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '3a53a9fb26ab'
down_revision = '07dcbe6c4f0e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'query')
op.drop_column('playlists', 'sort_column')
op.alter_column('tracks', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
op.alter_column('tracks', 'artist',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
op.alter_column('tracks', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'start_gap',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'fade_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'silence_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'mtime',
existing_type=mysql.FLOAT(),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('tracks', 'mtime',
existing_type=mysql.FLOAT(),
nullable=True)
op.alter_column('tracks', 'silence_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'fade_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'start_gap',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'artist',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
op.alter_column('tracks', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
op.add_column('playlists', sa.Column('sort_column', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.add_column('playlists', sa.Column('query', mysql.VARCHAR(length=256), nullable=True))
# ### end Alembic commands ###

View File

@ -1,54 +0,0 @@
"""schema changes for row notes
Revision ID: 3b063011ed67
Revises: 51f61433256f
Create Date: 2022-07-06 19:48:23.960471
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '3b063011ed67'
down_revision = '51f61433256f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notes')
op.add_column('playlist_rows', sa.Column('note', sa.String(length=2048), nullable=True))
op.alter_column('playlist_rows', 'track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_index('uniquerow', table_name='playlist_rows')
op.drop_column('playlist_rows', 'text')
op.alter_column('playlist_rows', 'row', new_column_name='row_number',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlist_rows', 'row_number', new_column_name='row',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.add_column('playlist_rows', sa.Column('text', mysql.VARCHAR(length=2048), nullable=True))
op.drop_index('uniquerow', table_name='playlist_rows')
op.create_index('uniquerow', 'playlist_rows', ['row', 'playlist_id'], unique=False)
op.drop_column('playlist_rows', 'note')
op.create_table('notes',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('playlist_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('row', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('note', mysql.VARCHAR(length=256), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], name='notes_ibfk_1'),
sa.PrimaryKeyConstraint('id'),
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
# ### end Alembic commands ###

View File

@ -1,26 +0,0 @@
"""Rename playlist_tracks to playlist_rows
Revision ID: 3f55ac7d80ad
Revises: 1c4048efee96
Create Date: 2022-07-04 20:51:59.874004
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3f55ac7d80ad'
down_revision = '1c4048efee96'
branch_labels = None
depends_on = None
def upgrade():
# Rename so as not to lose content
op.rename_table('playlist_tracks', 'playlist_rows')
def downgrade():
# Rename so as not to lose content
op.rename_table('playlist_rows', 'playlist_tracks')

View File

@ -1,32 +0,0 @@
"""Record tab number for open playlists
Revision ID: 4a7b4ab3354f
Revises: 6730f03317df
Create Date: 2022-12-20 15:38:28.318280
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '4a7b4ab3354f'
down_revision = '6730f03317df'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlists', sa.Column('tab', sa.Integer(), nullable=True))
op.create_unique_constraint(None, 'playlists', ['tab'])
op.drop_column('playlists', 'loaded')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlists', sa.Column('loaded', mysql.TINYINT(display_width=1), autoincrement=False, nullable=False))
op.drop_constraint(None, 'playlists', type_='unique')
op.drop_column('playlists', 'tab')
# ### end Alembic commands ###

View File

@ -0,0 +1,47 @@
"""create queries table
Revision ID: 4fc2a9a82ab0
Revises: ab475332d873
Create Date: 2025-02-26 13:13:25.118489
"""
from alembic import op
import sqlalchemy as sa
import dbtables
# revision identifiers, used by Alembic.
revision = '4fc2a9a82ab0'
down_revision = 'ab475332d873'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('queries',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('filter_data', dbtables.JSONEncodedDict(), nullable=False),
sa.Column('favourite', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('queries')
# ### end Alembic commands ###

View File

@ -1,34 +0,0 @@
"""Increase settings.name len and add playlist_rows.notes
Revision ID: 51f61433256f
Revises: 3f55ac7d80ad
Create Date: 2022-07-04 21:21:39.830406
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '51f61433256f'
down_revision = '3f55ac7d80ad'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_rows', sa.Column('text', sa.String(length=2048), nullable=True))
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.drop_column('playlist_rows', 'text')
# ### end Alembic commands ###

View File

@ -1,30 +0,0 @@
"""Update notecolours table
Revision ID: 52cbded98e7c
Revises: c55992d1fe5f
Create Date: 2022-02-06 12:34:30.099417
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '52cbded98e7c'
down_revision = 'c55992d1fe5f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notecolours', sa.Column('colour', sa.String(length=21), nullable=True))
op.drop_column('notecolours', 'hexcolour')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notecolours', sa.Column('hexcolour', mysql.VARCHAR(length=6), nullable=True))
op.drop_column('notecolours', 'colour')
# ### end Alembic commands ###

View File

@ -1,24 +0,0 @@
"""Initial
Revision ID: 52d82712d218
Revises:
Create Date: 2021-03-22 22:16:03.272827
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '52d82712d218'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@ -1,60 +0,0 @@
"""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 ###

View File

@ -1,41 +0,0 @@
"""Add carts
Revision ID: 6730f03317df
Revises: b4f524e2140c
Create Date: 2022-09-13 19:41:33.181752
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6730f03317df'
down_revision = 'b4f524e2140c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('carts',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('cart_number', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=256), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('path', sa.String(length=2048), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('cart_number')
)
op.create_index(op.f('ix_carts_duration'), 'carts', ['duration'], unique=False)
op.create_index(op.f('ix_carts_name'), 'carts', ['name'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_carts_name'), table_name='carts')
op.drop_index(op.f('ix_carts_duration'), table_name='carts')
op.drop_table('carts')
# ### end Alembic commands ###

View File

@ -0,0 +1,75 @@
"""Initial migration
Revision ID: 708a21f5c271
Revises:
Create Date: 2024-12-14 11:16:09.067598
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '708a21f5c271'
down_revision = None
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('carts', schema=None) as batch_op:
batch_op.drop_index('cart_number')
batch_op.drop_index('ix_carts_duration')
batch_op.drop_index('ix_carts_name')
op.drop_table('carts')
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.add_column(sa.Column('foreground', sa.String(length=21), nullable=True))
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_playlist_rows_playlist_id'), ['playlist_id'], unique=False)
batch_op.create_index(batch_op.f('ix_playlist_rows_row_number'), ['row_number'], unique=False)
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_playlist_rows_row_number'))
batch_op.drop_index(batch_op.f('ix_playlist_rows_playlist_id'))
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.drop_column('foreground')
op.create_table('carts',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('cart_number', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('name', mysql.VARCHAR(length=256), nullable=False),
sa.Column('duration', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('path', mysql.VARCHAR(length=2048), nullable=True),
sa.Column('enabled', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
with op.batch_alter_table('carts', schema=None) as batch_op:
batch_op.create_index('ix_carts_name', ['name'], unique=False)
batch_op.create_index('ix_carts_duration', ['duration'], unique=False)
batch_op.create_index('cart_number', ['cart_number'], unique=True)
# ### end Alembic commands ###

View File

@ -1,34 +0,0 @@
"""Add id to playlist association table
Revision ID: 9bf80ba3635f
Revises: f071129cbd93
Create Date: 2021-03-28 12:16:14.631579
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9bf80ba3635f'
down_revision = 'f071129cbd93'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
conn.execute(
"ALTER TABLE playlistracks ADD id INT PRIMARY KEY AUTO_INCREMENT FIRST"
)
conn.execute("RENAME TABLE playlistracks TO playlisttracks")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
conn.execute("ALTER TABLE playlistracks DROP id")
conn.execute("RENAME TABLE playlisttracks TO playlistracks")
# ### end Alembic commands ###

View File

@ -0,0 +1,44 @@
"""Add strip_substring to NoteColoursTable
Revision ID: a524796269fa
Revises: 708a21f5c271
Create Date: 2024-12-14 12:42:45.214707
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a524796269fa'
down_revision = '708a21f5c271'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.add_column(sa.Column('strip_substring', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.drop_column('strip_substring')
# ### end Alembic commands ###

View File

@ -1,36 +0,0 @@
"""Add NoteColours table
Revision ID: a5aada49f2fc
Revises: 2cc37d3cf07f
Create Date: 2022-02-05 17:34:54.880473
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a5aada49f2fc'
down_revision = '2cc37d3cf07f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notecolours',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('substring', sa.String(length=256), nullable=True),
sa.Column('hexcolour', sa.String(length=6), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=True),
sa.Column('is_regex', sa.Boolean(), nullable=True),
sa.Column('is_casesensitive', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notecolours')
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""fix playlist cascades
Revision ID: ab475332d873
Revises: 04df697e40cd
Create Date: 2025-02-26 13:11:15.417278
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ab475332d873'
down_revision = '04df697e40cd'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.drop_constraint('fk_playdates_track_id_tracks', type_='foreignkey')
batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.create_foreign_key('fk_playdates_track_id_tracks', 'tracks', ['track_id'], ['id'])
# ### end Alembic commands ###

View File

@ -1,37 +0,0 @@
"""Add settings table
Revision ID: b0983648595e
Revises: 1bc727e5e87f
Create Date: 2021-03-26 13:33:41.994508
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b0983648595e"
down_revision = "1bc727e5e87f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"settings",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=32), nullable=False),
sa.Column("f_datetime", sa.DateTime(), nullable=True),
sa.Column("f_int", sa.Integer(), nullable=True),
sa.Column("f_string", sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("settings")
# ### end Alembic commands ###

View File

@ -1,32 +0,0 @@
"""Add templates
Revision ID: b4f524e2140c
Revises: ed3100326c38
Create Date: 2022-10-01 13:30:21.663287
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b4f524e2140c'
down_revision = 'ed3100326c38'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_foreign_key(None, 'playlist_rows', 'tracks', ['track_id'], ['id'])
op.create_foreign_key(None, 'playlist_rows', 'playlists', ['playlist_id'], ['id'])
op.add_column('playlists', sa.Column('is_template', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'is_template')
op.drop_constraint(None, 'playlist_rows', type_='foreignkey')
op.drop_constraint(None, 'playlist_rows', type_='foreignkey')
# ### end Alembic commands ###

View File

@ -1,32 +0,0 @@
"""Add order to colours table
Revision ID: c55992d1fe5f
Revises: a5aada49f2fc
Create Date: 2022-02-05 21:28:36.391312
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c55992d1fe5f'
down_revision = 'a5aada49f2fc'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notecolours', sa.Column('order', sa.Integer(), nullable=True))
op.create_index(op.f('ix_notecolours_enabled'), 'notecolours', ['enabled'], unique=False)
op.create_index(op.f('ix_notecolours_order'), 'notecolours', ['order'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_notecolours_order'), table_name='notecolours')
op.drop_index(op.f('ix_notecolours_enabled'), table_name='notecolours')
op.drop_column('notecolours', 'order')
# ### end Alembic commands ###

View File

@ -1,51 +0,0 @@
"""Add structure for notes
Revision ID: e3b04db5506f
Revises: 269a002f989d
Create Date: 2021-04-05 16:33:50.117747
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'e3b04db5506f'
down_revision = '269a002f989d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notes',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('playlist_id', sa.Integer(), nullable=True),
sa.Column('row', sa.Integer(), nullable=False),
sa.Column('note', sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.add_column('playlisttracks', sa.Column('row', sa.Integer(), nullable=False))
op.alter_column('playlisttracks', 'playlist_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('playlisttracks', 'track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_column('playlisttracks', 'sort')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlisttracks', sa.Column('sort', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False))
op.alter_column('playlisttracks', 'track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('playlisttracks', 'playlist_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_column('playlisttracks', 'row')
op.drop_table('notes')
# ### end Alembic commands ###

View File

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

View File

@ -1,28 +0,0 @@
"""Add sort to playlist association table
Revision ID: f071129cbd93
Revises: f07b96a5e60f
Create Date: 2021-03-28 11:19:31.944110
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f071129cbd93'
down_revision = 'f07b96a5e60f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlistracks', sa.Column('sort', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlistracks', 'sort')
# ### end Alembic commands ###

View File

@ -1,63 +0,0 @@
"""Add playlist and playtimes
Revision ID: f07b96a5e60f
Revises: b0983648595e
Create Date: 2021-03-27 19:53:09.524989
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f07b96a5e60f"
down_revision = "b0983648595e"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"playdates",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("lastplayed", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_playdates_lastplayed"), "playdates", ["lastplayed"], unique=False
)
op.create_table(
"playlists",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table(
"playlistracks",
sa.Column("playlist_id", sa.Integer(), nullable=True),
sa.Column("track_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["playlist_id"],
["playlists.id"],
),
sa.ForeignKeyConstraint(
["track_id"],
["tracks.id"],
),
)
op.add_column("tracks", sa.Column("playdates_id", sa.Integer(), nullable=True))
op.create_foreign_key(None, "tracks", "playdates", ["playdates_id"], ["id"])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "tracks", type_="foreignkey")
op.drop_column("tracks", "playdates_id")
op.drop_table("playlistracks")
op.drop_table("playlists")
op.drop_index(op.f("ix_playdates_lastplayed"), table_name="playdates")
op.drop_table("playdates")
# ### end Alembic commands ###

View File

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

View File

@ -1,6 +0,0 @@
#!/bin/bash
# cd /home/kae/mm
# MYSQL_CONNECT="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod" ROOT="/home/kae/music" direnv exec .
for file in "$@"; do
app/songdb.py -i "$file"
done

2039
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,57 +1,73 @@
[tool.poetry] [project]
name = "musicmuster" name = "musicmuster"
version = "1.7.5" version = "4.1.10"
description = "Music player for internet radio" description = "Music player for internet radio"
authors = ["Keith Edmunds <kae@midnighthax.com>"] authors = [{ name = "Keith Edmunds", email = "kae@midnighthax.com" }]
requires-python = ">=3.13,<4"
readme = "README.md"
dependencies = [
"alchemical>=1.0.2",
"alembic>=1.14.0",
"colorlog>=6.9.0",
"fuzzywuzzy>=0.18.0",
"mutagen>=1.47.0",
"mysqlclient>=2.2.5",
"obs-websocket-py>=1.0",
"psutil>=6.1.0",
"pydub>=0.25.1",
"pydymenu>=0.5.2",
"pyfzf>=0.3.1",
"pygame>=2.6.1",
"pyqt6>=6.7.1",
"pyqt6-webengine>=6.7.0",
"pyqtgraph>=0.13.3",
"python-levenshtein>=0.26.1",
"python-slugify>=8.0.4",
"python-vlc>=3.0.21203",
"SQLAlchemy>=2.0.36",
"stackprinter>=0.2.10",
"tinytag>=1.10.1",
"types-psutil>=6.0.0.20240621",
"pyyaml (>=6.0.2,<7.0.0)",
"audioop-lts>=0.2.1",
"types-pyyaml>=6.0.12.20241230",
"dogpile-cache>=1.3.4",
"pdbpp>=0.10.3",
"filetype>=1.2.0",
"black>=25.1.0",
"slugify>=0.0.1",
]
[tool.poetry.dependencies] [dependency-groups]
python = "^3.11" dev = [
tinytag = "^1.10.1" "flakehell>=0.9.0,<0.10",
SQLAlchemy = "^2.0.29" "ipdb>=0.13.9,<0.14",
python-vlc = "^3.0.20123" "line-profiler>=4.2.0,<5",
mysqlclient = "^2.2.4" "mypy>=1.15.0,<2",
mutagen = "^1.47.0" "pudb",
alembic = "^1.13.1" "pydub-stubs>=0.25.1,<0.26",
psutil = "^5.9.8" "pytest>=8.3.4,<9",
pydub = "^0.25.1" "pytest-qt>=4.4.0,<5",
types-psutil = "^5.9.5.20240423" "black>=25.1.0,<26",
python-slugify = "^8.0.4" "pytest-cov>=6.0.0,<7",
pyfzf = "^0.3.1" ]
pydymenu = "^0.5.2"
stackprinter = "^0.2.10"
pyqt6 = "^6.7.0"
pyqt6-webengine = "^6.7.0"
pyqtgraph = "^0.13.3"
colorlog = "^6.8.2"
alchemical = "^1.0.2"
obs-websocket-py = "^1.0"
pygame = "^2.6.0"
[tool.poetry.dev-dependencies] [tool.uv]
ipdb = "^0.13.9" package = false
pytest-qt = "^4.4.0"
pydub-stubs = "^0.25.1"
line-profiler = "^4.1.3"
flakehell = "^0.9.0"
[tool.poetry.group.dev.dependencies]
pudb = "*"
sphinx = "^7.0.1"
flakehell = "^0.9.0"
mypy = "^1.7.0"
pdbp = "^1.5.0"
pytest-cov = "^5.0.0"
pytest = "^8.1.1"
snoop = "^0.4.3"
black = "^24.3.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["hatchling"]
build-backend = "poetry.core.masonry.api" build-backend = "hatchling.build"
[tool.mypy] [tool.mypy]
mypy_path = "/home/kae/git/musicmuster/app" mypy_path = "/home/kae/git/musicmuster/app"
explicit_package_bases = true explicit_package_bases = true
python_version = 3.11
warn_unused_configs = true
disallow_incomplete_defs = true
[tool.pylsp.plugins.pycodestyle]
maxLineLength = 88
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "--exitfirst --showlocals --capture=no" addopts = "--exitfirst --showlocals --capture=no"
@ -62,3 +78,4 @@ filterwarnings = ["ignore:'audioop' is deprecated", "ignore:pkg_resources"]
exclude = ["migrations", "app/ui", "archive"] exclude = ["migrations", "app/ui", "archive"]
paths = ["app"] paths = ["app"]
make_whitelist = true make_whitelist = true

489
tests/test_file_importer.py Normal file
View File

@ -0,0 +1,489 @@
"""
Tests are named 'test_nnn_xxxx' where 'nn n' is a number. This is used to ensure that
the tests run in order as we rely (in some cases) upon the results of an earlier test.
Yes, we shouldn't do that.
"""
# Standard library imports
import os
import shutil
import tempfile
import unittest
from unittest.mock import MagicMock, patch
# PyQt imports
from PyQt6.QtWidgets import QDialog, QFileDialog
# Third party imports
from mutagen.mp3 import MP3 # type: ignore
import pytest
from pytestqt.plugin import QtBot # type: ignore
# App imports
from app import musicmuster
from app.models import (
db,
Playlists,
Tracks,
)
from config import Config
from file_importer import FileImporter
# Custom fixture to adapt qtbot for use with unittest.TestCase
@pytest.fixture(scope="class")
def qtbot_adapter(qapp, request):
"""Adapt qtbot fixture for usefixtures and unittest.TestCase"""
request.cls.qtbot = QtBot(request)
# Fixture for tmp_path to be available in the class
@pytest.fixture(scope="class")
def class_tmp_path(request, tmp_path_factory):
"""Provide a class-wide tmp_path"""
request.cls.tmp_path = tmp_path_factory.mktemp("pytest_tmp")
@pytest.mark.usefixtures("qtbot_adapter", "class_tmp_path")
class MyTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class"""
db.create_all()
cls.widget = musicmuster.Window()
# Create a playlist for all tests
playlist_name = "file importer playlist"
with db.Session() as session:
playlist = Playlists(session=session, name=playlist_name, template_id=0)
cls.widget._open_playlist(playlist)
# Create our musicstore
cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp")
Config.REPLACE_FILES_DEFAULT_SOURCE = cls.import_source
cls.musicstore = tempfile.mkdtemp(suffix="_MMstore_pytest", dir="/tmp")
Config.IMPORT_DESTINATION = cls.musicstore
@classmethod
def tearDownClass(cls):
"""Runs once after all tests"""
db.drop_all()
shutil.rmtree(cls.musicstore)
shutil.rmtree(cls.import_source)
def setUp(self):
"""Runs before each test"""
with self.qtbot.waitExposed(self.widget):
self.widget.show()
def tearDown(self):
"""Runs after each test"""
self.widget.close() # Close UI to prevent side effects
def wait_for_workers(self, timeout: int = 10000):
"""
Let import threads workers run to completion
"""
def workers_empty():
assert FileImporter.workers == {}
self.qtbot.waitUntil(workers_empty, timeout=timeout)
def test_001_import_no_files(self):
"""Try importing with no files to import"""
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File import",
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
None,
)
def test_002_import_file_and_cancel(self):
"""Cancel file import"""
test_track_path = "testdata/isa.mp3"
shutil.copy(test_track_path, self.import_source)
with (
patch("file_importer.PickMatch") as MockPickMatch,
patch("file_importer.show_OK") as mock_show_ok,
):
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Rejected
mock_dialog_instance.selected_track_id = -1 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="I'm So Afraid (Fleetwood Mac)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id < 0
mock_show_ok.assert_called_once_with(
"File not imported",
"isa.mp3 will not be imported because you asked not to import this file",
)
def test_003_import_first_file(self):
"""Import file into empty directory"""
test_track_path = "testdata/isa.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="I'm So Afraid (Fleetwood Mac)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id == 0
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 1
track = tracks[0]
assert track.title == "I'm So Afraid"
assert track.artist == "Fleetwood Mac"
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_004_import_second_file(self):
"""Import a second file"""
test_track_path = "testdata/lovecats.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id == 0
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_005_replace_file(self):
"""Import the same file again and update existing track"""
test_track_path = "testdata/lovecats.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 2 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (100%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
assert track.id == 2
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_006_import_file_no_tags(self) -> None:
"""Try to import untagged file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Remove tags
src = MP3(import_file)
src.delete()
src.save()
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File not imported",
f"{test_filename} will not be imported because of tag errors "
f"(Missing tags in {import_file})",
)
def test_007_import_unreadable_file(self) -> None:
"""Import unreadable file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Make undreadable
os.chmod(import_file, 0)
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File not imported",
f"{test_filename} will not be imported because {import_file} is unreadable",
)
# clean up
os.chmod(import_file, 0o777)
os.unlink(import_file)
def test_008_import_new_file_existing_destination(self) -> None:
"""Import duplicate file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
new_destination = os.path.join(self.musicstore, "lc2.mp3")
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
with (
patch("file_importer.PickMatch") as MockPickMatch,
patch.object(
QFileDialog, "getSaveFileName", return_value=(new_destination, "")
) as mock_file_dialog,
patch("file_importer.show_OK") as mock_show_ok,
):
mock_file_dialog.return_value = (
new_destination,
"",
) # Ensure mock correctly returns expected value
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (100%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
destination = os.path.join(self.musicstore, test_filename)
mock_show_ok.assert_called_once_with(
title="Desintation path exists",
msg=f"New import requested but default destination path ({destination}) "
"already exists. Click OK and choose where to save this track",
parent=None,
)
self.wait_for_workers()
# Ensure QFileDialog was called and returned expected value
assert mock_file_dialog.called # Ensure the mock was used
result = mock_file_dialog()
assert result[0] == new_destination # Validate return value
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 3
track = tracks[2]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
assert track.id == 3
assert track.path == new_destination
assert os.path.exists(new_destination)
assert os.listdir(self.import_source) == []
# Remove file so as not to interfere with later tests
session.delete(track)
tracks = Tracks.get_all(session)
assert len(tracks) == 2
session.commit()
os.unlink(new_destination)
assert not os.path.exists(new_destination)
def test_009_import_similar_file(self) -> None:
"""Import file with similar, but different, title"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Change title tag
src = MP3(import_file)
src["TIT2"].text[0] += " xyz"
src.save()
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 2 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats xyz (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (93%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats xyz"
assert track.artist == "The Cure"
assert track.id == 2
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []

View File

@ -55,8 +55,8 @@ class TestMMHelpers(unittest.TestCase):
with open(test_track_data) as f: with open(test_track_data) as f:
testdata = eval(f.read()) testdata = eval(f.read())
assert tags["artist"] == testdata["artist"] assert tags.artist == testdata["artist"]
assert tags["title"] == testdata["title"] assert tags.title == testdata["title"]
def test_get_relative_date(self): def test_get_relative_date(self):
assert get_relative_date(None) == "Never" assert get_relative_date(None) == "Never"
@ -64,9 +64,9 @@ class TestMMHelpers(unittest.TestCase):
today_at_11 = dt.datetime.now().replace(hour=11, minute=0) today_at_11 = dt.datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00" assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
eight_days_ago = today_at_10 - dt.timedelta(days=8) eight_days_ago = today_at_10 - dt.timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago" assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day"
sixteen_days_ago = today_at_10 - dt.timedelta(days=16) sixteen_days_ago = today_at_10 - dt.timedelta(days=16)
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago" assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days"
def test_leading_silence(self): def test_leading_silence(self):
test_track_path = "testdata/isa.mp3" test_track_path = "testdata/isa.mp3"

View File

@ -21,7 +21,9 @@ from app.models import (
class TestMMModels(unittest.TestCase): class TestMMModels(unittest.TestCase):
def setUp(self): def setUp(self):
"""Runs before each test"""
db.create_all() db.create_all()
NoteColours.invalidate_cache()
with db.Session() as session: with db.Session() as session:
track1_path = "testdata/isa.mp3" track1_path = "testdata/isa.mp3"
@ -31,6 +33,7 @@ class TestMMModels(unittest.TestCase):
self.track2 = Tracks(session, **helpers.get_all_track_metadata(track2_path)) self.track2 = Tracks(session, **helpers.get_all_track_metadata(track2_path))
def tearDown(self): def tearDown(self):
"""Runs after each test"""
db.drop_all() db.drop_all()
def test_track_repr(self): def test_track_repr(self):
@ -70,7 +73,7 @@ class TestMMModels(unittest.TestCase):
NoteColours(session, substring="substring", colour=note_colour) NoteColours(session, substring="substring", colour=note_colour)
result = NoteColours.get_colour(session, "xyz") result = NoteColours.get_colour(session, "xyz")
assert result is None assert result == ""
def test_notecolours_get_colour_match(self): def test_notecolours_get_colour_match(self):
note_colour = "#4bcdef" note_colour = "#4bcdef"
@ -108,7 +111,7 @@ class TestMMModels(unittest.TestCase):
TEMPLATE_NAME = "my template" TEMPLATE_NAME = "my template"
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, "my playlist") playlist = Playlists(session, "my playlist", template_id=0)
assert playlist assert playlist
# test repr # test repr
_ = str(playlist) _ = str(playlist)
@ -119,23 +122,18 @@ class TestMMModels(unittest.TestCase):
# create template # create template
Playlists.save_as_template(session, playlist.id, TEMPLATE_NAME) Playlists.save_as_template(session, playlist.id, TEMPLATE_NAME)
# test create template
_ = Playlists.create_playlist_from_template(
session, playlist, "my new name"
)
# get all templates # get all templates
all_templates = Playlists.get_all_templates(session) all_templates = Playlists.get_all_templates(session)
assert len(all_templates) == 1 assert len(all_templates) == 1
# Save as template creates new playlist # Save as template creates new playlist
assert all_templates[0] != playlist assert all_templates[0] != playlist
# test delete playlist # test delete playlist
playlist.delete(session) session.delete(playlist)
def test_playlist_open_and_close(self): def test_playlist_open_and_close(self):
# We need a playlist # We need a playlist
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, "my playlist") playlist = Playlists(session, "my playlist", template_id=0)
assert len(Playlists.get_open(session)) == 0 assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1 assert len(Playlists.get_closed(session)) == 1
@ -155,8 +153,8 @@ class TestMMModels(unittest.TestCase):
p1_name = "playlist one" p1_name = "playlist one"
p2_name = "playlist two" p2_name = "playlist two"
with db.Session() as session: with db.Session() as session:
playlist1 = Playlists(session, p1_name) playlist1 = Playlists(session, p1_name, template_id=0)
_ = Playlists(session, p2_name) _ = Playlists(session, p2_name, template_id=0)
all_playlists = Playlists.get_all(session) all_playlists = Playlists.get_all(session)
assert len(all_playlists) == 2 assert len(all_playlists) == 2
@ -205,7 +203,7 @@ class TestMMModels(unittest.TestCase):
nc = NoteColours(session, substring="x", colour="x") nc = NoteColours(session, substring="x", colour="x")
_ = str(nc) _ = str(nc)
def test_get_colour(self): def test_get_colour_1(self):
"""Test for errors in execution""" """Test for errors in execution"""
GOOD_STRING = "cantelope" GOOD_STRING = "cantelope"
@ -218,22 +216,42 @@ class TestMMModels(unittest.TestCase):
session, substring=SUBSTR, colour=COLOUR, is_casesensitive=True session, substring=SUBSTR, colour=COLOUR, is_casesensitive=True
) )
session.commit()
_ = nc1.get_colour(session, "") _ = nc1.get_colour(session, "")
colour = nc1.get_colour(session, GOOD_STRING) colour = nc1.get_colour(session, GOOD_STRING)
assert colour == COLOUR assert colour == COLOUR
colour = nc1.get_colour(session, BAD_STRING) colour = nc1.get_colour(session, BAD_STRING)
assert colour is None assert colour == ""
def test_get_colour_2(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
BAD_STRING = "ericTheBee"
SUBSTR = "ant"
COLOUR = "blue"
with db.Session() as session:
nc2 = NoteColours( nc2 = NoteColours(
session, substring=".*" + SUBSTR, colour=COLOUR, is_regex=True session, substring=".*" + SUBSTR, colour=COLOUR, is_regex=True
) )
session.commit()
colour = nc2.get_colour(session, GOOD_STRING) colour = nc2.get_colour(session, GOOD_STRING)
assert colour == COLOUR assert colour == COLOUR
colour = nc2.get_colour(session, BAD_STRING) colour = nc2.get_colour(session, BAD_STRING)
assert colour is None assert colour == ""
def test_get_colour_3(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
BAD_STRING = "ericTheBee"
SUBSTR = "ant"
COLOUR = "blue"
with db.Session() as session:
nc3 = NoteColours( nc3 = NoteColours(
session, session,
substring=".*" + SUBSTR, substring=".*" + SUBSTR,
@ -241,12 +259,13 @@ class TestMMModels(unittest.TestCase):
is_regex=True, is_regex=True,
is_casesensitive=True, is_casesensitive=True,
) )
session.commit()
colour = nc3.get_colour(session, GOOD_STRING) colour = nc3.get_colour(session, GOOD_STRING)
assert colour == COLOUR assert colour == COLOUR
colour = nc3.get_colour(session, BAD_STRING) colour = nc3.get_colour(session, BAD_STRING)
assert colour is None assert colour == ""
def test_name_available(self): def test_name_available(self):
PLAYLIST_NAME = "a name" PLAYLIST_NAME = "a name"
@ -254,7 +273,7 @@ class TestMMModels(unittest.TestCase):
with db.Session() as session: with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME): if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME) playlist = Playlists(session, PLAYLIST_NAME, template_id=0)
assert playlist assert playlist
assert Playlists.name_is_available(session, PLAYLIST_NAME) is False assert Playlists.name_is_available(session, PLAYLIST_NAME) is False
@ -266,7 +285,7 @@ class TestMMModels(unittest.TestCase):
with db.Session() as session: with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME): if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME) playlist = Playlists(session=session, name=PLAYLIST_NAME, template_id=0)
plr = PlaylistRows(session, playlist.id, 1) plr = PlaylistRows(session, playlist.id, 1)
assert plr assert plr
@ -279,7 +298,7 @@ class TestMMModels(unittest.TestCase):
with db.Session() as session: with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME): if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME) playlist = Playlists(session=session, name=PLAYLIST_NAME, template_id=0)
plr = PlaylistRows(session, playlist.id, 1) plr = PlaylistRows(session, playlist.id, 1)
assert plr assert plr

View File

@ -34,8 +34,8 @@ class TestMMMiscTracks(unittest.TestCase):
# Create a playlist and model # Create a playlist and model
with db.Session() as session: with db.Session() as session:
self.playlist = Playlists(session, PLAYLIST_NAME) self.playlist = Playlists(session, PLAYLIST_NAME, template_id=0)
self.model = playlistmodel.PlaylistModel(self.playlist.id) self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False)
for row in range(len(self.test_tracks)): for row in range(len(self.test_tracks)):
track_path = self.test_tracks[row % len(self.test_tracks)] track_path = self.test_tracks[row % len(self.test_tracks)]
@ -56,7 +56,7 @@ class TestMMMiscTracks(unittest.TestCase):
assert max(self.model.playlist_rows.keys()) == 7 assert max(self.model.playlist_rows.keys()) == 7
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
def test_timing_one_track(self): def test_timing_one_track(self):
START_ROW = 0 START_ROW = 0
@ -66,10 +66,10 @@ class TestMMMiscTracks(unittest.TestCase):
self.model.insert_row(proposed_row_number=END_ROW, note="-") self.model.insert_row(proposed_row_number=END_ROW, note="-")
prd = self.model.playlist_rows[START_ROW] prd = self.model.playlist_rows[START_ROW]
qv_value = self.model.display_role( qv_value = self.model._display_role(
START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd
) )
assert qv_value.value() == "start [1 tracks, 4:23 unplayed]" assert qv_value == "start [1 tracks, 4:23 unplayed]"
class TestMMMiscNoPlaylist(unittest.TestCase): class TestMMMiscNoPlaylist(unittest.TestCase):
@ -93,9 +93,9 @@ class TestMMMiscNoPlaylist(unittest.TestCase):
def test_insert_track_new_playlist(self): def test_insert_track_new_playlist(self):
# insert a track into a new playlist # insert a track into a new playlist
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, self.PLAYLIST_NAME) playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0)
# Create a model # Create a model
model = playlistmodel.PlaylistModel(playlist.id) model = playlistmodel.PlaylistModel(playlist.id, is_template=False)
# test repr # test repr
_ = str(model) _ = str(model)
@ -109,7 +109,7 @@ class TestMMMiscNoPlaylist(unittest.TestCase):
_ = str(prd) _ = str(prd)
assert ( assert (
model.edit_role( model._edit_role(
model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd
) )
== metadata["title"] == metadata["title"]
@ -124,8 +124,8 @@ class TestMMMiscRowMove(unittest.TestCase):
db.create_all() db.create_all()
with db.Session() as session: with db.Session() as session:
self.playlist = Playlists(session, self.PLAYLIST_NAME) self.playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0)
self.model = playlistmodel.PlaylistModel(self.playlist.id) self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False)
for row in range(self.ROWS_TO_CREATE): for row in range(self.ROWS_TO_CREATE):
self.model.insert_row(proposed_row_number=row, note=str(row)) self.model.insert_row(proposed_row_number=row, note=str(row))
@ -140,7 +140,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
if row not in [3, 4, 5]: if row not in [3, 4, 5]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 3: elif row == 3:
@ -158,7 +158,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
if row not in [3, 4]: if row not in [3, 4]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 3: elif row == 3:
@ -174,7 +174,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
if row not in [2, 3, 4]: if row not in [2, 3, 4]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 2: elif row == 2:
@ -193,7 +193,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9] assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9]
@ -206,7 +206,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10] assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10]
@ -219,7 +219,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10] assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10]
@ -232,7 +232,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9] assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
@ -246,7 +246,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
@ -262,7 +262,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Test against edit_role because display_role for headers is # Test against edit_role because display_role for headers is
# handled differently (sets up row span) # handled differently (sets up row span)
assert ( assert (
self.model.edit_role( self.model._edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
) )
== note_text == note_text
@ -280,7 +280,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Test against edit_role because display_role for headers is # Test against edit_role because display_role for headers is
# handled differently (sets up row span) # handled differently (sets up row span)
assert ( assert (
self.model.edit_role( self.model._edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
) )
== note_text == note_text
@ -318,8 +318,8 @@ class TestMMMiscRowMove(unittest.TestCase):
model_src = self.model model_src = self.model
with db.Session() as session: with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist) playlist_dst = Playlists(session, destination_playlist, template_id=0)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id) model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
for row in range(self.ROWS_TO_CREATE): for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row)) model_dst.insert_row(proposed_row_number=row, note=str(row))
@ -328,7 +328,7 @@ class TestMMMiscRowMove(unittest.TestCase):
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
assert sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list( assert sorted([a.row_number for a in model_src.playlist_rows.values()]) == list(
range(len(model_src.playlist_rows)) range(len(model_src.playlist_rows))
) )
@ -339,8 +339,8 @@ class TestMMMiscRowMove(unittest.TestCase):
model_src = self.model model_src = self.model
with db.Session() as session: with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist) playlist_dst = Playlists(session, destination_playlist, template_id=0)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id) model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
for row in range(self.ROWS_TO_CREATE): for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row)) model_dst.insert_row(proposed_row_number=row, note=str(row))
@ -353,7 +353,7 @@ class TestMMMiscRowMove(unittest.TestCase):
index = model_dst.index( index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex() row_number, playlistmodel.Col.TITLE.value, QModelIndex()
) )
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value()) row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole))
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
@ -366,8 +366,8 @@ class TestMMMiscRowMove(unittest.TestCase):
model_src = self.model model_src = self.model
with db.Session() as session: with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist) playlist_dst = Playlists(session, destination_playlist, template_id=0)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id) model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
for row in range(self.ROWS_TO_CREATE): for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row)) model_dst.insert_row(proposed_row_number=row, note=str(row))
@ -380,16 +380,16 @@ class TestMMMiscRowMove(unittest.TestCase):
index = model_dst.index( index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex() row_number, playlistmodel.Col.TITLE.value, QModelIndex()
) )
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value()) row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole))
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
assert [int(a) for a in row_notes] == [ assert [int(a) for a in row_notes] == [
0, 0,
1, 1,
1,
3, 3,
4, 4,
1,
2, 2,
3, 3,
4, 4,

130
tests/test_queries.py Normal file
View File

@ -0,0 +1,130 @@
# Standard library imports
import datetime as dt
import unittest
# PyQt imports
# Third party imports
# App imports
from app.models import (
db,
Playdates,
Tracks,
)
from classes import (
Filter,
)
class MyTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class"""
db.create_all()
with db.Session() as session:
# Create some track entries
_ = Tracks(**dict(
session=session,
artist="a",
bitrate=0,
duration=100,
fade_at=0,
path="/alpha/bravo/charlie",
silence_at=0,
start_gap=0,
title="abc"
))
track2 = Tracks(**dict(
session=session,
artist="a",
bitrate=0,
duration=100,
fade_at=0,
path="/xray/yankee/zulu",
silence_at=0,
start_gap=0,
title="xyz"
))
track2_id = track2.id
# Add playdates
# Track 2 played just over a year ago
just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367)
_ = Playdates(session, track2_id, when=just_over_a_year_ago)
@classmethod
def tearDownClass(cls):
"""Runs once after all tests"""
db.drop_all()
def setUp(self):
"""Runs before each test"""
pass
def tearDown(self):
"""Runs after each test"""
pass
def test_search_path_1(self):
"""Search for unplayed track"""
filter = Filter(path="alpha", last_played_comparator="never")
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 1
assert 'alpha' in results[0].path
def test_search_path_2(self):
"""Search for unplayed track that doesn't exist"""
filter = Filter(path="xray", last_played_comparator="never")
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 0
def test_played_over_a_year_ago(self):
"""Search for tracks played over a year ago"""
filter = Filter(last_played_unit="years", last_played_number=1)
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 1
assert 'zulu' in results[0].path
def test_played_over_two_years_ago(self):
"""Search for tracks played over 2 years ago"""
filter = Filter(last_played_unit="years", last_played_number=2)
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 0
def test_never_played(self):
"""Search for tracks never played"""
filter = Filter(last_played_comparator="never")
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 1
assert 'alpha' in results[0].path
def test_played_anytime(self):
"""Search for tracks played over a year ago"""
filter = Filter(last_played_comparator="Any time")
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 1
assert 'zulu' in results[0].path

View File

@ -3,19 +3,15 @@ import os
import unittest import unittest
# PyQt imports # PyQt imports
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
# Third party imports # Third party imports
import pytest import pytest
from pytestqt.plugin import QtBot # type: ignore from pytestqt.plugin import QtBot # type: ignore
# App imports # App imports
from config import Config
from app import playlistmodel, utilities from app import playlistmodel, utilities
from app.models import ( from app.models import (
db, db,
NoteColours,
Playlists, Playlists,
Tracks, Tracks,
) )
@ -63,7 +59,6 @@ class MyTestCase(unittest.TestCase):
"start_gap": 60, "start_gap": 60,
"fade_at": 236263, "fade_at": 236263,
"silence_at": 260343, "silence_at": 260343,
"mtime": 371900000,
}, },
2: { 2: {
"path": "testdata/mom.mp3", "path": "testdata/mom.mp3",
@ -74,7 +69,6 @@ class MyTestCase(unittest.TestCase):
"start_gap": 70, "start_gap": 70,
"fade_at": 115000, "fade_at": 115000,
"silence_at": 118000, "silence_at": 118000,
"mtime": 1642760000,
}, },
} }
@ -82,7 +76,7 @@ class MyTestCase(unittest.TestCase):
for track in self.tracks.values(): for track in self.tracks.values():
db_track = Tracks(session=session, **track) db_track = Tracks(session=session, **track)
session.add(db_track) session.add(db_track)
track['id'] = db_track.id track["id"] = db_track.id
session.commit() session.commit()
@ -96,8 +90,8 @@ class MyTestCase(unittest.TestCase):
playlist_name = "test_init playlist" playlist_name = "test_init playlist"
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, playlist_name) playlist = Playlists(session, playlist_name, template_id=0)
self.widget.create_playlist_tab(playlist) self.widget._open_playlist(playlist, is_template=False)
with self.qtbot.waitExposed(self.widget): with self.qtbot.waitExposed(self.widget):
self.widget.show() self.widget.show()
@ -109,8 +103,8 @@ class MyTestCase(unittest.TestCase):
playlist_name = "test_save_and_restore playlist" playlist_name = "test_save_and_restore playlist"
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, playlist_name) playlist = Playlists(session, playlist_name, template_id=0)
model = playlistmodel.PlaylistModel(playlist.id) model = playlistmodel.PlaylistModel(playlist.id, is_template=False)
# Add a track with a note # Add a track with a note
model.insert_row( model.insert_row(
@ -136,15 +130,16 @@ class MyTestCase(unittest.TestCase):
from config import Config from config import Config
Config.ROOT = os.path.join(os.path.dirname(__file__), 'testdata') Config.ROOT = os.path.join(os.path.dirname(__file__), "testdata")
with db.Session() as session: with db.Session() as session:
utilities.check_db(session) utilities.check_db(session)
utilities.update_bitrates(session) utilities.update_bitrates(session)
# def test_meta_all_clear(qtbot, session): # def test_meta_all_clear(qtbot, session):
# # Create playlist # # Create playlist
# playlist = models.Playlists(session, "my playlist") # playlist = models.Playlists(session, "my playlist", template_id=0)
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -172,7 +167,8 @@ class MyTestCase(unittest.TestCase):
# def test_meta(qtbot, session): # def test_meta(qtbot, session):
# # Create playlist # # Create playlist
# playlist = playlists.Playlists(session, "my playlist") # playlist = playlists.Playlists(session, "my playlist",
# template_id=0)
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -253,7 +249,7 @@ class MyTestCase(unittest.TestCase):
# def test_clear_next(qtbot, session): # def test_clear_next(qtbot, session):
# # Create playlist # # Create playlist
# playlist = models.Playlists(session, "my playlist") # playlist = models.Playlists(session, "my playlist", template_id=0)
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -279,7 +275,7 @@ class MyTestCase(unittest.TestCase):
# # Create playlist and playlist_tab # # Create playlist and playlist_tab
# window = musicmuster.Window() # window = musicmuster.Window()
# playlist = models.Playlists(session, "test playlist") # playlist = models.Playlists(session, "test playlist", template_id=0)
# playlist_tab = playlists.PlaylistTab(window, session, playlist.id) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -311,7 +307,7 @@ class MyTestCase(unittest.TestCase):
# playlist_name = "test playlist" # playlist_name = "test playlist"
# # Create testing playlist # # Create testing playlist
# window = musicmuster.Window() # window = musicmuster.Window()
# playlist = models.Playlists(session, playlist_name) # playlist = models.Playlists(session, playlist_name, template_id=0)
# playlist_tab = playlists.PlaylistTab(window, session, playlist.id) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
# idx = window.tabPlaylist.addTab(playlist_tab, playlist_name) # idx = window.tabPlaylist.addTab(playlist_tab, playlist_name)
# window.tabPlaylist.setCurrentIndex(idx) # window.tabPlaylist.setCurrentIndex(idx)

1316
uv.lock Normal file

File diff suppressed because it is too large Load Diff

16
web.py
View File

@ -1,16 +0,0 @@
#!/usr/bin/env python3
import sys
from PyQt6.QtWidgets import QApplication, QLabel
from PyQt6.QtGui import QColor, QPalette
app = QApplication(sys.argv)
pal = app.palette()
pal.setColor(QPalette.ColorRole.WindowText, QColor("#000000"))
app.setPalette(pal)
label = QLabel("my label")
label.resize(300, 200)
label.show()
sys.exit(app.exec())