Compare commits

...

462 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
Keith Edmunds
2394327d38 Make load playlists the last init action 2024-07-07 11:57:24 +01:00
Keith Edmunds
c7d6ae4cb6 Fix end of track signalling 2024-07-07 11:16:49 +01:00
Keith Edmunds
7333fd570f Error checking, type annotations, minor edits 2024-07-07 10:19:17 +01:00
Keith Edmunds
68a253bc7c Improve type hints, other minor edits 2024-07-06 20:35:06 +01:00
Keith Edmunds
c11573906a Make tick_100ms more efficient 2024-07-06 19:01:27 +01:00
Keith Edmunds
87d2d7adae Add issue 223 debugging and quicklog function 2024-07-06 14:26:29 +01:00
Keith Edmunds
dc3b46d2d6 Unload pygame music file after use 2024-07-06 12:37:15 +01:00
Keith Edmunds
f2867deb2f mypy linting 2024-07-03 18:03:41 +01:00
Keith Edmunds
553376a99e Preview with pygame working 2024-07-03 17:55:09 +01:00
Keith Edmunds
e3d7ae8e0f WIP: preview forward/back working 2024-07-03 16:11:13 +01:00
Keith Edmunds
9656bac49f WIP: preview via pygame working 2024-07-03 15:41:14 +01:00
Keith Edmunds
a971298982 WIP: remove some references to preview track manager 2024-07-03 14:01:34 +01:00
Keith Edmunds
4fe6e9186c Merge branch 'sounddevice' into dev 2024-07-03 13:50:46 +01:00
Keith Edmunds
8bc41f2fcd Fix error message 2024-07-03 13:50:40 +01:00
Keith Edmunds
92eb3fc953 Fix inability to close playlists 2024-07-03 12:52:46 +01:00
Keith Edmunds
a8f709d2da Quieten logging 2024-06-27 21:41:15 +01:00
Keith Edmunds
e711ab84ab Move some logging from info to debug 2024-06-27 20:43:38 +01:00
Keith Edmunds
67bc3377cb Fix logging error 2024-06-27 20:41:56 +01:00
Keith Edmunds
8618813197 Tidy up function 2024-06-23 14:35:05 +01:00
Keith Edmunds
c139215603 Improve drag and drop targetting 2024-06-22 21:52:08 +01:00
Keith Edmunds
3831ebb01d File header, type hints, Black 2024-06-22 21:51:41 +01:00
Keith Edmunds
0cd5d97405 Make default syslog level DEBUG 2024-06-21 23:17:00 +01:00
Keith Edmunds
a8fad358b9 Fix database URL reference 2024-06-20 18:23:04 +01:00
Keith Edmunds
6e4c386fe2 Manage deleting rows better
Fix incorrect updating of track_sequence row numbers.
2024-06-18 19:45:12 +01:00
Keith Edmunds
a4174e84ce Make use of get_tags more resilient 2024-06-17 16:31:23 +01:00
Keith Edmunds
fe8537c9c1 replace_files.py functionality now in musicmuster 2024-06-17 16:18:06 +01:00
Keith Edmunds
5e4277646b Black formatting 2024-06-16 08:40:45 +01:00
Keith Edmunds
71257e4d67 Ensure one db instance only
Ensure testing db is correctly set to sqlite
2024-06-16 08:40:03 +01:00
Keith Edmunds
c0b7bf76f5 Clean up tmpdir after normalise tests 2024-06-16 08:18:24 +01:00
Keith Edmunds
5624d77519 Remove pygame dependency 2024-06-16 08:17:09 +01:00
Keith Edmunds
21156d8fa1 Improve getting/setting of Settings 2024-06-16 08:16:24 +01:00
Keith Edmunds
a46b9a3d6f Return True/False on set_next_row 2024-06-03 20:29:50 +01:00
Keith Edmunds
1ee9a1ae22 Speed up moving rows 2024-06-03 20:29:17 +01:00
Keith Edmunds
e884201df4 Don't accept unreadable track into _TrackManager 2024-06-03 19:06:00 +01:00
Keith Edmunds
2f32f2e914 Update fade graph when starting next track before current has finished 2024-06-03 19:05:19 +01:00
Keith Edmunds
1d51edc50f Most recent track first in tooltips 2024-06-02 21:05:09 +01:00
Keith Edmunds
35b5402853 Fix: end of preview caused main play end of track actions 2024-06-02 20:53:26 +01:00
Keith Edmunds
2a1d9e94bc All tests pass 2024-06-02 19:33:41 +01:00
Keith Edmunds
9f7af072dc Remove carts from tests 2024-06-02 19:28:26 +01:00
Keith Edmunds
648ef76234 Resume working 2024-06-02 19:19:35 +01:00
Keith Edmunds
909fb27bed All preview/intro management working 2024-06-02 17:58:20 +01:00
Keith Edmunds
09fdd7e4dc Display of countdown timer works 2024-06-02 16:50:49 +01:00
Keith Edmunds
983716e009 Row times updating working 2024-06-02 16:34:30 +01:00
Keith Edmunds
4ec1c0e09c Fade graph no longer lagging 2024-06-02 14:31:14 +01:00
Keith Edmunds
0361d25c7b WIP: fade graph working, slightly laggy 2024-06-02 13:33:57 +01:00
Keith Edmunds
c5ca1469dc Remove all carts code 2024-06-02 12:04:26 +01:00
Keith Edmunds
5278b124ca WIP: implemented trackmanager, tracks play, clocks work 2024-06-02 11:57:45 +01:00
Keith Edmunds
fbcedb6c3b Create trackmanager.py
music.py is fully absorbed into trackmanager.py and thus removed
Substantial parts of classes.py are absorbed into trackmanager.py
2024-06-02 10:00:31 +01:00
Keith Edmunds
8ea0a0dad5 WIP: moving player to PlaylistTrack. Player works. 2024-06-01 17:41:22 +01:00
Keith Edmunds
b1f682d2e6 Uncheck preview armed at end of preview 2024-05-25 09:36:19 +01:00
Keith Edmunds
3d3df85845 PoC: added intro time display and editing 2024-05-25 09:29:03 +01:00
Keith Edmunds
8ebaa2798f Set intro timer background colour 2024-05-24 16:48:48 +01:00
Keith Edmunds
afc3014b18 Much improved stderr reporting on exceptions 2024-05-24 15:04:07 +01:00
Keith Edmunds
45a22c47d0 Implement intro timing and countdown 2024-05-24 14:27:00 +01:00
Keith Edmunds
0c03db14d4 Migrate Alembic to Alchemical format 2024-05-24 14:25:54 +01:00
Keith Edmunds
fb5376cdf0 WIP time to vocals: record button icons 2024-05-24 14:25:54 +01:00
Keith Edmunds
01916c4adc WIP: time to vocals: preview +- working 2024-05-24 14:25:51 +01:00
Keith Edmunds
1d33622c13 WIP: time to vocals 2024-05-24 14:20:59 +01:00
Keith Edmunds
b86f0ac1b7 Unifty format of VLC config variables 2024-05-24 14:19:16 +01:00
Keith Edmunds
f7f5579c25 Merge branch 'dev' 2024-05-24 13:43:51 +01:00
Keith Edmunds
3871da048d Reimplement issue #223 fix 2024-05-24 13:36:06 +01:00
Keith Edmunds
73199121d9 Reimplement issue #223 fix 2024-05-24 13:35:56 +01:00
Keith Edmunds
dcbb040045 Merge branch 'dev' 2024-05-23 19:19:35 +01:00
Keith Edmunds
bd125f2a1a Fix typo getting play time in tick_1000ms 2024-05-23 19:19:11 +01:00
Keith Edmunds
fa52b7ffaf Merge branch 'dev' 2024-05-22 16:53:06 +01:00
Keith Edmunds
36e28ca4f4 Fix bug storing open tabs 2024-05-22 16:52:35 +01:00
Keith Edmunds
f2d01e003d Merge changes made to master 2024-05-22 15:47:00 +01:00
Keith Edmunds
71e76e02d1 Merge changes from master 2024-05-22 15:45:21 +01:00
Keith Edmunds
fc4129994b Fix move rows bug
Fixes #244
2024-05-22 15:26:57 +01:00
Keith Edmunds
1421934415 Merge branch 'dev' 2024-05-20 17:55:49 +01:00
Keith Edmunds
a7932adfe4 Add more protection against hitting return twice 2024-05-10 12:48:39 +01:00
Keith Edmunds
f825304de4 Update track times after rescan
Fixes #242
2024-05-10 12:06:04 +01:00
Keith Edmunds
37e450ab22 Bugfix replace files
Fixes #243
2024-05-10 11:48:40 +01:00
Keith Edmunds
be0fc27896 Move player functionality into music.py 2024-05-06 15:55:51 +01:00
Keith Edmunds
4a5fe74a9f Save open state of playlists 2024-05-06 12:25:04 +01:00
Keith Edmunds
d050fa0d84 Fix file importing
Imported track wasn't moved to destination
2024-05-06 12:12:56 +01:00
Keith Edmunds
e25d4ad311 Fixup tests 2024-05-05 18:38:50 +01:00
Keith Edmunds
c1d2fcd8cd Save open tabs properly
Fixes #239
2024-05-04 21:15:08 +01:00
Keith Edmunds
253550b490 Implement SQLAlchemy Pool.pre_ping
Fixes #241
2024-05-04 20:35:14 +01:00
Keith Edmunds
9fb7cce82c Update dependencies 2024-05-04 02:08:18 +01:00
Keith Edmunds
f2db9967fb Reduce stdout output 2024-05-04 02:08:00 +01:00
Keith Edmunds
a24ff76b6b Build in replace_file functionality
Major rewrite of file importing

