Compare commits

...

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

Fixup some type hints in models.

Comment out stackprinter calls - they mostly get in the way
interactively.
2023-10-15 19:04:58 +01:00
Keith Edmunds
ae87ac82ba Migrate model to SQLAlchemy 2.0 DeclarativeBase 2023-10-15 09:51:02 +01:00
Keith Edmunds
a8c5a56c1a Implent subtotal times and unplayed time 2023-10-13 19:01:22 +01:00
Keith Edmunds
8cebf7829b Save playlist after undoing sort 2023-10-12 18:44:01 +01:00
Keith Edmunds
c8a7ae7f73 Black formatting 2023-10-12 08:55:26 +01:00
Keith Edmunds
87ab973439 Implement playlist range sort and unsort 2023-10-12 02:43:17 +01:00
Keith Edmunds
06e457a3da Save sorted selection 2023-10-10 01:28:31 +01:00
Keith Edmunds
8e2edb6af3 Add sort selection 2023-10-10 01:27:36 +01:00
Keith Edmunds
ee391e42e7 Minor tidying 2023-10-10 01:27:13 +01:00
Keith Edmunds
c078fa69e7 Only create one infotab at initialisation. 2023-10-06 19:10:19 +01:00
Keith Edmunds
da8272b29b Fix flickering when first marking a track as next
Pre-create the infotabs as adding the first one caused the flicker.
2023-10-06 18:04:11 +01:00
Keith Edmunds
b3905e062d Improve artist search
Replicate recent changes in title search to artist search
2023-10-06 10:58:15 +01:00
Keith Edmunds
6d48bcc9d0 Remove double ampersand in last track header 2023-10-06 10:52:28 +01:00
Keith Edmunds
f3a5ed2e72 Track selection dialog bugfix
If multiple tracks had the name name, only one would be listed.
2023-10-05 13:49:52 +01:00
Keith Edmunds
bb700d26f1 Faster track selection diaglog
Use better query to load last_played times along with tracks
2023-10-04 08:11:41 +01:00
Keith Edmunds
b2f826dfcc Much improved performance adding tracks 2023-10-01 15:17:27 +01:00
Keith Edmunds
c1fae2f91a Much improved performance adding tracks 2023-10-01 15:09:41 +01:00
Keith Edmunds
403c470c8a In track add dialog, ESC clears currently selected track 2023-09-30 21:32:08 +01:00
Keith Edmunds
8651dae3f3 Save wheel events 2023-09-30 20:46:16 +01:00
Keith Edmunds
c087858674 Add track dialog: add header if note given but no track selected 2023-09-30 20:45:12 +01:00
Keith Edmunds
494e124ac8 Separate path display from title/artist buttons, track add dialog 2023-09-30 20:38:46 +01:00
Keith Edmunds
b7a33d2676 Update Black 2023-09-30 20:37:56 +01:00
Keith Edmunds
dcab21bdde Reset preview button if preview track ends
Fixes #178
2023-07-14 17:16:14 +01:00
Keith Edmunds
cd04ec6339 Flake8 fixes 2023-07-09 23:27:13 +01:00
Keith Edmunds
a0a2903706 Make updating of clock backgrounds more efficient 2023-07-09 23:23:18 +01:00
Keith Edmunds
da267562ea Fix clocks for resumed track 2023-07-09 23:19:27 +01:00
Keith Edmunds
2ca1d30609 Rewrite timers/tick code
Fixes #176
2023-07-09 23:18:37 +01:00
Keith Edmunds
cb2017e953 Add button icons; wire up Stop button 2023-07-09 16:20:10 +01:00
Keith Edmunds
c7284c4397 Merge branch 'buttons' into dev 2023-07-09 16:15:29 +01:00
Keith Edmunds
986257bef6 Flake8 and Black run on all files 2023-07-09 16:12:21 +01:00
Keith Edmunds
fbc780b579 Put elapsed/total time below Preview button 2023-07-09 15:40:56 +01:00
Keith Edmunds
722043d049 Move Stop button away from other buttons
Fixes #177
2023-07-09 15:39:22 +01:00
Keith Edmunds
f44d6aa25e Documentation skeleton in place 2023-07-02 19:18:28 +01:00
Keith Edmunds
d3834928fd Remove padding around volume graph 2023-06-22 00:24:32 +01:00
Keith Edmunds
09f0e11aa7 Improve clock management
- tick() implemented independently of Config.TIMER_MS
 - have tick() call periodic functions
 - don't rely on vlc get_time() (too coarse)
2023-06-19 09:40:32 +01:00
Keith Edmunds
b706008101 Make volume fade graph update much smoother.
VLC get_time and get_position are very granular, only updating about
3-4 times a second. Instead, calculate play_time by substracting track
start time from current time and expressing that as milliseconds.
2023-06-19 00:55:04 +01:00
Keith Edmunds
4eb3a98c95 Added volume fade graph. 2023-06-18 09:20:55 +01:00
Keith Edmunds
af0d715423 Suppress pygame message at startup 2023-06-14 07:20:36 +01:00
Keith Edmunds
6ae6d8e94e WIP volume graphs using matlibplot 2023-06-13 07:55:24 +01:00
Keith Edmunds
df265ead69 Unset preview button if there's no track to preview 2023-06-12 17:54:58 +01:00
Keith Edmunds
52a4de0c01 Implement preview button
Fixes #172
2023-06-11 17:49:29 +01:00
Keith Edmunds
bcb079727a Install and set up pudb for non-production environment 2023-06-07 15:14:52 +01:00
Keith Edmunds
c0ae9eba9f Don't scroll display after drop
With no code, display scroll back to where the source rows came from.
With code we had, we ensured dropped rows were visible, but display
would still scroll.
Now freeze display as it is when rows are dropped.
2023-05-01 18:04:03 +01:00
Keith Edmunds
e3ad7787af Make header row span columns after drag and drop 2023-05-01 17:27:30 +01:00
Keith Edmunds
65f2f4f351 Ensure volume is set to VOLUME_VLC_DEFAULT on play 2023-04-21 14:37:48 +01:00
Keith Edmunds
f07ff56987 Intercept ESC on cell edit 2023-04-18 21:33:48 +01:00
Keith Edmunds
4a927084c9 Fix (workaround) volume going to zero after track starts 2023-04-14 11:12:13 +01:00
Keith Edmunds
8a6812e405 Greatly simplifed drag and drop code 2023-04-13 17:29:58 +01:00
Keith Edmunds
32cc0468e8 Disable drag and drop (todo: fix in qt6) 2023-04-13 14:45:29 +01:00
Keith Edmunds
a8ffa6f231 Upgrade PyQt5 → PyQt6 2023-04-12 21:55:13 +01:00
Keith Edmunds
7ff9146bd1 Update dependencies 2023-04-10 13:59:00 +01:00
Keith Edmunds
69d379ab10 Disconnect _cell_changed signal on edit abort 2023-04-10 13:58:32 +01:00
Keith Edmunds
ebc087f1f6 Improve typing 2023-04-10 13:58:07 +01:00
Keith Edmunds
3d32ce2f34 WIP to improve info tabs 2023-04-10 10:50:09 +01:00
Keith Edmunds
b122ac06a9 Workround to have tabs display 2023-04-10 10:49:54 +01:00
Keith Edmunds
543d0be7f2 Upgrade dependencies 2023-04-09 17:59:09 +01:00
Keith Edmunds
028c6cd43c Fix(?) music starting with volume=0 2023-04-09 17:53:06 +01:00
Keith Edmunds
fe338aaf4a Tidy up scene change code 2023-04-09 17:45:48 +01:00
Keith Edmunds
a923f32070 First pass of OBS scene change 2023-04-09 16:02:44 +01:00
Keith Edmunds
7dac80dcf6 Use QThreadPool to manage fades 2023-04-08 17:48:41 +01:00
Keith Edmunds
c0e1732bbc Fix replace_files prompt not showing 2023-04-06 18:51:08 +01:00
Keith Edmunds
c5c5c28583 Change PlaylistRows.row_number to plr_rownnum 2023-04-02 17:23:49 +01:00
Keith Edmunds
f3c86484fe Change remaining PlaylistRows.row_number to row_no 2023-04-02 15:14:11 +01:00
Keith Edmunds
034993b737 Remove .idea files 2023-04-01 19:59:58 +01:00
Keith Edmunds
f9f1e5f237 Change PlaylistRows.row_number to row_no 2023-04-01 19:59:25 +01:00
Keith Edmunds
5cb6e83cd5 Specifiy Python3 in hashbang line 2023-04-01 19:45:48 +01:00
Keith Edmunds
16a9880583 Improve track search performance
Searching for a track was wrapping the search string in % signs
(wildcards). The leading % meant the database didn't use the index.
Dropped leading % (user can add it manually if needed).
2023-04-01 19:45:07 +01:00
Keith Edmunds
69bfd3cff9 Default to moving existing track when adding a new track 2023-03-25 17:20:10 +00:00
Keith Edmunds
3a14207c71 Ensure we pass ints to signal 2023-03-25 16:30:58 +00:00
Keith Edmunds
25287c8f7f Tidy playlist header colours
Simplify and also ensure that playlist tab is uncoloured after
unsetting next track.
2023-03-25 15:52:17 +00:00
Keith Edmunds
4a03596bd3 Ensure current track visible toggling hide/show played 2023-03-25 10:30:18 +00:00
Keith Edmunds
9c66333729 musicmuster refactor: signal next tracks, tab colouring 2023-03-19 15:21:02 +00:00
Keith Edmunds
728feb1c8e Allow multiple selected rows to be marked unplayed 2023-03-19 13:47:49 +00:00
Keith Edmunds
c9c47c3133 Retain current/next colours when pasting tracks 2023-03-17 23:07:14 +00:00
Keith Edmunds
0b2e7c7e31 Update last played time when track ends 2023-03-17 22:50:54 +00:00
Keith Edmunds
a30f054eb0 Set tab colours correctly 2023-03-17 22:43:36 +00:00
Keith Edmunds
eafacc3b21 Retain current/next colouring after editing notes 2023-03-17 18:28:32 +00:00
Keith Edmunds
a29bf3fce5 Fix first track staying green after end 2023-03-17 16:27:01 +00:00
Keith Edmunds
b30f2d5cc3 Fix play with mplayer 2023-03-16 15:20:31 +00:00
Keith Edmunds
6bf9330b62 Fix 'called set_header on track row'
Ensure playlist is saved before updating track start/end times.
2023-03-15 18:28:37 +00:00
Keith Edmunds
f3631b2c2b Synchronise row start/end updates
Row start/end time updates were being run in a different SQLAlchemy
session to the database updates and thus there was a lack of
synchronisation.