Fixes #141
2024-05-03 22:40:21 +01:00
Keith Edmunds
6aa09bf28a Save new playlist (commit to db) 2024-05-02 22:44:11 +01:00
Keith Edmunds
049a5508cc Commit when adding track to header
Fixes #238
2024-05-02 19:46:43 +01:00
Keith Edmunds
aa208d72c1 Save to db after rescan
Fixes #237
2024-05-02 19:43:03 +01:00
Keith Edmunds
57e81b0f17 Allow artist search in title box with "a/" 2024-04-28 18:45:37 +01:00
Keith Edmunds
dfc51e1399 Hover previous track to see list
Fixes #205
2024-04-28 17:07:02 +01:00
Keith Edmunds
f898e4645b Hover last played column to show list
Fixes #205
2024-04-28 16:41:16 +01:00
Keith Edmunds
2475f817f9 Merge branch 'dev' 2024-04-28 13:17:29 +01:00
Keith Edmunds
80687df82e Don't react to second of two quick 'return' key presses
Fixes #228
2024-04-28 13:15:15 +01:00
Keith Edmunds
09dcba90a9 Attempt to detect sound system access problem
Fixes #232
2024-04-28 13:02:54 +01:00
Keith Edmunds
1ce64804fb Fix moving rows
Also fix associated tests.
Fixes #234
2024-04-28 12:54:32 +01:00
Keith Edmunds
2a55cd9c92 Replaced obsws-python with obs-websocket-py
Fixes #235
2024-04-28 11:30:54 +01:00
Keith Edmunds
e9a3047f00 Improve logging and FadeCurve generation. Tidy. 2024-04-28 10:50:20 +01:00
Keith Edmunds
feb09db3d7 Merge session.commit() calls 2024-04-27 21:58:26 +01:00
Keith Edmunds
e179e57459 Add required session.commit() calls 2024-04-27 21:56:11 +01:00
Keith Edmunds
cad26ff8f9 Fix #233 2024-04-27 21:54:18 +01:00
Keith Edmunds
9a6313bfae Update environment for Alchemical 2024-04-27 21:54:18 +01:00
Keith Edmunds
46964b5f66 Update tests 2024-04-27 21:54:18 +01:00
Keith Edmunds
1011b2f549 Fixup replace_files for Alchemical 2024-04-27 21:54:18 +01:00
Keith Edmunds
2e8fae99ed Pull in recent V3 updates 2024-04-27 21:54:17 +01:00
Keith Edmunds
e4a9520908 Tidy up tests 2024-04-27 21:52:31 +01:00
Keith Edmunds
52ab4fa43e Remove superflous __repr__ 2024-04-27 21:52:31 +01:00
Keith Edmunds
0519006bb2 Update dependencies 2024-04-27 21:52:31 +01:00
Keith Edmunds
a9763b7a11 Initial GUI test running. Test coverage: 42%. 2024-04-27 21:52:31 +01:00
Keith Edmunds
7cd03d7a2b Fix up db import 2024-04-27 21:52:31 +01:00
Keith Edmunds
a4858761c6 All tests working 2024-04-27 21:52:31 +01:00
Keith Edmunds
76021aa1c6 Put commit()s where needed, move some info to debug logging 2024-04-27 21:52:31 +01:00
Keith Edmunds
16e3c8235e V4 WIP: mostly Black formatting 2024-04-27 21:52:31 +01:00
Keith Edmunds
df620cde86 Migrated to Alchemical 2024-04-27 21:52:30 +01:00
Keith Edmunds
9d44642fea Migrate to Alchemical 2024-04-27 21:52:28 +01:00
Keith Edmunds
6890e0d0c2 Improve test coverage 2024-04-27 21:51:47 +01:00
Keith Edmunds
b8c19c6046 Merge v4 branch 2024-04-27 20:40:17 +01:00
Keith Edmunds
503aac530a Update environment for Alchemical 2024-04-27 20:38:21 +01:00
Keith Edmunds
35438f59fb Update tests 2024-04-27 20:36:04 +01:00
Keith Edmunds
189efb379f Fixup replace_files for Alchemical 2024-04-27 20:25:22 +01:00
Keith Edmunds
134961abfd Pull in recent V3 updates 2024-04-27 19:33:35 +01:00
Keith Edmunds
ebf62fe161 Fix #233 2024-04-27 18:47:06 +01:00
Keith Edmunds
4c638ab608 More issue #223 debug 2024-04-24 17:56:16 +01:00
Keith Edmunds
f1ef2f4d5a Merge branch 'dev' 2024-04-18 19:23:55 +01:00
Keith Edmunds
b562f90b25 Don't track .coverage file 2024-04-13 10:40:04 +01:00
Keith Edmunds
2454e8e4b9 Tidy up logging around issue #223 2024-04-13 10:39:51 +01:00
Keith Edmunds
5f1682c0c6 Tidy up tests 2024-04-06 11:16:00 +01:00
Keith Edmunds
cce7194aa1 Remove superflous __repr__ 2024-04-06 11:15:17 +01:00
Keith Edmunds
c1b0b333b8 Update dependencies 2024-04-06 11:14:51 +01:00
Keith Edmunds
373f4eeb23 Initial GUI test running. Test coverage: 42%. 2024-04-05 21:12:41 +01:00
Keith Edmunds
836d812ef3 Migrate to Alchemical and unittest+pytest framework 2024-04-05 17:47:26 +01:00
Keith Edmunds
6624ac8f31 Fix up db import 2024-04-05 17:29:06 +01:00
Keith Edmunds
c5595bb61b All tests working 2024-04-05 16:42:02 +01:00
Keith Edmunds
92d85304f2 Put commit()s where needed, move some info to debug logging 2024-04-05 14:42:04 +01:00
Keith Edmunds
1a5bd638c0 Merge branch 'dev' 2024-04-05 11:23:59 +01:00
Keith Edmunds
e813a80a5b Debugging for #223 2024-04-05 11:23:00 +01:00
Keith Edmunds
c380d37cf9 V4 WIP: mostly Black formatting 2024-04-05 10:41:14 +01:00
Keith Edmunds
3821a7061b Migrated to Alchemical 2024-04-05 10:38:03 +01:00
Keith Edmunds
6fd541060e Migrate to Alchemical 2024-04-05 10:38:03 +01:00
Keith Edmunds
dbe71c3be4 Improve test coverage 2024-04-05 10:38:03 +01:00
Keith Edmunds
aaf2257117 Fix opening Audacity with non-ASCII file path
Fixes #227 #224
2024-04-05 10:37:22 +01:00
Keith Edmunds
92320c8922 Re-enable status line messages re play controls enabled/disabled 2024-04-05 10:21:26 +01:00
Keith Edmunds
a653bff29e Merge branch 'dev' 2024-04-04 14:17:37 +01:00
Keith Edmunds
6699d829e5 Resize rows on copy/paste
Fixes #226
2024-04-04 14:14:44 +01:00
Keith Edmunds
4714364517 Resize rows on copy/paste
Fixes #226
2024-03-27 08:47:32 +00:00
Keith Edmunds
056c66ebec Re-enable automatic Wikipedia searches 2024-03-27 08:47:32 +00:00
Keith Edmunds
9cec490855 Don't automatically show Wikipedia page (debugging #223) 2024-03-27 08:47:32 +00:00
Keith Edmunds
1a38676ff0 Re-enable automatic Wikipedia searches 2024-03-26 08:28:17 +00:00
Keith Edmunds
851e52c3e1 Use dialog box to check for unintended play next track 2024-03-26 08:23:05 +00:00
Keith Edmunds
cf66cef60a Use dialog box to check for unintended play next track 2024-03-25 17:48:56 +00:00
Keith Edmunds
38bb5dc7cd Merge branch 'dev' 2024-03-25 16:42:25 +00:00
Keith Edmunds
50b051a864 Improve resize rows speed 2024-03-22 14:21:37 +00:00
Keith Edmunds
a7301eb909 Merge branch 'dev' 2024-03-21 16:58:17 +00:00
Keith Edmunds
90697652b0 Speed up row insertion 2024-03-21 16:57:37 +00:00
Keith Edmunds
fd2183b7f0 Merge branch 'dev' 2024-03-08 23:33:46 +00:00
Keith Edmunds
1363010da8 More logging changes to try to debug #223 2024-03-08 23:32:50 +00:00
Keith Edmunds
609544ddd4 Implement random sort 2024-03-08 23:25:07 +00:00
Keith Edmunds
b116f062e9 Update packages, fix one bug 2024-03-01 17:58:25 +00:00
Keith Edmunds
9a9f894215 Don't automatically show Wikipedia page (debugging #223) 2024-03-01 13:08:52 +00:00
Keith Edmunds
ab867c1a67 Logging changes to try to debug #223 2024-03-01 11:07:42 +00:00
Keith Edmunds
1753534e20 Allow canceling Audactity edit
Fixes #221
2024-02-23 17:30:01 +00:00
Keith Edmunds
2932f32771 Highlight releasing player
Work on #223
2024-02-23 12:27:50 +00:00
Keith Edmunds
0e2c8c6056 Fix delete rows bug
Fixes #225
2024-02-23 12:26:42 +00:00
Keith Edmunds
2976ceaa22 Temporarily make releasing music player an error notification
To try to establish whether releasing the music player is related to
2024-02-18 12:47:02 +00:00
Keith Edmunds
f0c6d884ef Fix bug importing tracks with no row selected 2024-02-15 23:00:27 +00:00
Keith Edmunds
5d95748640 Possibly fix row deleting bug
Fixes #220
2024-02-10 15:52:03 +00:00
Keith Edmunds
c511bf053e Rework Audacity initialisation 2024-02-10 15:51:23 +00:00
Keith Edmunds
468ecda450 Don't copy header rows when moving unplayed tracks
Fixes #222
2024-02-10 09:11:28 +00:00
Keith Edmunds
42676789c1 Remove commented-out code 2024-01-19 10:27:53 +00:00
Keith Edmunds
af6e0f69be Speed up changing to tab with lots of tracks
Fixes: #219
2024-01-19 10:16:28 +00:00
Keith Edmunds
2ecb67629e Don't exit if Audacity not running
Handle Audacity integration better

Fixes: #215
2024-01-19 10:12:41 +00:00
Keith Edmunds
4fce750223 Refactor / simplify start/stop times
Fixes #218
2024-01-19 09:58:52 +00:00
Keith Edmunds
fcbdfc65ac Resize rows after hiding/showing played
Fixes #216
2024-01-19 09:57:25 +00:00
Keith Edmunds
c5dd913b98 Resize rows on show/hide played 2024-01-18 22:59:02 +00:00
Keith Edmunds
b0278b92b0 Try to eliminate occasional short pause at start of track
Made playing the track the last thing in play_next()
2024-01-14 15:11:38 +00:00
Keith Edmunds
128fe2925f Disable selected row timing during move_unplayed 2024-01-12 10:36:05 +00:00
Keith Edmunds
84cf22a196 Escape double quotes in finlename for Audacity 2024-01-11 23:08:56 +00:00
Keith Edmunds
d3999ca63d Fix moving rows between playlists 2024-01-05 09:54:55 +00:00
Keith Edmunds
8de9bf0d6e Deselect track after pasting 2023-12-22 15:36:42 +00:00
Keith Edmunds
37711f883f Rework Audacity import/export 2023-12-22 13:40:24 +00:00
Keith Edmunds
3922be2642 Report track import errors correctly 2023-12-22 13:21:12 +00:00
Keith Edmunds
91cef9e506 Fix bug moving unplayed tracks between playlists 2023-12-22 08:14:51 +00:00
Keith Edmunds
c6a0e8c749 Remve log level letter from stderr output 2023-12-21 17:15:44 +00:00
Keith Edmunds
e43c9f3b17 Add successive tracks below those just added 2023-12-17 14:33:24 +00:00
Keith Edmunds
2bf1e442be Fix row spanning error leading to high CPU idle load 2023-12-17 00:12:39 +00:00
Keith Edmunds
4b6c8b0634 Rewrite logging
Add lots of log.info() statements
2023-12-17 00:12:03 +00:00
Keith Edmunds
2432039b72 Best-efforts resize row heights on open 2023-12-16 12:37:41 +00:00
Keith Edmunds
74bdbe2975 Improve open in / import from Audacity 2023-12-16 12:34:23 +00:00
Keith Edmunds
f228a371f2 Ensure all rows correctly resized for height 2023-12-16 02:37:49 +00:00
Keith Edmunds
b74007119d Name source and proxy models consistently 2023-12-16 02:36:16 +00:00
Keith Edmunds
45243759b8 Stackprinter dump if no fade graph 2023-12-15 18:46:30 +00:00
Keith Edmunds
d73bdb264d Don't prompt user when editing if no changes made 2023-12-15 18:46:03 +00:00
Keith Edmunds
90f8e20843 Fix scroll to current/next track with played tracks hidden 2023-12-15 18:27:42 +00:00
Keith Edmunds
184318078f Better fix for setting track/header row spans correctly 2023-12-15 17:55:22 +00:00
Keith Edmunds
c6befd219c Improve playlist load speed 2023-12-15 17:48:42 +00:00
Keith Edmunds
2f0ad5cd52 Fix track rows sometimes displayed as header rows 2023-12-15 14:10:54 +00:00
Keith Edmunds
60c085ad12 Fix errors copy rows from search results 2023-12-14 18:20:16 +00:00
Keith Edmunds
bf438c3d99 Fix sometimes rows too tall after loading playlist 2023-12-14 18:12:00 +00:00
Keith Edmunds
33fdc40f66 Clean up AudacityManager 2023-12-09 14:05:29 +00:00
Keith Edmunds
0082f76b56 Rescan after Audacity 2023-12-08 20:54:37 +00:00
Keith Edmunds
83a817234d Remotely open and save files in Audacity 2023-12-08 19:57:25 +00:00
Keith Edmunds
540846223b WIP Audacity 2023-12-08 18:21:42 +00:00
Keith Edmunds
6985170378 Audacity class 2023-12-08 18:21:42 +00:00
Keith Edmunds
b86b7f7f33 Resize row on insertion
Fixes #207
2023-12-08 18:15:47 +00:00
Keith Edmunds
c1dd111453 Fix add note only from track dialog
Fixes #208
2023-12-08 18:10:38 +00:00
Keith Edmunds
7ed54f2bab Fix issues saving/restoring active tab
Fixes #212
2023-12-08 14:00:59 +00:00
Keith Edmunds
06ef175b46 Fix moving rows when played rows are hidden
Fixes #210
2023-12-08 13:35:22 +00:00
Keith Edmunds
e313e84010 Fix move unplayed rows
Fixes #211
2023-12-07 23:07:45 +00:00
Keith Edmunds
6391490f9d Select next track after header
Fixes #209
2023-12-07 23:04:25 +00:00
Keith Edmunds
243bc765f9 Clean up editing
No need to disable (and re-enable) play controls.

Fixes #191
2023-12-01 22:44:28 +00:00
Keith Edmunds
1b92b79cf0 Stop inadvertent editing of cell after cancelling search 2023-12-01 18:45:30 +00:00
Keith Edmunds
03f19dfb9c Improve loading time for long playlists
Fixes #199
2023-12-01 17:08:13 +00:00
117 changed files with 18387 additions and 8697 deletions

13
.envrc
View File

@ -1,20 +1,25 @@
layout poetry
layout uv
export LINE_PROFILE=1
export MAIL_PASSWORD="ewacyay5seu2qske"
export MAIL_PORT=587
export MAIL_SERVER="smtp.fastmail.com"
export MAIL_USERNAME="kae@midnighthax.com"
export MAIL_USE_TLS=True
export PYGAME_HIDE_SUPPORT_PROMPT=1
branch=$(git branch --show-current)
# Always treat running from /home/kae/mm as production
if [ $(pwd) == /home/kae/mm ]; then
export MM_ENV="PRODUCTION"
export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
export 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
export MM_ENV="PRODUCTION"
export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
else
export MM_ENV="DEVELOPMENT"
export MM_DB="mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster"
export DATABASE_URL="mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster"
export PYTHONBREAKPOINT="pudb.set_trace"
fi

1
.gitattributes vendored Normal file
View File

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

4
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.pyc
*.swp
tags
.venv/
venv/
Session.vim
*.flac
@ -11,3 +12,6 @@ StudioPlaylist.png
*.howto
.direnv
tmp/
.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,91 +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()

View File

@ -1,286 +0,0 @@
from PyQt6.QtCore import Qt
from app import playlists
from app import models
from app import musicmuster
def seed2tracks(session):
tracks = [
{
"path": "testdata/isa.mp3",
"title": "I'm so afraid",
"artist": "Fleetwood Mac",
"duration": 263000,
"start_gap": 60,
"fade_at": 236263,
"silence_at": 260343,
"mtime": 371900000,
},
{
"path": "testdata/mom.mp3",
"title": "Man of Mystery",
"artist": "The Shadows",
"duration": 120000,
"start_gap": 70,
"fade_at": 115000,
"silence_at": 118000,
"mtime": 1642760000,
},
]
for track in tracks:
db_track = models.Tracks(session=session, **track)
session.add(db_track)
session.commit()
def test_init(qtbot, session):
"""Just check we can create a playlist_tab"""
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
assert playlist_tab
def test_save_and_restore(qtbot, session):
"""Playlist with one track, one note, save and restore"""
# Create playlist
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Insert a note
note_text = "my note"
note_row = 7
note = models.Notes(session, playlist.id, note_row, note_text)
playlist_tab._insert_note(session, note)
# Add a track
track_path = "/a/b/c"
track = models.Tracks(session, track_path)
# Inserting the track will also save the playlist
playlist_tab.insert_track(session, track)
# We need to commit the session before re-querying
session.commit()
# Retrieve playlist
all_playlists = playlists.Playlists.get_open(session)
assert len(all_playlists) == 1
retrieved_playlist = all_playlists[0]
paths = [a.path for a in retrieved_playlist.tracks.values()]
assert track_path in paths
notes = [a.note for a in retrieved_playlist.notes]
assert note_text in notes
def test_meta_all_clear(qtbot, session):
# Create playlist
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Add some tracks
# Need to commit session after each one so that new row is found
# for subsequent inserts
track1_path = "/a/b/c"
track1 = models.Tracks(session, track1_path)
playlist_tab.insert_track(session, track1)
session.commit()
track2_path = "/d/e/f"
track2 = models.Tracks(session, track2_path)
playlist_tab.insert_track(session, track2)
session.commit()
track3_path = "/h/i/j"
track3 = models.Tracks(session, track3_path)
playlist_tab.insert_track(session, track3)
session.commit()
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == []
assert playlist_tab._get_played_track_rows() == []
assert len(playlist_tab._get_unreadable_track_rows()) == 3
def test_meta(qtbot, session):
# Create playlist
playlist = playlists.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Add some tracks
track1_path = "/a/b/c"
track1 = models.Tracks(session, track1_path)
playlist_tab.insert_track(session, track1)
session.commit()
track2_path = "/d/e/f"
track2 = models.Tracks(session, track2_path)
playlist_tab.insert_track(session, track2)
session.commit()
track3_path = "/h/i/j"
track3 = models.Tracks(session, track3_path)
playlist_tab.insert_track(session, track3)
session.commit()
assert len(playlist_tab._get_unreadable_track_rows()) == 3
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == []
playlist_tab._set_played_row(0)
assert playlist_tab._get_played_track_rows() == [0]
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == []
# Add a note
note_text = "my note"
note_row = 7 # will be added as row 3
note = models.Notes(session, playlist.id, note_row, note_text)
playlist_tab._insert_note(session, note)
assert playlist_tab._get_played_track_rows() == [0]
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._set_next_track_row(1)
assert playlist_tab._get_played_track_rows() == [0]
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() == 1
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._set_current_track_row(2)
assert playlist_tab._get_played_track_rows() == [0]
assert playlist_tab._get_current_track_row() == 2
assert playlist_tab._get_next_track_row() == 1
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._clear_played_row_status(0)
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() == 2
assert playlist_tab._get_next_track_row() == 1
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._meta_clear_next()
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() == 2
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._clear_current_track_row()
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == [3]
# Test clearing again has no effect
playlist_tab._clear_current_track_row()
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == [3]
def test_clear_next(qtbot, session):
# Create playlist
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Add some tracks
track1_path = "/a/b/c"
track1 = models.Tracks(session, track1_path)
playlist_tab.insert_track(session, track1)
session.commit()
track2_path = "/d/e/f"
track2 = models.Tracks(session, track2_path)
playlist_tab.insert_track(session, track2)
session.commit()
playlist_tab._set_next_track_row(1)
assert playlist_tab._get_next_track_row() == 1
playlist_tab.clear_next(session)
assert playlist_tab._get_next_track_row() is None
def test_get_selected_row(qtbot, monkeypatch, session):
monkeypatch.setattr(musicmuster, "Session", session)
monkeypatch.setattr(playlists, "Session", session)
# Create playlist and playlist_tab
window = musicmuster.Window()
playlist = models.Playlists(session, "test playlist")
playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
# Add some tracks
track1_path = "/a/b/c"
track1 = models.Tracks(session, track1_path)
playlist_tab.insert_track(session, track1)
session.commit()
track2_path = "/d/e/f"
track2 = models.Tracks(session, track2_path)
playlist_tab.insert_track(session, track2)
session.commit()
qtbot.addWidget(playlist_tab)
with qtbot.waitExposed(window):
window.show()
row0_item0 = playlist_tab.item(0, 0)
assert row0_item0 is not None
rect = playlist_tab.visualItemRect(row0_item0)
qtbot.mouseClick(playlist_tab.viewport(), Qt.LeftButton, pos=rect.center())
row_number = playlist_tab.get_selected_row()
assert row_number == 0
def test_set_next(qtbot, monkeypatch, session):
monkeypatch.setattr(musicmuster, "Session", session)
monkeypatch.setattr(playlists, "Session", session)
seed2tracks(session)
playlist_name = "test playlist"
# Create testing playlist
window = musicmuster.Window()
playlist = models.Playlists(session, playlist_name)
playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
idx = window.tabPlaylist.addTab(playlist_tab, playlist_name)
window.tabPlaylist.setCurrentIndex(idx)
qtbot.addWidget(playlist_tab)
# Add some tracks
track1 = models.Tracks.get_by_filename(session, "isa.mp3")
track1_title = track1.title
assert track1_title
playlist_tab.insert_track(session, track1)
session.commit()
track2 = models.Tracks.get_by_filename(session, "mom.mp3")
playlist_tab.insert_track(session, track2)
with qtbot.waitExposed(window):
window.show()
row0_item2 = playlist_tab.item(0, 2)
assert row0_item2 is not None
rect = playlist_tab.visualItemRect(row0_item2)
qtbot.mouseClick(playlist_tab.viewport(), Qt.LeftButton, pos=rect.center())
selected_title = playlist_tab.get_selected_title()
assert selected_title == track1_title
qtbot.keyPress(playlist_tab.viewport(), "N", modifier=Qt.ControlModifier)
qtbot.wait(1000)
def test_kae(monkeypatch, session):
# monkeypatch.setattr(dbconfig, "Session", session)
monkeypatch.setattr(musicmuster, "Session", session)
musicmuster.Window.kae()
# monkeypatch.setattr(musicmuster, "Session", session)
# monkeypatch.setattr(dbconfig, "Session", session)
# monkeypatch.setattr(models, "Session", session)
# monkeypatch.setattr(playlists, "Session", session)

View File