Now they run in the same session.
2023-03-14 22:59:39 +00:00
Keith Edmunds
3197c844a5 Print stack trace to stdout on internal error 2023-03-13 09:12:19 +00:00
Keith Edmunds
e22351386f Fix bug editing header rows 2023-03-13 09:11:47 +00:00
Keith Edmunds
ee422aacb3 Update track times after drag and drop 2023-03-12 18:48:36 +00:00
Keith Edmunds
380806d27a Change row to row_number 2023-03-12 18:43:23 +00:00
Keith Edmunds
c6840d2356 Improve copying track path 2023-03-12 18:41:44 +00:00
Keith Edmunds
453e42172b Use Audacity and mplayer without session 2023-03-12 18:41:13 +00:00
Keith Edmunds
019bc87eb0 Fix sense of file_is_unreadable() 2023-03-12 18:38:00 +00:00
Keith Edmunds
ee64a4a035 Remove unused function _get_row_start_time 2023-03-12 17:08:13 +00:00
Keith Edmunds
9c67b9bd8e Change row to row_number 2023-03-12 17:03:47 +00:00
Keith Edmunds
3cc90f8c11 Remove unneeded function _get_current_track_start_time 2023-03-12 16:53:13 +00:00
Keith Edmunds
71daccab12 Remove unneeded function _get_current_track_end_time 2023-03-12 16:51:02 +00:00
Keith Edmunds
ca86f59736 Rename function file_is_readable to file_is_unreadable 2023-03-12 16:47:45 +00:00
Keith Edmunds
d609656ae3 Change row to row_number 2023-03-12 16:44:12 +00:00
Keith Edmunds
f96e02d9ae Remove unused function _deferred_save() 2023-03-12 16:43:51 +00:00
Keith Edmunds
6c53d59f1a Fix adding/removing track from row 2023-03-12 16:23:56 +00:00
Keith Edmunds
b9fd7a5d21 Clean up editing 2023-03-12 16:23:36 +00:00
Keith Edmunds
669125794f Change row to row_number 2023-03-12 16:22:59 +00:00
Keith Edmunds
4094b63f44 Switch to existing infotab if it contains required URL 2023-03-12 13:25:20 +00:00
Keith Edmunds
b126a70139 Fix and improve hide played tracks
Last played track is now not hidden until
Config.HIDE_AFTER_PLAYING_OFFSET milliseconds after next track starts
playing.
2023-03-12 13:00:46 +00:00
Keith Edmunds
f30fff5356 Fix development bug that truncated playlists
Saving of playlist and updating note colours more consistent.
2023-03-12 10:33:10 +00:00
Keith Edmunds
80e698680b Clean up header and note updates
• rationalise number of functions
• make colour handling cleaner
• optimise when playlist is saved
2023-03-12 09:53:11 +00:00
Keith Edmunds
39ec7f470b Fixup section duration times 2023-03-11 16:05:10 +00:00
Keith Edmunds
16ad7ae5aa Produce consistent log output 2023-03-11 16:02:26 +00:00
Keith Edmunds
d54f1bedda Remove colon-in-path conditional fix 2023-03-10 23:11:23 +00:00
Keith Edmunds
ad071bb74b Consistently use clear_next() to clear next track 2023-03-10 23:10:54 +00:00
Keith Edmunds
2422adea21 Fix play sometimes stopping almost immediately 2023-03-10 22:34:23 +00:00
Keith Edmunds
ee7436221e Playlist save / session work 2023-03-09 08:35:16 +00:00
Keith Edmunds
889d32cc90 Post editing fixes
Fix row or cell colour
Fix row height after expanding when editing starts
2023-03-08 20:09:15 +00:00
Keith Edmunds
ae1835a421 Use signals to updates note text after editing
Hypothesis: this stops some kind of database race condition or
similar.
2023-03-08 20:08:17 +00:00
Keith Edmunds
8dd13a2ba2 Log file that creates sessions 2023-03-08 20:07:07 +00:00
Keith Edmunds
dda74782b6 Keep section headers bold 2023-03-07 18:02:17 +00:00
Keith Edmunds
4af1d4906c Never hide current or next row 2023-03-07 18:02:00 +00:00
Keith Edmunds
ea5e4a2215 Fix merge error 2023-03-05 20:23:43 +00:00
Keith Edmunds
e80a74cc40 Fixup track start/end times 2023-03-05 20:21:27 +00:00
Keith Edmunds
d92612c69a Tidy up when track start/stop times are recalculated 2023-03-05 20:19:40 +00:00
Keith Edmunds
b1841b02ea WIP playlists refactor: set header colour when removing track 2023-03-05 20:05:04 +00:00
Keith Edmunds
c5f89dbcf4 WIP playlists refactor: unify note and tab colour settings 2023-03-05 19:44:13 +00:00
Keith Edmunds
188623e574 Always treat running from mm dir as production 2023-03-05 14:59:00 +00:00
Keith Edmunds
0978e93ee7 WIP: playlists refactor: fixup context menu 2023-03-05 14:36:01 +00:00
Keith Edmunds
15f4bec197 WIP: playlists refactoring 2023-03-05 14:31:30 +00:00
Keith Edmunds
530ee60015 WIP: playlists.py refactoring 2023-03-04 23:02:21 +00:00
Keith Edmunds
4a6ce3b4ee WIP: playists refactor: fix rescan 2023-03-01 20:27:17 +00:00
Keith Edmunds
9d3743ceb5 Update section timer and end of section note 2023-02-28 21:27:48 +00:00
Keith Edmunds
bc06722633 Launch Wikipedia on select vis singleShot timer 2023-02-28 21:27:20 +00:00
Keith Edmunds
aa3388f732 WIP: playlist refactor: section timings 2023-02-28 20:46:22 +00:00
Keith Edmunds
634637f42c WIP: playlist refacton: start/end times
Clean up function. If next track is above current track, flow start
times around current track rather than resetting them at current
track.
2023-02-26 22:04:39 +00:00
Keith Edmunds
613fa4343b Fix cancelling creation of new playlist 2023-02-26 22:03:13 +00:00
Keith Edmunds
e23f8afed2 WIP: playlist refactor fix default header colour 2023-02-26 21:31:20 +00:00
Keith Edmunds
9a7d24b895 Sample tree.py showing expansion/contraction 2023-02-25 19:46:25 +00:00
Keith Edmunds
45a564729b WIP playlists refactor including fixing saving playlist 2023-02-25 19:45:56 +00:00
Keith Edmunds
cc2f3733b2 Start using signals to call for saving playlist 2023-02-25 19:44:02 +00:00
Keith Edmunds
77716005c7 Modify session logging 2023-02-25 19:40:15 +00:00
Keith Edmunds
fed4e9fbde Open Wikipedia page on single row selection 2023-02-24 20:32:27 +00:00
Keith Edmunds
5902428c23 Clear selection after edit 2023-02-24 20:25:19 +00:00
Keith Edmunds
58ec47517d WIP: playlists.py refactor 2023-02-24 19:31:38 +00:00
Keith Edmunds
c14f03f0c1 WIP: playlists.py refactor
Reset colour of current track when it has finished and is on a
different tab to the next track.
2023-02-19 21:31:06 +00:00
Keith Edmunds
2cd49b5898 WIP: playlists.py refactor
Tracks are bold on import
2023-02-19 21:22:04 +00:00
Keith Edmunds
6de95573ff WIP: playlists.py refactor
Hide/show played tracks
2023-02-19 20:36:08 +00:00
Keith Edmunds
19377a8e1c WIP: playlists.py refactor
Reset background colour of current track when track ended.
2023-02-19 20:26:55 +00:00
Keith Edmunds
0794f061ee WIP: playlists.py refactor 2023-02-19 20:09:00 +00:00
Keith Edmunds
1c294e1ce4 Only send exception mails from production environment 2023-02-18 18:11:56 +00:00
Keith Edmunds
a41aea2d36 Fix potential bug in get_selected_playlistrows()
It was possible for the returned list to have embedded None objects
2023-02-18 10:43:38 +00:00
Keith Edmunds
c93f24970f Fix moving tracks to another playlist 2023-02-16 11:23:16 +00:00
Keith Edmunds
5fc1d21f5a Clean up "importing track" message 2023-02-16 11:22:52 +00:00
Keith Edmunds
8476dd4ace Add ability to delete and rename playlists
Fixes #54
2023-02-13 19:34:28 +00:00
Keith Edmunds
2a6dfc8b63 Fix can't add track to header row 2023-02-10 08:43:04 +00:00
Keith Edmunds
424709ca74 Fix startup issues after refactor 2023-02-10 08:42:41 +00:00
Keith Edmunds
4b104798b8 Add unmark as next, played to context menu
Fixes #166
Fixex #138
2023-02-08 20:52:42 +00:00
Keith Edmunds
a15f181008 Fix start time calc that stopped app starting 2023-02-08 16:10:31 +00:00
Keith Edmunds
0c38fc2ef4 Fix all bar one typing errors 2023-02-07 21:58:08 +00:00
Keith Edmunds
df2652e6cc Import tracks in QThread
Allows progress messages to be sent
Fixes #164
2023-02-07 21:25:16 +00:00
Keith Edmunds
1cc1f1a185 Display ampersands correctly in header
Fixes #165
2023-02-06 21:51:53 +00:00
Keith Edmunds
4a6d6fa208 Log errors importing tracks 2023-02-05 22:48:32 +00:00
Keith Edmunds
4f3fb6c1ae No mypy errors; four FIXMEs 2023-02-05 21:04:10 +00:00
Keith Edmunds
e4ef0b34c8 Improve type hints, rework code
Fixes #147
2023-02-05 17:38:56 +00:00
Keith Edmunds
9e6c700644 More typing fixes 2023-02-04 15:01:39 +00:00
Keith Edmunds
f182f49f15 More typing 2023-02-01 08:43:53 +00:00
Keith Edmunds
5d50ebf3aa Typing and other cleanups 2023-01-31 21:14:02 +00:00
Keith Edmunds
73bb4b3a7f WIP: typing 2023-01-30 19:29:33 +00:00
Keith Edmunds
dfb9326d5e Fix display corruption adding a track
Fixes #137
2023-01-29 18:31:50 +00:00
Keith Edmunds
e736cb82d2 Close MySQL session after running standalone commands 2023-01-27 10:53:28 +00:00
Keith Edmunds
d471082e3f Clean up dependencies, update them, add vulture settings 2023-01-20 22:01:52 +00:00
Keith Edmunds
e77c05b908 Remove unused functions 2023-01-20 22:01:05 +00:00
Keith Edmunds
ffa3015ac3 Fix move existing track when adding duplicate with note
Fixes #161
2023-01-20 22:00:47 +00:00
Keith Edmunds
f8dcc69a55 Python typing fixups 2023-01-20 21:59:40 +00:00
Keith Edmunds
c04114b07a Fix some type hints 2023-01-19 23:32:20 +00:00
Keith Edmunds
92852f7e27 Fix bug moving unplayed tracks
Fixes #162
2023-01-19 23:29:52 +00:00
Keith Edmunds
0507f495ad Fix adding only a note from track dialog
Fixes #160
2023-01-16 17:20:18 +00:00
Keith Edmunds
d87ff80bef Fix next track not selected when playing top row 2023-01-07 12:26:38 +00:00
Keith Edmunds
27cc86d48d Ensure when track stops playing it is no longer highlighted as current
track in playlist.
2023-01-07 12:21:53 +00:00
Keith Edmunds
7584ad2090 Make row number a playlist-only concept
Solves problem of rows being moved in playlist and musicmuster not
knowing which row the current/next track is (but it doesn't need to
know; it only needs to know the PlaylistRows id).
2023-01-07 11:50:05 +00:00
Keith Edmunds
087139f4de Quickfix: couldn't close tabs 2023-01-02 08:58:16 +00:00
Keith Edmunds
ed4a106bec Fix not recording playlist rows as played 2023-01-01 18:56:02 +00:00
Keith Edmunds
90424e917e Fix not recording playlist rows as played 2023-01-01 18:51:56 +00:00
Keith Edmunds
4870647387 Fix current track highligting when starting track on another playlist 2023-01-01 18:27:41 +00:00
Keith Edmunds
046b689882 Fixup moving tracks between playlists.
Fixes #155
2023-01-01 15:52:06 +00:00
Keith Edmunds
74028fadf7 Set colours of tabs correctly. 2023-01-01 14:25:06 +00:00
Keith Edmunds
4edcab1542 Skip over unreadable tracks when selecting next track. 2023-01-01 11:08:37 +00:00
Keith Edmunds
5e75659c48 Don't use row metadat for next/current track
Get them from musicmuster.
2023-01-01 10:49:54 +00:00
Keith Edmunds
daf8069de2 Tidy up moving to PlaylistTrack object 2023-01-01 09:19:34 +00:00
Keith Edmunds
4beafe7cfc Fix typo Session→session 2023-01-01 08:33:06 +00:00
Keith Edmunds
2a484d51d3 Remove function_logger
It doesn't work properly (call methods with an additional "None"
argument).
2023-01-01 08:11:41 +00:00
Keith Edmunds
b476db188f Implement PlaylistTrack object 2022-12-30 21:43:47 +00:00
Keith Edmunds
ce08790343 Merge branch 'resume_use_plrs_issue128' into use_plrs 2022-12-30 10:37:02 +00:00
Keith Edmunds
1d35574224 Add flakehell to pyproject 2022-12-30 10:29:38 +00:00
Keith Edmunds
f1c27e0e8c WIP 2022-12-29 08:56:58 +00:00
Keith Edmunds
aa405cd6d9 WIP for resume play 2022-12-28 15:08:54 +00:00
Keith Edmunds
8f8c6a1034 Remove redundant code 2022-12-28 09:33:40 +00:00
202 changed files with 45690 additions and 30926 deletions