@ -1,18 +1,29 @@
# A generic, single database configuration.
# a multi-database configuration.
[alembic]
# this must be configured to point to the Alchemical database instance
# there are two components separated by a colon:
# the left part is the import path to the module containing the database instance
# the right part is the name of the database instance, typically 'db'
alchemical_db = models:db
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
prepend_sys_path = app
# timezone to use when rendering the date
# within the migration file as well as the filename.
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
@ -30,30 +41,36 @@ prepend_sys_path = .
# versions/ directory
# sourceless = false
# version location specification; this defaults
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = SET
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod
# sqlalchemy.url = mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster
# sqlalchemy.url = mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster_carts
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options=-l 79
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]

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,82 +1,103 @@
# Standard library imports
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
from enum import auto, Enum
import functools
import threading
from typing import NamedTuple
from PyQt6.QtCore import pyqtSignal, QObject, QThread
import numpy as np
import pyqtgraph as pg # type: ignore
# Third party imports
from config import Config
from dbconfig import scoped_session
from models import PlaylistRows
import helpers
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
)
from PyQt6.QtWidgets import (
QProxyStyle,
QStyle,
QStyleOption,
)
# App imports
class FadeCurve:
GraphWidget = None
# 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)
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
Added locking.
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
return None
lock = threading.Lock()
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms = max(0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.end_ms = track_silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
@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
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.region = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self):
self.curve = self.GraphWidget.plot(self.graph_array)
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
def tick(self, play_time) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
wrapper_singleton.instance = None
return wrapper_singleton
@helpers.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):
START_GAP = 0
TITLE = auto()
ARTIST = auto()
INTRO = auto()
DURATION = auto()
START_TIME = auto()
END_TIME = auto()
LAST_PLAYED = auto()
BITRATE = auto()
NOTE = auto()
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
class MusicMusterSignals(QObject):
"""
Class for all MusicMuster signals. See:
- https://zetcode.com/gui/pyqt5/eventssignals/
- https://stackoverflow.com/questions/62654525/
emit-a-signal-from-another-class-to-main-class
and Singleton class at
https://refactoring.guru/design-patterns/singleton/python/example#example-0
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
"""
begin_reset_model_signal = pyqtSignal(int)
@ -84,140 +105,50 @@ class MusicMusterSignals(QObject):
end_reset_model_signal = pyqtSignal(int)
next_track_changed_signal = pyqtSignal()
resize_rows_signal = pyqtSignal(int)
row_order_changed_signal = pyqtSignal(int)
search_songfacts_signal = pyqtSignal(str)
search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str)
span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int)
track_ended_signal = pyqtSignal()
def __post_init__(self):
super().__init__()
class PlaylistTrack:
"""
Used to provide a single reference point for specific playlist tracks,
typically the previous, current and next track.
"""
def __init__(self) -> None:
class PlaylistStyle(QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Only initialises data structure. Call set_plr to populate.
Draw a line across the entire row rather than just the column
we're hovering over.
"""
self.artist: Optional[str] = None
self.duration: Optional[int] = None
self.end_time: Optional[datetime] = None
self.fade_at: Optional[int] = None
self.fade_graph: Optional[FadeCurve] = None
self.fade_length: Optional[int] = None
self.path: Optional[str] = None
self.playlist_id: Optional[int] = None
self.plr_id: Optional[int] = None
self.plr_rownum: Optional[int] = None
self.resume_marker: Optional[float] = None
self.silence_at: Optional[int] = None
self.start_gap: Optional[int] = None
self.start_time: Optional[datetime] = None
self.title: Optional[str] = None
self.track_id: Optional[int] = None
def __repr__(self) -> str:
return (
f"<PlaylistTrack(title={self.title}, artist={self.artist}, "
f"plr_rownum={self.plr_rownum}, playlist_id={self.playlist_id}>"
)
def set_plr(self, session: scoped_session, plr: PlaylistRows) -> None:
"""
Update with new plr information
"""
session.add(plr)
self.plr_rownum = plr.plr_rownum
if not plr.track:
return
track = plr.track
self.artist = track.artist
self.duration = track.duration
self.end_time = None
self.fade_at = track.fade_at
self.path = track.path
self.playlist_id = plr.playlist_id
self.plr_id = plr.id
self.silence_at = track.silence_at
self.start_gap = track.start_gap
self.start_time = None
self.title = track.title
self.track_id = track.id
if track.silence_at and track.fade_at:
self.fade_length = track.silence_at - track.fade_at
# Initialise and add FadeCurve in a thread as it's slow
# Import in separate thread
self.fadecurve_thread = QThread()
self.worker = AddFadeCurve(
self,
track_path=track.path,
track_fade_at=track.fade_at,
track_silence_at=track.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def start(self) -> None:
"""
Called when track starts playing
"""
self.start_time = datetime.now()
if self.duration:
self.end_time = self.start_time + timedelta(milliseconds=self.duration)
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)
class AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
playlist_track: PlaylistTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
):
super().__init__()
self.playlist_track = playlist_track
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self):
"""
Create fade curve and add to PlaylistTrack object
"""
self.playlist_track.fade_graph = FadeCurve(
self.track_path, self.track_fade_at, self.track_silence_at
)
self.finished.emit()
class QueryCol(Enum):
TITLE = 0
ARTIST = auto()
DURATION = auto()
LAST_PLAYED = auto()
BITRATE = auto()
class TrackSequence:
next = PlaylistTrack()
now = PlaylistTrack()
previous = PlaylistTrack()
class Tags(NamedTuple):
artist: str = ""
title: str = ""
bitrate: int = 0
duration: int = 0
track_sequence = TrackSequence()
class TrackInfo(NamedTuple):
track_id: int
row_number: int

View File

@ -1,24 +1,25 @@
import datetime
# Standard library imports
import datetime as dt
import logging
import os
from typing import List, Optional
# PyQt imports
# Third party imports
# App imports
class Config(object):
AUDACITY_TEST_COMMAND = "Message"
AUDACITY_TEST_RESPONSE = "Some message"
AUDACITY_TIMEOUT_SECONDS = 20
AUDIO_SEGMENT_CHUNK_SIZE = 10
BITRATE_LOW_THRESHOLD = 192
BITRATE_OK_THRESHOLD = 300
CART_DIRECTORY = "/home/kae/radio/CartTracks"
CARTS_COUNT = 10
CARTS_HIDE = True
COLOUR_BITRATE_LOW = "#ffcdd2"
COLOUR_BITRATE_MEDIUM = "#ffeb6f"
COLOUR_BITRATE_OK = "#dcedc8"
COLOUR_CART_ERROR = "#dc3545"
COLOUR_CART_PLAYING = "#248f24"
COLOUR_CART_PROGRESSBAR = "#000000"
COLOUR_CART_READY = "#ffc107"
COLOUR_CART_UNCONFIGURED = "#f2f2f2"
COLOUR_CURRENT_PLAYLIST = "#7eca8f"
COLOUR_CURRENT_TAB = "#248f24"
COLOUR_ENDING_TIMER = "#dc3545"
@ -30,35 +31,62 @@ class Config(object):
COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ["dbconfig"]
DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False
EPOCH = datetime.datetime(1970, 1, 1)
DO_NOT_IMPORT = "Do not import"
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_TO = ["kae@midnighthax.com"]
EXTERNAL_BROWSER_PATH = "/usr/bin/vivaldi"
FADE_CURVE_BACKGROUND = "lightyellow"
FADE_CURVE_FOREGROUND = "blue"
FADE_CURVE_MS_BEFORE_FADE = 5000
FADEOUT_DB = -10
FADEOUT_SECONDS = 5
FADEOUT_STEPS_PER_SECOND = 5
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_BITRATE = "bps"
HEADER_DURATION = "Length"
HEADER_END_TIME = "End"
HEADER_INTRO = "Intro"
HEADER_LAST_PLAYED = "Last played"
HEADER_NOTE = "Notes"
HEADER_START_GAP = "Gap"
HEADER_START_TIME = "Start"
HEADER_TITLE = "Title"
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
INTRO_SECONDS_FORMAT = ".1f"
INTRO_SECONDS_WARNING_MS = 3000
LAST_PLAYED_TODAY_STRING = "Today"
LOG_LEVEL_STDERR = logging.ERROR
LAST_PLAYED_TOOLTIP_DATE_FORMAT = "%a, %d %b %Y"
LOG_LEVEL_STDERR = logging.INFO
LOG_LEVEL_SYSLOG = logging.DEBUG
LOG_NAME = "musicmuster"
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
@ -66,26 +94,52 @@ class Config(object):
MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
MAIN_WINDOW_TITLE = "MusicMuster"
MAX_IMPORT_MATCHES = 5
MAX_IMPORT_THREADS = 3
MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0
MINIMUM_ROW_HEIGHT = 30
NO_QUERY_NAME = "Select query"
NO_TEMPLATE_NAME = "None"
NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost"
OBS_PASSWORD = "auster"
OBS_PORT = 4455
PLAY_NEXT_GUARD_MS = 10000
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_BACK_MS = 5000
PREVIEW_END_BUFFER_MS = 1000
REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp"
RESIZE_ROW_CHUNK_SIZE = 40
RETURN_KEY_DEBOUNCE_MS = 1000
ROOT = os.environ.get("ROOT") or "/home/kae/music"
ROW_PADDING = 4
ROWS_FROM_ZERO = True
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
SCROLL_TOP_MARGIN = 3
SECTION_ENDINGS = ("-", "+-", "-+")
SECTION_HEADER = "[Section header]"
SECTION_STARTS = ("+", "+-", "-+")
SONGFACTS_ON_NEXT = False
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"
TRACK_TIME_FORMAT = "%H:%M:%S"
VOLUME_VLC_DEFAULT = 75
VOLUME_VLC_DROP3db = 65
VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player"
VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player"
VLC_VOLUME_DEFAULT = 100
VLC_VOLUME_DROP3db = 70
WARNING_MS_BEFORE_FADE = 5500
WARNING_MS_BEFORE_SILENCE = 5500
WEB_ZOOM_FACTOR = 1.2
WIKIPEDIA_ON_NEXT = False
# These rely on earlier definitions
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_TRACKS
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE)

View File

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

31
app/dbmanager.py Normal file
View File

@ -0,0 +1,31 @@
# Standard library imports
# PyQt imports
# Third party imports
from alchemical import Alchemical # type:ignore
# App imports
class DatabaseManager:
"""
Singleton class to ensure we only ever have one db object
"""
__instance = None
def __init__(self, database_url: str, **kwargs: dict) -> None:
if DatabaseManager.__instance is None:
self.db = Alchemical(database_url, **kwargs)
# Database managed by Alembic so no create_all() required
# self.db.create_all()
DatabaseManager.__instance = self
else:
raise Exception("Attempted to create a second DatabaseManager instance")
@staticmethod
def get_instance(database_url: str, **kwargs: dict) -> Alchemical:
if DatabaseManager.__instance is None:
DatabaseManager(database_url, **kwargs)
return DatabaseManager.__instance

226
app/dbtables.py Normal file
View File

@ -0,0 +1,226 @@
# Standard library imports
from typing import Optional
from dataclasses import asdict
import datetime as dt
import json
# PyQt imports
# Third party imports
from alchemical import Model # type: ignore
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
String,
)
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from sqlalchemy.types import TypeDecorator, TEXT
# 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
class NoteColoursTable(Model):
__tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=True, unique=True)
colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True)
foreground: Mapped[Optional[str]] = mapped_column(String(21), index=False)
is_regex: Mapped[bool] = mapped_column(default=False, index=False)
is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
order: Mapped[Optional[int]] = mapped_column(index=True)
strip_substring: Mapped[bool] = mapped_column(default=True, index=False)
def __repr__(self) -> str:
return (
f"<NoteColours(id={self.id}, substring={self.substring}, "
f"colour={self.colour}>"
)
class PlaydatesTable(Model):
__tablename__ = "playdates"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
track: Mapped["TracksTable"] = relationship(
"TracksTable",
back_populates="playdates",
)
def __repr__(self) -> str:
return (
f"<Playdates(id={self.id}, track_id={self.track_id} "
f"lastplayed={self.lastplayed}>"
)
class PlaylistsTable(Model):
"""
Manage playlists
"""
__tablename__ = "playlists"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(32), unique=True)
last_used: Mapped[Optional[dt.datetime]] = mapped_column(DateTime, default=None)
tab: Mapped[Optional[int]] = mapped_column(default=None)
open: Mapped[bool] = mapped_column(default=False)
is_template: Mapped[bool] = mapped_column(default=False)
rows: Mapped[list["PlaylistRowsTable"]] = relationship(
"PlaylistRowsTable",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistRowsTable.row_number",
)
favourite: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
return (
f"<Playlists(id={self.id}, name={self.name}, "
f"is_templatee={self.is_template}, open={self.open}>"
)
class PlaylistRowsTable(Model):
__tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
row_number: Mapped[int] = mapped_column(index=True)
note: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False
)
playlist_id: Mapped[int] = mapped_column(
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
)
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE")
)
track: Mapped["TracksTable"] = relationship(
"TracksTable",
back_populates="playlistrows",
)
played: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
return (
f"<PlaylistRows(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, "
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):
"""Manage settings"""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
f_datetime: Mapped[Optional[dt.datetime]] = mapped_column(default=None)
f_int: Mapped[Optional[int]] = mapped_column(default=None)
f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str:
return (
f"<Settings(id={self.id}, name={self.name}, "
f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>"
)
class TracksTable(Model):
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[int] = mapped_column(default=None)
duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False)
intro: Mapped[Optional[int]] = mapped_column(default=None)
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False)
title: Mapped[str] = mapped_column(String(256), index=True)
playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable",
back_populates="track",
cascade="all, delete-orphan",
)
playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[list[PlaydatesTable]] = relationship(
"PlaydatesTable",
back_populates="track",
cascade="all, delete-orphan",
lazy="joined",
)
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)

View File

@ -1,18 +1,29 @@
# Standard library imports
from typing import Optional
# PyQt imports
from PyQt6.QtCore import QEvent, Qt
from PyQt6.QtWidgets import QDialog, QListWidgetItem
from PyQt6.QtGui import QKeyEvent
from PyQt6.QtWidgets import (
QDialog,
QListWidgetItem,
QMainWindow,
)
# Third party imports
from sqlalchemy.orm.session import Session
# App imports
from classes import MusicMusterSignals
from dbconfig import scoped_session
from helpers import (
ask_yes_no,
get_relative_date,
ms_to_mmss,
)
from log import log
from models import Settings, Tracks
from playlistmodel import PlaylistModel
from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore
from ui import dlg_TrackSelect_ui
class TrackSelectDialog(QDialog):
@ -20,23 +31,24 @@ class TrackSelectDialog(QDialog):
def __init__(
self,
session: scoped_session,
parent: QMainWindow,
session: Session,
new_row_number: int,
model: PlaylistModel,
base_model: PlaylistModel,
add_to_header: Optional[bool] = False,
*args,
**kwargs,
*args: Qt.WindowType,
**kwargs: Qt.WindowType,
) -> None:
"""
Subclassed QDialog to manage track selection
"""
super().__init__(*args, **kwargs)
super().__init__(parent, *args, **kwargs)
self.session = session
self.new_row_number = new_row_number
self.model = model
self.base_model = base_model
self.add_to_header = add_to_header
self.ui = Ui_Dialog()
self.ui = dlg_TrackSelect_ui.Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
@ -48,12 +60,16 @@ class TrackSelectDialog(QDialog):
self.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
record = Settings.get_int_settings(self.session, "dbdialog_width")
record = Settings.get_setting(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(self.session, "dbdialog_height")
record = Settings.get_setting(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
if add_to_header:
self.ui.lblNote.setVisible(False)
self.ui.txtNote.setVisible(False)
def add_selected(self) -> None:
"""Handle Add button"""
@ -66,20 +82,29 @@ class TrackSelectDialog(QDialog):
note = self.ui.txtNote.text()
if not note and not track:
if not (track or note):
return
track_id = None
if track:
track_id = track.id
if note and not track_id:
self.base_model.insert_row(self.new_row_number, track_id, note)
self.ui.txtNote.clear()
self.new_row_number += 1
return
self.ui.txtNote.clear()
self.select_searchtext()
track_id = None
if track:
track_id = track.id
else:
if track_id is None:
log.error("track_id is None and should not be")
return
# Check whether track is already in playlist
move_existing = False
existing_prd = self.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 ask_yes_no(
"Duplicate row",
@ -87,18 +112,26 @@ class TrackSelectDialog(QDialog):
default_yes=True,
):
move_existing = True
if self.add_to_header and existing_prd: # "and existing_prd" for mypy's benefit
if move_existing:
self.model.move_track_to_header(self.new_row_number, existing_prd, note)
if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.base_model.move_track_to_header(
self.new_row_number, existing_prd, note
)
else:
self.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
self.accept()
else:
# Adding a new track row
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.model.move_track_add_note(self.new_row_number, existing_prd, note)
self.base_model.move_track_add_note(
self.new_row_number, existing_prd, note
)
else:
self.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
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
@ -111,7 +144,9 @@ class TrackSelectDialog(QDialog):
self.ui.matchList.clear()
if len(s) > 0:
if self.ui.radioTitle.isChecked():
if s.startswith("a/") and len(s) > 2:
matches = Tracks.search_artists(self.session, "%" + s[2:])
elif self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, "%" + s)
else:
matches = Tracks.search_artists(self.session, "%" + s)
@ -141,22 +176,22 @@ class TrackSelectDialog(QDialog):
if not event:
return
record = Settings.get_int_settings(self.session, "dbdialog_height")
if record.f_int != self.height():
record.update(self.session, {"f_int": self.height()})
record = Settings.get_setting(self.session, "dbdialog_height")
record.f_int = self.height()
record = Settings.get_int_settings(self.session, "dbdialog_width")
if record.f_int != self.width():
record.update(self.session, {"f_int": self.width()})
record = Settings.get_setting(self.session, "dbdialog_width")
record.f_int = self.width()
self.session.commit()
event.accept()
def keyPressEvent(self, event):
def keyPressEvent(self, event: QKeyEvent | None) -> None:
"""
Clear selection on ESC if there is one
"""
if event.key() == Qt.Key.Key_Escape:
if event and event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return

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,35 +1,43 @@
from datetime import datetime
# Standard library imports
import datetime as dt
from email.message import EmailMessage
from typing import Any, Dict, Optional
import functools
from typing import Optional
import os
import psutil
import re
import shutil
import smtplib
import ssl
import tempfile
# PyQt imports
from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget
# Third party imports
import filetype
from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects
from pydub.utils import mediainfo
from PyQt6.QtWidgets import QMainWindow, QMessageBox
from tinytag import TinyTag # type: ignore
from tinytag import TinyTag, TinyTagException # type: ignore
# App imports
from classes import AudioMetadata, ApplicationError, Tags
from config import Config
from log import log
from models import Tracks
start_time_re = re.compile(r"@\d\d:\d\d")
# Classes are defined after global functions so that classes can use
# those functions.
def ask_yes_no(title: str, question: str, default_yes: bool = False) -> bool:
def ask_yes_no(
title: str,
question: str,
default_yes: bool = False,
parent: Optional[QMainWindow] = None,
) -> bool:
"""Ask question; return True for yes, False for no"""
dlg = QMessageBox()
dlg = QMessageBox(parent)
dlg.setWindowTitle(title)
dlg.setText(question)
dlg.setStandardButtons(
@ -43,6 +51,14 @@ def ask_yes_no(title: str, question: str, default_yes: bool = False) -> bool:
return button == QMessageBox.StandardButton.Yes
def audio_file_extension(fpath: str) -> str | None:
"""
Return the correct extension for this type of file.
"""
return filetype.guess(fpath).extension
def fade_point(
audio_segment: AudioSegment,
fade_threshold: float = 0.0,
@ -65,7 +81,7 @@ def fade_point(
fade_threshold = max_vol
while (
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
audio_segment[trim_ms: trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0
): # noqa W503
trim_ms -= chunk_size
@ -87,6 +103,9 @@ def file_is_unreadable(path: Optional[str]) -> bool:
def get_audio_segment(path: str) -> Optional[AudioSegment]:
if not path.endswith(audio_file_extension(path)):
return None
try:
if path.endswith(".mp3"):
return AudioSegment.from_mp3(path)
@ -98,7 +117,7 @@ def get_audio_segment(path: str) -> Optional[AudioSegment]:
return None
def get_embedded_time(text: str) -> Optional[datetime]:
def get_embedded_time(text: str) -> Optional[dt.datetime]:
"""Return datetime specified as @hh:mm in text"""
try:
@ -109,25 +128,30 @@ def get_embedded_time(text: str) -> Optional[datetime]:
return None
try:
return datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
return dt.datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
except ValueError:
return None
def get_file_metadata(filepath: str) -> dict:
"""Return track metadata"""
def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]:
"""Return all track metadata"""
# Get title, artist, bitrate, duration, path
metadata: Dict[str, str | int | float] = get_tags(filepath)
return (
get_audio_metadata(filepath)._asdict()
| get_tags(filepath)._asdict()
| dict(path=filepath)
)
metadata["mtime"] = os.path.getmtime(filepath)
def get_audio_metadata(filepath: str) -> AudioMetadata:
"""Return audio metadata"""
# Set start_gap, fade_at and silence_at
audio = get_audio_segment(filepath)
if not audio:
audio_values = dict(start_gap=0, fade_at=0, silence_at=0)
return AudioMetadata()
else:
audio_values = dict(
return AudioMetadata(
start_gap=leading_silence(audio),
fade_at=int(
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
@ -136,13 +160,27 @@ def get_file_metadata(filepath: str) -> dict:
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(
past_date: Optional[datetime], reference_date: Optional[datetime] = None
past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None
) -> str:
"""
Return how long before reference_date past_date is as string.
@ -157,7 +195,7 @@ def get_relative_date(
if not past_date or past_date == Config.EPOCH:
return "Never"
if not reference_date:
reference_date = datetime.now()
reference_date = dt.datetime.now()
# Check parameters
if past_date > reference_date:
@ -180,22 +218,34 @@ def get_relative_date(
days_str = "day"
else:
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.
"""
tag = TinyTag.get(path)
try:
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,
artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path,
)
@ -261,13 +311,13 @@ def ms_to_mmss(
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
def normalise_track(path):
def normalise_track(path: str) -> None:
"""Normalise track"""
# Check type
ftype = os.path.splitext(path)[1][1:]
if ftype not in ["mp3", "flac"]:
log.info(
log.error(
f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
)
@ -294,6 +344,7 @@ def normalise_track(path):
os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode)
# Copy tags
tag_handler: type[FLAC | MP3]
if ftype == "flac":
tag_handler = FLAC
elif ftype == "mp3":
@ -314,57 +365,33 @@ def normalise_track(path):
os.remove(temp_path)
def open_in_audacity(path: str) -> bool:
def remove_substring_case_insensitive(parent_string: str, substring: str) -> str:
"""
Open passed file in Audacity
Return True if apparently opened successfully, else False
Remove all instances of substring from parent string, case insensitively
"""
# Return if audacity not running
if "audacity" not in [i.name() for i in psutil.process_iter()]:
return False
# Convert both strings to lowercase for case-insensitive comparison
lower_parent = parent_string.lower()
lower_substring = substring.lower()
# Return if path not given
if not path:
return False
# Initialize the result string
result = parent_string
to_pipe: str = "/tmp/audacity_script_pipe.to." + str(os.getuid())
from_pipe: str = "/tmp/audacity_script_pipe.from." + str(os.getuid())
eol: str = "\n"
# 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)
def send_command(command: str) -> None:
"""Send a single command."""
to_audacity.write(command + eol)
to_audacity.flush()
# Remove the substring
result = result[:index] + result[index + len(substring) :]
def get_response() -> str:
"""Return the command response."""
# Update the lowercase versions
lower_parent = result.lower()
result: str = ""
line: str = ""
while True:
result += line
line = from_audacity.readline()
if line == "\n" and len(result) > 0:
break
return result
def do_command(command: str) -> str:
"""Send one command, and return the response."""
send_command(command)
response = get_response()
return response
with open(to_pipe, "w") as to_audacity, open(from_pipe, "rt") as from_audacity:
do_command(f'Import2: Filename="{path}"')
return True
return result
def send_mail(to_addr, from_addr, subj, body):
def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
# From https://docs.python.org/3/library/email.examples.html
# Create a text/plain message
@ -390,43 +417,36 @@ def send_mail(to_addr, from_addr, subj, body):
s.quit()
def set_track_metadata(track):
def set_track_metadata(track: Tracks) -> None:
"""Set/update track metadata in database"""
metadata = get_file_metadata(track.path)
audio_metadata = get_audio_metadata(track.path)
tags = get_tags(track.path)
for key in metadata:
setattr(track, key, metadata[key])
for audio_key in AudioMetadata._fields:
setattr(track, audio_key, getattr(audio_metadata, audio_key))
for tag_key in Tags._fields:
setattr(track, tag_key, getattr(tags, tag_key))
def show_OK(parent: QMainWindow, title: str, msg: str) -> None:
def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
"""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: QMainWindow, title: str, msg: str) -> None:
def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
"""Display a warning to user"""
QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel)
def singleton(cls):
"""
Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons)
"""
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if not wrapper_singleton.instance:
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None
return wrapper_singleton
def trailing_silence(
audio_segment: AudioSegment,
silence_threshold: int = -50,

View File

@ -1,84 +0,0 @@
import urllib.parse
from datetime import datetime
from slugify import slugify # type: ignore
from typing import Dict
from PyQt6.QtCore import QUrl # type: ignore
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QTabWidget
from config import Config
from classes import MusicMusterSignals
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, datetime] = {}
self.tabtitles: Dict[int, str] = {}
# Create one tab which (for some reason) creates flickering if
# done later
widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
self.last_update[widget] = datetime.now()
_ = self.addTab(widget, "")
def open_in_songfacts(self, title):
"""Search Songfacts for title"""
slug = slugify(title, replacements=([["'", ""]]))
url = f"https://www.songfacts.com/search/songs/{slug}"
self.open_tab(url, title)
def open_in_wikipedia(self, title):
"""Search Wikipedia for title"""
str = urllib.parse.quote_plus(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] = 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,90 +1,138 @@
#!/usr/bin/python3
#!/usr/bin/env python3
# Standard library imports
from collections import defaultdict
from functools import wraps
import logging
import logging.config
import logging.handlers
import os
import stackprinter # type: ignore
import sys
import traceback
import yaml
# PyQt imports
from PyQt6.QtWidgets import QApplication, QMessageBox
# Third party imports
import stackprinter # type: ignore
# App imports
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):
"""Add leveltag"""
def filter(self, record: logging.LogRecord):
def filter(self, record: logging.LogRecord) -> bool:
# Extract the first character of the level name
record.leveltag = record.levelname[0]
# We never actually filter messages out, just abuse filtering to add an
# extra field to the LogRecord
# We never actually filter messages out, just add an extra field
# to the LogRecord
return True
class DebugStdoutFilter(logging.Filter):
"""Filter debug messages sent to stdout"""
def filter(self, record: logging.LogRecord):
# Exceptions are logged at ERROR level
if record.levelno in [logging.DEBUG, logging.ERROR]:
return True
if record.module in Config.DEBUG_MODULES:
return True
if record.funcName in Config.DEBUG_FUNCTIONS:
return True
return False
# 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.setLevel(logging.DEBUG)
# stderr
stderr = logging.StreamHandler()
stderr.setLevel(Config.LOG_LEVEL_STDERR)
# syslog
syslog = logging.handlers.SysLogHandler(address="/dev/log")
syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
# Filter
local_filter = LevelTagFilter()
debug_filter = DebugStdoutFilter()
syslog.addFilter(local_filter)
stderr.addFilter(local_filter)
stderr.addFilter(debug_filter)
stderr_fmt = logging.Formatter(
"[%(asctime)s] %(leveltag)s: %(message)s", datefmt="%H:%M:%S"
)
syslog_fmt = logging.Formatter(
"[%(name)s] %(module)s.%(funcName)s - %(leveltag)s: %(message)s"
)
stderr.setFormatter(stderr_fmt)
syslog.setFormatter(syslog_fmt)
log.addHandler(stderr)
log.addHandler(syslog)
def log_uncaught_exceptions(_ex_cls, ex, tb):
from helpers import send_mail
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("\033[1;31;47m")
logging.critical("".join(traceback.format_tb(tb)))
print("\033[1;37;40m")
print(
stackprinter.format(
ex, suppressed_paths=["/pypoetry/virtualenvs/"], style="darkbg"
)
)
if os.environ["MM_ENV"] == "PRODUCTION":
msg = stackprinter.format(ex)
send_mail(
Config.ERRORS_TO, Config.ERRORS_FROM, "Exception from musicmuster", msg
)
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
send_mail(
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"

File diff suppressed because it is too large Load Diff

View File

@ -1,139 +0,0 @@
import threading
import vlc # type: ignore
from config import Config
from helpers import file_is_unreadable
from typing import Optional
from time import sleep
from log import log
from PyQt6.QtCore import (
QRunnable,
QThreadPool,
)
lock = threading.Lock()
class FadeTrack(QRunnable):
def __init__(self, player: vlc.MediaPlayer, fade_seconds) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.player.stop()
log.debug(f"Releasing player {self.player=}")
self.player.release()
class Music:
"""
Manage the playing of music tracks
"""
def __init__(self) -> None:
self.VLC = vlc.Instance()
self.player = None
self.max_volume = Config.VOLUME_VLC_DEFAULT
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""
Fade the currently playing track.
The actual management of fading runs in its own thread so as not
to hold up the UI during the fade.
"""
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
# 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()
fader = FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def play(self, path: str, position: Optional[float] = None) -> None:
"""
Start playing the track at path.
Log and return if path not found.
"""
if file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
media = self.VLC.media_new_path(path)
self.player = media.player_new_from_media()
if self.player:
_ = self.player.play()
self.set_volume(self.max_volume)
if position:
self.player.set_position(position)
def set_volume(self, volume=None, set_default=True) -> None:
"""Set maximum volume used for player"""
if not self.player:
return
if set_default:
self.max_volume = volume
if volume is None:
volume = Config.VOLUME_VLC_DEFAULT
self.player.audio_set_volume(volume)
def stop(self) -> float:
"""Immediately stop playing"""
if not self.player:
return 0.0
p = self.player
self.player = None
with lock:
position = p.get_position()
p.stop()
p.release()
p = None
return position

724
app/music_manager.py Normal file
View File

@ -0,0 +1,724 @@
# Standard library imports
from __future__ import annotations
import datetime as dt
from time import sleep
from typing import Optional
# Third party imports
# import line_profiler
import numpy as np
import pyqtgraph as pg # type: ignore
from sqlalchemy.orm.session import Session
import vlc # type: ignore
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QThread,
)
from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
# App imports
from classes import ApplicationError, MusicMusterSignals
from config import Config
import helpers
from log import log
from models import PlaylistRows
from vlcmanager import VLCManager
# Define the VLC callback function type
# import ctypes
# import platform
# VLC logging is very noisy so comment out unless needed
# VLC_LOG_CB = ctypes.CFUNCTYPE(
# None,
# ctypes.c_void_p,
# ctypes.c_int,
# ctypes.c_void_p,
# ctypes.c_char_p,
# ctypes.c_void_p,
# )
# # Determine the correct C library for vsnprintf based on the platform
# if platform.system() == "Windows":
# libc = ctypes.CDLL("msvcrt")
# elif platform.system() == "Linux":
# libc = ctypes.CDLL("libc.so.6")
# elif platform.system() == "Darwin": # macOS
# libc = ctypes.CDLL("libc.dylib")
# else:
# raise OSError("Unsupported operating system")
# # Define the vsnprintf function
# libc.vsnprintf.argtypes = [
# ctypes.c_char_p,
# ctypes.c_size_t,
# ctypes.c_char_p,
# ctypes.c_void_p,
# ]
# libc.vsnprintf.restype = ctypes.c_int
class _AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
rat: RowAndTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.rat = rat
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self) -> None:
"""
Create fade curve and add to PlaylistTrack object
"""
fc = _FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.rat.fade_graph = fc
self.finished.emit()
class _FadeCurve:
GraphWidget: Optional[PlotWidget] = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.curve: Optional[PlotDataItem] = None
self.region: Optional[LinearRegionItem] = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self) -> None:
if self.GraphWidget:
self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time: int) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QThread):
finished = pyqtSignal()
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
if total_steps > 0:
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.finished.emit()
# TODO can we move this into the _Music class?
vlc_instance = VLCManager().vlc_instance
class _Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name: str) -> None:
vlc_instance.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
self.name = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
# Set up logging
# self._set_vlc_log()
# VLC logging very noisy so comment out unless needed
# @VLC_LOG_CB
# def log_callback(data, level, ctx, fmt, args):
# try:
# # Create a ctypes string buffer to hold the formatted message
# buf = ctypes.create_string_buffer(1024)
# # Use vsnprintf to format the string with the va_list
# libc.vsnprintf(buf, len(buf), fmt, args)
# # Decode the formatted message
# message = buf.value.decode("utf-8", errors="replace")
# log.debug("VLC: " + message)
# except Exception as e:
# log.error(f"Error in VLC log callback: {e}")
# def _set_vlc_log(self):
# try:
# vlc.libvlc_log_set(vlc_instance, self.log_callback, None)
# log.debug("VLC logging set up successfully")
# except Exception as e:
# log.error(f"Failed to set up VLC logging: {e}")
def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds"""
if not self.player:
return
elapsed_ms = self.get_playtime()
position = self.get_position()
if not position:
position = 0.0
new_position = max(0.0, position + ((position * ms) / elapsed_ms))
self.set_position(new_position)
# Adjus start time so elapsed time calculations are correct
if new_position == 0:
self.start_dt = dt.datetime.now()
else:
if self.start_dt:
self.start_dt -= dt.timedelta(milliseconds=ms)
else:
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
def fade(self, fade_seconds: int) -> None:
"""
Fade the currently playing track.
The actual management of fading runs in its own thread so as not
to hold up the UI during the fade.
"""
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds)
self.fader_worker.finished.connect(self.player.release)
self.fader_worker.start()
self.start_dt = None
def get_playtime(self) -> int:
"""
Return number of milliseconds current track has been playing or
zero if not playing. The vlc function get_time() only updates 3-4
times a second; this function has much better resolution.
"""
if self.start_dt is None:
return 0
now = dt.datetime.now()
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def is_playing(self) -> bool:
"""
Return True if we're playing
"""
if not self.player:
return False
# There is a discrete time between starting playing a track and
# player.is_playing() returning True, so assume playing if less
# than Config.PLAY_SETTLE microseconds have passed since
# starting play.
return self.start_dt is not None and (
self.player.is_playing()
or (dt.datetime.now() - self.start_dt)
< dt.timedelta(microseconds=Config.PLAY_SETTLE)
)
def play(
self,
path: str,
start_time: dt.datetime,
position: Optional[float] = None,
) -> None:
"""
Start playing the track at path.
Log and return if path not found.
start_time ensures our version and our caller's version of
the start time is the same
"""
log.debug(f"Music[{self.name}].play({path=}, {position=}")
if helpers.file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
self.player = vlc.MediaPlayer(vlc_instance, path)
if self.player is None:
log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
helpers.show_warning(
None, "Error creating MediaPlayer", f"Cannot play file ({path})"
)
return
_ = self.player.play()
self.set_volume(self.max_volume)
if position:
self.player.set_position(position)
self.start_dt = start_time
def set_position(self, position: float) -> None:
"""
Set player position
"""
if self.player:
self.player.set_position(position)
def set_volume(
self, volume: Optional[int] = None, set_default: bool = True
) -> None:
"""Set maximum volume used for player"""
if not self.player:
return
if set_default and volume:
self.max_volume = volume
if volume is None:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
def stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
if self.player.is_playing():
self.player.stop()
self.player.release()
self.player = None
class RowAndTrack:
"""
Object to manage playlist rows and tracks.
"""
def __init__(self, playlist_row: PlaylistRows) -> None:
"""
Initialises data structure.
The passed PlaylistRows object will include a Tracks object if this
row has a track.
"""
# Collect playlistrow data
self.note = playlist_row.note
self.played = playlist_row.played
self.playlist_id = playlist_row.playlist_id
self.playlistrow_id = playlist_row.id
self.row_number = playlist_row.row_number
self.track_id = playlist_row.track_id
# Playlist display data
self.row_fg: Optional[str] = None
self.row_bg: Optional[str] = None
self.note_fg: Optional[str] = None
self.note_bg: Optional[str] = None
# Collect track data if there's a track
if playlist_row.track_id:
self.artist = playlist_row.track.artist
self.bitrate = playlist_row.track.bitrate
self.duration = playlist_row.track.duration
self.fade_at = playlist_row.track.fade_at
self.intro = playlist_row.track.intro
if playlist_row.track.playdates:
self.lastplayed = max(
[a.lastplayed for a in playlist_row.track.playdates]
)
else:
self.lastplayed = Config.EPOCH
self.path = playlist_row.track.path
self.silence_at = playlist_row.track.silence_at
self.start_gap = playlist_row.track.start_gap
self.title = playlist_row.track.title
else:
self.artist = ""
self.bitrate = 0
self.duration = 0
self.fade_at = 0
self.intro = None
self.lastplayed = Config.EPOCH
self.path = ""
self.silence_at = 0
self.start_gap = 0
self.title = ""
# Track playing data
self.end_of_track_signalled: bool = False
self.end_time: Optional[dt.datetime] = None
self.fade_graph: Optional[_FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None
self.resume_marker: Optional[float] = 0.0
self.forecast_end_time: Optional[dt.datetime] = None
self.forecast_start_time: Optional[dt.datetime] = None
self.start_time: Optional[dt.datetime] = None
# Other object initialisation
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
def __repr__(self) -> str:
return (
f"<RowAndTrack(playlist_id={self.playlist_id}, "
f"row_number={self.row_number}, "
f"playlistrow_id={self.playlistrow_id}, "
f"note={self.note}, track_id={self.track_id}>"
)
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.start_time is None:
return
if self.end_of_track_signalled:
return
if self.music.is_playing():
return
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
# Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=self.path,
track_fade_at=self.fade_at,
track_silence_at=self.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def drop3db(self, enable: bool) -> None:
"""
If enable is true, drop output by 3db else restore to full volume
"""
if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music"""
self.resume_marker = self.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.music.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def restart(self) -> None:
"""
Restart player
"""
self.music.adjust_by_ms(self.time_playing() * -1)
def set_forecast_start_time(
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
"""
Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
if self.fade_graph:
self.fade_graph.clear()
def time_playing(self) -> int:
"""
Return time track has been playing in milliseconds, zero if not playing
"""
if self.start_time is None:
return 0
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.fade_at - self.time_playing()
def time_to_silence(self) -> int:
"""
Return milliseconds until silent. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.silence_at - self.time_playing()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
def update_playlist_and_row(self, session: Session) -> None:
"""
Update local playlist_id and row_number from playlistrow_id
"""
plr = session.get(PlaylistRows, self.playlistrow_id)
if not plr:
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
self.playlist_id = plr.playlist_id
self.row_number = plr.row_number
class TrackSequence:
next: Optional[RowAndTrack] = None
current: Optional[RowAndTrack] = None
previous: Optional[RowAndTrack] = None
def set_next(self, rat: Optional[RowAndTrack]) -> None:
"""
Set the 'next' track to be passed rat. Clear
any previous next track. If passed rat is None
just clear existing next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if rat is None:
self.next = None
else:
self.next = rat
self.next.create_fade_graph()
track_sequence = TrackSequence()

File diff suppressed because it is too large Load Diff

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)
]
)
)

View File

@ -1,54 +0,0 @@
#!/usr/bin/env python
#
# Script to manage renaming existing files in given directory and
# propagating that change to database. Typical usage: renaming files
# from 'title.mp3' to title - artist.mp3'
#
# Actions:
#
# - record all filenames and inode numbers
# - external: rename the files
# - update records with new filenames for each inode number
# - update external database with new paths
import os
import sqlite3
PHASE = 2
# Check file of same name exists in parent directory
source_dir = '/home/kae/tmp/Singles' # os.getcwd()
db = "/home/kae/tmp/singles.sqlite"
def main():
with sqlite3.connect(db) as connection:
cursor = connection.cursor()
if PHASE == 1:
cursor.execute(
"CREATE TABLE IF NOT EXISTS mp3s "
"(inode INTEGER, oldname TEXT, newname TEXT)"
)
for fname in os.listdir(source_dir):
fullpath = os.path.join(source_dir, fname)
inode = os.stat(fullpath).st_ino
sql = f'INSERT INTO mp3s VALUES ({inode}, "{fname}", "")'
cursor.execute(sql)
if PHASE == 2:
for fname in os.listdir(source_dir):
fullpath = os.path.join(source_dir, fname)
inode = os.stat(fullpath).st_ino
sql = (
f'UPDATE mp3s SET newname = "{fname}" WHERE inode={inode}'
)
try:
cursor.execute(sql)
except sqlite3.OperationalError:
print(f"Error with {inode} -> {fname}")
cursor.close()
main()