19
.envrc
View File

@ -1,14 +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)
if on_git_branch master; then
# 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 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

13
.flake8 Normal file
View File

@ -0,0 +1,13 @@
[flake8]
max-line-length = 88
select = C,E,F,W,B,B950
extend-ignore = E203, E501
exclude =
.git
app/ui,
__pycache__,
archive,
migrations,
prof,
docs,
app/icons_rc.py

1
.gitattributes vendored Normal file
View File

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

5
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.pyc
*.swp
tags
.venv/
venv/
Session.vim
*.flac
@ -10,3 +11,7 @@ StudioPlaylist.png
*.otl
*.howto
.direnv
tmp/
.coverage
profile_output*
kae.py

8
.idea/.gitignore vendored
View File

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="musicmuster_dev@localhost" uuid="49514dbe-26ec-4cb2-b457-06666d93ac47">
<driver-ref>mariadb</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mariadb://localhost:3306/musicmuster_dev</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@ -1,3 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="kae" />
</component>

View File

@ -1,14 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="PyQt5.QtWidgets.customContextMenuRequested.*" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@ -1,7 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="PROJECT_PROFILE" value="Default" />
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Poetry (musicmuster)" project-jdk-type="Python SDK" />
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>
</project>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/musicmuster.iml" filepath="$PROJECT_DIR$/.idea/musicmuster.iml" />
</modules>
</component>
</project>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" />
</content>
<orderEntry type="jdk" jdkName="Poetry (musicmuster)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="PROJECT" dialect="MariaDB" />
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -1 +1 @@
musicmuster
3.13

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]

View File

@ -2,7 +2,7 @@
import os
from pydub import AudioSegment, effects
from pydub import AudioSegment
# DIR = "/home/kae/git/musicmuster/archive"
DIR = "/home/kae/git/musicmuster"

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

154
app/classes.py Normal file
View File

@ -0,0 +1,154 @@
# Standard library imports
from __future__ import annotations
from dataclasses import dataclass
from enum import auto, Enum
import functools
import threading
from typing import NamedTuple
# Third party imports
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
)
from PyQt6.QtWidgets import (
QProxyStyle,
QStyle,
QStyleOption,
)
# App imports
# Define singleton first as it's needed below
def singleton(cls):
"""
Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons)
Added locking.
"""
lock = threading.Lock()
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if wrapper_singleton.instance is None:
with lock:
if wrapper_singleton.instance is None: # Check still None
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None
return wrapper_singleton
class ApplicationError(Exception):
"""
Custom exception
"""
pass
class AudioMetadata(NamedTuple):
start_gap: int = 0
silence_at: int = 0
fade_at: int = 0
class Col(Enum):
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
"""
begin_reset_model_signal = pyqtSignal(int)
enable_escape_signal = pyqtSignal(bool)
end_reset_model_signal = pyqtSignal(int)
next_track_changed_signal = pyqtSignal()
resize_rows_signal = pyqtSignal(int)
search_songfacts_signal = pyqtSignal(str)
search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str)
span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int)
track_ended_signal = pyqtSignal()
def __post_init__(self):
super().__init__()
class PlaylistStyle(QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Draw a line across the entire row rather than just the column
we're hovering over.
"""
if (
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
and not option.rect.isNull()
):
option_new = QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class QueryCol(Enum):
TITLE = 0
ARTIST = auto()
DURATION = auto()
LAST_PLAYED = auto()
BITRATE = auto()
class Tags(NamedTuple):
artist: str = ""
title: str = ""
bitrate: int = 0
duration: int = 0
class TrackInfo(NamedTuple):
track_id: int
row_number: int

View File

@ -1,79 +1,145 @@
# 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
COLON_IN_PATH_FIX = 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"
COLOUR_EVEN_PLAYLIST = "#d9d9d9"
COLOUR_LABEL_TEXT = "#000000"
COLOUR_LONG_START = "#dc3545"
COLOUR_NEXT_PLAYLIST = "#ffc107"
COLOUR_NEXT_TAB = "#b38600"
COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107"
COLUMN_NAME_ARTIST = "Artist"
COLUMN_NAME_AUTOPLAY = "A"
COLUMN_NAME_BITRATE = "bps"
COLUMN_NAME_END_TIME = "End"
COLUMN_NAME_LAST_PLAYED = "Last played"
COLUMN_NAME_LEADING_SILENCE = "Gap"
COLUMN_NAME_LENGTH = "Length"
COLUMN_NAME_NOTES = "Notes"
COLUMN_NAME_START_TIME = "Start"
COLUMN_NAME_TITLE = "Title"
DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ['dbconfig']
DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False
ERRORS_FROM = ['noreply@midnighthax.com']
ERRORS_TO = ['kae@midnighthax.com']
FADE_STEPS = 20
FADE_TIME = 3000
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')
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
MAIL_SERVER = os.environ.get('MAIL_SERVER') or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
MAIL_PORT = int(os.environ.get("MAIL_PORT") or 25)
MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
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
MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2" # noqa E501
NORMALISE_ON_IMPORT = True
NOTE_TIME_FORMAT = "%H:%M:%S"
ROOT = os.environ.get('ROOT') or "/home/kae/music"
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
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
SCROLL_TOP_MARGIN = 3
TEXT_NO_TRACK_NO_NOTE = "[Section header]"
SECTION_ENDINGS = ("-", "+-", "-+")
SECTION_HEADER = "[Section header]"
SECTION_STARTS = ("+", "+-", "-+")
SONGFACTS_ON_NEXT = False
START_GAP_WARNING_THRESHOLD = 300
SUBTOTAL_ON_ROW_ZERO = "[No subtotal on first row]"
TOD_TIME_FORMAT = "%H:%M:%S"
TIMER_MS = 500
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,55 +0,0 @@
import inspect
import logging
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}")
# MM_ENV = os.environ.get('MM_ENV', 'PRODUCTION')
# testing = False
# if MM_ENV == 'TESTING':
# dbname = os.environ.get('MM_TESTING_DBNAME', 'musicmuster_testing')
# dbuser = os.environ.get('MM_TESTING_DBUSER', 'musicmuster_testing')
# dbpw = os.environ.get('MM_TESTING_DBPW', 'musicmuster_testing')
# dbhost = os.environ.get('MM_TESTING_DBHOST', 'localhost')
# testing = True
# else:
# raise ValueError(f"Unknown MusicMuster environment: {MM_ENV=}")
#
# MYSQL_CONNECT = f"mysql+mysqldb://{dbuser}:{dbpw}@{dbhost}/{dbname}"
engine = create_engine(
MYSQL_CONNECT,
encoding='utf-8',
echo=Config.DISPLAY_SQL,
pool_pre_ping=True,
future=True
)
@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, future=True))
log.debug(
f"Session acquired, {file=}, {function=}, "
f"function{lineno=}, {Session=}"
)
yield Session
log.debug(" Session released")
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}>"
)