View File

@ -1,267 +0,0 @@
#!/usr/bin/env python
#
# Script to replace existing files in parent directory. Typical usage:
# the current directory contains a "better" version of the file than the
# parent (eg, bettet bitrate).
import os
import pydymenu # type: ignore
import shutil
import sys
from helpers import (
get_tags,
set_track_metadata,
)
from models import Tracks
from dbconfig import Session
from sqlalchemy.exc import IntegrityError
from typing import List
# ###################### SETTINGS #########################
process_name_and_tags_matches = True
process_tag_matches = True
do_processing = True
process_no_matches = True
source_dir = "/home/kae/music/Singles/tmp"
parent_dir = os.path.dirname(source_dir)
# #########################################################
name_and_tags: List[str] = []
tags_not_name: List[str] = []
# multiple_similar: List[str] = []
# possibles: List[str] = []
no_match: int = 0
def main():
global no_match
# We only want to run this against the production database because
# we will affect files in the common pool of tracks used by all
# databases
if "musicmuster_prod" not in os.environ.get("MM_DB"):
response = input("Not on production database - c to continue: ")
if response != "c":
sys.exit(0)
# Sanity check
assert source_dir != parent_dir
# Scan parent directory
with Session() as session:
all_tracks = Tracks.get_all(session)
parent_tracks = [a for a in all_tracks if parent_dir in a.path]
parent_fnames = [os.path.basename(a.path) for a in parent_tracks]
# Create a dictionary of parent paths with their titles and
# artists
parents = {}
for t in parent_tracks:
parents[t.path] = {"title": t.title, "artist": t.artist}
titles_to_path = {}
artists_to_path = {}
for k, v in parents.items():
try:
titles_to_path[v["title"].lower()] = k
artists_to_path[v["artist"].lower()] = k
except AttributeError:
continue
for new_fname in os.listdir(source_dir):
new_path = os.path.join(source_dir, new_fname)
if not os.path.isfile(new_path):
continue
new_tags = get_tags(new_path)
new_title = new_tags["title"]
if not new_title:
print(f"{new_fname} does not have a title tag")
sys.exit(1)
new_artist = new_tags["artist"]
if not new_artist:
print(f"{new_fname} does not have an artist tag")
sys.exit(1)
bitrate = new_tags["bitrate"]
# If same filename exists in parent direcory, check tags
parent_path = os.path.join(parent_dir, new_fname)
if os.path.exists(parent_path):
parent_tags = get_tags(parent_path)
parent_title = parent_tags["title"]
parent_artist = parent_tags["artist"]
if (str(parent_title).lower() == str(new_title).lower()) and (
str(parent_artist).lower() == str(new_artist).lower()
):
name_and_tags.append(
f" {new_fname=}, {parent_title}{new_title}, "
f" {parent_artist}{new_artist}"
)
if process_name_and_tags_matches:
process_track(new_path, parent_path, new_title, new_artist, bitrate)
continue
# Check for matching tags although filename is different
if new_title.lower() in titles_to_path:
possible_path = titles_to_path[new_title.lower()]
if parents[possible_path]["artist"].lower() == new_artist.lower():
# print(
# f"title={new_title}, artist={new_artist}:\n"
# f" {new_path} → {parent_path}"
# )
tags_not_name.append(
f"title={new_title}, artist={new_artist}:\n"
f" {new_path}{parent_path}"
)
if process_tag_matches:
process_track(
new_path, possible_path, new_title, new_artist, bitrate
)
continue
else:
no_match += 1
else:
no_match += 1
# Try to find a near match
if process_no_matches:
prompt = f"file={new_fname}\n title={new_title}\n artist={new_artist}: "
# Use fzf to search
choice = pydymenu.fzf(parent_fnames, prompt=prompt)
if choice:
old_file = os.path.join(parent_dir, choice[0])
oldtags = get_tags(old_file)
old_title = oldtags["title"]
old_artist = oldtags["artist"]
print()
print(f" File name will change {choice[0]}")
print(f"{new_fname}")
print()
print(f" Title tag will change {old_title}")
print(f"{new_title}")
print()
print(f" Artist tag will change {old_artist}")
print(f"{new_artist}")
print()
data = input("Go ahead (y to accept)? ")
if data == "y":
process_track(new_path, old_file, new_title, new_artist, bitrate)
continue
if data == "q":
sys.exit(0)
else:
continue
# else:
# no_match.append(f"{new_fname}, {new_title=}, {new_artist=}")
# continue
# if match_count > 1:
# multiple_similar.append(new_fname + "\n " + "\n ".join(matches))
# if match_count <= 26 and process_multiple_matches:
# print(f"\n file={new_fname}\n title={new_title}\n artist={new_artist}\n")
# d = {}
# while True:
# for i, match in enumerate(matches):
# d[i] = match
# for k, v in d.items():
# print(f"{k}: {v}")
# data = input("pick one, return to quit: ")
# if data == "":
# break
# try:
# key = int(data)
# except ValueError:
# continue
# if key in d:
# dst = d[key]
# process_track(new_path, dst, new_title, new_artist, bitrate)
# break
# else:
# continue
# continue # from break after testing for "" in data
# # One match, check tags
# sim_name = matches[0]
# p = get_tags(sim_name)
# parent_title = p['title']
# parent_artist = p['artist']
# if (
# (str(parent_title).lower() != str(new_title).lower()) or
# (str(parent_artist).lower() != str(new_artist).lower())
# ):
# possibles.append(
# f"File: {os.path.basename(sim_name)} → {new_fname}"
# f"\n {parent_title} → {new_title}\n {parent_artist} → {new_artist}"
# )
# process_track(new_path, sim_name, new_title, new_artist, bitrate)
# continue
# tags_not_name.append(f"Rename {os.path.basename(sim_name)} → {new_fname}")
# process_track(new_path, sim_name, new_title, new_artist, bitrate)
print(f"Name and tags match ({len(name_and_tags)}):")
# print(" \n".join(name_and_tags))
# print()
# print(f"Name but not tags match ({len(name_not_tags)}):")
# print(" \n".join(name_not_tags))
# print()
print(f"Tags but not name match ({len(tags_not_name)}):")
# print(" \n".join(tags_not_name))
# print()
# print(f"Multiple similar names ({len(multiple_similar)}):")
# print(" \n".join(multiple_similar))
# print()
# print(f"Possibles: ({len(possibles)}):")
# print(" \n".join(possibles))
# print()
# print(f"No match ({len(no_match)}):")
# print(" \n".join(no_match))
# print()
# print(f"Name and tags match ({len(name_and_tags)}):")
# print(f"Name but not tags match ({len(name_not_tags)}):")
# print(f"Tags but not name match ({len(tags_not_name)}):")
# print(f"Multiple similar names ({len(multiple_similar)}):")
# print(f"Possibles: ({len(possibles)}):")
# print(f"No match ({len(no_match)}):")
print(f"No matches: {no_match}")
def process_track(src, dst, title, artist, bitrate):
new_path = os.path.join(os.path.dirname(dst), os.path.basename(src))
print(f"process_track:\n {src=}\n {dst=}\n {title=}, {artist=}\n")
if not do_processing:
return
with Session() as session:
track = Tracks.get_by_path(session, dst)
if track:
# Update path, but workaround MariaDB bug
track.path = new_path
try:
session.commit()
except IntegrityError:
# https://jira.mariadb.org/browse/MDEV-29345 workaround
session.rollback()
track.path = "DUMMY"
session.commit()
track.path = new_path
session.commit()
print(f"os.unlink({dst}")
print(f"shutil.move({src}, {new_path}")
os.unlink(dst)
shutil.move(src, new_path)
# Update track metadata
set_track_metadata(track)
main()

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"))

145
app/ui/dlgReplaceFiles.ui Normal file
View File

@ -0,0 +1,145 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1038</width>
<height>774</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="geometry">
<rect>
<x>680</x>
<y>730</y>
<width>341</width>
<height>32</height>
</rect>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
<widget class="QLabel" name="label">
<property name="geometry">
<rect>
<x>10</x>
<y>15</y>
<width>181</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Source directory:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
<widget class="QLabel" name="label_2">
<property name="geometry">
<rect>
<x>10</x>
<y>50</y>
<width>181</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Destination directory:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
<widget class="QLabel" name="lblSourceDirectory">
<property name="geometry">
<rect>
<x>200</x>
<y>15</y>
<width>811</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>lblSourceDirectory</string>
</property>
</widget>
<widget class="QLabel" name="lblDestinationDirectory">
<property name="geometry">
<rect>
<x>200</x>
<y>50</y>
<width>811</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>lblDestinationDirectory</string>
</property>
</widget>
<widget class="QTableWidget" name="tableWidget">
<property name="geometry">
<rect>
<x>20</x>
<y>90</y>
<width>1001</width>
<height>621</height>
</rect>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="columnCount">
<number>3</number>
</property>
<column/>
<column/>
<column/>
</widget>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,53 @@
# Form implementation generated from reading ui file 'app/ui/dlgReplaceFiles.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_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(1038, 774)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=Dialog)
self.buttonBox.setGeometry(QtCore.QRect(680, 730, 341, 32))
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox")
self.label = QtWidgets.QLabel(parent=Dialog)
self.label.setGeometry(QtCore.QRect(10, 15, 181, 24))
self.label.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.label.setObjectName("label")
self.label_2 = QtWidgets.QLabel(parent=Dialog)
self.label_2.setGeometry(QtCore.QRect(10, 50, 181, 24))
self.label_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.label_2.setObjectName("label_2")
self.lblSourceDirectory = QtWidgets.QLabel(parent=Dialog)
self.lblSourceDirectory.setGeometry(QtCore.QRect(200, 15, 811, 24))
self.lblSourceDirectory.setObjectName("lblSourceDirectory")
self.lblDestinationDirectory = QtWidgets.QLabel(parent=Dialog)
self.lblDestinationDirectory.setGeometry(QtCore.QRect(200, 50, 811, 24))
self.lblDestinationDirectory.setObjectName("lblDestinationDirectory")
self.tableWidget = QtWidgets.QTableWidget(parent=Dialog)
self.tableWidget.setGeometry(QtCore.QRect(20, 90, 1001, 621))
self.tableWidget.setAlternatingRowColors(True)
self.tableWidget.setColumnCount(3)
self.tableWidget.setObjectName("tableWidget")
self.tableWidget.setRowCount(0)
self.retranslateUi(Dialog)
self.buttonBox.accepted.connect(Dialog.accept) # type: ignore
self.buttonBox.rejected.connect(Dialog.reject) # type: ignore
QtCore.QMetaObject.connectSlotsByName(Dialog)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.label.setText(_translate("Dialog", "Source directory:"))
self.label_2.setText(_translate("Dialog", "Destination directory:"))
self.lblSourceDirectory.setText(_translate("Dialog", "lblSourceDirectory"))
self.lblDestinationDirectory.setText(_translate("Dialog", "lblDestinationDirectory"))

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,5 +1,12 @@
<RCC>
<qresource prefix="icons">
<file>yellow-circle.png</file>
<file>redstar.png</file>
<file>green-circle.png</file>
<file>star.png</file>
<file>star_empty.png</file>
<file>record-red-button.png</file>
<file>record-button.png</file>
<file alias="headphones">headphone-symbol.png</file>
<file alias="musicmuster">musicmuster.png</file>
<file alias="stopsign">stopsign.png</file>

File diff suppressed because it is too large Load Diff

View File