231
app/dialogs.py Normal file
View File

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

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,38 +1,69 @@
# Standard library imports
import datetime as dt
from email.message import EmailMessage
from typing import Optional
import os
import psutil
import re
import shutil
import smtplib
import ssl
import tempfile
from email.message import EmailMessage
# 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 effects
from pydub import AudioSegment, effects
from pydub.utils import mediainfo
from tinytag import TinyTag, TinyTagException # type: ignore
# App imports
from classes import AudioMetadata, ApplicationError, Tags
from config import Config
from datetime import datetime
from log import log
from pydub import AudioSegment
from PyQt5.QtWidgets import QMessageBox
from tinytag import TinyTag # type: ignore
from typing import Optional
# from typing import Dict, Optional, Union
from typing import Dict, Union
from models import Tracks
start_time_re = re.compile(r"@\d\d:\d\d")
def ask_yes_no(title: str, question: str) -> 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"""
button_reply = QMessageBox.question(None, title, question)
dlg = QMessageBox(parent)
dlg.setWindowTitle(title)
dlg.setText(question)
dlg.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
dlg.setIcon(QMessageBox.Icon.Question)
if default_yes:
dlg.setDefaultButton(QMessageBox.StandardButton.Yes)
button = dlg.exec()
return button_reply == QMessageBox.Yes
return button == QMessageBox.StandardButton.Yes
def audio_file_extension(fpath: str) -> str | None:
"""
Return the correct extension for this type of file.
"""
return filetype.guess(fpath).extension
def fade_point(
audio_segment: AudioSegment, fade_threshold: float = 0.0,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
audio_segment: AudioSegment,
fade_threshold: float = 0.0,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
"""
Returns the millisecond/index of the point where the volume drops below
the maximum and doesn't get louder again.
@ -50,8 +81,9 @@ def fade_point(
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
@ -59,21 +91,25 @@ def fade_point(
return int(trim_ms)
def file_is_readable(path: str) -> bool:
def file_is_unreadable(path: Optional[str]) -> bool:
"""
Returns True if passed path is readable, else False
vlc cannot read files with a colon in the path
"""
return os.access(path, os.R_OK)
if not path:
return True
return not os.access(path, os.R_OK)
def get_audio_segment(path: str) -> Optional[AudioSegment]:
if not path.endswith(audio_file_extension(path)):
return None
try:
if path.endswith('.mp3'):
if path.endswith(".mp3"):
return AudioSegment.from_mp3(path)
elif path.endswith('.flac'):
elif path.endswith(".flac"):
return AudioSegment.from_file(path, "flac") # type: ignore
except AttributeError:
return None
@ -81,24 +117,71 @@ def get_audio_segment(path: str) -> Optional[AudioSegment]:
return None
def get_tags(path: str) -> Dict[str, Union[str, int]]:
"""
Return a dictionary of title, artist, duration-in-milliseconds and path.
"""
def get_embedded_time(text: str) -> Optional[dt.datetime]:
"""Return datetime specified as @hh:mm in text"""
tag = TinyTag.get(path)
try:
match = start_time_re.search(text)
except TypeError:
return None
if not match:
return None
return dict(
title=tag.title,
artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path
try:
return dt.datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
except ValueError:
return None
def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]:
"""Return all track metadata"""
return (
get_audio_metadata(filepath)._asdict()
| get_tags(filepath)._asdict()
| dict(path=filepath)
)
def get_relative_date(past_date: datetime, reference_date: datetime = None) \
-> str:
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:
return AudioMetadata()
else:
return AudioMetadata(
start_gap=leading_silence(audio),
fade_at=int(
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
),
silence_at=int(
round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
),
)
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[dt.datetime], reference_date: Optional[dt.datetime] = None
) -> str:
"""
Return how long before reference_date past_date is as string.
@ -109,10 +192,10 @@ def get_relative_date(past_date: datetime, reference_date: datetime = None) \
@return: string
"""
if not past_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:
@ -126,7 +209,7 @@ def get_relative_date(past_date: datetime, reference_date: datetime = None) \
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0:
# Same day so return time instead
return past_date.strftime("%H:%M")
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M")
if weeks == 1:
weeks_str = "week"
else:
@ -135,13 +218,42 @@ def get_relative_date(past_date: datetime, reference_date: datetime = None) \
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) -> Tags:
"""
Return a dictionary of title, artist, bitrate and duration-in-milliseconds.
"""
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}")
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),
)
def leading_silence(
audio_segment: AudioSegment,
silence_threshold: int = Config.DBFS_SILENCE,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
"""
Returns the millisecond/index that the leading silence ends.
audio_segment - the segment to find silence in
@ -153,42 +265,23 @@ def leading_silence(
trim_ms: int = 0 # ms
assert chunk_size > 0 # to avoid infinite loop
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < # noqa W504
silence_threshold and trim_ms < len(audio_segment)):
while audio_segment[
trim_ms : trim_ms + chunk_size
].dBFS < silence_threshold and trim_ms < len( # noqa W504
audio_segment
):
trim_ms += chunk_size
# if there is no end it should return the length of the segment
return min(trim_ms, len(audio_segment))
def send_mail(to_addr, from_addr, subj, body):
# From https://docs.python.org/3/library/email.examples.html
# Create a text/plain message
msg = EmailMessage()
msg.set_content(body)
msg['Subject'] = subj
msg['From'] = from_addr
msg['To'] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
try:
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
if Config.MAIL_USE_TLS:
s.starttls(context=context)
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
s.send_message(msg)
except Exception as e:
print(e)
finally:
s.quit()
def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
def ms_to_mmss(
ms: Optional[int],
decimals: int = 0,
negative: bool = False,
none: Optional[str] = None,
) -> str:
"""Convert milliseconds to mm:ss"""
minutes: int
@ -196,6 +289,9 @@ def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
seconds: float
if not ms:
if none:
return none
else:
return "-"
sign = ""
if ms < 0:
@ -215,18 +311,17 @@ def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
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(
f"helpers.normalise_track({path}): "
f"File type {ftype} not implemented"
if ftype not in ["mp3", "flac"]:
log.error(
f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
)
bitrate = mediainfo(path)['bit_rate']
bitrate = mediainfo(path)["bit_rate"]
audio = get_audio_segment(path)
if not audio:
return
@ -238,23 +333,21 @@ def normalise_track(path):
_, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path)
except Exception as err:
log.debug(
f"helpers.normalise_track({path}): err1: {repr(err)}"
)
log.debug(f"helpers.normalise_track({path}): err1: {repr(err)}")
return
# Overwrite original file with normalised output
normalised = effects.normalize(audio)
try:
normalised.export(path, format=os.path.splitext(path)[1][1:],
bitrate=bitrate)
normalised.export(path, format=os.path.splitext(path)[1][1:], bitrate=bitrate)
# Fix up permssions and ownership
os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode)
# Copy tags
if ftype == 'flac':
tag_handler: type[FLAC | MP3]
if ftype == "flac":
tag_handler = FLAC
elif ftype == 'mp3':
elif ftype == "mp3":
tag_handler = MP3
else:
return
@ -264,9 +357,7 @@ def normalise_track(path):
dst[tag] = src[tag]
dst.save()
except Exception as err:
log.debug(
f"helpers.normalise_track({path}): err2: {repr(err)}"
)
log.debug(f"helpers.normalise_track({path}): err2: {repr(err)}")
# Restore original file
shutil.copyfile(path, temp_path)
finally:
@ -274,95 +365,93 @@ 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
def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
# From https://docs.python.org/3/library/email.examples.html
with open(to_pipe, 'w') as to_audacity, open(
from_pipe, 'rt') as from_audacity:
do_command(f'Import2: Filename="{path}"')
# Create a text/plain message
msg = EmailMessage()
msg.set_content(body)
return True
msg["Subject"] = subj
msg["From"] = from_addr
msg["To"] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
try:
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
if Config.MAIL_USE_TLS:
s.starttls(context=context)
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
s.send_message(msg)
except Exception as e:
print(e)
finally:
s.quit()
def set_track_metadata(session, track):
def set_track_metadata(track: Tracks) -> None:
"""Set/update track metadata in database"""
t = get_tags(track.path)
audio = get_audio_segment(track.path)
audio_metadata = get_audio_metadata(track.path)
tags = get_tags(track.path)
track.title = t['title']
track.artist = t['artist']
track.bitrate = t['bitrate']
if not audio:
return
track.duration = len(audio)
track.start_gap = leading_silence(audio)
track.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.mtime = os.path.getmtime(track.path)
session.commit()
for audio_key in AudioMetadata._fields:
setattr(track, audio_key, getattr(audio_metadata, audio_key))
for tag_key in Tags._fields:
setattr(track, tag_key, getattr(tags, tag_key))
def show_OK(title: str, msg: str) -> None:
def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
"""Display a message to user"""
QMessageBox.information(None, title, msg, buttons=QMessageBox.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(title: str, msg: str) -> None:
def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
"""Display a warning to user"""
QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel)
QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel)
def trailing_silence(
audio_segment: AudioSegment, silence_threshold: int = -50,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
audio_segment: AudioSegment,
silence_threshold: int = -50,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
"""Return fade point from start in milliseconds"""
return fade_point(audio_segment, silence_threshold, chunk_size)

View File

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

View File

@ -1,66 +0,0 @@
import urllib.parse
from datetime import datetime
from slugify import slugify # type: ignore
from typing import Dict, Optional
from PyQt5.QtCore import QUrl
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWidgets import QTabWidget
from config import Config
class InfoTabs(QTabWidget):
"""
Class to manage info tabs
"""
def __init__(self, parent=None) -> None:
super().__init__(parent)
# Dictionary to record when tabs were last updated (so we can
# re-use the oldest one later)
self.last_update: Dict[QWebEngineView, datetime] = {}
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. Create new tab if we're below the maximum
number otherwise reuse oldest content tab.
"""
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()
# 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,85 +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):
def handle_exception(exc_type, exc_value, exc_traceback):
error = str(exc_value)
if issubclass(exc_type, ApplicationError):
log.error(error)
else:
# Handle unexpected errors (log and display)
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg'))
msg = stackprinter.format(exc_value)
log.error(msg)
log.error(error_msg)
print("Critical error:", error_msg) # Consider logging instead of print
if os.environ["MM_ENV"] == "PRODUCTION":
from helpers import send_mail
print("\033[1;31;47m")
logging.critical(''.join(traceback.format_tb(tb)))
print("\033[1;37;40m")
print(stackprinter.format(ex, style="darkbg2", add_summary=True))
if os.environ["MM_ENV"] != "DEVELOPMENT":
msg = stackprinter.format(ex)
send_mail(Config.ERRORS_TO, Config.ERRORS_FROM,
"Exception from musicmuster", msg)
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,154 +0,0 @@
# import os
import threading
import vlc
#
from config import Config
from datetime import datetime
from helpers import file_is_readable
from typing import Optional
from time import sleep
from log import log
lock = threading.Lock()
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) -> 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
thread = threading.Thread(target=self._fade)
thread.start()
def _fade(self) -> None:
"""
Implementation of fading the current track in a separate thread.
"""
# Take a copy of current player to allow another track to be
# started without interfering here
with lock:
p = self.player
self.player = None
# Sanity check
if not p:
return
fade_time = Config.FADE_TIME / 1000
steps = Config.FADE_STEPS
sleep_time = fade_time / steps
# We reduce volume by one mesure first, then by two measures,
# then three, and so on.
# The sum of the arithmetic sequence 1, 2, 3, ..n is
# (n**2 + n) / 2
total_measures_count = (steps**2 + steps) / 2
measures_to_reduce_by = 0
for i in range(1, steps + 1):
measures_to_reduce_by += i
volume_factor = 1 - (
measures_to_reduce_by / total_measures_count)
p.audio_set_volume(int(self.max_volume * volume_factor))
sleep(sleep_time)
with lock:
p.stop()
log.debug(f"Releasing player {p=}")
p.release()
def get_playtime(self) -> Optional[int]:
"""Return elapsed play time"""
if not self.player:
return None
return self.player.get_time()
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) -> Optional[int]:
"""
Start playing the track at path.
Log and return if path not found.
"""
if not file_is_readable(path):
log.error(f"play({path}): path not readable")
return None
status = -1
if Config.COLON_IN_PATH_FIX:
media = self.VLC.media_new_path(path)
self.player = media.player_new_from_media()
else:
self.player = self.VLC.media_player_new(path)
if self.player:
self.player.audio_set_volume(self.max_volume)
self.current_track_start_time = datetime.now()
status = self.player.play()
if position:
self.player.set_position(position)
return status
#
# def set_position(self, ms):
# """Set current play time in milliseconds from start"""
#
# with lock:
# return self.player.set_time(ms)
def set_volume(self, volume, set_default=True):
"""Set maximum volume used for player"""
if not self.player:
return
if set_default:
self.max_volume = volume
self.player.audio_set_volume(volume)
def stop(self) -> float:
"""Immediately stop playing"""
with lock:
if not self.player:
return 0.0
position = self.player.get_position()
self.player.stop()
self.player.release()
# Ensure we don't reference player after release
self.player = 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

1836
app/playlistmodel.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,270 +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 (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
trailing_silence,
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] = []
no_match: 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']
new_artist = new_tags['artist']
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"\n file={new_fname}\n title={new_title}\n artist={new_artist}: "
# Use fzf to search
choice = pydymenu.fzf(parent_fnames, 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(session, 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

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'ui/dlg_SelectPlaylist.ui'
# Form implementation generated from reading ui file 'app/ui/dlg_SelectPlaylist.ui'
#
# Created by: PyQt5 UI code generator 5.11.3
# Created by: PyQt6 UI code generator 6.5.0
#
# WARNING! All changes made in this file will be lost!
# 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
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_dlgSelectPlaylist(object):
def setupUi(self, dlgSelectPlaylist):
@ -14,21 +15,20 @@ class Ui_dlgSelectPlaylist(object):
dlgSelectPlaylist.resize(276, 150)
self.verticalLayout = QtWidgets.QVBoxLayout(dlgSelectPlaylist)
self.verticalLayout.setObjectName("verticalLayout")
self.lstPlaylists = QtWidgets.QListWidget(dlgSelectPlaylist)
self.lstPlaylists = QtWidgets.QListWidget(parent=dlgSelectPlaylist)
self.lstPlaylists.setObjectName("lstPlaylists")
self.verticalLayout.addWidget(self.lstPlaylists)
self.buttonBox = QtWidgets.QDialogButtonBox(dlgSelectPlaylist)
self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=dlgSelectPlaylist)
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox")
self.verticalLayout.addWidget(self.buttonBox)
self.retranslateUi(dlgSelectPlaylist)
self.buttonBox.accepted.connect(dlgSelectPlaylist.accept)
self.buttonBox.rejected.connect(dlgSelectPlaylist.reject)
self.buttonBox.accepted.connect(dlgSelectPlaylist.accept) # type: ignore
self.buttonBox.rejected.connect(dlgSelectPlaylist.reject) # type: ignore
QtCore.QMetaObject.connectSlotsByName(dlgSelectPlaylist)
def retranslateUi(self, dlgSelectPlaylist):
_translate = QtCore.QCoreApplication.translate
dlgSelectPlaylist.setWindowTitle(_translate("dlgSelectPlaylist", "Dialog"))

View File

@ -13,8 +13,6 @@
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
@ -26,13 +24,11 @@
<item row="0" column="1">
<widget class="QLineEdit" name="searchString"/>
</item>
</layout>
</item>
<item>
<item row="1" column="0" colspan="2">
<widget class="QListWidget" name="matchList"/>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item row="2" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="lblNote">
<property name="sizePolicy">
@ -63,8 +59,15 @@
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item row="3" column="0" colspan="2">
<widget class="QLabel" name="dbPath">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QRadioButton" name="radioTitle">
<property name="text">
@ -121,13 +124,6 @@
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLabel" name="dbPath">
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>

View File

@ -1,81 +1,71 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'app/ui/dlg_SearchDatabase.ui'
# Form implementation generated from reading ui file 'dlg_TrackSelect.ui'
#
# Created by: PyQt5 UI code generator 5.15.6
# Created by: PyQt6 UI code generator 6.5.3
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# 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 PyQt5 import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(584, 377)
self.verticalLayout = QtWidgets.QVBoxLayout(Dialog)
self.verticalLayout.setObjectName("verticalLayout")
self.gridLayout = QtWidgets.QGridLayout()
self.gridLayout = QtWidgets.QGridLayout(Dialog)
self.gridLayout.setObjectName("gridLayout")
self.label = QtWidgets.QLabel(Dialog)
self.label = QtWidgets.QLabel(parent=Dialog)
self.label.setObjectName("label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.searchString = QtWidgets.QLineEdit(Dialog)
self.searchString = QtWidgets.QLineEdit(parent=Dialog)
self.searchString.setObjectName("searchString")
self.gridLayout.addWidget(self.searchString, 0, 1, 1, 1)
self.verticalLayout.addLayout(self.gridLayout)
self.matchList = QtWidgets.QListWidget(Dialog)
self.matchList = QtWidgets.QListWidget(parent=Dialog)
self.matchList.setObjectName("matchList")
self.verticalLayout.addWidget(self.matchList)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.lblNote = QtWidgets.QLabel(Dialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred)
self.gridLayout.addWidget(self.matchList, 1, 0, 1, 2)
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.lblNote = QtWidgets.QLabel(parent=Dialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.lblNote.sizePolicy().hasHeightForWidth())
self.lblNote.setSizePolicy(sizePolicy)
self.lblNote.setMaximumSize(QtCore.QSize(46, 16777215))
self.lblNote.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.lblNote.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblNote.setObjectName("lblNote")
self.horizontalLayout_2.addWidget(self.lblNote)
self.txtNote = QtWidgets.QLineEdit(Dialog)
self.horizontalLayout.addWidget(self.lblNote)
self.txtNote = QtWidgets.QLineEdit(parent=Dialog)
self.txtNote.setObjectName("txtNote")
self.horizontalLayout_2.addWidget(self.txtNote)
self.verticalLayout.addLayout(self.horizontalLayout_2)
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.radioTitle = QtWidgets.QRadioButton(Dialog)
self.radioTitle.setChecked(True)
self.radioTitle.setObjectName("radioTitle")
self.horizontalLayout.addWidget(self.radioTitle)
self.radioArtist = QtWidgets.QRadioButton(Dialog)
self.radioArtist.setObjectName("radioArtist")
self.horizontalLayout.addWidget(self.radioArtist)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem)
self.btnAdd = QtWidgets.QPushButton(Dialog)
self.btnAdd.setDefault(True)
self.btnAdd.setObjectName("btnAdd")
self.horizontalLayout.addWidget(self.btnAdd)
self.btnAddClose = QtWidgets.QPushButton(Dialog)
self.btnAddClose.setObjectName("btnAddClose")
self.horizontalLayout.addWidget(self.btnAddClose)
self.btnClose = QtWidgets.QPushButton(Dialog)
self.btnClose.setObjectName("btnClose")
self.horizontalLayout.addWidget(self.btnClose)
self.verticalLayout.addLayout(self.horizontalLayout)
self.dbPath = QtWidgets.QLabel(Dialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.dbPath.sizePolicy().hasHeightForWidth())
self.dbPath.setSizePolicy(sizePolicy)
self.horizontalLayout.addWidget(self.txtNote)
self.gridLayout.addLayout(self.horizontalLayout, 2, 0, 1, 2)
self.dbPath = QtWidgets.QLabel(parent=Dialog)
self.dbPath.setText("")
self.dbPath.setObjectName("dbPath")
self.verticalLayout.addWidget(self.dbPath)
self.gridLayout.addWidget(self.dbPath, 3, 0, 1, 2)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.radioTitle = QtWidgets.QRadioButton(parent=Dialog)
self.radioTitle.setChecked(True)
self.radioTitle.setObjectName("radioTitle")
self.horizontalLayout_2.addWidget(self.radioTitle)
self.radioArtist = QtWidgets.QRadioButton(parent=Dialog)
self.radioArtist.setObjectName("radioArtist")
self.horizontalLayout_2.addWidget(self.radioArtist)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.horizontalLayout_2.addItem(spacerItem)
self.btnAdd = QtWidgets.QPushButton(parent=Dialog)
self.btnAdd.setDefault(True)
self.btnAdd.setObjectName("btnAdd")
self.horizontalLayout_2.addWidget(self.btnAdd)
self.btnAddClose = QtWidgets.QPushButton(parent=Dialog)
self.btnAddClose.setObjectName("btnAddClose")
self.horizontalLayout_2.addWidget(self.btnAddClose)
self.btnClose = QtWidgets.QPushButton(parent=Dialog)
self.btnClose.setObjectName("btnClose")
self.horizontalLayout_2.addWidget(self.btnClose)
self.gridLayout.addLayout(self.horizontalLayout_2, 4, 0, 1, 2)
self.lblNote.setBuddy(self.txtNote)
self.retranslateUi(Dialog)

View File

@ -1,14 +1,12 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'app/ui/dlg_Cart.ui'
# Form implementation generated from reading ui file 'dlg_Cart.ui'
#
# Created by: PyQt5 UI code generator 5.15.6
# Created by: PyQt6 UI code generator 6.5.3
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# 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 PyQt5 import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_DialogCartEdit(object):
@ -17,43 +15,43 @@ class Ui_DialogCartEdit(object):
DialogCartEdit.resize(564, 148)
self.gridLayout = QtWidgets.QGridLayout(DialogCartEdit)
self.gridLayout.setObjectName("gridLayout")
self.label = QtWidgets.QLabel(DialogCartEdit)
self.label = QtWidgets.QLabel(parent=DialogCartEdit)
self.label.setMaximumSize(QtCore.QSize(56, 16777215))
self.label.setObjectName("label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.lineEditName = QtWidgets.QLineEdit(DialogCartEdit)
self.lineEditName = QtWidgets.QLineEdit(parent=DialogCartEdit)
self.lineEditName.setInputMask("")
self.lineEditName.setObjectName("lineEditName")
self.gridLayout.addWidget(self.lineEditName, 0, 1, 1, 2)
self.chkEnabled = QtWidgets.QCheckBox(DialogCartEdit)
self.chkEnabled = QtWidgets.QCheckBox(parent=DialogCartEdit)
self.chkEnabled.setObjectName("chkEnabled")
self.gridLayout.addWidget(self.chkEnabled, 0, 3, 1, 1)
self.label_2 = QtWidgets.QLabel(DialogCartEdit)
self.label_2 = QtWidgets.QLabel(parent=DialogCartEdit)
self.label_2.setMaximumSize(QtCore.QSize(56, 16777215))
self.label_2.setObjectName("label_2")
self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1)
self.lblPath = QtWidgets.QLabel(DialogCartEdit)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
self.lblPath = QtWidgets.QLabel(parent=DialogCartEdit)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.lblPath.sizePolicy().hasHeightForWidth())
self.lblPath.setSizePolicy(sizePolicy)
self.lblPath.setMinimumSize(QtCore.QSize(301, 41))
self.lblPath.setText("")
self.lblPath.setTextFormat(QtCore.Qt.PlainText)
self.lblPath.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.lblPath.setTextFormat(QtCore.Qt.TextFormat.PlainText)
self.lblPath.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblPath.setWordWrap(True)
self.lblPath.setObjectName("lblPath")
self.gridLayout.addWidget(self.lblPath, 1, 1, 1, 1)
self.btnFile = QtWidgets.QPushButton(DialogCartEdit)
self.btnFile = QtWidgets.QPushButton(parent=DialogCartEdit)
self.btnFile.setMaximumSize(QtCore.QSize(31, 16777215))
self.btnFile.setObjectName("btnFile")
self.gridLayout.addWidget(self.btnFile, 1, 3, 1, 1)
spacerItem = QtWidgets.QSpacerItem(116, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
spacerItem = QtWidgets.QSpacerItem(116, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.gridLayout.addItem(spacerItem, 2, 1, 1, 1)
self.buttonBox = QtWidgets.QDialogButtonBox(DialogCartEdit)
self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=DialogCartEdit)
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox")
self.gridLayout.addWidget(self.buttonBox, 2, 2, 1, 2)
self.label.setBuddy(self.lineEditName)

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

View File

@ -1,33 +1,31 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'ui/downloadcsv.ui'
# Form implementation generated from reading ui file 'app/ui/downloadcsv.ui'
#
# Created by: PyQt5 UI code generator 5.15.6
# Created by: PyQt6 UI code generator 6.5.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# 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 PyQt5 import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_DateSelect(object):
def setupUi(self, DateSelect):
DateSelect.setObjectName("DateSelect")
DateSelect.resize(280, 166)
self.buttonBox = QtWidgets.QDialogButtonBox(DateSelect)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=DateSelect)
self.buttonBox.setGeometry(QtCore.QRect(70, 110, 191, 32))
self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok)
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox")
self.dateTimeEdit = QtWidgets.QDateTimeEdit(DateSelect)
self.dateTimeEdit = QtWidgets.QDateTimeEdit(parent=DateSelect)
self.dateTimeEdit.setGeometry(QtCore.QRect(70, 60, 194, 28))
self.dateTimeEdit.setCalendarPopup(True)
self.dateTimeEdit.setObjectName("dateTimeEdit")
self.label = QtWidgets.QLabel(DateSelect)
self.label = QtWidgets.QLabel(parent=DateSelect)
self.label.setGeometry(QtCore.QRect(10, 20, 261, 19))
self.label.setObjectName("label")
self.label_2 = QtWidgets.QLabel(DateSelect)
self.label_2 = QtWidgets.QLabel(parent=DateSelect)
self.label_2.setGeometry(QtCore.QRect(15, 66, 51, 19))
self.label_2.setObjectName("label_2")

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
app/ui/headphone-symbol.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,5 +1,13 @@
<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 alias="wikipedia">wikipedia-logo.png</file>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,650 +0,0 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
#
# Created by: PyQt5 UI code generator 5.15.6
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt5 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.Normal, QtGui.QIcon.Off)
MainWindow.setWindowIcon(icon)
MainWindow.setStyleSheet("")
self.centralwidget = QtWidgets.QWidget(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(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.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(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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(self.centralwidget)
self.frame_2.setMaximumSize(QtCore.QSize(230, 16777215))
self.frame_2.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_2.setFrameShadow(QtWidgets.QFrame.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(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.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(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.StyledPanel)
self.frame_4.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_4.setObjectName("frame_4")
self.gridLayout_4.addWidget(self.frame_4, 1, 0, 1, 1)
self.frame_6 = QtWidgets.QFrame(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.StyledPanel)
self.frame_6.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_6.setObjectName("frame_6")
self.gridLayout_4.addWidget(self.frame_6, 3, 0, 1, 1)
self.splitter = QtWidgets.QSplitter(self.centralwidget)
self.splitter.setOrientation(QtCore.Qt.Vertical)
self.splitter.setObjectName("splitter")
self.tabPlaylist = QtWidgets.QTabWidget(self.splitter)
self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist")
self.tabInfolist = InfoTabs(self.splitter)
self.tabInfolist.setDocumentMode(False)
self.tabInfolist.setTabsClosable(True)
self.tabInfolist.setMovable(True)
self.tabInfolist.setObjectName("tabInfolist")
self.gridLayout_4.addWidget(self.splitter, 4, 0, 1, 1)
self.frame_5 = QtWidgets.QFrame(self.centralwidget)
self.frame_5.setStyleSheet("background-color: rgb(192, 191, 188)")
self.frame_5.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_5.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_5.setObjectName("frame_5")
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.frame_5)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.frame = QtWidgets.QFrame(self.frame_5)
self.frame.setMinimumSize(QtCore.QSize(321, 0))
self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame.setObjectName("frame")
self.gridLayout = QtWidgets.QGridLayout(self.frame)
self.gridLayout.setObjectName("gridLayout")
self.label_x = QtWidgets.QLabel(self.frame)
self.label_x.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_x.setObjectName("label_x")
self.gridLayout.addWidget(self.label_x, 0, 0, 1, 1)
self.label_track_length = QtWidgets.QLabel(self.frame)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(16)
self.label_track_length.setFont(font)
self.label_track_length.setScaledContents(False)
self.label_track_length.setObjectName("label_track_length")
self.gridLayout.addWidget(self.label_track_length, 0, 1, 1, 1)
self.label_x_2 = QtWidgets.QLabel(self.frame)
self.label_x_2.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_x_2.setObjectName("label_x_2")
self.gridLayout.addWidget(self.label_x_2, 0, 2, 1, 1)
self.label_start_time = QtWidgets.QLabel(self.frame)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(16)
self.label_start_time.setFont(font)
self.label_start_time.setScaledContents(False)
self.label_start_time.setObjectName("label_start_time")
self.gridLayout.addWidget(self.label_start_time, 0, 3, 1, 1)
self.label_7 = QtWidgets.QLabel(self.frame)
self.label_7.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_7.setObjectName("label_7")
self.gridLayout.addWidget(self.label_7, 1, 0, 1, 1)
self.label_fade_length = QtWidgets.QLabel(self.frame)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(16)
self.label_fade_length.setFont(font)
self.label_fade_length.setScaledContents(False)
self.label_fade_length.setObjectName("label_fade_length")
self.gridLayout.addWidget(self.label_fade_length, 1, 1, 1, 1)
self.label_8 = QtWidgets.QLabel(self.frame)
self.label_8.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_8.setObjectName("label_8")
self.gridLayout.addWidget(self.label_8, 1, 2, 1, 1)
self.label_end_time = QtWidgets.QLabel(self.frame)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(16)
self.label_end_time.setFont(font)
self.label_end_time.setScaledContents(False)
self.label_end_time.setObjectName("label_end_time")
self.gridLayout.addWidget(self.label_end_time, 1, 3, 1, 1)
self.btnFade = QtWidgets.QPushButton(self.frame)
icon1 = QtGui.QIcon()
icon1.addPixmap(QtGui.QPixmap(":/icons/fade"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnFade.setIcon(icon1)
self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade")
self.gridLayout.addWidget(self.btnFade, 2, 0, 1, 2)
self.btnStop = QtWidgets.QPushButton(self.frame)
icon2 = QtGui.QIcon()
icon2.addPixmap(QtGui.QPixmap(":/icons/stopsign"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnStop.setIcon(icon2)
self.btnStop.setIconSize(QtCore.QSize(30, 30))
self.btnStop.setObjectName("btnStop")
self.gridLayout.addWidget(self.btnStop, 2, 2, 1, 2)
self.horizontalLayout.addWidget(self.frame)
self.frame_elapsed = QtWidgets.QFrame(self.frame_5)
self.frame_elapsed.setMinimumSize(QtCore.QSize(0, 112))
self.frame_elapsed.setStyleSheet("")
self.frame_elapsed.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_elapsed.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_elapsed.setObjectName("frame_elapsed")
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.frame_elapsed)
self.verticalLayout_4.setObjectName("verticalLayout_4")
self.label = QtWidgets.QLabel(self.frame_elapsed)
self.label.setAlignment(QtCore.Qt.AlignCenter)
self.label.setObjectName("label")
self.verticalLayout_4.addWidget(self.label)
self.label_elapsed_timer = QtWidgets.QLabel(self.frame_elapsed)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_elapsed_timer.setFont(font)
self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignCenter)
self.label_elapsed_timer.setObjectName("label_elapsed_timer")
self.verticalLayout_4.addWidget(self.label_elapsed_timer)
self.horizontalLayout.addWidget(self.frame_elapsed)
self.frame_fade = QtWidgets.QFrame(self.frame_5)
self.frame_fade.setMinimumSize(QtCore.QSize(0, 112))
self.frame_fade.setStyleSheet("")
self.frame_fade.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_fade.setFrameShadow(QtWidgets.QFrame.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(self.frame_fade)
self.label_4.setAlignment(QtCore.Qt.AlignCenter)
self.label_4.setObjectName("label_4")
self.verticalLayout_2.addWidget(self.label_4)
self.label_fade_timer = QtWidgets.QLabel(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.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(self.frame_5)
self.frame_silent.setMinimumSize(QtCore.QSize(0, 112))
self.frame_silent.setStyleSheet("")
self.frame_silent.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_silent.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_silent.setObjectName("frame_silent")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.frame_silent)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.label_5 = QtWidgets.QLabel(self.frame_silent)
self.label_5.setAlignment(QtCore.Qt.AlignCenter)
self.label_5.setObjectName("label_5")
self.verticalLayout_5.addWidget(self.label_5)
self.label_silent_timer = QtWidgets.QLabel(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.AlignCenter)
self.label_silent_timer.setObjectName("label_silent_timer")
self.verticalLayout_5.addWidget(self.label_silent_timer)
self.horizontalLayout.addWidget(self.frame_silent)
self.frame_end = QtWidgets.QFrame(self.frame_5)
self.frame_end.setMinimumSize(QtCore.QSize(0, 112))
self.frame_end.setStyleSheet("")
self.frame_end.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_end.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_end.setObjectName("frame_end")
self.label_6 = QtWidgets.QLabel(self.frame_end)
self.label_6.setGeometry(QtCore.QRect(10, 10, 31, 19))
self.label_6.setAlignment(QtCore.Qt.AlignCenter)
self.label_6.setObjectName("label_6")
self.label_end_timer = QtWidgets.QLabel(self.frame_end)
self.label_end_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_end_timer.setFont(font)
self.label_end_timer.setAlignment(QtCore.Qt.AlignCenter)
self.label_end_timer.setObjectName("label_end_timer")
self.horizontalLayout.addWidget(self.frame_end)
self.frame_3 = QtWidgets.QFrame(self.frame_5)
self.frame_3.setMinimumSize(QtCore.QSize(152, 112))
self.frame_3.setMaximumSize(QtCore.QSize(184, 16777215))
self.frame_3.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_3.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_3.setObjectName("frame_3")
self.gridLayout_3 = QtWidgets.QGridLayout(self.frame_3)
self.gridLayout_3.setObjectName("gridLayout_3")
self.btnDrop3db = QtWidgets.QPushButton(self.frame_3)
self.btnDrop3db.setMinimumSize(QtCore.QSize(132, 36))
self.btnDrop3db.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnDrop3db.setCheckable(True)
self.btnDrop3db.setObjectName("btnDrop3db")
self.gridLayout_3.addWidget(self.btnDrop3db, 0, 0, 1, 1)
self.btnHidePlayed = QtWidgets.QPushButton(self.frame_3)
self.btnHidePlayed.setMinimumSize(QtCore.QSize(132, 36))
self.btnHidePlayed.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnHidePlayed.setCheckable(True)
self.btnHidePlayed.setObjectName("btnHidePlayed")
self.gridLayout_3.addWidget(self.btnHidePlayed, 1, 0, 1, 1)
self.horizontalLayout.addWidget(self.frame_3)
self.horizontalLayout_2.addLayout(self.horizontalLayout)
self.gridLayout_4.addWidget(self.frame_5, 5, 0, 1, 1)
self.cartsWidget = QtWidgets.QWidget(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.Expanding, QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout_Carts.addItem(spacerItem)
self.gridLayout_4.addWidget(self.cartsWidget, 2, 0, 1, 1)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 26))
self.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(self.menubar)
self.menuFile.setObjectName("menuFile")
self.menuPlaylist = QtWidgets.QMenu(self.menubar)
self.menuPlaylist.setObjectName("menuPlaylist")
self.menuSearc_h = QtWidgets.QMenu(self.menubar)
self.menuSearc_h.setObjectName("menuSearc_h")
self.menuHelp = QtWidgets.QMenu(self.menubar)
self.menuHelp.setObjectName("menuHelp")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(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 = QtWidgets.QAction(MainWindow)
icon3 = QtGui.QIcon()
icon3.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionPlay_next.setIcon(icon3)
self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkipToNext = QtWidgets.QAction(MainWindow)
icon4 = QtGui.QIcon()
icon4.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionSkipToNext.setIcon(icon4)
self.actionSkipToNext.setObjectName("actionSkipToNext")
self.actionInsertTrack = QtWidgets.QAction(MainWindow)
icon5 = QtGui.QIcon()
icon5.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionInsertTrack.setIcon(icon5)
self.actionInsertTrack.setObjectName("actionInsertTrack")
self.actionAdd_file = QtWidgets.QAction(MainWindow)
icon6 = QtGui.QIcon()
icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionAdd_file.setIcon(icon6)
self.actionAdd_file.setObjectName("actionAdd_file")
self.actionFade = QtWidgets.QAction(MainWindow)
icon7 = QtGui.QIcon()
icon7.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-fade.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionFade.setIcon(icon7)
self.actionFade.setObjectName("actionFade")
self.actionStop = QtWidgets.QAction(MainWindow)
icon8 = QtGui.QIcon()
icon8.addPixmap(QtGui.QPixmap(":/icons/stop"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionStop.setIcon(icon8)
self.actionStop.setObjectName("actionStop")
self.action_Clear_selection = QtWidgets.QAction(MainWindow)
self.action_Clear_selection.setObjectName("action_Clear_selection")
self.action_Resume_previous = QtWidgets.QAction(MainWindow)
icon9 = QtGui.QIcon()
icon9.addPixmap(QtGui.QPixmap(":/icons/previous"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.action_Resume_previous.setIcon(icon9)
self.action_Resume_previous.setObjectName("action_Resume_previous")
self.actionE_xit = QtWidgets.QAction(MainWindow)
self.actionE_xit.setObjectName("actionE_xit")
self.actionTest = QtWidgets.QAction(MainWindow)
self.actionTest.setObjectName("actionTest")
self.actionOpenPlaylist = QtWidgets.QAction(MainWindow)
self.actionOpenPlaylist.setObjectName("actionOpenPlaylist")
self.actionNewPlaylist = QtWidgets.QAction(MainWindow)
self.actionNewPlaylist.setObjectName("actionNewPlaylist")
self.actionTestFunction = QtWidgets.QAction(MainWindow)
self.actionTestFunction.setObjectName("actionTestFunction")
self.actionSkipToFade = QtWidgets.QAction(MainWindow)
self.actionSkipToFade.setObjectName("actionSkipToFade")
self.actionSkipToEnd = QtWidgets.QAction(MainWindow)
self.actionSkipToEnd.setObjectName("actionSkipToEnd")
self.actionClosePlaylist = QtWidgets.QAction(MainWindow)
self.actionClosePlaylist.setEnabled(True)
self.actionClosePlaylist.setObjectName("actionClosePlaylist")
self.actionRenamePlaylist = QtWidgets.QAction(MainWindow)
self.actionRenamePlaylist.setEnabled(False)
self.actionRenamePlaylist.setObjectName("actionRenamePlaylist")
self.actionDeletePlaylist = QtWidgets.QAction(MainWindow)
self.actionDeletePlaylist.setEnabled(False)
self.actionDeletePlaylist.setObjectName("actionDeletePlaylist")
self.actionMoveSelected = QtWidgets.QAction(MainWindow)
self.actionMoveSelected.setObjectName("actionMoveSelected")
self.actionExport_playlist = QtWidgets.QAction(MainWindow)
self.actionExport_playlist.setObjectName("actionExport_playlist")
self.actionSetNext = QtWidgets.QAction(MainWindow)
self.actionSetNext.setObjectName("actionSetNext")
self.actionSelect_next_track = QtWidgets.QAction(MainWindow)
self.actionSelect_next_track.setObjectName("actionSelect_next_track")
self.actionSelect_previous_track = QtWidgets.QAction(MainWindow)
self.actionSelect_previous_track.setObjectName("actionSelect_previous_track")
self.actionSelect_played_tracks = QtWidgets.QAction(MainWindow)
self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks")
self.actionMoveUnplayed = QtWidgets.QAction(MainWindow)
self.actionMoveUnplayed.setObjectName("actionMoveUnplayed")
self.actionAdd_note = QtWidgets.QAction(MainWindow)
self.actionAdd_note.setObjectName("actionAdd_note")
self.actionEnable_controls = QtWidgets.QAction(MainWindow)
self.actionEnable_controls.setObjectName("actionEnable_controls")
self.actionImport = QtWidgets.QAction(MainWindow)
self.actionImport.setObjectName("actionImport")
self.actionDownload_CSV_of_played_tracks = QtWidgets.QAction(MainWindow)
self.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks")
self.actionSearch = QtWidgets.QAction(MainWindow)
self.actionSearch.setObjectName("actionSearch")
self.actionInsertSectionHeader = QtWidgets.QAction(MainWindow)
self.actionInsertSectionHeader.setObjectName("actionInsertSectionHeader")
self.actionRemove = QtWidgets.QAction(MainWindow)
self.actionRemove.setObjectName("actionRemove")
self.actionFind_next = QtWidgets.QAction(MainWindow)
self.actionFind_next.setObjectName("actionFind_next")
self.actionFind_previous = QtWidgets.QAction(MainWindow)
self.actionFind_previous.setObjectName("actionFind_previous")
self.action_About = QtWidgets.QAction(MainWindow)
self.action_About.setObjectName("action_About")
self.actionSave_as_template = QtWidgets.QAction(MainWindow)
self.actionSave_as_template.setObjectName("actionSave_as_template")
self.actionNew_from_template = QtWidgets.QAction(MainWindow)
self.actionNew_from_template.setObjectName("actionNew_from_template")
self.actionDebug = QtWidgets.QAction(MainWindow)
self.actionDebug.setObjectName("actionDebug")
self.actionAdd_cart = QtWidgets.QAction(MainWindow)
self.actionAdd_cart.setObjectName("actionAdd_cart")
self.actionMark_for_moving = QtWidgets.QAction(MainWindow)
self.actionMark_for_moving.setObjectName("actionMark_for_moving")
self.actionPaste = QtWidgets.QAction(MainWindow)
self.actionPaste.setObjectName("actionPaste")
self.menuFile.addAction(self.actionNewPlaylist)
self.menuFile.addAction(self.actionOpenPlaylist)
self.menuFile.addAction(self.actionClosePlaylist)
self.menuFile.addAction(self.actionRenamePlaylist)
self.menuFile.addAction(self.actionExport_playlist)
self.menuFile.addAction(self.actionDeletePlaylist)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionNew_from_template)
self.menuFile.addAction(self.actionSave_as_template)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionMoveSelected)
self.menuFile.addAction(self.actionMoveUnplayed)
self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks)
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.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.addAction(self.actionFind_next)
self.menuSearc_h.addAction(self.actionFind_previous)
self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSelect_next_track)
self.menuSearc_h.addAction(self.actionSelect_previous_track)
self.menuHelp.addAction(self.action_About)
self.menuHelp.addAction(self.actionDebug)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuPlaylist.menuAction())
self.menubar.addAction(self.menuSearc_h.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1)
self.tabInfolist.setCurrentIndex(-1)
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "Music Muster"))
self.previous_track_2.setText(_translate("MainWindow", "Last track:"))
self.current_track_2.setText(_translate("MainWindow", "Current track:"))
self.next_track_2.setText(_translate("MainWindow", "Next track:"))
self.lblTOD.setText(_translate("MainWindow", "00:00:00"))
self.label_x.setText(_translate("MainWindow", "Track length:"))
self.label_track_length.setText(_translate("MainWindow", "0:00"))
self.label_x_2.setText(_translate("MainWindow", "Start:"))
self.label_start_time.setText(_translate("MainWindow", "00:00:00"))
self.label_7.setText(_translate("MainWindow", "Fade length:"))
self.label_fade_length.setText(_translate("MainWindow", "0:00"))
self.label_8.setText(_translate("MainWindow", "End:"))
self.label_end_time.setText(_translate("MainWindow", "00:00:00"))
self.btnFade.setText(_translate("MainWindow", "Fade"))
self.btnStop.setText(_translate("MainWindow", "Stop"))
self.label.setText(_translate("MainWindow", "Elapsed time"))
self.label_elapsed_timer.setText(_translate("MainWindow", "00:00"))
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.label_6.setText(_translate("MainWindow", "End"))
self.label_end_timer.setText(_translate("MainWindow", "00:00"))
self.btnDrop3db.setText(_translate("MainWindow", "-3dB to talk"))
self.btnHidePlayed.setText(_translate("MainWindow", "Hide played"))
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+S"))
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"))
from infotabs import InfoTabs
import icons_rc

View File

@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'ui/playlist.ui'
#
# Created by: PyQt5 UI code generator 5.15.4
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.

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,17 +0,0 @@
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFontMetrics, QPainter
from PyQt5.QtWidgets import QLabel
# class ElideLabel(QLabel):
# """
# From https://stackoverflow.com/questions/11446478/
# pyside-pyqt-truncate-text-in-qlabel-based-on-minimumsize
# """
#
# def paintEvent(self, event):
# painter = QPainter(self)
# metrics = QFontMetrics(self.font())
# elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
#
# painter.drawText(self.rect(), self.alignment(), elided)

View File

@ -1,22 +1,22 @@
# #!/usr/bin/env python
#
#!/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 (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
normalise_track,
set_track_metadata,
trailing_silence,
)
from log import log
from models import Tracks
def check_db(session):
def check_db(session: Session) -> None:
"""
Database consistency check.
@ -72,17 +72,19 @@ def check_db(session):
print("Invalid paths in database")
print("-------------------------")
for t in paths_not_found:
print(f"""
print(
f"""
Track ID: {t.id}
Path: {t.path}
Title: {t.title}
Artist: {t.artist}
""")
"""
)
if more_files_to_report:
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
"""
@ -90,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

190
archive/DragAndDropReference.py Executable file
View File

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

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

84
archive/db_experiments.py Executable file
View File

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

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

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

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)

BIN
archive/todo/.DS_Store vendored Normal file

Binary file not shown.

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

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

View File

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

BIN
archive/todo/tick.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

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

@ -0,0 +1,104 @@
import sys
import datetime
import json
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import Qt
qt_creator_file = "mainwindow.ui"
Ui_MainWindow, QtBaseClass = uic.loadUiType(qt_creator_file)
tick = QtGui.QImage("tick.png")
class TodoModel(QtCore.QAbstractListModel):
def __init__(self, *args, todos=None, **kwargs):
super(TodoModel, self).__init__(*args, **kwargs)
self.todos = todos or []
def data(self, index, role):
if role == Qt.DisplayRole:
_, text = self.todos[index.row()]
return text
if role == Qt.DecorationRole:
status, _ = self.todos[index.row()]
if status:
return tick
def rowCount(self, index):
return len(self.todos)
def flags(self, index):
print(datetime.datetime.now().time().strftime("%H:%M:%S"))
return super().flags(index)
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def __init__(self):
QtWidgets.QMainWindow.__init__(self)
Ui_MainWindow.__init__(self)
self.setupUi(self)
self.model = TodoModel()
self.load()
self.todoView.setModel(self.model)
self.addButton.pressed.connect(self.add)
self.deleteButton.pressed.connect(self.delete)
self.completeButton.pressed.connect(self.complete)
def add(self):
"""
Add an item to our todo list, getting the text from the QLineEdit .todoEdit
and then clearing it.
"""
text = self.todoEdit.text()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit()
# Empty the input
self.todoEdit.setText("")
self.save()
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
# Indexes is a list of a single item in single-select mode.
index = indexes[0]
# Remove the item and refresh.
del self.model.todos[index.row()]
self.model.layoutChanged.emit()
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def complete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
index = indexes[0]
row = index.row()
status, text = self.model.todos[row]
self.model.todos[row] = (True, text)
# .dataChanged takes top-left and bottom right, which are equal
# for a single selection.
self.model.dataChanged.emit(index, index)
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def load(self):
try:
with open("data.db", "r") as f:
self.model.todos = json.load(f)
except Exception:
pass
def save(self):
with open("data.db", "w") as f:
data = json.dump(self.model.todos, f)
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

22
archive/wheel_events.txt Normal file
View File

@ -0,0 +1,22 @@
diff --git a/app/musicmuster.py b/app/musicmuster.py
index d90644b..b53f3a8 100755
--- a/app/musicmuster.py
+++ b/app/musicmuster.py
@@ -1803,6 +1803,17 @@ class Window(QMainWindow, Ui_MainWindow):
else:
self.hdrNextTrack.setText("")
+ def wheelEvent(self, event):
+ """
+ Detect wheel events
+ """
+
+ delta = event.angleDelta().y() / 120
+ if delta > 0:
+ print("musicmuster.py Scroll Up")
+ else:
+ print("musicmuster.py Scroll Down")
+
class CartDialog(QDialog):
"""Edit cart details"""

View File

@ -1,80 +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
def quick_test():
"""Example list of commands."""
do_command('Help: Command=Help')
do_command('Help: Command="GetInfo"')
# do_command('SetPreference: Name=GUI/Theme Value=classic Reload=1')
do_command('Import2: Filename=/home/kae/git/musicmuster/archive/boot.flac')

View File

@ -1,40 +0,0 @@
# https://itnext.io/setting-up-transactional-tests-with-pytest-and-sqlalchemy-b2d726347629
import pytest
import sys
sys.path.append("app")
import models
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
@pytest.fixture(scope="session")
def connection():
engine = create_engine(
"mysql+mysqldb://musicmuster_testing:musicmuster_testing@"
"localhost/musicmuster_testing"
)
return engine.connect()
@pytest.fixture(scope="session")
def setup_database(connection):
from app.models import Base # noqa E402
Base.metadata.bind = connection
Base.metadata.create_all()
# seed_database()
yield
Base.metadata.drop_all()
@pytest.fixture
def session(setup_database, connection):
transaction = connection.begin()
yield scoped_session(
sessionmaker(autocommit=False, autoflush=False, bind=connection)
)
transaction.rollback()

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

BIN
docs/build/doctrees/development.doctree vendored Normal file

Binary file not shown.

BIN
docs/build/doctrees/environment.pickle vendored Normal file

Binary file not shown.

BIN
docs/build/doctrees/index.doctree vendored Normal file

Binary file not shown.

BIN
docs/build/doctrees/installation.doctree vendored Normal file

Binary file not shown.

BIN
docs/build/doctrees/introduction.doctree vendored Normal file

Binary file not shown.

BIN
docs/build/doctrees/reference.doctree vendored Normal file

Binary file not shown.

BIN
docs/build/doctrees/tutorial.doctree vendored Normal file

Binary file not shown.

4
docs/build/html/.buildinfo vendored Normal file
View File

@ -0,0 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: dcac9a6ec03f5a33392ad4b5bcf42637
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@ -0,0 +1,2 @@
Development
===========

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