@ -229,10 +229,16 @@ padding-left: 8px;</string>
</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>16777215</height>
<height>131</height>
</size>
</property>
<property name="frameShape">
@ -241,13 +247,13 @@ padding-left: 8px;</string>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout_10">
<item>
<widget class="QLabel" name="lblTOD">
<property name="minimumSize">
<size>
<width>208</width>
<height>109</height>
<height>0</height>
</size>
</property>
<property name="font">
@ -263,6 +269,27 @@ padding-left: 8px;</string>
</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>
@ -435,20 +462,234 @@ padding-left: 8px;</string>
</widget>
</item>
<item>
<widget class="QLabel" name="label_elapsed_timer">
<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>18</pointsize>
<pointsize>40</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>
<string>0:0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
@ -592,46 +833,36 @@ padding-left: 8px;</string>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<widget class="QLabel" name="label_5">
<property name="geometry">
<rect>
<x>10</x>
<y>10</y>
<width>45</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Silent</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
<widget class="QLabel" name="label_silent_timer">
<property name="geometry">
<rect>
<x>10</x>
<y>48</y>
<width>132</width>
<height>54</height>
</rect>
</property>
<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>
<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>
@ -661,7 +892,7 @@ padding-left: 8px;</string>
<property name="maximumSize">
<size>
<width>151</width>
<height>16777215</height>
<height>112</height>
</size>
</property>
<property name="frameShape">
@ -736,68 +967,70 @@ padding-left: 8px;</string>
</property>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>&amp;Playlists</string>
<string>&amp;Playlist</string>
</property>
<addaction name="actionNewPlaylist"/>
<addaction name="actionNew_from_template"/>
<addaction name="actionOpenPlaylist"/>
<addaction name="actionClosePlaylist"/>
<addaction name="actionRenamePlaylist"/>
<addaction name="actionDeletePlaylist"/>
<addaction name="separator"/>
<addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/>
<addaction name="actionInsertSectionHeader"/>
<addaction name="separator"/>
<addaction name="actionMark_for_moving"/>
<addaction name="actionPaste"/>
<addaction name="separator"/>
<addaction name="actionExport_playlist"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="separator"/>
<addaction name="actionSelect_duplicate_rows"/>
<addaction name="separator"/>
<addaction name="actionMoveSelected"/>
<addaction name="actionMoveUnplayed"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="actionSave_as_template"/>
<addaction name="separator"/>
<addaction name="actionE_xit"/>
<addaction name="action_Clear_selection"/>
</widget>
<widget class="QMenu" name="menuPlaylist">
<property name="title">
<string>Sho&amp;wtime</string>
<string>&amp;File</string>
</property>
<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="actionFade"/>
<addaction name="actionStop"/>
<addaction name="actionResume"/>
<addaction name="separator"/>
<addaction name="actionSkipToNext"/>
<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="actionEnable_controls"/>
<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="separator"/>
<addaction name="actionSearch_title_in_Wikipedia"/>
<addaction name="actionSearch_title_in_Songfacts"/>
</widget>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>&amp;Help</string>
<string>Help</string>
</property>
<addaction name="action_About"/>
<addaction name="actionDebug"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuPlaylist"/>
<addaction name="menuFile"/>
<addaction name="menuSearc_h"/>
<addaction name="menuHelp"/>
</widget>
@ -812,7 +1045,7 @@ padding-left: 8px;</string>
<action name="actionPlay_next">
<property name="icon">
<iconset>
<normaloff>../../../../.designer/backup/icon-play.png</normaloff>../../../../.designer/backup/icon-play.png</iconset>
<normaloff>../../../../../../.designer/backup/icon-play.png</normaloff>../../../../../../.designer/backup/icon-play.png</iconset>
</property>
<property name="text">
<string>&amp;Play next</string>
@ -836,7 +1069,7 @@ padding-left: 8px;</string>
<action name="actionInsertTrack">
<property name="icon">
<iconset>
<normaloff>../../../../.designer/backup/icon_search_database.png</normaloff>../../../../.designer/backup/icon_search_database.png</iconset>
<normaloff>../../../../../../.designer/backup/icon_search_database.png</normaloff>../../../../../../.designer/backup/icon_search_database.png</iconset>
</property>
<property name="text">
<string>Insert &amp;track...</string>
@ -848,7 +1081,7 @@ padding-left: 8px;</string>
<action name="actionAdd_file">
<property name="icon">
<iconset>
<normaloff>../../../../.designer/backup/icon_open_file.png</normaloff>../../../../.designer/backup/icon_open_file.png</iconset>
<normaloff>../../../../../../.designer/backup/icon_open_file.png</normaloff>../../../../../../.designer/backup/icon_open_file.png</iconset>
</property>
<property name="text">
<string>Add &amp;file</string>
@ -860,7 +1093,7 @@ padding-left: 8px;</string>
<action name="actionFade">
<property name="icon">
<iconset>
<normaloff>../../../../.designer/backup/icon-fade.png</normaloff>../../../../.designer/backup/icon-fade.png</iconset>
<normaloff>../../../../../../.designer/backup/icon-fade.png</normaloff>../../../../../../.designer/backup/icon-fade.png</iconset>
</property>
<property name="text">
<string>F&amp;ade</string>
@ -1074,9 +1307,9 @@ padding-left: 8px;</string>
<string>Save as template...</string>
</property>
</action>
<action name="actionNew_from_template">
<action name="actionManage_templates">
<property name="text">
<string>New from template...</string>
<string>Manage templates...</string>
</property>
</action>
<action name="actionDebug">
@ -1134,6 +1367,21 @@ padding-left: 8px;</string>
<string>Select duplicate rows...</string>
</property>
</action>
<action name="actionImport_files">
<property name="text">
<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>
</action>
</widget>
<customwidgets>
<customwidget>

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,599 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
#
# Created by: PyQt6 UI code generator 6.6.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
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.setMaximumSize(QtCore.QSize(230, 16777215))
self.frame_2.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_2.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_2.setObjectName("frame_2")
self.gridLayout_2 = QtWidgets.QGridLayout(self.frame_2)
self.gridLayout_2.setObjectName("gridLayout_2")
self.lblTOD = QtWidgets.QLabel(parent=self.frame_2)
self.lblTOD.setMinimumSize(QtCore.QSize(208, 109))
font = QtGui.QFont()
font.setPointSize(35)
self.lblTOD.setFont(font)
self.lblTOD.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.lblTOD.setObjectName("lblTOD")
self.gridLayout_2.addWidget(self.lblTOD, 0, 0, 1, 1)
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.label_elapsed_timer = QtWidgets.QLabel(parent=self.FadeStopInfoFrame)
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_4.addWidget(self.label_elapsed_timer)
self.horizontalLayout.addWidget(self.FadeStopInfoFrame)
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.label_5 = QtWidgets.QLabel(parent=self.frame_silent)
self.label_5.setGeometry(QtCore.QRect(10, 10, 45, 24))
self.label_5.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_5.setObjectName("label_5")
self.label_silent_timer = QtWidgets.QLabel(parent=self.frame_silent)
self.label_silent_timer.setGeometry(QtCore.QRect(10, 48, 132, 54))
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.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, 16777215))
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))
icon2 = QtGui.QIcon()
icon2.addPixmap(QtGui.QPixmap(":/icons/fade"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.btnFade.setIcon(icon2)
self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade")
self.verticalLayout_5.addWidget(self.btnFade)
self.btnStop = QtWidgets.QPushButton(parent=self.frame)
self.btnStop.setMinimumSize(QtCore.QSize(0, 36))
icon3 = QtGui.QIcon()
icon3.addPixmap(QtGui.QPixmap(":/icons/stopsign"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.btnStop.setIcon(icon3)
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)
icon4 = QtGui.QIcon()
icon4.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.actionPlay_next.setIcon(icon4)
self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkipToNext = QtGui.QAction(parent=MainWindow)
icon5 = QtGui.QIcon()
icon5.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.actionSkipToNext.setIcon(icon5)
self.actionSkipToNext.setObjectName("actionSkipToNext")
self.actionInsertTrack = QtGui.QAction(parent=MainWindow)
icon6 = QtGui.QIcon()
icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.actionInsertTrack.setIcon(icon6)
self.actionInsertTrack.setObjectName("actionInsertTrack")
self.actionAdd_file = QtGui.QAction(parent=MainWindow)
icon7 = QtGui.QIcon()
icon7.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.actionAdd_file.setIcon(icon7)
self.actionAdd_file.setObjectName("actionAdd_file")
self.actionFade = QtGui.QAction(parent=MainWindow)
icon8 = QtGui.QIcon()
icon8.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-fade.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.actionFade.setIcon(icon8)
self.actionFade.setObjectName("actionFade")
self.actionStop = QtGui.QAction(parent=MainWindow)
icon9 = QtGui.QIcon()
icon9.addPixmap(QtGui.QPixmap(":/icons/stop"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.actionStop.setIcon(icon9)
self.actionStop.setObjectName("actionStop")
self.action_Clear_selection = QtGui.QAction(parent=MainWindow)
self.action_Clear_selection.setObjectName("action_Clear_selection")
self.action_Resume_previous = QtGui.QAction(parent=MainWindow)
icon10 = QtGui.QIcon()
icon10.addPixmap(QtGui.QPixmap(":/icons/previous"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
self.action_Resume_previous.setIcon(icon10)
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.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.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.actionEnable_controls)
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.btnPreview.setText(_translate("MainWindow", " Preview"))
self.label_elapsed_timer.setText(_translate("MainWindow", "00:00 / 00:00"))
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..."))
from infotabs import InfoTabs
from pyqtgraph import PlotWidget

BIN
app/ui/record-button.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
app/ui/redstar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
app/ui/star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
app/ui/star_empty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,7 +1,13 @@
#!/usr/bin/env python
#
# Standard library imports
import os
# PyQt imports
# Third party imports
from sqlalchemy.orm.session import Session
# App imports
from config import Config
from helpers import (
get_tags,
@ -10,7 +16,7 @@ from log import log
from models import Tracks
def check_db(session):
def check_db(session: Session) -> None:
"""
Database consistency check.
@ -78,7 +84,7 @@ def check_db(session):
print("There were more paths than listed that were not found")
def update_bitrates(session):
def update_bitrates(session: Session) -> None:
"""
Update bitrates on all tracks in database
"""
@ -86,6 +92,6 @@ def update_bitrates(session):
for track in Tracks.get_all(session):
try:
t = get_tags(track.path)
track.bitrate = t["bitrate"]
track.bitrate = t.bitrate
except FileNotFoundError:
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

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

101
archive/dragdrop.py Executable file
View File

@ -0,0 +1,101 @@
#!/usr/bin/env python3
# https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
import sys
from PyQt6.QtWidgets import (
QTableWidget,
QAbstractItemView,
QTableWidgetItem,
QWidget,
QHBoxLayout,
QApplication,
)
from PyQt6.QtCore import Qt
class TableWidgetDragRows(QTableWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.viewport().setAcceptDrops(True)
self.setDragDropOverwriteMode(False)
self.setDropIndicatorShown(True)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.DropIndicatorPosition(QAbstractItemView.DropIndicatorPosition.BelowItem)
def dropEvent(self, event):
if event.source() == self:
rows = set([mi.row() for mi in self.selectedIndexes()])
targetRow = self.indexAt(event.position().toPoint()).row()
if self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.BelowItem:
targetRow += 1
rows.discard(targetRow)
rows = sorted(rows)
if not rows:
return
if targetRow == -1:
targetRow = self.rowCount()
for _ in range(len(rows)):
self.insertRow(targetRow)
rowMapping = dict() # Src row to target row.
for idx, row in enumerate(rows):
if row < targetRow:
rowMapping[row] = targetRow + idx
else:
rowMapping[row + len(rows)] = targetRow + idx
colCount = self.columnCount()
for srcRow, tgtRow in sorted(rowMapping.items()):
for col in range(0, colCount):
self.setItem(tgtRow, col, self.takeItem(srcRow, col))
for row in reversed(sorted(rowMapping.keys())):
self.removeRow(row)
event.accept()
return
class Window(QWidget):
def __init__(self):
super(Window, self).__init__()
layout = QHBoxLayout()
self.setLayout(layout)
self.table_widget = TableWidgetDragRows()
layout.addWidget(self.table_widget)
# setup table widget
self.table_widget.setColumnCount(2)
self.table_widget.setHorizontalHeaderLabels(["Type", "Name"])
items = [
("Red", "Toyota"),
("Blue", "RV"),
("Green", "Beetle"),
("Silver", "Chevy"),
("Black", "BMW"),
]
self.table_widget.setRowCount(len(items))
for i, (color, model) in enumerate(items):
item_flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled
colour_item = QTableWidgetItem(color)
colour_item.setFlags(item_flags)
model_item = QTableWidgetItem(model)
model_item.setFlags(item_flags)
self.table_widget.setItem(i, 0, QTableWidgetItem(color))
self.table_widget.setItem(i, 1, QTableWidgetItem(model))
self.resize(400, 400)
self.show()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = Window()
sys.exit(app.exec())

View File

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

View File

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

View File

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

View File

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

7
file_header.txt Normal file
View File

@ -0,0 +1,7 @@
# Standard library imports
# PyQt imports
# Third party imports
# App imports

View File

@ -1 +1 @@
Generic single-database configuration.
Alembic configuration for Alchemical

View File

@ -1,84 +1,27 @@
import sys
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from importlib import import_module
from alembic import context
from alchemical.alembic.env import run_migrations
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
# https://stackoverflow.com/questions/32032940/how-to-import-the-own-model-into-myproject-alembic-env-py
path = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, path)
sys.path.insert(0, os.path.join(path, "app"))
from app.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
# import the application's Alchemical instance
try:
import_mod, db_name = config.get_main_option('alchemical_db', '').split(
':')
db = getattr(import_module(import_mod), db_name)
except (ModuleNotFoundError, AttributeError):
raise ValueError(
'Could not import the Alchemical database instance. '
'Ensure that the alchemical_db setting in alembic.ini is correct.'
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
# run the migration engine
# The dictionary provided as second argument includes options to pass to the
# Alembic context. For details on what other options are available, see
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html
run_migrations(db, {
'render_as_batch': True,
'compare_type': True,
})

View File

@ -1,4 +1,7 @@
"""${message}
<%!
import re
%>"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
@ -16,9 +19,27 @@ branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade():
${downgrades if downgrades else "pass"}
def downgrade(engine_name: str) -> None:
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,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,36 +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,52 +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 ###

Some files were not shown because too many files have changed in this diff Show More